5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Serverless v3 向けプラグインの作り方

Posted at

はじめに

今年の 1 月末に Serverless Framework v3 がリリースされました。

この記事では Serverless v3 を対象としたプラグインの作り方を詳しく解説しつつ、v2 との違いやベストプラクティスについて紹介したいと思います。

なお、記事中の情報は執筆時のバージョン (v3.7.1) に基づくものです。
また、記事中のサンプルコードでは簡略化のために Optional chainingNullish coalescing を使用していますが、これらは Node.js v14 以降でしか使えないため注意してください。

プラグイン開発の基本

Serverless プラグインは JavaScript で開発します。

基本的には次のように、プラグインの Class を定義して module.exports で CommonJS Module として export する形になります。

"use strict";

class MyPlugin {
  constructor() {
    // 初期化
  }
}

module.exports = MyPlugin;

作成したプラグインは serverless.yml から相対パスで読み込むことができます。

serverless.yaml
service: app

plugins:
  - ./my-plugin.js

この相対パスの書き方ですが、serverless.yml があるディレクトリを基準として require() でパス解決できる形であればなんでも OK です。

ちなみに v2 では .serverless_plugins というディレクトリ以下にあるファイルがローカルプラグインとしてみなされ、serverless.yml からプラグイン名 (ディレクトリ名やファイル名) のみで読み込むことができました。
これは v3 でも引き続き動作しますが、現在ではレガシー扱い1で、今後は非推奨として扱われるようなのでローカルプラグインの読み込みには必ず相対パスを使用するようにしましょう。
後述の ESM プラグイン.serverless_plugins からの読み込みに対応していません。

npm パッケージとして公開

作成したプラグインを npm パッケージとして公開したい場合は package.jsonmain を書くようにします。

package.json
{
  "main": "my-plugin.js"
}

また、peerDependencies でサポートしている Serverless のバージョンを指定しておくことが推奨されています。
v3 のみをサポートする場合は次のようになります。

package.json
{
  // ...
  "peerDependencies": {
    "serverless": "3"
  }
}

npm install したプラグインは serverless.yml から単にプラグイン名 (npm パッケージ名) で読み込むことができます。

serverless.yaml
service: app

plugins:
  - my-plugin

ESM プラグイン

v3.2.0 からは、Serverless 実行環境の Node.js が v12.22 以降である場合に限り Native ESM によるプラグインもサポートされています。
ESM としてプラグインを作る場合は default export します。
ただし、TypeScript や Babel に代表される疑似 ESM での default export はサポートされていないことに注意してください。

export default class MyPlugin {
  constructor() {
    // 初期化
  }
}

ESM プラグインを npm パッケージとして公開する場合は package.jsonpeerDependenciesengines をしっかり書いてあげると良いでしょう。

package.json
{
  // ...
  "peerDependencies": {
    "serverless": "^3.2.0"
  },
  "engines": {
    "node": ">=12.22"
  }
}

ライフサイクルイベントにフックする

Serverless プラグインは基本的に Serverless の ライフサイクルイベント にフックする形でカスタムロジックを実行します。

ライフサイクルイベントとは、serverless コマンドの実行時に順次発生するイベントのことを意味します。
例えば、デプロイ時には deploy:deploy deploy:finalize のようなイベントが発生します。
また、各ライフサイクルイベントには before イベントと after イベントもそれぞれ作成されます。これは before:deploy:deploy after:deploy:deploy のような形で表されます。

ライフサイクルイベントに対するフック処理は、コンストラクタ内で hooks というインスタンスプロパティに定義します。
次の例の場合、デプロイ前に before deploy デプロイ後に after deploy とコンソール出力されます。

class MyPlugin {
  constructor() {
    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
      "after:deploy:deploy": () => this.afterDeploy(),
    };
  }

  beforeDeploy() {
    console.log("before deploy");
  }

  afterDeploy() {
    console.log("after deploy");
  }
}

initialize というライフサイクルイベントは特殊で、すべての serverless コマンドで CLI 起動時に発生します。
設定値の上書きなど、すべてのコマンドで事前に実行しておきたい処理はこのイベントにフックすると良いでしょう。

class MyPlugin {
  constructor() {
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    console.log("initialize");
  }
}

他のプラグインなどによって既にロジックが定義されているライフサイクルイベントに対してフック処理を追加した場合、プラグインの読み込み順 (serverless.yml に記載した順) に応じて順番に処理されることを覚えておきましょう。

各コマンドでどのようなライフサイクルイベントがどのような順番で発生するかについては公式ドキュメントには記載されていませんが、Serverless の開発者が Gist でチートシートを公開しています。
随時更新されているようなのでこちらを参考にすると良いでしょう。

Serverless インスタンス

プラグインクラスのコンストラクタには引数として Serverless インスタンスが与えられます。
Serverless インスタンスを参照することで、フック処理の実行時にサービス構成にアクセスすることができます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    console.log("Serverless instance: ", this.serverless);

    const service = this.serverless.service;
    console.log("Provider name: ", service.provider.name);
    console.log("Functions: ", service.functions);
  }
}

Serverless インスタンスの詳細な仕様はドキュメント化されていませんが、service プロパティに (解決された) serverless.yml の設定値を持っていることさえ覚えておけば大体なんとかなります。

プラグインのコンストラクタが実行される時点では Serverless 変数 などが解決されていないので、必ずライフサイクルイベントのフック処理からアクセスする必要があることに注意してください。

CLI オプション

プラグインクラスのコンストラクタには第二引数として CLI オプション (serverless コマンドの実行時オプション) が与えられます。

class MyPlugin {
  constructor(serverless, options) {
    console.log("CLI options: ", options);
  }
}

大抵の場合、後述する カスタムコマンド で定義した独自オプションの値を取得するために使うことになります。

プロバイダー固有プラグイン

プラグインを特定のプロバイダーでのみ動作するように作ることも可能です。

