fish で湯婆婆を実装してみる

2020年11月

はじめに

深夜過ぎても眠れず今 5:30 くらいです。おはようございます。
眠れないので Twitter してたら Qiita で 湯婆婆を実装してみる のが流行っていることを知ったので fish で実装してみることにしました :fish:

ちなみにこの流行を巻き起こした第一人者である @Nemesis さんの 元ネタ記事はこちら です :ocean::cyclone:

コード

yubaba.fish
echo 契約書だよ。そこに名前を書きな。
# `-P` がプロンプトで入力受付を行うためのオプションで、
# `-l` が入力された内容を `current_name` 変数にセットするためのオプション
read -l -P '' current_name
echo フン。$current_nameというのかい。贅沢な名だねぇ。
# `current_name_length` の値が 1 以下だったらという条件文
set current_name_length (string length (echo $current_name))
if test $current_name_length -le 1
    set index 1
else
    # 1 から `current_name_length` までの値をランダムに取得して index 変数にセット
    # FYI: random コマンドで指定した START より小さい文字数の文字だと
    # `random: END must be greater than START` という警告が出てきてしまうので
    # 条件文で 2文字以上の場合のみランダムで文字列を取得するようにしている
    set index (random 1 (echo $current_name_length))
end
# `string split '' <分割したい文字列>` で文字列を 1文字ずつ分割して List として characters にセット
set characters (string split '' (echo $current_name))
# 文字列の中から適当な 1文字をピックアップ
set new_name $characters[$index]
echo 今からお前の名前は$new_nameだ。いいかい、$new_nameだよ。分かったら返事をするんだ、$new_name!!

fish のコードは :arrow_up: のようになります。
bash で慣れていると冗長な気がしますが、これには fish 特有の仕様が絡んでいます。

fish では string length $current_name のような形でコマンド置換ができません:x:
コマンド実行結果を set 等で変数に代入しておいた上で string length (echo $current_name) のような形で実行することは可能です:o:

コマンド実行時にわざわざ random 1 (echo $current_name_length) のような形で echo を挟んで値をセットしているのは上記が理由です。

他の部分の解説は大体ソースコード内にコメントを残してあるので省略します :bomb: :boom:

実行結果

スクリーンショット 2020-11-10 6.14.16.png
いきなり "馬" にされてしまった範馬勇次郎 :horse:

おまけ

テストで用いていたスクリプトは下記になります :pencil:

yubaba-test.fish
# yubaba.fish と同じディレクトリに配置して `fish yubaba-test.fish` を実行
set names テスト jhondoe 漢字 𠮷 🔨 \n
for val in $names
    echo $val | fish ./yubaba.fish
end

また、空白の例外処理については入れていないため、空白を入力したときの実行結果は下記になります :white_large_square: :arrow_double_down:

スクリーンショット 2020-11-10 6.23.31.png
虚無

おわりに

眠れなかったおかげで fish の書き方の勉強が出来てよかったです :raising_hand:

ただ眠くなるまでの暇つぶしのつもりで fish スクリプト & 記事を書いてたつもりが、ずっと PC ガン見してたせいで余計に目が冴えてきました :eye: :upside_down:
フルリモート環境とはいえ昼過ぎから MTG があるので一旦寝ときたいと思います (フラグ :bed: :zzz:

あと Qiita で fish のシンタックスハイライトが使えるようになりたい :sparkler:

参考リンク

Serverless のプラグインを TypeScript で作成する方法

2020年11月

はじめに

Serverless Framework を使っていて、度々デプロイ時に手動で設定していた作業内容を自動化したいなと思い、プラグイン作成の知識習得も兼ねてライブラリを作成し NPM で公開してみました。

serverless-amplify-auth 🔑

今後も開発する可能性はありそうなので Serverless のプラグインを TypeScript で作成する際の手順をまとめておきました。各手順はザックリと紹介しつつ、主にその過程でハマった点や工夫した点に重きをおいて記事を書いていきます。

動作環境

  • Node.js 12.19.0
  • Serverless Framework
    • Framework Core: 2.10.0
    • Plugin: 4.1.1
    • SDK: 2.3.2
    • Components: 3.3.0

TypeScript で Serverless Plugin を開発する環境を整える

本記事の内容を最後まで実践した際の最終的なプロジェクトのディレクトリ構造は下記になります。

tree -I node_modules -L 2 ./
./
├── example # ライブラリの動作検証用のサンプルコードを配置するフォルダ
│   ├── handler.js
│   ├── package.json
│   └── serverless.yml
├── lib     # src フォルダ内のファイルをコンパイルした結果を配置するフォルダ (ライブラリとして利用する際に含まれるソースコード群)
│   ├── index.js
│   └── index.js.map
├── package-lock.json
├── package.json
├── src     # Serverless プラグインのソースコードを配置するフォルダ
│   └── index.ts
└── tsconfig.json

基本的には TypeScriptでServerless FrameworkのPluginを書いてみる | Developers.IO の手順をなぞっていくだけで環境構築自体は可能です。そこで、ここでは自分なりに工夫した箇所について記載していきます。

まずは、開発に必要なパッケージを下記コマンドでまとめてインストールします。

# TypeScript の開発に必要なパッケージインストール
npm i -D typescript
# TypeScript の型定義ファイルのインストール
npm i -D @types/node @types/serverless
# 今回は AWS プロバイダー向けの開発を行うため SDK をインストールする
npm i --save aws-sdk

TypeScript のコンパイル時に必要となる tsconfig.json は下記のように設定しました。

tsconfig.json
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "moduleResolution": "node",
    "strict": true,
    "strictBindCallApply": false,
    "strictNullChecks": false,
    "outDir": "lib",
    "sourceMap": true
  },
  "include": [
    "src/**/*"
  ]
}

compilerOptions.strict には true を設定しつつ、compilerOptions.strictNullChecks 等には false を設定することで、部分的に TypeScript のコンパイルチェックを外すようにしました。

outDir には lib を指定することで、コンパイルされた TypeScript ファイルは lib フォルダに出力されるよう設定しました。

include には src/**/* を明示的に指定しており、src フォルダ内の全ファイルをコンパイル対象にしております。


package.json の内容は部分的に抜粋し、説明が必要そうな項目について説明いたします。
全容を把握したい方は こちら からご確認いただけます。

package.json
{
  "main": "lib/index.js",
  "files": [
    "lib"
  ],
  "scripts": {
    "build": "rm -rf lib && tsc",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

main には src/index.ts をコンパイルすると生成される lib/index.js を指定しました。そのため、ライブラリのエントリーポイントは lib/index.js が設定されます。

files には lib フォルダを指定することで、TypeScript をコンパイルした結果のみがライブラリのソースコードとして取り込まれるようになります。

TypeScript で Serverless Plugin の開発を進める

開発環境が整ったところで早速 Serverless Plugin のソースコードを書いていきます。TypeScript のソースコードは src/index.ts に配置します。

Serverless Plugin のプログラムを書く

src/index.ts
import * as Serverless from 'serverless'
import {
  SharedIniFileCredentials,
  config,
} from 'aws-sdk'
/**
 * serverless.yml の custom property の型定義
 */
interface Variables {
  value1: string
  value2: number
  value3: boolean
  profile?: string
}
export default class Plugin {
  serverless: Serverless
  options: Serverless.Options
  hooks: {
    [event: string]: () => Promise<void>
  }
  variables: Variables
  /**
   * プラグインの初期化関数。
   * 注意点として、初期化関数内では serverless.yml 内の変数展開が行われないので、
   * ${ssm:~} 等で設定した値を呼び出しても、適切に値が設定されない状態で呼び出すことになる。
   */
  constructor(serverless: Serverless, options: Serverless.Options) {
    this.serverless = serverless
    this.options = options
    /**
     * serverless.service.custom 内の特定プロパティを取得するための記述
     * 今回は Serverless のプラグイン名に serverless-typescript を設定したため、
     * serverless-typescript 文字列をキーとして指定する。
     */
    this.variables = serverless.service.custom['serverless-typescript']
    /**
     * プラグインがフックする関数を指定する。複数指定することも可能だが、
     * 今回は before:package:createDeploymentArtifacts を指定して、
     * パッケージングの手前の処理を定義した run 関数でフックする。
     */
    this.hooks = {
      'before:package:createDeploymentArtifacts': this.run.bind(this),
    }
  }
  /**
  * before:package:createDeploymentArtifacts 時に実行される関数
  */
  async run() {
    /**
    * プラグイン実行時に必要となるフィールドがセットされていなければ処理をスキップする
    */
    if (!this.variables) {
      this.serverless.cli.log(
        `serverless-typescript: Set the custom.serverless-typescript field to an appropriate value.`,
      )
      return
    }
    /**
     * this.serverless.getProvider 関数を用いることで、
     * デプロイ時のアカウントの各種情報について取得することが出来る
     */
    const awsProvider = this.serverless.getProvider('aws')
    const region = await awsProvider.getRegion()
    const accountId = await awsProvider.getAccountId()
    const stage = await awsProvider.getStage()
    /**
     * serverless.yml で指定した値や AWS 情報が取得できているか、
     * 確認するために標準出力する
     */
    this.serverless.cli.log(
      `serverless-typescript values: ${JSON.stringify({
        stage: stage,
        region: region,
        accountId: accountId,
        variables: this.variables,
      })}`,
    )
    /**
     * プラグイン内で処理を実行する際、別の特定 Profile を用いたい際は、
     * AWS SDK の SharedIniFileCredentials を用いて切り替えると楽に切替可能。
     * その際は process.env.AWS_SDK_LOAD_CONFIG に値を設定しておくこと
     */
    if (this.variables.profile) {
      process.env.AWS_SDK_LOAD_CONFIG = 'true'
      const credentials = new SharedIniFileCredentials({
        profile: this.variables.profile,
      })
      config.credentials = credentials
    }
  }
}
module.exports = Plugin

ソースコード内にいくつかコメントを残しましたが、何点か補足の説明をしていきます。

serverless.service.custom['serverless-typescript'] を呼び出すことで、serverless.yml 内の下記の記述内容を Object として取得できます。

serverless.yml
custom:
    # custom.serverless-typescript 内の定義を Object として取得可能
    serverless-typescript:
        value1: "value1"
        value2: 0
        value3: true
        # profile: default (optional)

this.hooks には必要に応じてフックを指定します。フックの書き方については 公式ドキュメント に詳細が記載されています。フックの種類については Gist でまとめてくださっている方がいました。

this.serverless.getProvider('aws') を用いることで、デプロイ時にアカウントの各種情報について取得することが出来ます。この記述を利用することで Serverless Pseudo Parameters のようなシンタックスを自身のプラグインに取り込むことが可能になります。
私が作成したプラグインでも serverless.ymlARN を構築する際に利用していて、index.ts 内で利用しました。

また、プラグイン内でデプロイ時とは異なる Profile を使用したいケースもあるかと存じます。それは AWS SDK の SharedIniFileCredentials を用いることで簡易に実装できました。1

それでは、次にプラグインの動作検証用コードを example フォルダに配置していきます。

Serverless Plugin の動作検証用プログラムを書く

example フォルダ内には検証用プロジェクトを作成するので、その前準備として example/package.json を作成します。

# package.json ファイルを作成する
cd example && npm init -y

example/package.json ファイルを作成したら開発用のスクリプトを example/package.json に追記します。

example/package.json
{
  "scripts": {
    "prestart": "cd ../ && npm run build",
    "start": "sls package",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

scripts 内の prestartstart スクリプト実行前に実行されるスクリプトです。npm start を実行すると prestart でプラグインの build タスクを実行した後、 Serverless Framework のパッケージングを行うことでプラグインの動作確認が行えます。2

次に動作検証用の serverless.ymlexample フォルダに配置します。

serverless.yml
service:
    name: serverless-typescript
    publish: false
# プラグイン内で利用する設定値を定義する
custom:
    serverless-typescript:
        value1: "value1"
        value2: 0
        value3: true
        profile: custom_profile
provider:
    name: aws
    runtime: nodejs12.x
    region: ap-northeast-1
# プラグインのパスを指定して読み込む
plugins:
    localPath: '../../'
    modules:
        - serverless-typescript
# 何でも良いので動作検証用の関数を定義する (関数の定義は後述)
functions:
    hello:
        handler: handler.hello

example フォルダ内に handler.js を配置して functions.hello.handler で用いる検証用の関数を定義します。

example/handler.js
'use strict';
// 検証用の関数。serverless.yml 内では handler.hello で参照可能
module.exports.hello = (event, context, callback) => {
  callback(null, {
    statusCode: 200,
    body: "Hello World!"
  });
};

上記作業が完了次第、cd example && npm start を実行して動作検証してみます。

cd example && npm start
> example@1.0.0 prestart /Users/nika/Desktop/serverless-typescript/example
> cd ../ && npm run build
> serverless-typescript@1.0.0 build /Users/nika/Desktop/serverless-typescript
> rm -rf lib && tsc
> example@1.0.0 start /Users/nika/Desktop/serverless-typescript/example
> sls package
Serverless: Configuration warning at 'service': unrecognized property 'publish'
Serverless:
Serverless: Learn more about configuration validation here: http://slss.io/configuration-validation
Serverless:
# src/index.ts 内の this.serverless.cli.log の出力内容
# 各種値が正常にセットされていることが確認出来る
Serverless: serverless-typescript values: {"stage":"dev","region":"ap-northeast-1","accountId":"XXXXXXXXXX","variables":{"value1":"value1","value2":0,"value3":true,"profile":"custom_profile"}}
Serverless: Packaging service...
Serverless: Excluding development dependencies...

標準出力にあるプラグイン内で出力したログから、適切に値が取得出来ていることが確認出来れば OK です。

Serverless プラグインの中で AWS Profile の切り替えが行えるか確認してみる

Serverless プラグインでの Profile の切り替えについて、動作検証がまだ出来ていないので確認していきます。

serverless.yml 内の custom.serverless-typescript.profile に設定箇所は既に用意してあるので、~/.aws/credentials に実在する Profile 名を指定します。

serverless.yml
custom:
    serverless-typescript:
        profile: <プラグイン実行時に使用したい Profile 名>

動作検証のため、src/index.ts 内にログ出力の記述を加えます。

src/index.ts
import * as Serverless from 'serverless'
import {
  SharedIniFileCredentials,
  config,
} from 'aws-sdk'
interface Variables {
  value1: string
  value2: number
  value3: boolean
  profile?: string
}
export default class Plugin {
  serverless: Serverless
  options: Serverless.Options
  hooks: {
    [event: string]: () => Promise<void>
  }
  variables: Variables
  constructor(serverless: Serverless, options: Serverless.Options) {
    this.serverless = serverless
    this.options = options
    this.variables = serverless.service.custom['serverless-typescript']
    this.hooks = {
      'before:package:createDeploymentArtifacts': this.run.bind(this),
    }
  }
  async run() {
    if (!this.variables) {
      this.serverless.cli.log(
        `serverless-typescript: Set the custom.serverless-typescript field to an appropriate value.`,
      )
      return
    }
    const awsProvider = this.serverless.getProvider('aws')
    const region = await awsProvider.getRegion()
    const accountId = await awsProvider.getAccountId()
    const stage = await awsProvider.getStage()
    this.serverless.cli.log(
      `serverless-typescript values: ${JSON.stringify({
        stage: stage,
        region: region,
        accountId: accountId,
        variables: this.variables,
      })}`,
    )
    if (this.variables.profile) {
      process.env.AWS_SDK_LOAD_CONFIG = 'true'
      const credentials = new SharedIniFileCredentials({
        profile: this.variables.profile,
      })
      config.credentials = credentials
      // Profile が切り替えられたか確認するためにログを出力する
      this.serverless.cli.log(`serverless-typescript profile: ${JSON.stringify(config.credentials)}`);
    }
  }
}
module.exports = Plugin

早速 cd example && npm start を実行して正常に profile が切り替えられていそうか確認してみます。

# 成功時の実行結果
cd example && npm start
# ...
# accessKeyId のフィールドに ~/.aws/credentials 内に存在する値が出力されている
Serverless: serverless-typescript profile: {"expired":false,"expireTime":null,"refreshCallbacks":[],"accessKeyId":"XXXXXXXXXXXXXX","profile":"XXXXXXXXXXXXXX","disableAssumeRole":false,"preferStaticCredentials":false,"tokenCodeFn":null,"httpOptions":null}
# ...

ちなみに存在しない Profile を指定した場合の出力は下記のようになります。

# 失敗時の実行結果
cd example && npm start
# ...
# accessKeyId のフィールドが存在しない時は Profile が正しく設定出来ていない
Serverless: serverless-typescript profile: {"expired":false,"expireTime":null,"refreshCallbacks":[],"profile":"custom_profile","disableAssumeRole":false,"preferStaticCredentials":false,"tokenCodeFn":null,"httpOptions":null}
# ...

おわりに

今回初めて Serverless プラグインの開発をしてみて、手軽に出来ることが分かったので自動化出来そうな作業は積極的にプラグイン化していきたいなと感じました。

プラグイン化した後は Git リポジトリにアップするだけでなく、NPM のパッケージGitHub Packages として公開しておくと、後々プラグインを利用する際に便利です。また、公開してライブラリのスタッツを見るのは案外楽しく開発のモチベーションにも繋がるのでオススメです。

参考リンク


  1. 注意点として、SharedIniFileCredentials を用いてプロファイルを切り替える時は、環境変数に AWS_SDK_LOAD_CONFIG="true" を設定する必要がありました。設定しないと ConfigError: Missing region in config というエラーが発生してしまい、プロファイルを切り替えることが出来ませんでした。。 

  2. 今回は Serverless の before:package:createDeploymentArtifacts フックを利用しているので、sls package コマンドで動作検証が可能となっている。before:deploy:deploy 等のデプロイ中に実行されるフックを利用する際は sls deploy --noDeploy コマンド等で動作検証を行う必要がある。 

Azure Functions の開発環境を NestJS で構築する方法

2020年10月

はじめに

本記事のソースコードは下記の GitHub リポジトリで公開しております。
https://github.com/nikaera/azure-nestjs-sample

PlayFabCloudScript 向けに Azure Functions の開発をすることになり、当初は .NET Azure Functions の採用を検討していました。

しかし、開発速度が求められる案件であり C# を書ける人材がいなくて Mac 使いが多く Node.js を使いたいとのことだったので、その中で良さそうな開発ツールを選定しました。

結果 NestJS の Azure Functions HTTP module を採用しようとなりました。
最終的には下記が決め手でした。

  1. Azure Functions へのアクセス手段として HTTP Trigger を使用する
  2. Webアプリケーション開発の感覚で Azure Functions の開発が可能である
  3. Azure Cosmos DB へは MongoDB API を用いれば TypeORM Module を用いて接続可能である
  4. ユニットテストや E2E テストを書くのが容易である

本記事では下記について記載していきます。

  • NestJS の導入から開発環境のセットアップ
  • Azure Cosmos DB を絡めた Azure Functions の開発からテスト環境の構築
  • GitHub Actions を用いた CI 環境の構築
  • Azure KeyVault を用いたシークレット情報の管理までの手順

動作環境

  • Azure Functions Core Tools
    • core 2.11.1
    • func 3.0.2931
  • Node.js 10.22.1
  • MongoDB 4.4.1
  • Docker 19.03.13
  • Docker Compose 1.27.4

Azure Functions Core Tools の導入

Azure Functions の開発をする上で、ローカルで動作確認を行ったり、CI 環境構築をするためにターミナルからデプロイ作業を済ませたくなる状況が発生します。

そのため、まずは上記のためのツールである Azure Functions Core Tools公式サイトの手順に従ってインストールします。本記事では v3.x 系を利用しています。

公式サイトの手順
公式サイトの Azure Functions Core Tools のインストール手順

NestJS のインストール及び Azure Functions HTTP module の導入

公式サイトを見る限り、現時点 (2020/10/23) では Node.js のバージョンとして 10.x もしくは 12.x を利用する必要があります。本記事では v10.22.1 を利用しました。

Node.js のバージョン 10.x または 12.x がインストールされていることを確認します。

何はともあれ NestJS では CLI を用いて開発していくので、まずは CLI ツールをグローバルインストールします。

npm install @nestjs/cli -g

インストールが完了したら、CLI 経由で NestJS プロジェクトを作成し、Azure Functions HTTP module のインポートまで行います。

# NestJS アプリケーションを作成する
nest new azure-sample
# NestJS アプリケーションに Azure Functions HTTP module をインポートする
cd azure-sample
nest add @nestjs/azure-func-http

無事にコマンドの実行に成功していれば、プロジェクトフォルダ内は下記の構造になっているはずです。

# tree コマンドでプロジェクトフォルダ構成を確認する
tree -I node_modules -L 2 ./
./
├── README.md
├── host.json
├── local.settings.json
├── main
│   ├── function.json
│   ├── index.ts
│   └── sample.dat
├── node_modules
├── nest-cli.json
├── package-lock.json
├── package.json
├── proxies.json
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   ├── main.azure.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json

これで Azure Functions の開発環境は整いました。
早速動作検証が可能な状態となっているか確かめてみましょう。

ローカル環境で Azure Functions の環境を起動する

src/app.controller.ts の中に既に getHello 関数が存在しているので、helloworld というパスでアクセス可能な状態にしてみます。

src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
  // @Get 内に helloworld と記載することで、
  // http://localhost:7071/api/helloworld のようなエンドポイントでアクセス可能になる
  @Get('helloworld')
  getHello(): string {
    return this.appService.getHello();
  }
}

上記改修が完了したら npm run start:azure コマンドを実行してみます。

コマンドの実行が成功すると http://localhost:7071/api/{*segments} で各種 API へアクセス可能になります。

早速先程アクセス可能にした API へ http://localhost:7071/api/helloworld でアクセスしてみましょう。

http://localhost:7071/api/helloworld のアクセス結果
http://localhost:7071/api/helloworld のアクセス結果

画面上に Hello World! の文字列が表示されていれば成功です。

NestJS で機能開発を行うための準備をする

本記事ではイベントを CRUD する機能を開発します。

NestJS では モジュール 単位で開発します。プログラムをモジュール単位で区切り、組み合わせることで、機能実装を進めていきます。 モジュールを区切る基準は開発者に委任されているため、採用するアーキテクチャによって異なります。

それでは早速 nest g module events コマンドでモジュールを新規作成します。

# モジュールを作成する
nest g module events
CREATE src/events/events.module.ts (82 bytes)
UPDATE src/app.module.ts (370 bytes)

すると、モジュールが新規作成されるのと同時に、そのモジュールを読み込むための記述が自動で src/app.module.ts に書き込まれます。

AppModule は NestJS でルートモジュールと呼ばれるもので、
アプリ起動時、最初に読み込まれるモジュールとなっております。

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// 新規追加された EventsModule を import で読み込む
import { EventsModule } from './events/events.module';
@Module({
  // ルートモジュールである AppModule から EventsModule を読み込む
  imports: [EventsModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

次に EventsModule の実体を作成するために コントローラー を作成します。NestJS のコントローラーは入出力の制御する役割を担います。 具体的にはルーティングやリクエストに応じたハンドリングを行います。

コントローラーもモジュールの時と同様 NestJS の nest g controller events コマンドで新規作成します。

# コントローラーを作成する
nest g controller events
CREATE src/events/events.controller.spec.ts (485 bytes)
CREATE src/events/events.controller.ts (99 bytes)
UPDATE src/events/events.module.ts (170 bytes)

*.spec.ts ファイルは生成したクラスのテストファイルになります。
NestJS はテストフレームワークに Jest を採用しています。

コントローラーが作成され、コントローラーを読み込むための記述が、自動で先程作成したモジュールに書き込まれました。このように NestJS では CLI でスキャフォールド (開発に必要なファイル群の生成) する際に同名パスを指定すると自動的にインポートの記述が追加されていきます。

src/events/events.module.ts
import { Module } from '@nestjs/common';
// 新規追加された EventsController を import で読み込む
import { EventsController } from './events.controller';
@Module({
  // EventsController を読み込むための記述が追加されている
  controllers: [EventsController]
})
export class EventsModule {}

次に、コントローラーに機能を実装していく前に、新たに プロバイダ を作成します。NestJS のプロバイダはいわゆるサービス層の役割を担うクラス全般を指します。 リポジトリやファクトリー、ヘルパー等を指します。

今回は EventsService というプロバイダを新規作成して、イベントの CRUD を行うための実装を書いていきます。コントローラーで EventsService を用いることで、CRUD の処理をコントローラー経由で実行可能にします。nest g service events コマンドでプロバイダを新規作成します。

# プロバイダ (サービス) を作成する
nest g service events
CREATE src/events/events.service.spec.ts (453 bytes)
CREATE src/events/events.service.ts (89 bytes)
UPDATE src/events/events.module.ts (247 bytes)

EventsService が作成され、EventsService を読み込むための記述が自動で EventsModule に書き込まれます。

src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
// 新規追加された EventsService を import で読み込む
import { EventsService } from './events.service';
@Module({
  controllers: [EventsController],
  // EventsService を読み込むための記述が追加されている
  providers: [EventsService]
})
export class EventsModule {}

これでイベントの CRUD 機能を Azure Functions の関数として実装していくための準備が整いました。

上述の通り、NestJS では nest コマンドを用いてスキャフォールド (開発に必要なファイル群の生成) した後に実装を進めるというフローを繰り返すのが一般的なフローとなっております。

Azure Cosmos DB の開発をローカルで行うための環境を構築する

イベントの CRUD 機能実装にあたって、今回はデータベースに Azure Cosmos DB を採用しますが、ローカルで開発する際は MongoDB を利用します。

そのため、まずは開発用に MongoDB の Docker イメージ を使用できるようにします。今後 E2E テストを行う際に Docker Compose を利用する想定のため、devenv/docker-compose.yml に MongoDB を利用する記述を書いていきます。

devenv/docker-compose.yml
version: '3.8'
services:
  mongodb:
    image: mongo:latest
    ports:
        - "27017:27017"
    environment:
        MONGO_INITDB_ROOT_USERNAME: azure-sample
        MONGO_INITDB_ROOT_PASSWORD: azure-sample
        MONGO_INITDB_DATABASE: azure-sample
        TZ: Asia/Tokyo

devenv/docker-compose.yml の記述が終わったら、下記コマンドで MongoDB のコンテナをデーモン状態で起動します。

# Docker Compose で MongoDB コンテナをデーモンで起動する
cd devenv
docker-compose up -d
# MongoDB のコンテナが起動できたか確認する (NAMES の欄に devenv_mongodb_1 が表示されていれば成功)
docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                      NAMES
f78f09cd4f5b        mongo:latest        "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:27017->27017/tcp   devenv_mongodb_1

無事起動できれば devenv_mongodb_1 という名前のコンテナが起動していることが docker ps コマンドで確認できるはずです。

これで MongoDB で開発する環境は整ったのですが、今はまだ使用しないので docker-compose down コマンドでコンテナを停止しておきます。

# Docker Compose で起動したコンテナを停止する
cd devenv
docker-compose down
Stopping devenv_mongodb_1 ... done
Removing devenv_mongodb_1 ... done
Removing network devenv_default

TypeORM を用いてイベントの CRUD 機能を実装する

それでは早速 TypeORM でイベントの CRUD 機能を実装していきます。

流れとして、まずはデータベース URI をコンフィグで管理できるようにした後、イベントのテーブルスキーマを定義していきます。その後、イベントの CRUD 機能を実装していきます。

NestJS の Configuration module でデータベースの接続情報を管理する

まずは TypeORM で MongoDB に接続するための情報を NestJS の Configuration module を使って管理できるようにします。

# Configuration module をインストールする
npm i --save @nestjs/config

Configuration module は内部的に dotenv を利用しています。そのため、記法は dotenv と同じになります。

.env
MONGODB_URI=mongodb://azure-sample:azure-sample@localhost:27017/azure-sample?authSource=admin

MongoDB への接続 URI を .env ファイルで MONGODB_URI として記載しました。NestJS で上記を読み込むための記述を src/app.module.ts に追記します。

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EventsModule } from './events/events.module';
@Module({
  imports: [
    // ConfigModule の記述を追加することで .env の変数を自動で読み込むようになる
    ConfigModule.forRoot(),
    EventsModule
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

ルートモジュールで読み込んでいるため、ConfigModule で読み込んだ .env ファイルに記載した変数は全てのモジュールで利用可能です。 試しに正常に読み込めていそうか EventsModuleconsole.log を追記して確かめてみます。

src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
  @Get('helloworld')
  getHello(): string {
    return this.appService.getHello();
  }
  // http://localhost:7071/api/database-uri にアクセスした時に変数の値を確認出来る API を追加
  @Get('database-uri')
  getDatabaseURI(): string {
    return process.env.MONGODB_URI;
  }
}
# Azure Functions のローカル環境を起動する
npm run start:azure

src/app.controller.ts を書き換えて、npm run start:azure した後に http://localhost:7071/api/database-uri へアクセスした結果は下記になるはずです。

http://localhost:7071/api/database-uri のアクセス結果
http://localhost:7071/api/database-uri のアクセス結果

無事に変数が読み込めていそうなことが確認できたら getDatabaseURI 関数は削除しておきましょう。本番環境でこの API が残っているとセキュリティ上問題があります。

これで TypeORM で MongoDB に接続するための情報を ConfigModule で管理できることが確認できました。

NestJS の TypeORM Module を導入してテーブルの定義を行う

次に NestJS の TypeORM Module をインストールします。また今回は MongoDB に接続するため Mongoose も同時にインストールしておきます。

TypeORM でサポートしているデータベースであれば、基本的に NestJS の TypeORM Module で利用可能です。たとえば MongoDB 以外にも PostgreSQL, MySQL, SQLite 等々がサポートされているようです。

# NestJS で TypeORM Module を Mongoose で利用するのに必要なライブラリをインストールする
npm i --save @nestjs/typeorm typeorm @nestjs/mongoose mongoose
# TypeScript で利用するため Mongoose の型情報を追加インストールする
npm i --save-dev @types/mongoose

TypeORM で MongoDB に接続するための記述からイベントテーブルの定義までを公式ページの手順に沿って進めます。まずは MongoDB への接続処理を src/app.module.ts に記載します。

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EventsModule } from './events/events.module';
@Module({
  imports: [
    ConfigModule.forRoot(),
    // MongooseModule を用いて MongoDB との接続を行う
    MongooseModule.forRoot(process.env.MONGODB_URI),
    EventsModule
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

イベントテーブルの定義を src/events/events.schema.ts に書いていきます。イベントテーブルには name 属性のみが存在します。

src/events/events.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
// イベントテーブルの定義
@Schema()
export class Event extends Document {
  // イベント名を保持する必須フィールドのみを持つ
  @Prop({ required: true })
  name: string;
}
export const EventSchema = SchemaFactory.createForClass(Event);

次にイベントテーブルの操作を扱うモジュール EventsModuleEventSchema を利用するための記述を追記します。

src/events/events.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { Event, EventSchema } from './events.schema'
@Module({
  // 先程定義した EventSchema を MongooseModule でインポートする
  // これで Controller や Provider で Event が利用可能になる
  // 今回は Provider である EventsService で利用する想定
  imports: [
    MongooseModule.forFeature([
      { name: Event.name, schema: EventSchema },
    ]),
  ],
  controllers: [EventsController],
  providers: [EventsService]
})
export class EventsModule {}

TypeORM でイベントの CRUD 機能を実装する

イベントテーブルを定義したクラス Event を用いて、まずは EventsService に CRUD を実装していきます。

src/events/events.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Event } from './events.schema'
@Injectable()
export class EventsService {
    // コンストラクターインジェクションで EventModule で import した Model<Event> を生成する
    constructor(
        // MongooseModule でインポートした場合は @nestjs/mongoose に用意されている
        // @InjectModel デコレータでインジェクション時に名前を定義する必要がある
        @InjectModel(Event.name)
        private eventModel: Model<Event>,
    ) {}
    // Event を作成する
    async create(name: string): Promise<Event> {
        const createdEvent = new this.eventModel({ name: name });
        return createdEvent.save();
    }
    // Id 指定した Event を読み込む
    async readOne(id: string): Promise<Event> {
        return this.eventModel.findById(id).exec();
    }
    // Event を全件読み込む
    async readAll(): Promise<Event[]> {
        return this.eventModel.find().exec();
    }
    // Id 指定した Event を更新する
    async update(id: string, name: string): Promise<Event> {
        // データ更新時に、更新後のデータを返却するためのオプションとして { new: true } を指定する
        // { new: true } を指定しないと更新前のデータが返却されるようになる
        return this.eventModel.findByIdAndUpdate(
            id, { name: name }, { new: true }
        ).exec();
    }
    // Id 指定した Event を削除する
    async delete(id: string): Promise<Event> {
        return this.eventModel.findByIdAndDelete(id).exec();
    }
}

次に EventsController を実装していきたいのですが、その前にリクエストやレスポンスのデータバリデーションも兼ねて、各種 DTO を作成します。

src/events/events.dto.ts
// POST events の際の Body Request 定義
export interface CreateRequest {
    name: string
}
// PATCH events/:id の際の Body Request 定義
export interface UpdateEventDto {
    name: string
}

リクエストパラメーターの DTO を作成したら EventsController を実装します。

src/events/events.controller.ts
import { Param, Body, Controller, Get, Post, Patch, Delete } from '@nestjs/common';
import { EventsService } from './events.service'
import { Event } from './events.schema'
import {
    CreateRequest,
    IdParams,
    UpdateRequest
} from './events.request'
@Controller('events')
export class EventsController {
    // コンストラクターインジェクションで EventsService を生成して EventsController で利用する
    constructor(private readonly eventsService: EventsService) {}
    // POST events へアクセス時に呼び出される関数
    @Post()
    async create(@Body() request: CreateEventDto): Promise<Event> {
        return this.eventsService.create(request.name)
    }
    // GET events/:id へアクセス時に呼び出される関数
    @Get(':id')
    async readOne(@Param('id') id: string): Promise<Event> {
        return this.eventsService.readOne(id)
    }
    // GET events へアクセス時に呼び出される関数
    @Get()
    async readAll(): Promise<Event[]> {
        return this.eventsService.readAll()
    }
    // PATCH events/:id へアクセス時に呼び出される関数
    @Patch(':id')
    async update(@Param('id') id: string, @Body() request: UpdateEventDto): Promise<Event> {
        return this.eventsService.update(id, request.name)
    }
    // DELETE events/:id へアクセス時に呼び出される関数
    @Delete(':id')
    async delete(@Param('id') id: string): Promise<Event> {
        return this.eventsService.delete(id)
    }
}

NestJS では events/:id のように定義した際、:id の内容は @Param デコレータを利用することで、引数から値を取得できます。

各種 API の正常系の動作検証を curl で行う

各種 API の正常系の動作確認を curl で行ってみます。

# 1. Azure Functions のローカル環境を起動する
npm run start:azure
# 2. Docker Compose で MongoDB インスタンスを起動する
cd devenv
docker-compose up -d
# 3. Event を 2つ作成する
curl -X POST -H "Content-Type: application/json" -d '{"name": "test-event-1"}' http://localhost:7071/api/events
{"_id":"5f945260a503fa9ceae111ae","name":"test-event-1","__v":0}
curl -X POST -H "Content-Type: application/json" -d '{"name": "test-event-2"}' http://localhost:7071/api/events
{"_id":"5f945278a503fa9ceae111af","name":"test-event-2","__v":0}
# 4. 特定の Event を読み込む
curl -X GET http://localhost:7071/api/events/5f945260a503fa9ceae111ae
{"_id":"5f945260a503fa9ceae111ae","name":"test-event-1","__v":0}
# 5. 登録されてる Event を全件読み込む
curl -X GET http://localhost:7071/api/events
[{"_id":"5f945260a503fa9ceae111ae","name":"test-event-1","__v":0},{"_id":"5f945278a503fa9ceae111af","name":"test-event-2","__v":0}]
# 6. 特定の Event を更新する
curl -X PATCH -H "Content-Type: application/json" -d '{"name": "test-event-3"}' http://localhost:7071/api/events/5f945260a503fa9ceae111ae
{"_id":"5f945260a503fa9ceae111ae","name":"test-event-3","__v":0}
# 6-1 特定の Event が更新されたかどうかを確認する
curl -X GET http://localhost:7071/api/events/5f945260a503fa9ceae111ae
# 先程は test-event-1 だった値が test-event-3 に更新されていることが確認できた
{"_id":"5f945260a503fa9ceae111ae","name":"test-event-3","__v":0}
# 7. 特定の Event を削除する
curl -X DELETE http://localhost:7071/api/events/5f945260a503fa9ceae111ae
{"_id":"5f945260a503fa9ceae111ae","name":"test-event-3","__v":0}
# 7-1 特定の Event が削除されたかどうかを確認する
curl -X GET http://localhost:7071/api/events
# イベント全件取得した際に 2件登録したうちの 1件しか返却されず、7. で削除した id を含むデータは返却されないことが確認できた
[{"_id":"5f945278a503fa9ceae111af","name":"test-event-2","__v":0}]
# 8. Docker Compose で MongoDB インスタンスを破棄する
cd devenv
docker-compose down

イベントの CRUD 機能の実装及び動作検証は完了しましたが、機能改修のたびにこれらの動作検証を手動で行うのは面倒なので、E2E テストを実装していきます。

また、E2E のテスト環境は Docker Compose で構築していきます。

イベントの CRUD 機能の E2E テストを実装する

それではイベントの CRUD API の正常系及び異常系の E2E テストを書いていきます。

NestJS では E2E テストを書く場所は test フォルダの中となっています。また、テスト実行時は環境変数を .env ではなく .env.test で管理するようにします。 ローカルでの検証環境とテスト環境でのコンフィグは分けておきたいからです。

.env.test
# 現状は .env と同じ内容を .env.test にも記載する
MONGODB_URI=mongodb://azure-sample:azure-sample@localhost:27017/azure-sample?authSource=admin
app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { EventsModule } from '../src/events/events.module';
import { AppController } from '../src/app.controller';
import { AppService } from '../src/app.service';
import { CreateRequest, UpdateRequest } from '../src/events/events.request';
import { Event } from '../src/events/events.schema'
describe('AppController (e2e)', () => {
  let app: INestApplication;
  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [
          // テスト用の dotenv ファイルである .env.test をコンフィグとして読み込む
        ConfigModule.forRoot({
          envFilePath: '.env.test',
        }),
        MongooseModule.forRoot(process.env.MONGODB_URI),
        EventsModule,
      ],
      controllers: [AppController],
      providers: [AppService],
    }).compile();
    app = moduleFixture.createNestApplication();
    await app.init();
  });
  // イベント全件取得のための関数
  const getEventAll = async (): Promise<Array<Event>> => {
    const res = await request(app.getHttpServer()).get('/events');
    expect(res.status).toEqual(200);
    return res.body as Array<Event>;
  }
  describe('EventsController (e2e)', () => {
    // イベントの Create API に関するテスト
    describe('Create API of Event', () => {
      // API の実行に成功する
      it('OK /events (POST)', async () => {
        const body: CreateRequest = {
          name: "test-event"
        }
        const res = await request(app.getHttpServer())
          .post('/events')
          .set('Accept', 'application/json')
          .send(body);
        expect(res.status).toEqual(201);
        const eventResponse = res.body as Event;
        expect(eventResponse).toHaveProperty('_id');
        expect(eventResponse.name).toEqual(body.name);
      });
      // 不正なパラメタで API の実行に失敗する
      it('NG /events (POST): Incorrect parameters', async () => {
        const body = {
          namee: "test-event"
        }
        const res = await request(app.getHttpServer())
          .post('/events')
          .set('Accept', 'application/json')
          .send(body);
        expect(res.status).toEqual(400);
      });
      // 空のパラメタで API の実行に失敗する
      it('NG /events (POST): Empty parameters.', async () => {
        const body = {}
        const res = await request(app.getHttpServer())
          .post('/events')
          .set('Accept', 'application/json')
          .send(body);
        expect(res.status).toEqual(400);
      });
    });
    // イベントの Read API に関するテスト
    describe('Read API of Event', () => {
      // API の実行に成功する
      it('OK /events (GET)', async () => {
        const eventsResponse = await getEventAll();
        expect(eventsResponse.length).toEqual(1);
      });
      // API の実行に成功する
      it('OK /events/:id (GET)', async () => {
        const eventsResponse = await getEventAll();
        const res = await request(app.getHttpServer())
          .get(`/events/${eventsResponse[0]._id}`);
        expect(res.status).toEqual(200);
        const eventResponse = res.body as Event;
        expect(eventResponse).toHaveProperty('_id');
        expect(eventResponse.name).toEqual('test-event');
      });
      // 不正な id で API の実行に失敗する
      it('NG /events/:id (GET): Invalid id.', async () => {
        const res = await request(app.getHttpServer())
          .get('/events/XXXXXXXXXXX');
        expect(res.status).toEqual(400);
      });
      // 存在しない id で API の実行に失敗する
      it('NG /events/:id (GET): id that doesn\'t exist.', async () => {
        const res = await request(app.getHttpServer())
          .get('/events/5349b4ddd2781d08c09890f4');
        expect(res.status).toEqual(404);
      });
    });
    // イベントの Update API に関するテスト
    describe('Update API of Event', () => {
        // API の実行に成功する
        it('OK /events/:id (PATCH)', async () => {
          const eventsResponse = await getEventAll();
          const body: UpdateRequest = {
            name: "new-test-event"
          }
          const res = await request(app.getHttpServer())
            .patch(`/events/${eventsResponse[0]._id}`)
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(200);
          const eventResponse = res.body as Event;
          expect(eventResponse).toHaveProperty('_id');
          expect(eventResponse.name).toEqual(body.name);
        });
        // 不正な id で API の実行に失敗する
        it('NG /events/:id (PATCH): Incorrect parameters', async () => {
          const eventsResponse = await getEventAll();
          const body = {
            namee: "new-test-event"
          }
          const res = await request(app.getHttpServer())
            .patch(`/events/${eventsResponse[0]._id}`)
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(400);
        });
        // 空のパラメタで API の実行に失敗する
        it('NG /events/:id (PATCH): Empty parameters.', async () => {
          const eventsResponse = await getEventAll();
          const body = {}
          const res = await request(app.getHttpServer())
            .patch(`/events/${eventsResponse[0]._id}`)
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(400);
        });
        // 空の id で API の実行に失敗する
        it('NG /events/:id (PATCH): Empty id.', async () => {
          const eventsResponse = await getEventAll();
          const body = {
            namee: "new-test-event"
          }
          const res = await request(app.getHttpServer())
            .patch('/events')
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(404);
        });
        // 不正な id で API の実行に失敗する
        it('NG /events/:id (PATCH): Invalid id.', async () => {
          const body: UpdateRequest = {
            name: "new-test-event"
          }
          const res = await request(app.getHttpServer())
            .patch('/events/XXXXXXXXXXX')
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(400);
        });
        // 存在しない id で API の実行に失敗する
        it('NG /events/:id (PATCH): id that doesn\'t exist.', async () => {
          const body = {}
          const res = await request(app.getHttpServer())
            .patch('/events/5349b4ddd2781d08c09890f4')
            .set('Accept', 'application/json')
            .send(body);
          expect(res.status).toEqual(404);
        });
    });
    // イベントの Delete API に関するテスト
    describe('Delete API of Event', () => {
        // API の実行に成功する
        it('OK /events/:id (DELETE)', async () => {
          const eventsResponse = await getEventAll();
          const res = await request(app.getHttpServer())
            .delete(`/events/${eventsResponse[0]._id}`);
          expect(res.status).toEqual(200);
        });
        // 空の id で API の実行に失敗する
        it('NG /events/:id (DELETE): Empty id.', async () => {
          const res = await request(app.getHttpServer())
            .delete('/events')
          expect(res.status).toEqual(404);
        });
         // 不正な id で API の実行に失敗する
        it('NG /events/:id (DELETE): Invalid id.', async () => {
          const res = await request(app.getHttpServer())
            .delete('/events/XXXXXXXXXXX')
          expect(res.status).toEqual(400);
        });
        // 存在しない id で API の実行に失敗する
        it('NG /events/:id (DELETE): id that doesn\'t exist.', async () => {
          const res = await request(app.getHttpServer())
            .delete('/events/5349b4ddd2781d08c09890f4');
          expect(res.status).toEqual(404);
        });
    });
  })
  // 各テスト実行後は app を破棄する (DB コネクションの解放を行わないと、テストを完了することが出来ない)
  afterEach(async () => {
    await app.close();
  });
});

上記内容で E2E テストを実行してみます。E2E テストには理想の結果を書いたので、正常系は手動で確認したとき同様成功するはずですが、異常系のテストはほとんど失敗しているはずです。

# 1. Docker Compose で MongoDB を起動する
cd devenv
docker-compose up -d
# 2. E2E テストを実行する
npm run test:e2e
# 3. Docker Compose で MongoDB の破棄する
cd devenv
docker-compose down

E2E テストの実行結果
E2E テストの実行結果 (失敗)

E2E テストが通るようにイベントの CRUD 機能を改修する

テストを通すために、例外処理周りの記述を追記していく際、NestJS の Exception filters を利用していきます。NestJS の Exception filters を利用することで簡易に例外処理を実装することが可能になると同時に、適切な範囲でそれらの適用範囲を設定することが可能になります。

今回は主に Mongoose 周りのエラーをフィルタリングしていきたいので Mongoose に関する ExceptionFilter を作成します。

src/mongoose.exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Error } from 'mongoose';
@Catch(Error)
export class MongooseExceptionFilter implements ExceptionFilter {
  catch(exception: Error, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse();
    switch(exception.name) {
      // Mongoose の検証エラーが発生したら HTTP BadRequest エラーを返却する
      case Error.ValidationError.name:
      case Error.CastError.name:
        response.status(HttpStatus.BAD_REQUEST).json(null);
        break;
      // Mongoose でデータが見つからなかった時に HTTP NotFound エラーを返却する
      case Error.DocumentNotFoundError.name:
        response.status(HttpStatus.NOT_FOUND).json(null);
        break;
    }
  }
}

EventsController に先程作成した MongooseExceptionFilter を適用します。これにより、Mongo DB クエリの例外処理をコントローラー全体に一括で設定可能です。

src/events/events.controller.ts
import { Param, Body, Controller, Get, Post, Patch, Delete, UseFilters } from '@nestjs/common';
import { EventsService } from './events.service'
import { Event } from './events.schema'
import {
    CreateEventDto,
    UpdateEventDto
} from './events.dto'
import { MongooseExceptionFilter } from '../mongoose.exception.filter';
@Controller('events')
// 関数全てに適用したいので class 定義の上で @UseFilters デコレータを用いて
// コントローラーの関数全てに MongooseExceptionFilter を適用する
@UseFilters(MongooseExceptionFilter)
export class EventsController {
    constructor(private readonly eventsService: EventsService) {}
    @Post()
    async create(@Body() request: CreateEventDto): Promise<Event> {
        return this.eventsService.create(request.name)
    }
    @Get(':id')
    async readOne(@Param('id') id: string): Promise<Event> {
        return this.eventsService.readOne(id)
    }
    @Get()
    async readAll(): Promise<Event[]> {
        return this.eventsService.readAll()
    }
    @Patch(':id')
    async update(@Param('id') id: string, @Body() request: UpdateEventDto): Promise<Event> {
        return this.eventsService.update(id, request.name)
    }
    @Delete(':id')
    async delete(@Param('id') id: string): Promise<Event> {
        return this.eventsService.delete(id)
    }
}

また Update 時に name の null チェックを行いたいので EventsService を改修します。null 時は明示的に Mongoose の Error を作成して throw することで、例外を MongooseExceptionFilter に補足させます。

src/events/events.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Error } from 'mongoose';
import { Event } from './events.schema'
@Injectable()
export class EventsService {
    constructor(
        @InjectModel(Event.name)
        private eventModel: Model<Event>,
    ) {}
    async create(name: string): Promise<Event> {
        const createdEvent = new this.eventModel({ name: name });
        return createdEvent.save();
    }
    async readOne(id: string): Promise<Event> {
        return this.eventModel.findById(id).orFail().exec();
    }
    async readAll(): Promise<Event[]> {
        return this.eventModel.find().exec();
    }
    async update(id: string, name: string): Promise<Event> {
        // name の null チェックをおこなう
        // https://github.com/Automattic/mongoose/issues/6161#issuecomment-368242099
        if(name == null) {
            const validationError = new Error.ValidationError(null);
            validationError.addError('docField', new Error.ValidatorError({ message: 'Empty name.' }));
            throw validationError;
        }
        return this.eventModel.findByIdAndUpdate(
            id, { name: name }, { new: true }
        ).orFail().exec();
    }
    async delete(id: string): Promise<Event> {
        return this.eventModel.findByIdAndDelete(id).orFail().exec();
    }
}

再度 E2E テストを実行してみます。今度は無事 E2E テストが全て通ることを確認できるはずです。

# 1. Docker Compose で MongoDB を起動する
cd devenv
docker-compose up -d
# 2. E2E テストを実行する
npm run test:e2e
# 3. Docker Compose で MongoDB の破棄する
cd devenv
docker-compose down

E2E テストの実行結果
E2E テストの実行結果 (成功)

しかし、現状だと手動で Docker Compose で MongoDB を起動して閉じるフローが挟まっているため、E2E テストを走らせるためには手動でいくつかの作業が必要な状況です。

流石に E2E テストの実行が面倒な上、CI で走らせることが出来ないため NestJS アプリケーション自体も Docker Compose に乗せていきます。それにより、自動で MongoDB と NestJS アプリケーションを同時に起動して E2E テストが実行できる環境を構築します。

Docker Compose で E2E テストを実行可能にする

まずは Docker Compose に NestJS アプリケーションを乗せるため、Dockerfile を書いていきます。また、不要なファイルを Docker イメージに含めたくないため .dockerignore も書いていきます。

Dockerfile
# Azure Functions で利用している Docker イメージを使用する
FROM mcr.microsoft.com/azure-functions/node:2.0-node10
# Docker イメージのビルドキャッシュに node_modules を含めるための記述
WORKDIR /azure-sample
COPY package*.json /azure-sample/
RUN npm install
# NestJS アプリケーションのコード群を Docker イメージに追加する
ADD ./ /azure-sample
# Docker コンテナ起動時にデフォで E2E テストを実行する
CMD npm run test:e2e
.dockerignore
# Ref: https://www.toptal.com/developers/gitignore?templates=node
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
README.md
.env*
local.settings.json
coverage
dist

次に devenv/docker-compose.yml を改修して、NestJS アプリケーションを定義します。

devenv/docker-compose.yml
version: '3.8'
services:
    # NestJS アプリケーションの定義を追加
    app:
        build: ../.
        env_file:
            - ../.env.test
        links:
            - mongodb
    mongodb:
        image: mongo:latest
        ports:
            - "27017:27017"
        environment:
            MONGO_INITDB_ROOT_USERNAME: azure-sample
            MONGO_INITDB_ROOT_PASSWORD: azure-sample
            MONGO_INITDB_DATABASE: azure-sample
            TZ: Asia/Tokyo

また E2E テストはこれから Docker Compose で回したいので、.env.test は Docker Compose で回す前提で改修します。

.env.test
# localhost の部分を docker-compose.yml で定義した mongodb に変更する
# Docker Compose 上でしか E2E テストの実行が出来なくなるので注意する
MONGODB_URI=mongodb://azure-sample:azure-sample@mongodb:27017/azure-sample?authSource=admin

この状態で Docker Compose を起動すると、MongoDB の起動後、NestJS アプリケーションの E2E テストが走るようになっているはずです。

それでは、試しに Docker Compose を起動してみましょう。

今回起動時に --abort-on-container-exit オプションを追加したのは、NestJS アプリケーションのテストが終了した時点で Docker Compose を落としたいからです。

# NestJS アプリケーションで E2E テストが完了してコンテナが終了したら Mongo DB コンテナも含めて Docker 停止させたいので --abort-on-container-exit オプションを指定する
# そうしないと明示的に停止しない限り docker-compose コマンドが終了しなくなる (テストが終了したと同時にコマンドも終了してほしい)
cd devenv
docker-compose up --abort-on-container-exit

実行した際に下記の標準出力が確認できたら OK です。

Docker Compose での E2E テストの実行結果
Docker Compose での E2E テストの実行結果 (成功)

GitHub Actions で E2E テストを実行する

本記事の内容を採用したプロジェクトではソースコード管理を GitHub で行っていたので、CI 環境として GitHub Actions を採用していました。

そのため、今回は GitHub Actions で Docker Compose の E2E テストを実行できるようにしていきます。

.github/workflows/build-and-test.yml
# Pull Request が更新されるたびに走らせる
on:
  pull_request:
    types: [opened, synchronize]
jobs:
  build-and-test:
    name: Build and Test
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [ '10.22.1' ]
    steps:
    # Pull Request 出された branch の最新の commit のソースコードを使用する
    - name: checkout pushed commit
      uses: actions/checkout@v2
      with:
        ref: ${{ github.event.pull_request.head.sha }}
    - name: Use Node.js ${{ matrix.node }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node }}
    # Node.js のビルドが通るか検証する
    - name: npm install, build.
      run: |
        npm install
        npm run build --if-present
    # E2E テストを Docker Compose で実行する
    - name: run test on docker-compose
      run: |
        docker-compose build
        docker-compose up --abort-on-container-exit
      working-directory: ./devenv

上記ファイル作成後、適当にブランチを切ってコミットしてからリモートリポジトリに push して PR を出すと、下記のように GitHub Actions が動いていることが確認できるはずです。

GitHub Actions で E2E テストが動いているか確認する
GitHub Actions で E2E テストが動いているか確認する

Azure Portal で Azure Funtions のデプロイ先を作成する

既に Azure アカウントは作成済み前提で進めていきます。まず Azure Portal のトップページにアクセスして、Azure Functions (関数アプリ) のページに遷移してから下記の作業します。

  1. 関数アプリの新規作成ページ から関数アプリを作成する
    1. 関数アプリの新規作成ページから関数アプリを作成する

  2. 関数アプリの作成が成功して、各種リソースがデプロイされたことを確認する
    2. 関数アプリの作成が成功して各種リソースがデプロイされたことを確認する

Azure Functions の環境をセットアップする

Azure Cosmos DB アカウントを作成した後、MONGODB_URI をシークレットとして Azure KeyVault に設定します。Azure KeyVault で設定した値を Azure Functinos (関数アプリ) に環境変数としてセットして利用可能にします。

Azure Cosmos DB アカウントを作成する

  1. Azure Cosmos DB アカウントの作成ページからアカウント作成する。API には MongoDB~ を選択する。
    1. Azure Cosmos DB アカウントの作成ページから Azure Cosmos DB アカウントを作成する。API には `MongoDB~` を選択する

  2. 作成した Azure Cosmos DB アカウントのプライマリ接続文字列 (MongoDB URI) を取得して控えておく
    2. Azure Cosmos DB の MongoDB URI を取得して控えておく

プライマリ接続文字列を Azure KeyVault でシークレットに登録する

Azure Cosmos DB のプライマリ接続文字列はセキュアな情報なので Azure KeyVault (キーコンテナー) で管理します。

  1. キーコンテナーの作成ページからキーコンテナーを作成する
    1. キーコンテナーの作成ページからキーコンテナーを作成する

    1. で作成したキーコンテナーのシークレット登録画面に遷移する 2. プライマリ文字列を登録するため、1. で作成したキーコンテナーのシークレット登録画面に遷移する
  2. 控えておいた Azure Cosmos DB のプライマリ文字列 (MongoDB URI) をシークレットに登録する
    3. 控えておいた Azure Cosmos DB のプライマリ文字列 (MongoDB URI) をシークレットに登録する

  3. 後に関数アプリで値を設定するため、登録したシークレットの識別子を控えておく
    4. 後に関数アプリで値を設定するため、登録したシークレットの識別子を控えておく

Azure Functions から Azure KeyVault が参照できるようにする

Azure Functions (関数アプリ) では、Azure KeyVault の値を環境変数の設定が可能です。公式ページの手順に沿って設定作業を進めていきます。

  1. 関数アプリのシステム割り当て済みの状態をオンにする
    1. 関数アプリのシステム割り当て済みの状態をオンにする

  2. キーコンテナーの一覧ページ から該当するキーコンテナーのアクセスポリシー追加画面に遷移する
    2. キーコンテナーの一覧ページから該当するキーコンテナーのアクセスポリシー追加画面に遷移する

  3. 該当する関数アプリにアクセスポリシーを設定する
    3. 該当する関数アプリにアクセスポリシーを設定する

アクセスポリシー設定時に 承認されているアプリケーション の設定項目がありますが、ここには何も設定しないでください。設定してしまうと関数アプリから KeyVault のアプリケーション設定が読み込めないためです。

Azure KeyVault に登録した値を Azure Functions から参照する

  1. 該当する関数アプリのアプリケーション設定追加画面に遷移する。
    キーコンテナーのシークレットをアプリケーション設定から追加する際、値のフォーマットは @Microsoft.KeyVault(SecretUri=<KeyVault で値を設定した時に取得可能なシークレット識別子>) になります。
    1. 関数アプリのアプリケーション設定追加画面に遷移する

  2. 該当する関数アプリのアプリケーション設定にキーコンテナーのシークレットを追加する
    2. 関数アプリのアプリケーション設定に KeyVault のシークレットを追加する

  3. 該当する関数アプリのアプリケーション設定の変更内容を関数アプリに反映させる
    3. 該当する関数アプリのアプリケーション設定の変更内容を関数アプリに反映させる

GitHub Actions で Azure Functions にデプロイする

Azure Functions へのデプロイも GitHub Actions で行えるようにするため、GitHub Actions から Azure Functions へのデプロイ時に必要になる関数アプリの発行プロファイルを下記からダウンロードします。

関数アプリの発行プロファイルを取得する
関数アプリの発行プロファイルをダウンロードする

発行プロファイルを取得したら、ダウンロードしたファイル内容をコピー&ペーストで GitHub の Secrets に登録します。

2. GitHub リポジトリの Secrets の登録画面に遷移する
2. GitHub リポジトリの Secrets の登録画面に遷移する

3. GitHub リポジトリの Secrets に発行プロファイルを登録する
3. GitHub リポジトリの Secrets に発行プロファイルを登録する

上記が完了したら Azure Functions へデプロイするための GitHub Actions のワークフローファイル .github/workflows/deploy-azure.yml を作成して、main ブランチに push します。

.github/workflows/deploy-azure.yml
# main ブランチに何らかの commit が追加されたら走らせる
on:
  push:
    branches:
      - main
env:
  AZURE_FUNCTIONAPP_NAME: azure-nestjs-sample
  AZURE_FUNCTIONAPP_PACKAGE_PATH: '.'
  NODE_VERSION: '10.x'
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
    - name: 'Checkout GitHub Action'
      uses: actions/checkout@master
    - name: Setup Node ${{ env.NODE_VERSION }} Environment
      uses: actions/setup-node@v1
      with:
        node-version: ${{ env.NODE_VERSION }}
    - name: 'Resolve Project Dependencies Using Npm'
      shell: bash
      run: |
        pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}'
        npm install
        npm run build --if-present
        popd
    - name: 'Run Azure Functions Action'
      uses: Azure/functions-action@v1
      id: fa
      with:
        app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
        package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
        publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