コンストラクタ内で provider というインスタンスプロパティに、serverless.getProvider() で得られた provider インスタンスを設定します。
serverless.getProvider() には動作させたいプロバイダーの名前 (serverless.ymlprovider.name に指定する名前) を与えます。

AWS のみで動作するプラグインの場合は次のようになります。

class MyPlugin {
  constructor(serverless, options) {
    this.provider = serverless.getProvider("aws");

    // ...
  }
}

ここで this.provider にセットした provider インスタンスはプロバイダー固有の処理を書くときに色々と活用することができます (後述)。

カスタムコマンドを定義する

Serverless に独自のカスタムコマンドを追加することもできます。

次の例では my-command というカスタムコマンドを定義しています。

class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        lifecycleEvents: ["init", "run"],
      },
    };
  }
}

カスタムコマンドを定義するときは、必ずライフサイクルイベントも定義します。
上記の例では serverless my-command を設定すると、my-command:init my-command:run というライフサイクルイベントが順番に発生します (それぞれのイベントの beforeafter も)。

これだけだとコマンドに対するロジックがないので実行しても何も起こりません。
定義したコマンドにカスタムロジックを追加するには、ライフサイクルイベントにフック します。

class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        lifecycleEvents: ["init", "run"],
      },
    };

    this.hooks = {
      "my-command:init": () => this.init(),
      "my-command:run": () => this.run(),
    };
  }

  init() {
    console.log("init");
  }

  run() {
    console.log("run");
  }
}

my-command を実行すると次のようになります。

$ npx serverless my-command
init
run

コマンドの定義に usage プロパティを追加すると、-h でヘルプを参照したときにコマンドの説明を表示させることができます。

class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log("run");
  }
}
$ npx serverless -h
Serverless Framework v3.7.1

Usage
serverless <command> <options>
sls <command> <options>

MyPlugin
my-command                      My custom command

$ npx serverless my-command -h
my-command                      My custom command
--region / -r                   Region of the service
--aws-profile                   AWS profile to use with the command
...

コマンド名について

コマンド名は Serverless の組み込みコマンドや他のプラグインと衝突しないようにユニークである必要があります。
なるべくマルチワードで命名し、プラグイン名や組織名を含めるなどの工夫をしましょう。

サブコマンドについて

Serverless v2 では次のようにカスタムコマンドの定義をネストさせ、サブコマンドを定義することが可能でした。

class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        commands: {
          "sub-command": {
            lifecycleEvents: ["run"],
          },
        },
      },
    };

    this.hooks = {
      "my-command:sub-command:run": () => this.run(),
    };
  }

  run() {
    console.log("run");
  }
}
$ npx serverless my-command sub-command
run

これは互換性のため v3 でも引き続き動作しますが、v2 時点ですでに非推奨になっている機能で現在は公式ドキュメントからも削除されているので使わないようにしましょう。

カスタムコマンドにオプションを定義する

カスタムコマンドに独自のオプションを定義することも可能です。

次の例では --function オプション (ショートカット -f) を定義しています。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            default: "myFunction",
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.function);
  }
}

必須オプション

オプションを必須にしたい場合は、default でデフォルト値を設定する代わりに required プロパティを指定します。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            required: true,
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.function);
  }
}

ちなみに、defaultrequired も指定せず、実行時に与えなかったオプションの値は undefined になります。

オプションの型指定

type プロパティではオプションの型を指定します。
"string" "boolean" "multiple" のいずれかを指定することができます。
type の指定は Serverless v4 から必須になる予定2なので、必ず指定するようにしましょう。

"boolean" を指定した場合は値の型が boolean になります。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          "dry-run": {
            usage: "Use dry-run mode",
            default: false,
            type: "boolean",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log("Dry-run:", this.options["dry-run"]);
    console.log("Option type:", typeof this.options["dry-run"]);
  }
}
$ npx serverless my-command --dry-run
Dry-run: true
Option type: boolean

明示的に false を指定したい場合は、オプション名の前に no- を付加します。

npx serverless my-command --no-dry-run
Dry-run: false
Option type: boolean

"multiple" を指定した場合は文字列の配列として扱われます。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          functions: {
            usage: "Specify the functions you want to handle",
            shortcut: "f",
            required: true,
            type: "multiple",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.functions);
  }
}
$ npx serverless my-command --functions func1 --functions func2
[ 'func1', 'func2' ]

既存のコマンドにカスタムオプションを追加する

v2 では未定義のオプションであっても値を取得することができたため、適当なオプションをでっちあげることが可能でした。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
    };
  }

  beforeDeploy() {
    console.log(this.options["my-option"]); // 定義していないオプションを勝手に参照
  }
}

v3 では未定義のオプションを指定して serverless コマンドを実行した場合にエラーが発生するようになった3ため、この方法は使用できなくなりました。

v3 で同等のことを実現するには 2 通りの方法があります。

代わりにカスタム設定を使う

公式に言及されているのは、オプションを追加する代わりに後述の カスタム設定 を利用する方法です。

環境変数dotenv ファイルStage パラメータ 等との組み合わせにより、CLI オプションに近い役割を担うことが可能です。
v3.3.0 からは CLI パラメータ を利用することもできます。

既存のコマンドを再定義する

公式ドキュメントには記載がありませんが、既存のコマンドを再定義するような形で独自オプションの追加が可能なようです。

class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      // 組み込みのコマンドである deploy に独自オプションを追加
      deploy: {
        options: {
          "my-option": {
            usage: "My custom option",
            default: "",
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
    };
  }

  beforeDeploy() {
    console.log(this.options["my-option"]);
  }
}

この方法は Serverless 内部プラグイン4 や、Serverless の公式プラグインである serverless-azure-functions で使用されています。

serverless-azure-functions のような 独自プロバイダー プラグインを作る場合は、組み込みコマンドである deploy などを再定義していくことになるのでこの方法で良さそうです。

ただ既存プロバイダーの既存コマンド (例えば AWS プロバイダーにおける serverless deploy) に独自オプションを追加するのは、次の理由から避けておいた方が無難かと思います。

  • 公式ドキュメントに記載がない
  • コマンドが本来持っているオプションと競合する可能性がある
    • 現時点では同名のオプションがなくても、将来的に追加される可能性もあります

カスタム設定を利用する

servreless.yml に記述されたプラグイン独自の設定を利用することもできます。

serverless.ymlService configuration validation によりスキーマ検証が行われるため、独自の設定を勝手に書くと警告 (v4 からはエラー5) が出てしまいますが、custom 以下だけは自由に使うことが許されています。

他のプラグインと競合しないように、プラグイン名などをキーにして設定を分けるようにすると良いでしょう。

serverless.yml
service: plugin-example

frameworkVersion: "3"

provider:
  name: aws
  runtime: nodejs14.x

plugins:
  - ./my-plugin

custom:
  myPlugin:
    foo: bar

custom 以下の値は serverless.service.custom で取得することができます。

"use strict";

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.myPlugin?.foo);
  }
}

module.exports = MyPlugin;

スキーマ定義を拡張する

プラグイン側で serverless.yml のスキーマ定義を拡張することもできます。
これにより、Service configuration validation をプラグイン独自の設定に対して適用させることが可能となります。
また、スキーマの拡張によりトップレベルのカスタム設定や関数単位のカスタム設定も利用することができるようになります。

Serverless は Ajv という JSON Schema の検証ライブラリによって serverless.yml の検証を行います。

そのため、スキーマは JSON Schema によって定義することになります。
(v3.7.1) 時点では Ajv v8 のデフォルトが使用されているようなので、JSON Schema のバージョンは draft-07 です。

custom 以下のスキーマを定義する

前述 の通り、ビルトインのスキーマでは custom 以下は自由に使用できるようになっています。
そのためスキーマの定義は必須ではありませんが、設定ミスを防ぐためにも定義しておいた方が良いでしょう。

例として次のような設定のスキーマを定義してみます。
custom.myPlugin.type には foo bar baz いずれかの文字列が設定されます。

serverless.yaml
custom:
  myPlugin:
    type: foo

Serverless インスタンスの configSchemaHandler プロパティに、スキーマ拡張用のメソッドがいくつか定義されています。

custom 以下のスキーマを定義するには defineCustomProperties() を使用します。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineCustomProperties({
      type: "object",
      properties: {
        myPlugin: {
          type: "object",
          properties: {
            type: {
              type: "string",
              enum: ["foo", "bar", "baz"],
            },
          },
          additionalProperties: false,
        },
      },
    });
  }
}

custom.myPlugin.type にスキーマ違反の qux を設定してを設定すると、コマンド実行時に次のように警告を出してくれます。

$ npx serverless print

Warning: Invalid configuration encountered
  at 'custom.myPlugin.type': must be equal to one of the allowed values [foo, bar, baz]

Learn more about configuration validation here: http://slss.io/configuration-validation
...

トップレベルのスキーマを拡張する

後述しますがトップレベルのカスタム設定は使用しない方が良さそうです。

ビルトインのスキーマでは、トップレベルに未知のプロパティがあることは許容されていません。
そのためトップレベルのプロパティをカスタム設定として利用したい場合はスキーマの拡張が必須となります。

例として次のような設定のスキーマを定義してみます。
myPlugin.type には foo bar baz いずれかの文字列が設定されます。

serverless.yml
myPlugin:
  type: foo

トップレベルのスキーマを拡張するには defineTopLevelProperty() を使用します。
defineCustomProperties() とは異なり、第一引数にプロパティ名、第二引数に JSON Schema を与える形になります。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineTopLevelProperty("myPlugin", {
      type: "object",
      properties: {
        type: {
          type: "string",
          enum: ["foo", "bar", "baz"],
        },
      },
      additionalProperties: false,
    });
  }
}

ただし、トップレベルに定義したプロパティは参照方法がいまいちはっきりしていません。
serverless.service.myPlugin のような形では参照できず、serverless.configurationInput.myPlugin または serverless.service.initialServerlessConfig.myPlugin で取得することができますが、これらのプロパティはドキュメント化されていないようです。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineTopLevelProperty("myPlugin", {
      type: "object",
      properties: {
        type: {
          type: "string",
          enum: ["foo", "bar", "baz"],
        },
      },
      additionalProperties: false,
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.myPlugin?.type); // 取得できない
    console.log(this.serverless.configurationInput.myPlugin?.type); // 取得できる
    console.log(this.serverless.service.initialServerlessConfig.myPlugin?.type); // 取得できる
  }
}

あえてトップレベルのプロパティを使用するメリットも特にないので、custom 以下を利用するようにした方が無難でしょう。

関数設定のスキーマを拡張する

関数単位で挙動をカスタマイズしたい場合は、関数設定にカスタムプロパティを追加できると便利です。
ビルトインのスキーマでは関数設定に未知のプロパティがあることは許容されていないませんが、関数設定のスキーマも拡張することが可能です。

例として関数設定に customProperty プロパティを追加してみます。

serverless.yaml
functions:
  myFunction:
    handler: handler.main
    customProperty: foo

関数設定のスキーマを拡張するには defineFunctionProperties() を使用します。
functions 以下の設定はプロバイダーに強く依存しているため、プロバイダー名を第一引数として与える必要があります。

定義したプロパティは serverless.service.functions 以下の各関数のオブジェクトから参照することができます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionProperties("aws", {
      properties: {
        customProperty: { type: "string" },
      },
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const [funcName, funcObj] of Object.entries(functions)) {
      console.log(`${funcName}: ${funcObj.customProperty}`);
    }
  }
}

独自の関数イベントを定義する

独自の関数イベントも定義することができます。

例として次のような myEvent のスキーマを定義してみます。

serverless.yml
functions:
  myFunction:
    handler: handler.main
    events:
      - myEvent:
          foo: value
          bar: 1