# For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples

main ブランチに push 後、実際に GitHub Actions が動作しているか Actions タブから確認します。

main ブランチを更新した後、GitHub Actions を確認する
main ブランチが更新された後、GitHub Actions を確認する

関数アプリのアプリケーション設定に WEBSITE_RUN_FROM_PACKAGE が設定されていると、デプロイに失敗する時があります。失敗したらアプリケーション設定から WEBSITE_RUN_FROM_PACKAGE を削除して再度 GitHub Actions を実行してみてください。

最後にデプロイした先の API が正常に動作していそうか curl で確認してみます。デフォルトでは HTTP Trigger の authLevelanonymouse になっているので URL 直打ちでアクセス可能です。

PlayFab の CloudScript から Azure Functions を実行する際の authLevel は function が推奨されています。また、CloudScript から Azure Functions を呼び出す際は必ず POST Method で HTTP リクエストされます。

# POST events でデータ登録出来るか確認してみる
curl -X POST -H "Content-Type: application/json" -d '{"name": "test-event"}' https://azure-nestjs-sample.azurewebsites.net/api/events
{"_id":"5f963d6d2867990052b9bac8","name":"test-event","__v":0}

Azure Cosmos DB にデータが正常に登録されていることを確認する
Azure Cosmos DB にデータが正常に登録されていることを確認する