関数イベントのスキーマを定義するには defineFunctionEvent() を使用します。
第一引数にプロバイダー名、第二引数にイベント名を与えます。

定義した関数イベントは各関数のオブジェクトの events から探して参照することになります。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionEvent("aws", "myEvent", {
      type: "object",
      properties: {
        foo: { type: "string" },
        bar: { type: "number" },
      },
      required: ["foo"],
      additionalProperties: false,
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const [funcName, funcObj] of Object.entries(functions)) {
      const myEvent = funcObj.events.find((v) => {
        return v.myEvent != null;
      })?.myEvent;

      if (!myEvent) {
        continue;
      }

      console.log(`${funcName}:`, myEvent);
    }
  }
}

関数イベントのスキーマを拡張する

既存の関数イベントのスキーマを拡張することも可能です。
これにより、イベントにプラグインのための設定を追加することができます。

例として http イベントを拡張し、documentation プロパティを追加してみます。

serverless.yml
functions:
  auth:
    handler: handler.main
    events:
      - http:
          path: /auth
          method: post
          documentation: Authenticate client app

既存の関数イベントのスキーマを拡張するには defineFunctionEventProperties() を使用します。
第一引数にプロバイダー名、第二引数にイベント名を与えます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionEventProperties(
      "aws",
      "http",
      {
        properties: {
          documentation: { type: "string" },
        },
        required: ["documentation"],
      }
    );

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const funcObj of Object.values(functions)) {
      const httpEvent = funcObj.events.find((v) => {
        return v.http != null;
      })?.http;

      if (!httpEvent) {
        continue;
      }

      console.log(this.genDocumentation(httpEvent));
    }
  }

  genDocumentation(httpEvent) {
    if (typeof httpEvent === "string") {
      return httpEvent;
    }

    const { path, method, documentation } = httpEvent;
    return `${method.toUpperCase()} ${path} : ${documentation}`;
  }
}

独自プロバイダーを定義する

独自プロバイダーを定義することも可能です。

例として次のような設定を書ける独自プロバイダーのスキーマを定義してみます。

serverless.yml
service: provider-example

frameworkVersion: "3"

provider:
  name: my-provider
  stage: dev

plugins:
  - my-provider

functions:
  myFunction:
    handler: handler.main
    runtime: nodejs
    events:
      - http:
          method: GET

resources:
  storage:
    name: storage-name

layers:
  name: layer-name

独自プロバイダーのスキーマを定義するには defineProvider() を使用します。
第一引数にプロバイダー名を与えます。

class MyProvider {
  constructor(serverless) {
    serverless.configSchemaHandler.defineProvider("my-provider", {
      // 再利用するためのスキーマ定義
      definitions: {
        runtime: {
          enum: ["nodejs", "ruby", "go"],
        },
      },

      // provider のスキーマ定義
      provider: {
        properties: {
          stage: { type: "string" },
        },
        additionalProperties: false,
      },

      // 関数 (functions の要素) のスキーマ定義
      function: {
        properties: {
          handler: { type: "string" },
          // definitions で定義したスキーマを利用できます。
          runtime: { $ref: "#/definitions/runtime" },
        },
        additionalProperties: false,
      },

      // 関数イベントのスキーマ定義
      // 公式ドキュメントの例は誤っているので注意が必要です。
      functionEvents: {
        http: {
          type: "object",
          properties: {
            method: {
              enum: ["GET", "POST", "PUT", "DELETE", "HEAD"],
            },
          },
          additionalProperties: false,
        },
      },

      // resouces のスキーマ定義
      resources: {
        type: "object",
        properties: {
          storage: {
            type: "object",
            properties: {
              name: { type: "string" },
            },
            additionalProperties: false,
          },
        },
        additionalProperties: false,
      },

      // layers のスキーマ定義
      layers: {
        type: "object",
        properties: {
          name: { type: "string" },
        },
        additionalProperties: false,
      },
    });
  }
}

これだけだとスキーマを定義しただけなので、実際には独自プロバイダー用のコマンド等を実装していく必要があります。

また、なぜかドキュメントには記載がありませんが、独自プロバイダーを serverless.getProvider() (プロバイダー固有プラグイン を参照) に対応させるには serverless.setProvider() を使用します。
第一引数にプロバイダー名、第二引数に serverless.getProvider() で取得させたいオブジェクトを与えます。
このオブジェクトは静的メソッドとしてプロバイダー名を返す getProviderName() を持つクラスのインスタンスである必要があります。
実際には、独自プロバイダークラスのインスタンスそのものを与えるケースが多いようです。

const providerName = "my-provider";

class MyProvider {
  static getProviderName() {
    return providerName;
  }

  constructor(serverless) {
    this.provider = this;
    serverless.setProvider(providerName, this);

    serverless.configSchemaHandler.defineProvider(providerName, {
      // ...
    });
  }
}

カスタム変数ソースを定義する

serverless.yml で使用できる独自の Serverless 変数 ソースを定義することができます。

カスタム変数ソースは、コンストラクタ内で configurationVariablesSources というインスタンスプロパティに定義します。

次の例では、, 区切りの文字列が格納された環境変数を分割して配列として解決するカスタム変数ソースを定義しています。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      // オブジェクトのキーがソース名となります。
      splitEnv: {
        // 値のオブジェクトには `resolve()` メソッドを定義します。
        // async function であること (Promise を返すこと) が許容されているため、
        // 非同期な変数解決も可能です。
        async resolve({ address }) {
          // address には解決する変数の名前が格納されています。
          // `${splitEnv:ALLOWED_HOSTS}` の場合は `ALLOWED_HOSTS` です。
          const value = process.env[address];

          const resovled = value ? value.split(",") : [];

          // 解決された値を `value` に持つオブジェクトを返します。
          return { value: resovled };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}

このプラグインにより、次のように splitEnv 変数が使用できるようになります。

serverless.yml
custom:
  foo: ${splitEnv:ALLOWED_HOSTS}
$ export ALLOWED_HOSTS="localhost,127.0.0.1"
$ npx serverless my-command
[ 'localhost', '127.0.0.1' ]

変数パラメータを参照する

カスタム変数ソースは次のようにパラメータを受け取ることもできます。

serverless.yml
custom:
  value: ${foo(one, two):bar}

パラメータは resolve() の引数の params プロパティから配列の形で参照することができます。
各パラメータは文字列として扱われます。

次の例では文字列とセパレータをパラメータとして受け取り、文字列を分割して配列として解決するカスタム変数ソースを定義しています。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      split: {
        async resolve({ params }) {
          const paramsLength = (params ?? []).length;
          const value = paramsLength > 0 ? params[0] : "";
          const separator = paramsLength > 1 ? params[1] : ",";
          const resovled = value ? value.split(separator) : [];

          return { value: resovled };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}

先ほどの splitEnv よりも汎用的な変数ソースとして使うことができます。

serverless.yml
custom:
  # 記号やスペースなどを渡したい場合はクォートなどで括ります。
  foo: ${split(${env:ALLOWED_HOSTS, ''}, ' ')}
$ export ALLOWED_HOSTS="localhost 127.0.0.1"
$ npx serverless my-command
[ 'localhost', '127.0.0.1' ]

他の変数を参照する

他の Serverless 変数 の解決された値を参照することもできます。

resolve() の引数の resolveVariable() メソッドで他の変数の値を解決することができます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      stageEnv: {
        async resolve({ address, resolveVariable }) {
          const stage = await resolveVariable("sls:stage");
          const key = `${address}_${stage.toUpperCase()}`;
          const value = process.env[key];
          return { value };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}

CLI オプションを参照する

resolve() の引数の options プロパティから CLI オプションを参照することもできます。
ただしカスタム変数はどのコマンドからも参照される可能性があるので、特定コマンドに固有のオプションには依存しない方が良さそうです。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      myVariable: {
        async resolve({ options }) {
          return { value: options["my-option"] };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          "my-option": {
            type: "string",
            required: true,
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}

CLI 出力のベストプラクティス

v3 からはプラグインからのコンソール出力に関するベストプラクティスが定められています。

v2 では次のように Serverless インスタンスのログ出力機能がよく使われていましたが、これは v3 では非推奨となります。

serverless.cli.log("message");

v3 ではプラグインのコンストラクタの第三引数にプラグイン用のユーティリティが渡されるようになっています。
このプラグインユーティリティに含まれる機能を使ってコンソール出力を行うことが推奨されています。

class MyPlugin {
  constructor(serverless, options, utils) {
    this.utils = utils;
  }
}

ログ出力

ログ出力には log ユーティリティ を使用します。
すべてのログは標準エラー出力に書き込まれます。

class MyPlugin {
  constructor(serverless, options, { log }) {
    this.log = log;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    this.log.error("error");
    this.log.warning("warning");
    this.log.notice("notice");
    this.log.info("info");
    this.log.debug("debug");
  }
}

log.error()

エラーログを出力するためのメソッドです。

log.error("message");

次のようなログが出力されます (実際には赤色で出力されます)。

✖ message

各メソッドの仕様

log.warning()

警告ログを出力するためのメソッドです。

log.warning("warning");

次のようなログが出力されます (実際にはオレンジ色で出力されます)。

$ npx serverless my-command
Warning: message
log.notice()

エラーや警告ではないものの必ず表示したいログを出力するためのメソッドです。

log.notice("message");

log()log.notice() のエイリアスとして使用できます。

log("message");

次のようなログが出力されます。
色やプレフィックスはつきません。

$ npx serverless my-command
message
log.info()

フォーマットは log.notice() と同等ですが、コマンドに --verbose オプションがつけられている場合にのみログが表示されます。

log.info("message");

log.verbose()log.info() のエイリアスとして使用できます。

log.verbose("message");
$ npx serverless my-command
$ npx serverless my-command --verbose
message
log.debug()

デバッグ用のログを出力するためのメソッドです。
コマンドに --debug オプションがつけられている場合にのみログが表示されます。

log.debug("message");

Serverless のデバッグログは debug という npm と同じスタイルのネームスペース機能を持っています。

--debug オプションには出力したいネームスペースを値として渡す必要があります。

$ npx serverless my-command
$ npx serverless my-command --debug=plugin:my-plugin
plugin:my-plugin: message

ネームスペース名はデフォルトで plugin:${PLUGIN_NAME} になっています。
PLUGIN_NAME には serverless.ymlplugins 以下に書いてある名前が使用されますが、英数字と - 以外の文字はすべて - に変換されることに注意が必要です。

serverless.yml
plugins:
  - my-plugin   # ネームスペースは `plugin:my-plugin`
  - ./my-plugin # ネームスペースは `plugin:--my-plugin`

また、log.get() を使用することでさらに細かくネームスペースを区切ることも可能です。

log.get("namespace").debug("message");
$ npx serverless my-command --debug=plugin:my-plugin:namespace
plugin:my-plugin:namespace: message
log.success()

成功ログを出力するためのメソッドです。

log.success("message");

次のようなログが出力されます (実際には のみ赤色で出力されます)。

$ npx serverless my-command
✔ message

printf フォーマット

各ログメソッドは printf フォーマットによるログ出力をサポートしています。

log.notice("address: %s:%d", "localhost", 8080);

次のようなログが出力されます (実際には使用されたフォーマット指定子に応じて色がつきます)。

$ npx serverless my-command
address: localhost:8080

ログ出力のベストプラクティス

次のようなベストプラクティスが公式に提示されています。

  • デフォルトの出力を最小限に抑える
    • なるべく log.info() を使用するようにしましょう
  • log.warning() は基本的に使わない
    • 代わりにエラーを throw するか、log.info() を使用しましょう
    • 非推奨となった機能に対して警告を出したい場合は後述の logDeprecation() ヘルパー を使用しましょう
  • log.error() は基本的に使わない
    • 代わりにエラーを throw しましょう
  • デバッグログはなるべくネームスペースを分ける

なぜ標準エラー出力なのか

Serverless では標準出力が他のプログラムにパイプされたり、解析されたりすることを想定しています。
そのため、プラグインが自由にログ出力しても Serverless 本来の出力を壊さないように標準エラー出力に書き込む仕様となっています。

このことからも分かるように、console.log() など標準出力を使用する言語機能やライブラリは使用しない方が良いでしょう。
安全のため、Serverless プラグイン開発では ESLint の no-console ルールを有効にしておくのも手です。

標準出力の使用

標準出力への書き込みには writeText() を使用します。

class MyPlugin {
  constructor(serverless, options, { writeText }) {
    this.writeText = writeText;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    this.writeText("command output");
  }
}

前述 の通り、Serverless の標準出力は他のプログラムにパイプされたり、解析されたりすることを目的としています。
そのため、標準出力への書き込みには注意が必要です。

次のようなベストプラクティスが公式に提示されています。

  • 標準出力への書き込みはプラグイン自身が定義したカスタムコマンドでのみ行う
    • 組み込みのコマンドや他のプラグインのコマンドの出力を壊さないようにするためです
  • 標準出力にはコマンドのメイン出力 (コマンドの結果) のみを書き込む

基本的に人間にとって読みやすいメッセージは log ユーティリティ を使用して標準エラー出力に書き込むようにしましょう。

色とフォーマットに関するベストプラクティス

出力に色をつけたい場合は、Serverless も内部で使用している chalk を使用することが推奨されています。

log.notice(chalk.gray("message"));

次のようなベストプラクティスが公式に提示されています。

  • プライマリ情報には white、セカンダリ情報には gray を使用する
    • プライマリ情報とは「コマンドの結果」を意味します
    • セカンダリ情報はプライマリ情報以外のすべてです
  • 基本的に whitegray 以外の色は使用しない
  • プラグイン側で出力に独自のフォーマットを使用しない
    • 原則として出力には Serverless が公式に提供しているユーティリティのみを使用するようにしましょう

また、ユーザーの注意を引くために Serverless red と名づけられた色 (#fd5750) を使用することができます。

const serverlessRed = chalk.hex("#fd5750");
log.notice(serverlessRed("message"));

Serverless red を使用する際は次の点に注意する必要があります。

  • 使用は最小限に抑える
    • 1 つのコマンドにつき最大でも 1 回までとされています
  • コマンドが出力する最も重要な情報にユーザーの注意を引くためだけに使用する

progress

プラグインが時間のかかる処理を実行する際、インタラクティブな progress 表示を行うことができます。

class MyPlugin {
  constructor(serverless, options, { progress }) {
    this.progress = progress;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  async run() {
    const filename = "data.json";

    // progress 作成
    const progress = this.progress.create({
      message: `Downloading ${filename} in my-plugin`,
    });
    const data = await this.download(filename);

    // progress のメッセージを更新
    progress.update(`Checking ${filename} in my-plugin`);
    const result = await this.check(data);

    if (!result) {
      throw new Error(`${filename} is invalid`);
    }

    // progress 削除
    progress.remove();
  }

  async download(filename) {
    // 時間がかかる処理
  }

  async check(data) {
    // 時間がかかる処理
  }
}
$ npx serverless my-command

⠋ Downloading data.json in my-plugin

次のように progress インスタンスにユニークな名前をつけることも可能です。
この場合は作成したインスタンスそのものを引き回さずに progress にアクセスすることができます。

progress.create({
  name: "my-plugin-progress",
  message: `Downloading in my-plugin`,
});

progress.get("my-plugin-progress").remove();

progress の使用について、次のようなベストプラクティスが公式に提示されています。

  • 2 秒以上かかると想定されるタスクに対して使用する
    • 2 秒かからないタスクの場合は progress を表示せずに log.info() でログ出力するようにしましょう
  • どのプラグインによる progress なのかが分かるようなメッセージを設定する
    • 悪い例: "Compiling"
    • 悪い例: "[Webpack] Compiling"
      • プレフィックスのような独自のフォーマットを使用してはいけません
    • 良い例: "Compiling with webpack"
  • なるべく複数の progress 同時に表示しない
    • 表示するとしても 3 - 4 個程度に留めましょう

@serverless/utils の利用

log, writeText, progress といったプラグインユーティリティは @serverless/utils という npm パッケージから取得することもできます。

const { log, writeText, progress } = require("@serverless/utils/log");

この場合、log.debug() のデフォルトのネームスペースが設定されないことに注意してください。

サービス情報の拡張

serverless info コマンドなどで表示されるサービス情報にプラグイン独自のセクションを追加できます。
何らかのリソースを作成するようなプラグインの場合、そのリソースに関する情報を追加してあげると良さそうです。

Serverless インスタンスの addServiceOutputSection() メソッドを使用しますが、コンストラクタ内で実行するとエラーになるので initialize のタイミングで実行しましょう。

次の例では一行のセクションを追加しています。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    this.serverless.addServiceOutputSection("my section", "content");
  }
}
$ npx serverless info
...
my section: content

複数行のセクションを追加することもできます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    this.serverless.addServiceOutputSection("my section", ["line 1", "line 2"]);
  }
}
$ npx serverless info
...
my section:
  line 1
  line 2

非推奨機能に対する警告

プラグインの非推奨機能が利用された場合に警告を出したい場合は、Serverless インスタンスの logDeprecation() メソッドを使用します。
第一引数にその非推奨機能を表すコードを、第二引数に表示するメッセージを与えます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "old-command": {
        usage: "My custom command (deprecated)",
        lifecycleEvents: ["run"],
      },
      "new-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "old-command:run": () => this.runOldCommand(),
      "new-command:run": () => this.runNewCommand(),
    };
  }

  async runOldCommand() {
    this.serverless.logDeprecation(
      "MY_PLUGIN_OLD_COMMAND",
      "old-command of my-plugin is deprecated. Please use new-command."
    );
  }

  async runNewCommand() {
    // ...
  }
}