無事にデータが登録されていることが確認出来れば OK です。

おわりに

今回は NestJS を用いて Azure Functions の開発手順についてまとめました。他にも Open API にも対応したのですが、どこかのタイミングでそれらの記事も追加したいと考えております。

Azure 関連のサービスも触るのは初めてだったのですが、ドキュメントを参照しながら進めれば、特に引っかかる箇所無く環境構築できました。

記事内容について、誤りや改善点等ございましたらご指摘頂けますと大変ありがたいです。

最後までお読みいただきありがとうございました。

参考リンク

Android Studio 4.1 RC1で公式日本語パックの導入と文字化けを解消する

2020年8月

はじめに

Android Studio 4.1系からIntelliJ IDEAのベースバージョンが2020.2に上がったので公式の日本語パックを導入できるようになりました。ですがランタイムが古く文字化けしてしまうので解消します。

image.png

手順とランタイムの最新バージョンは以下のリンクで確認できます。

ランタイムを変更する

  1. PluginsのMarketplaceタブからChoose Runtimeをインストールする。
    image.png
  2. Ctrl + Shift + AでFind Actionを開いてchooseを入力して絞り込む。
    image.png
  3. Choose Runtimeを開いて最新版のJetBrains Runtimeを選択する。
    • 現時点で1035.1が最新でした。
      image.png
  4. Android Studioを再起動する。