非推奨機能を実行すると次のように警告が表示されるようになります。

$ npx serverless old-command

1 deprecation found: run 'serverless doctor' for more details

serverless doctor コマンドで詳細を確認できます。
このとき、設定した非推奨メッセージを確認することができます。

$ npx serverless doctor
1 deprecation triggered in the last command:

old-command of my-plugin is deprecated. Please use new-command.

公式ドキュメントには記載されていませんが、設定した非推奨コードは 警告を無効化する 際に使用することができます。

プラグインの非推奨コードを参照する場合はプレフィックス EXT_ が必要なことに注意してください。

serverless.yaml
disabledDeprecations:
  - EXT_MY_PLUGIN_OLD_COMMAND

次のようなベストプラクティスが公式に提示されています。

  • 非推奨コードにはプレフィックスとしてプラグイン名を付加する
  • メッセージはユーザーにとって実用的なものにする
    • 代わりに何を使用すればよいかを提示するようにしましょう
    • 必要に応じてリンクを追加しましょう

エラーハンドリング

v3 ではエラーは次の 2 種類に区別されます。

  • ユーザーエラー
  • プログラマエラー (バグ)

ユーザーエラーはユーザーによる誤った入力や設定に起因するエラーを指します。
このようなエラーは、次のように Serverless 側で定義された Error クラスを使用してエラーを throw することが推奨されています。

throw new serverless.classes.Error("Invalid configuration");

次の例ではオプションで指定された関数が serverless.yml に存在しなかった場合にユーザーエラーを throw しています。

class MyPlugin {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            required: true,
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const funcName = this.options.function;
    const func = this.serverless.service.functions[funcName];
    if (!func) {
      throw new this.serverless.classes.Error(`${funcName} does not exist`);
    }

    // ...
  }
}

Serverless は throw されたユーザーエラーを適切にフォーマットして出力します。
上記の例で実際に存在しない関数を指定して実行すると次のようになります。

$ npx serverless my-command --function myFunction
Environment: linux, node 14.19.0, framework 3.7.1 (local), plugin 6.1.5, SDK 4.3.2
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
myFunction does not exist

プログラマエラーはユーザーエラー以外のすべてのエラーです。
プログラマエラーの場合は発生したエラーを単純に throw します。

class MyPlugin {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    // ...

    throw new Error("unexpected error");
  }
}

プログラマエラーの場合、Serverless はスタックトレースと共にエラー情報を出力します。
上記の例の場合は次のようになります。

$ npx serverless my-command
Environment: linux, node 14.19.0, framework 3.7.1 (local), plugin 6.1.5, SDK 4.3.2
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
Error: unexpected error
    at MyPlugin.run (/home/frozenbonito/plugin-example/my-plugin/index.js:23:11)
    at my-command:run (/home/frozenbonito/plugin-example/my-plugin/index.js:16:36)
    at PluginManager.runHooks (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:530:15)
    at PluginManager.invoke (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:564:20)
    at async PluginManager.run (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:604:7)
    at async Serverless.run (/home/frozenbonito/plugin-example/node_modules/serverless/lib/serverless.js:174:5)
    at async /home/frozenbonito/plugin-example/node_modules/serverless/scripts/serverless.js:687:9

エラーハンドリングについて、次のようなベストプラクティスが公式に提示されています。

  • エラーによってコマンドが停止する場合は、エラーを throw する
  • エラーによってコマンドが停止しない場合は log.error() を使用してエラーをログ出力する
    • 特に理由がない場合は基本的に throw することが推奨されます
    • serverless-offline のようなプラグインの場合はエラーが発生してもローカルサーバーは停止すべきではないため、例外的に log.error() を使用できます

Tips

プラグイン開発の Tips を紹介します。

serverless.yml があるディレクトリを取得する

serverless.yml からの相対パスを解決したい場合など、 serverless.yml があるディレクトリの絶対パスが欲しくなることがあります。

これを取得するには 2 通りの方法があります。

cwd を取得する

v3 では serverless.yml がコマンドの実行ディレクトリにない場合にエラーが出るようになっています。

逆に言うとコマンドの実行ディレクトリに serverless.yml があることが保障されているので、プロセスの cwd を取得すれば OK です。

class MyPlugin {
  constructor() {
    this.servicePath = process.cwd();

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.servicePath);
  }
}

Serverless インスタンスから取得する

Serverless インスタンスの serviceDir プロパティから取得することができます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.serviceDir);
  }
}

ただしこの方法は公式ドキュメントには記載されていないため、使わない方が良いかもしれません。

任意の Serverless コマンドを呼び出す

serverless.pluginManager.spawn() というメソッドにより、任意の Serverless コマンドを呼び出すことが可能です。

これを使うことで、プラグインから Serverless 組み込みコマンドの機能 (デプロイアーティファクトの作成など) や他のプラグインの機能を利用することができます。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
      spawned: {
        usage: "This command is spawend by my-command",
        lifecycleEvents: ["init", "run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.spawn(),
      "spawned:init": () => this.spawnedInit(),
      "spawned:run": () => this.spawnedRun(),
    };
  }

  spawn() {
    this.serverless.pluginManager.spawn("spawned");
  }

  spawnedInit() {
    console.log("init");
  }

  spawnedRun() {
    console.log("spawned");
  }
}

このテクニックは公式ドキュメントには記載されていませんが、前述の開発者によるチートシートで紹介されています。

サブライフサイクルを定義する

serverless.pluginManager.spawn() の応用で、既存のライフサイクルイベントにサブライフサイクルを定義することができます。