日本語パックをインストールする

  • PluginsのMarketplaceタブからJapanese Language Packをインストールする。
    image.png
  • Android Studioを再起動する。

以上で日本語パックを導入できます。
image.png

AppSync の DynamoDB リゾルバの Scan で意図したデータが取得出来なくなった時の対処法

2020年8月

はじめに

開発中盤で AppSync で開発している最中に追加したデータが、
突然 List Query で取得出来ない事態が発生しました... :scream_cat:

調査したところ、
AppSync のデータソースで DynamoDB のデータソース生成した際に GraphQL を自動生成したのですが、
そこにヒントがあることを発見しました:bulb:

結局の所、DynamoDB への理解不足で片付く話なのですが、、
何となく AppSync を使っていると遭遇してしまう問題だと思います... :punch:

そこで、同じ問題で悩む人を助けられればと思い、
記事に残しておくことにしました :writing_hand:

DynamoDB に登録されているはずのデータが AppSync の List Query で取得したデータに含まれない事象が発生することがある

AppSync で開発を開始した直後や、
登録データ数が少ない間は List Query の filter を用いてデータの絞り込みを行いつつ、
正常に意図したデータが取得出来るはずです :arrow_down:
4a96c5227343327499ec4c380f65b661.png
8b41f67abe2fc998ca2d6e97925d87ec.png