これは特に 独自プロバイダー プラグインにおいて有用なテクニックで、例えば次のように組み込みの deploy コマンドで独自のライフサイクルイベントを発生させることができます。
これにより、他のプラグインがフックできるタイミングを増やすことが可能です。

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.provider = serverless.getProvider("my-provider");

    this.commands = {
      "my-deploy": {
        usage: "My deploy command",
        lifecycleEvents: ["createStack", "uploadArtifacts", "updateStack"],
      },
    };

    this.hooks = {
      "deploy:deploy": () => this.deploy(),
      "my-deploy:createStack": () => this.createStack(),
      "my-deploy:uploadArtifacts": () => this.uploadArtifacts(),
      "my-deploy:updateStack": () => this.updateStack(),
    };
  }

  deploy() {
    this.serverless.pluginManager.spawn("my-deploy");
  }

  createStack() {
    console.log("create stack");
  }

  uploadArtifacts() {
    console.log("upload artifacts");
  }

  updateStack() {
    console.log("update stack");
  }
}

ちなみにこの方法だと my-deploy コマンドがユーザーから直接利用できてしまいますが、次のように type"entrypoint" にしてあげることでコマンドを隠蔽できます。
ただし公式ドキュメントには記載されていない仕様で、内部プラグイン以外での利用例が見つからなかったため利用しない方が良いかもしれません。

this.commands = {
  "my-deploy": {
    type: "entrypoint",
    lifecycleEvents: ["createStack", "uploadArtifacts", "updateStack"],
  },
};

プラグインを機能単位で分ける

独自プロバイダー のような大規模なプラグインを開発する場合、分かりやすさのために機能単位でプラグインを分けることがあります。

このような場合にはインデックスとなるプラグインを用意し、そこから各プラグインを読み込むようにしてあげるとユーザー側からは単一のプラグインのように扱うことができます。

const MyProvider = require("../my-provider");
const MyProviderDeploy = require("../deploy");

class MyProviderIndex {
  constructor(serverless) {
    this.serverless = serverless;

    // pluginManager の addPlugin() でプラグインを追加できます。
    serverless.pluginManager.addPlugin(MyProvider);
    serverless.pluginManager.addPlugin(MyProviderDeploy);
  }
}

これは serverless-azure-functionsserverless-google-cloudfunctions などの公式プラグインで使われているテクニックです。

AWS 用プラグイン開発の Tips

AWS プロバイダー専用プラグイン開発の Tips を紹介します。

解決されたリージョンを取得する

provider インスタンスの getRegion() で、Serverless 側で解決されたリージョンを取得することができます。

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.provider.getRegion());
  }
}

CLI オプションや serverless.yml から自前で解決するよりは Serverless が解決したものを直接参照した方が手っ取り早くて確実です。

ちなみに provider クラスの仕様はドキュメント化されていないのでこういったメソッドを使っていいかどうかは実際のところ不明ですが、メジャーなプラグインでも使われているので基本的には問題ないはずです。

解決された認証情報を取得する

Serverless では様々な方法で AWS 認証情報 を設定できるので、これを自前で解決するのは至難の業です。
こちらも provider インスタンスの getCredentials() から解決されたものを取得できます。

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const { credentials } = this.provider.getCredentials();
    console.log(credentials);
  }
}

Serverless 内部では AWS SDK for JavaScript v2 が使われているので、取得できる認証情報も v2 のもの になります。

AWS SDK を使う

provider インスタンスの sdk プロパティから Serverless が内部で利用している AWS SDK (v2) を取得することができます。

const s3 = new provider.sdk.S3();

しかしこのプロパティは v2.30.0 で一度削除されています。
その後、プラグインへの影響を鑑みて v2.30.2 で差し戻されていますが、Serverless 側としてはこのプロパティを非推奨にしようという動きがあるようです。

このことから、今後はプラグイン側で直接 AWS SDK を依存関係として持つようにした方が良さそうです。
この方法であれば Serverless 内部で使用されている AWS SDK のバージョンに縛られないというメリットもあります。

AWS SDK for JavaScript v2 を使う

前述の getRegion()getCredentials() を活用すれば簡単にサービスクライアントのセットアップが可能です。

const SSM = require("aws-sdk/clients/ssm");

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    const region = this.provider.getRegion();
    const { credentials } = this.provider.getCredentials();
    const config = {
      region,
      credentials,
    };
    this.ssm = new SSM(config);
  }
}

AWS SDK for JavaScript v3 を使う

provider インスタンスから取得できるのは v2 の認証情報なので、AWS SDK for JavaScript v3 を使うには少し工夫が必要です。

const { SSMClient } = require("@aws-sdk/client-ssm");

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    const region = this.provider.getRegion();
    const { credentials: v2Credentials } = this.provider.getCredentials();
    const credentials = {
      accessKeyId: v2Credentials.accessKeyId,
      secretAccessKey: v2Credentials.secretAccessKey,
      sessionToken: v2Credentials.sessionToken ?? undefined,
      expiration: v2Credentials.expireTime ?? undefined,
    };
    const config = {
      region,
      credentials,
    };
    this.ssm = new SSMClient(config);
  }
}

TypeScript による開発

TypeScript による開発も可能です。

DefinitelyTyped型定義 もありますが、あまり正確ではないので自分で必要な型を定義した方が早いかもしれません。

公式プラグインである serverless-azure-functions が TypeScript で開発されているのでこちらのコードが参考になるかと思います。

  1. 最新のドキュメントからは削除されています。またコード内でも legacyLocalPluginsPath という変数名になっています。

  2. https://www.serverless.com/framework/docs/deprecations#cli-options-extensions-type-requirement

  3. https://www.serverless.com/framework/docs/deprecations#handling-of-unrecognized-cli-options

  4. Serverless の組み込みコマンドや AWS プロバイダーは「内部プラグイン」という形で実装されています。

  5. https://www.serverless.com/framework/docs/deprecations#configvalidationmode-error-will-be-new-default

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?