しかし、ある日 DynamoDB にデータが登録されているのに List Query の filter で、
正常に意図したデータが取れない事象が特定の List Query で発生するようになってしまいました... :upside_down:

20b80b348009f35572b930aeb5bbdeb2.png
5492af978c4e56f29e6f8234d999344d.png

DynamoDB には channel_id42713673-eb21-43ef-bdbc-f460989fb505 でデータが 1件登録されていることは確認済みなのに AppSync の List Query では 1件も取得できないのは何故でしょうか。。??:thinking:

DynamoDB データソース追加時に自動生成されたリゾルバを確認してみる

DynamoDB のデータソース登録時に自動で GraphQL 生成にチェックを入れていれば、
AppSync の List Query は自動生成され DynamoDB リゾルバが登録されています :white_check_mark:

原因調査のため AppSync の裏の DynamoDB リゾルバが何を行っているか中を覗いてみます :mag:

9a7d6dcbfb03aab6abfaf21cdf91a20a.png
3a91f0e79dcd05153f6e52543975dcda.png

:arrow_up: を見ると、Scan を用いて filter で検索条件を絞って limit で指定した件数のデータが取得出来るようになっているように見受けられます :mag:

DynamoDB の Scan の仕様を確認する

次に DynamoDB の公式ドキュメント を見て Scan の仕様について確認していきます。
早速公式ドキュメントのトップに Scan についての説明が記載されていました :pencil:

Amazon DynamoDB での Scan オペレーションは、テーブルまたは セカンダリインデックス のすべての項目を読み込みます。デフォルトでは、Scan オペレーションはテーブルまたはインデックスのすべての項目のデータ属性を返します。

:arrow_up: を見ると Scan はデフォでレコードを全件取得するような挙動となるようです。

結果セットの項目数の制限

ドキュメントを読み進めていくと、
次に 結果セットの項目数の制限 の項目が目に止まりました :eyes:

ここで、Scan にフィルタ式を追加するとします。この場合、DynamoDB は返される 6 つの項目にフィルタ式を適用し、一致しない項目を廃棄します。最終的な Scan 結果はフィルタリングされる項目の数に応じて、6 つ以下の項目を含みます。

:arrow_up: を見ると Scanfilter を使用した際の挙動として 一致しない項目を廃棄します とあります :wastebasket:
AppSync の DynamoDB リゾルバを確認した際、Scanlimit はデフォでは 20 でした。

つまり、正常にデータが取得出来なかった AppSync の List Query を元に、
実際に実行された DynamoDB の Scan の挙動を推測すると、
データを 20件取得した中に該当する channel_id のデータが見つからなかったので空を返した
という挙動になりそうです :pencil:

RDBMS の SQL の Where 句のような、
該当する channel_id のデータを 20件まで検索して取得する
という挙動を想定していましたが、それがそもそもの誤りだったようです... :upside_down:

スキャンの読み込み整合性

更にドキュメントを読み進めていくと スキャンの読み込み整合性 という項目も発見しました :eyes:

Scan オペレーションは、結果的に整合性のある読み込みをデフォルトで行います。つまり、Scan 結果が、最近完了した PutItem または UpdateItem オペレーションによる変更を反映しない場合があります。詳細については、「Read Consistency」を参照してください。

強力な整合性のある読み込みが必要な場合は、Scan が開始する時に ConsistentRead パラメータを true リクエストで Scan に設定できます。これにより、Scan が開始する前に完了した書き込みオペレーションがすべて Scan 応答に含められます。

:arrow_up: を見ると RDBMS では保証されている一貫性は無いので、
Scan を実行するタイミングによって取得可能なデータは変動する可能性がある
ということを Scan を用いたデータ取得を行う際は考慮する必要がありそうです :pencil:

対策方法

まず、AppSync の limit がデフォで 20 なので GraphQL クエリの limit100 等の、
一度の Query で filter で絞り込むデータが十分に取得出来そうな数に設定しておきます1 :printer:

8bbf374ac035a470852cad3f425466ab.png

また、Web アプリケーション等でよく使用される、画面最下部までスクロールした際にロードを行う処理の実装等を考慮した場合、nextToken を用いたページネーションの実装も考慮しておく必要があります :white_check_mark:

d3e7bd3b6c78b2c47f1693baa83fca03.png
6e5f9b851e6d2af8b0e83b716f3a6cc3.png

:arrow_up: の流れで List Query を実行するようにしておけば、
画面最下部までスクロールした際のロード処理が実装可能です :thumbsup:

おわりに

結果 AppSync というよりも DynamoDB の仕様にフォーカスに当たった記事内容になりました :writing_hand:

AppSync の DynamoDB リゾルバを雰囲気で扱っていたため起きた問題で、
ステージング環境で実際にデータがある程度追加されてきてから問題が発覚したため、
個人でデバッグしているときには全く気づくことが出来ませんでした... :bomb: :boom:

また保守運用の観点から予め DynamoDB の設計についても考慮しておいたほうが良いと思われます :raised_hand:
公式ドキュメントに DynamoDB の設計のベストプラクティス についての解説ページがあるので読んでおいた方が良さそうです :book:

以上 AppSync を用いる際に DynamoDB リゾルバを自動生成して利用する際は十分にご注意ください :bow:

参考リンク


  1. 大量のデータが登録されている状況であれば Scan の Query から limit は無くしてしまい、一度の Scan で最大の 1.0MBまでのデータを取得するようにして良いかも知れません 

Serverless Framework でエラーを検知して Webhook で Slack に通知を飛ばす方法

2020年8月

はじめに

AppSync の Lambda リゾルバを書く際に Serverless Framework を使用したのですが、
デプロイ後のバグ調査の際、毎回ブラウザから AWS Console を開いて該当 Lambda の CloudWatch のログを見に行くのが面倒でした。。 :upside_down:

そのため、エラーレポートの仕組みが欲しくなり、Lambda のエラーを Slack に通知する仕組みを Serverless Framework で実装する方法について調査したので、備忘録も兼ねて記事にまとめました :writing_hand:

追記 (2020/08/19)

ローカルから Lambda 関数のログを確認したいだけなら、Serverless Framework CLI の logs コマンド もしくは、 AWS CLI の logs コマンド で可能みたいです :mag:

動作環境

1. Slack で Webhook URL を発行する

まずは 公式サイトの手順 に従って Webhook URL を発行します :earth_americas:

無事発行できると、
https://hooks.slack.com/services/~~~~~/~~~~~/~~~~~~~~~~~ のようなフォーマットの URL が取得出来るはずなのでメモっておきます :pencil:

2. 必要な npm パッケージをインストールする

Slack の Incoming Webhook の仕組みを使用し、
チャンネルにメッセージを送信するための npm パッケージをインストールします :arrow_down:

npm install @slack/webhook --save

3. Slack にエラーレポートを送信する Lambda 関数を作成する (TypeScript)

Serverless Framework の handler に Slack にエラーレポートを送信する関数を追加します :arrow_down:

handlers/Reporter.ts
import { gunzip } from "zlib";
import { IncomingWebhook } from "@slack/webhook";
/**
 * CloudWatch のログ情報
 */
interface CloudWatchLogContent {
  messageType: string;
  owner: string;
  logGroup: string;
  logStream: string;
  subscriptionFilters: string[];
  logEvents: {
    id: string;
    timestamp: string;
    message: string;
  }[];
}
/**
 * Lambda リゾルバの型定義
 */
type LambdaResolver<TEvent = any> = (event: TEvent) => Promise<any> | any;
/**
 * CloudWatch のロググループに出現するエラーを通知する
 * @param event 該当するエラーログの内容
 * @return {object} event オブジェクトをそのまま返却する
 */
export const NotifyError: LambdaResolver = async (event: any) => {
/**
{
  awslogs: {
    data: 'H4sIAAA...'
  }
}
CloudWatch から呼ばれた際の event には上記フォーマットでデータが入っている。
data 内には Base64 でエンコードされた gzip 形式で圧縮されたデータが入っているので、
gzip 形式のデータを解凍しつつ、Base64 デコードを行い JSON 文字列を取得するための関数
*/
  const gunzipAsync = async (base64Logs): Promise<string> => {
    return new Promise(function (resolve, reject) {
      gunzip(base64Logs, function (err, binary) {
        err ? reject(err) : resolve(binary.toString("ascii"));
      });
    });
  };
// 1. Base64 でエンコードされた gzip 形式で圧縮されたデータを Base64 でデコードし、gzip のバイナリとして取得する
// gunzipAsync 関数で gzip 解凍して ascii 文字列として取得することで CloudWatch のログ内容を JSON 文字列で取得する
  const base64Logs = Buffer.from(event["awslogs"]["data"], "base64");
  const uncompressedLogs = await gunzipAsync(base64Logs);
  console.log(uncompressedLogs);
// 2. 取得した JSON 文字列を CloudWatchLogContent に変換して取得する
  const content = <CloudWatchLogContent>JSON.parse(uncompressedLogs);
  console.log(content);
// 3. 発行した Slack の Webhook URL で IncomingWebhook クラスを生成し、
// send 関数で Slack チャンネル名 (ex. #serverless-error-report) と、
// CloudWatchLogContent の内容を元に作成したテキストを引数に指定して、
// 該当する Slack チャンネルにテキストを投稿する
  const webhook = new IncomingWebhook("<1. で発行した Slack の Incoming Webhook URL>");
  await webhook.send({
    channel: "<通知したいチャンネル名 (例: #serverless-error-report)>",
    icon_emoji: "hammer", // icon_emoji パラメタを指定すると Slack へのメッセージ通知の際のアイコンを変更することが可能
    text: `*Group*\n_${content.logGroup}_\n\n*Message*\n\`\`\`${content.logEvents[0].message}\`\`\``,
  });
  return event;
};

4. 3. の関数をデプロイする関数として追加し、各種 Lambda 関数の CloudWatch のログを監視するイベントと紐付ける

serverless.yml手順 3. で作成した関数 NotifyError を記載すると共に、
監視したい関数の CloudWatch ロググループを eventscloudwatchLog に定義し、filterERROR を指定します :heavy_check_mark:

これで、
該当ロググループに ERROR が含まれていた場合、
都度 Lambda 関数が実行されるようになります :fire: :arrow_down:

serverless.yml
#...
# 3. で作成した Slack にエラーレポートを送信する関数 NotifyError を functions に追記し、
# events を用いて、他 Lambda 関数のロググループに 'ERROR' が出力されていた場合、 NotifyError 関数が実行されるようにする
functions:
#...
  NotifyError:
    handler: CloudWatch.NotifyError
    events:
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction1
          filter: ERROR
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction2
          filter: ERROR
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction3
          filter: ERROR
#...

:arrow_up: が完了したら sls deployNotifyError 関数をデプロイします :keyboard:

最後に AWS CLI で CloudWatch にイベントログデータを送信してみて、
本当に Slack に通知が飛んでくるか動作確認を行いましょう :hammer_pick:

注意事項 (複数の cloudwatchLog を定義した場合)

events に複数の cloudwatchLog を定義した場合、
関数のデプロイ後、AWS Console で該当する Lambda 関数を見に行くと、
正しく CloudWatch のイベントが紐付けられていないように見えます :arrow_down:
492d4761e7fadb62612e539c17b3d05f.png

同様のケースが発生している方は他にもいらっしゃるようですが、
手順 5. で Slack への通知まで確認出来れば問題なく設定できています :thumbsup:

5. AWS CLI を利用してCloudWatch にイベントログデータを送信して Slack に通知が飛んでくるか検証する

CloudWatch にイベントログデータを送信するコマンドです :white_check_mark: :arrow_down:

aws logs put-log-events \
    --log-group-name '<該当するロググループ名>(例: /aws/lambda/test-dev-TestFunction1)' \
    --log-stream-name '<ログストリーム名(例: test-stream)>' --log-events \
    timestamp=(node -e 'console.log(Date.now())'),message="This is ERROR"

コマンド実行後、
AWS Console から CloudWatch の該当するロググループのログストリームを見に行くと、
This is ERROR という文字列が出力されている事が確認できるはずです :mag:

あとは Slack に通知が飛んできたことまで確認できれば動作確認完了です! :tada: :arrow_down:
4a5d063485ed635c83e3ea1917043673.png

おわりに

Serverless Framework 内で完結する形で、
Lambda 関数のエラーを捕捉して Slack に通知を飛ばす方法についてまとめました :writing_hand:

events には cloudwatchLog の他にも eventBridge というものも指定できます。
eventBridge を使用すると CloudWatch 以外の様々な AWS サービスのイベント駆動で Lambda 関数を実行することが可能です :muscle:

events を有効活用することで効率よくイベント駆動の処理を書いていけるので、
是非とも有効活用していきましょう! :runner: :dash:

参考リンク

UnityでもRoslynで構文解析やコード評価したい

2020年7月

はじめに

RoslynはC#の構文解析やコード評価を行えるライブラリでC# 6以降のコンパイラでも使われています。Roslynを利用すると構文解析された結果を利用できるので文字列比較や正規表現と比べると解析漏れをなくせます。

RoslynのインストールはNuGetで使えますがUnityだと重複するdllがありそのままでは導入できません。それをUPMで簡単に導入する方法が分かったのでまとめてみました。

Unityでは以下のプロダクトでRoslynが使われています。

環境構築

Unity2018.3以上

  • Package Managerから Add package from git URL... できるバージョンでは com.unity.code-analysis を追加する
  • Package Managerから Add package from git URL... できないバージョンでは Packages/manifest.json"com.unity.code-analysis": "0.1.2-preview", を追加する

インストールできると以下のようにPackage Managerに追加されます。

image.png

インストールだけではDLLを参照できないのでRoslynを使いたいスクリプトフォルダにアセンブリ定義を作り、以下の設定を変更します。

  • 一般 > リファレンスをオーバーライドをチェック
  • アセンブリ参照に Microsoft.CodeAnalysis で始まるdllを追加
  • エディタ拡張で使うならプラットフォームの Editor のみをチェック、アプリ内で使うならデフォルトの 任意のプラットフォーム のままでOK

image.png

Roslynのサンプル

Scripting API SamplesGetting Started C# Syntax Analysisを参考にサンプルを実行してみます。それぞれcode欄に実行または解析するコード、result欄にその結果を表示しています。また実行できるUnityプロジェクトは https://github.com/shiena/UnityRoslynSample にありメニューの Tools > Roslyn Sample を選択するとウインドウが開きます。

Evaluate a C# expression

コードを実行します。

string code = "1 + 2";
var result = CSharpScript.EvaluateAsync(code);

image.png

Evaluate a C# expression (strongly-typed)

ジェネリクスで結果の型を指定してコードを実行します。

string code = "1 + 2";
var result = CSharpScript.EvaluateAsync<int>(code);

image.png

Parameterize a script

クラス定義したパラメータをコードに適用して実行します。

public class Globals
{
    public int X;
    public int Y;
}
string code = "X+Y";
var globals = new Globals {X = 1, Y = 2};
var result = CSharpScript.EvaluateAsync<int>(code, globals: globals);

image.png

Query Methods

コードを解析してMainメソッドの最初の引数を出力します。

string code =
            @"using System;
using System.Collections;
using System.Linq;
using System.Text;
namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(""Hello, World!"");
        }
    }
}";
SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
var root = (CompilationUnitSyntax) tree.GetRoot();
var firstMember = root.Members[0];
var helloWorldDeclaration = (NamespaceDeclarationSyntax) firstMember;
var programDeclaration = (ClassDeclarationSyntax) helloWorldDeclaration.Members[0];
var mainDeclaration = (MethodDeclarationSyntax) programDeclaration.Members[0];
var argsParameter = mainDeclaration.ParameterList.Parameters[0];
var firstParameters = from methodDeclaration in root.DescendantNodes()
                .OfType<MethodDeclarationSyntax>()
            where methodDeclaration.Identifier.ValueText == "Main"
            select methodDeclaration.ParameterList.Parameters.First();
var argsParameter2 = firstParameters.Single();

image.png

SyntaxWalkers

コードを解析してSystemまたはSystem.以外で始まるusingを出力します。

class UsingCollector : CSharpSyntaxWalker
{
    public readonly List<UsingDirectiveSyntax> Usings = new List<UsingDirectiveSyntax>();
    public override void VisitUsingDirective(UsingDirectiveSyntax node)
    {
        if (node.Name.ToString() != "System" &&
            !node.Name.ToString().StartsWith("System."))
        {
            this.Usings.Add(node);
        }
    }
}
string code =
            @"using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace TopLevel
{
    using Microsoft;
    using System.ComponentModel;
    namespace Child1
    {
        using Microsoft.Win32;
        using System.Runtime.InteropServices;
        class Foo { }
    }
    namespace Child2
    {
        using System.CodeDom;
        using Microsoft.CSharp;
        class Bar { }
    }
}";
SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
var root = (CompilationUnitSyntax) tree.GetRoot();
var collector = new UsingCollector();
collector.Visit(root);

image.png

参考リンク

React Nativeのrun-androidはデバイスIDの有無で実行されるタスクが違う

2020年7月

はじめに

React Nativeのrun-androidコマンドはPCに接続したAndroid端末すべてにapkをインストールします。
一方で--deviceIdでデバイスIDを指定すると特定のAndroid端末だけにapkをインストールできます。
ですがデバイスID指定ありとなしでは実行されるGradleタスクに違いがあったので調べた結果をまとめました。

バージョン

  • @react-native-community/cli-platform-android: 4.9.0

実行されるタスクの調査方法

react-native コマンドの --verbose オプションを指定すると実行されるコマンドが分かるのでこれで確認します。

デバイスIDを指定しない場合

$ react-native run-android --verbose
info Running jetifier to migrate libraries to AndroidX. You can disable it using "--no-jetifier" flag.
Jetifier found 1041 file(s) to forward-jetify. Using 8 workers...
info JS server already running.
info Installing the app...
debug Running command "cd android && gradlew.bat app:installDebug -PreactNativeDevServerPort=8081"

app:installDebugタスクが実行されています。これはアプリをビルドして全Android端末にapkをインストールします。

デバイスIDを指定する場合

$ react-native run-android --verbose --deviceId xxxxx
info Running jetifier to migrate libraries to AndroidX. You can disable it using "--no-jetifier" flag.
Jetifier found 1391 file(s) to forward-jetify. Using 8 workers...
info JS server already running.
info Building the app...
debug Running command "gradlew.bat build -x lint"

buildタスクが実行されています。これはアプリのビルド、テスト、Javadocのビルドなどが実行されます。
更に--tasksオプションでタスクを指定しても反映されないのでビルド以外のタスクを除外できません。

apkビルドだけで特定の端末にインストールしたい

現状はオプションなどで変更できないので以下のどちらかしかなさそうです。

  • インストールしたくないAndroid端末の電源を切るかUSBケーブルを抜く
  • node_modules/@react-native-community/cli-platform-android/build/commands/runAndroid/index.jsbuildApk内の"build""assembleDebug"に書き換える

以下のPull Requestが取り込まれるとビルドコマンドが統一されてassembleDebugタスクに置き換わるので将来的には解消しそうです。

Gradleで任意のフォルダのプロジェクトを追加する

2020年7月

はじめに

React NativeのAndroidプロジェクトで変則的なフォルダ構成だったのでGradleでうまく扱う方法を紹介します。
サブフォルダや同じ階層のフォルダの場合はGradleでマルチプロジェクトで実現できます。

フォルダ構成

myapp/
  android/ ★rootプロジェクト
    build.gradle
    settings.gradle ★これを編集する
    app/ ★アプリプロジェクト
      build.gradle
library/
  android/ ★ライブラリプロジェクト
    build.gradle

設定

settings.gradle
include ':app'
// ここから追加
include ':library'
project(':library').projectDir = file('../../library/android')
// ここまで追加

参考サイト

Oculus Platform APIをコールバックからTaskへ変換する

2020年6月

はじめに

Oculus Storeからアプリを配布する時はOculus Platform APIが必須になっています。
そのOculus Platform APIは結果をコールバックで返すので処理が冗長になってしまいますが簡単にTask化できたので紹介します。

開発環境

  • System.Threading.Tasksを扱える設定およびバージョンのUnity
    • Unity2017 + Experimental (.NET 4.6 Equivalent)
    • Unity2018 + .NET 4.x Equivalent
    • Unity2019以上
  • Oculus Integration v1.41.0以上

コールバックの場合

以下はエンタイトルメントチェックのベストプラクティスのサンプルコードです。Oculus Platform APIはIsUserEntitledToApplication()のようにOnComplete()で結果が返ってくるので同様の呼び出しが続くとコールバック地獄になって処理の流れが分かりにくくなります。

using UnityEngine;
using Oculus.Platform;
public class AppEntitlementCheck: MonoBehaviour {
  void Awake ()
  {
    try
    {
      Core.AsyncInitialize();
      Entitlements.IsUserEntitledToApplication().OnComplete(EntitlementCallback); // ここがコールバック
    }
    catch(UnityException e)
    {
      Debug.LogError("Platform failed to initialize due to exception.");
      Debug.LogException(e);
      // Immediately quit the application.
      UnityEngine.Application.Quit();
    }
  }
  void EntitlementCallback (Message msg)
  {
    if (msg.IsError)
    {
      Debug.LogError("You are NOT entitled to use this app.");
      UnityEngine.Application.Quit();
    }
    else
    {
      Debug.Log("You are entitled to use this app.");
    }
  }
}

Taskへ変換する

スクリプティング定義シンボルにOVR_PLATFORM_ASYNC_MESSAGESを追加します。
image.png

するとTaskを返すGen()メソッドがOculus.Platform.Requestクラスで使えるようになるので以下のように書く事ができます。

private async void Awake()
{
    try
    {
        if (!Core.Initialized())
        {
            var initialized = await Core.AsyncInitialize().Gen(); // ここをTask化
            if (initialized.IsError)
            {
                Debug.Log($"failed initialize: {initialized.GetError().Message}");
                return;
            }
        }
        var entitlements = await Entitlements.IsUserEntitledToApplication().Gen(); // ここをTask化
        if (entitlements.IsError)
        {
            Debug.Log($"failed entitlement: {entitlements.GetError().Message}");
            return;
        }
        var user = await Users.GetLoggedInUser().Gen(); // ここをTask化
        if (user.IsError)
        {
            Debug.Log($"failed get user: {user.GetError().Message}");
            return;
        }
        Debug.Log($"{user.Data.ID}, {user.Data.OculusID}");
    }
    catch (UnityException e)
    {
        Debug.LogException(e);
        Application.Quit();
    }
}

更にUniTaskと組み合わせるとタイムアウトも簡単に扱えます。

var initialized = await Core.AsyncInitialize().Gen().AsUniTask().Timeout(TimeSpan.FromSeconds(10));

1つ残念なのはGen()が返すTaskTaskCompletionSourceで作られてprivateフィールドにあるのですがTrySetCanceled()を呼ぶメソッドが用意されていないのでCancellationTokenでキャンセルできない事です。どうしてもキャンセルしたい場合はSDKに多少の変更が必要です。