react-scriptsはwebpackで何をしているのか

  • create-react-appが利用しているreact-scriptは、webpackで何をしているのか
  • eject コマンドで挿入される設定ファイル群はreact-scriptのものなので、設定を変更するときの参考に

対象


publicPathpublicUrl

const publicPath = isEnvProduction
  ? paths.servedPath
  : isEnvDevelopment && '/';
const shouldUseRelativeAssetPaths = publicPath === './';

const publicUrl = isEnvProduction
  ? publicPath.slice(0, -1)
  : isEnvDevelopment && '';
  • ビルドしたファイル群がどこにデプロイされるか
  • 開発時は相対パス
  • index.htmlcssファイルのコンテンツへのパスの制御
  • publicPath のデフォルトはWebサーバのルートディレクト
  • publicUrl のデフォルトは空文字
  • 共に config/paths.js で定義

getStyleLoaders

const getStyleLoaders = (cssOptions, preProcessor) => {
  ...
  return loaders;
};
  • cssのローダーを返す関数
  • 開発時は style-loader を、ビルド時は MiniCssExtractPlugin を適用
    • ビルド時はcssファイルが生成される
  • postcss-loaderoptions でpostcssのプラグイン等を設定
  • preProcessor (sass-loaderとか) が渡された場合、loadersに追加

entry

[
  isEnvDevelopment &&
    require.resolve('react-dev-utils/webpackHotDevClient'),
  paths.appIndexJs,
 ]

output

devtoolModuleFilenameTemplate

{
  devtoolModuleFilenameTemplate: isEnvProduction
    ? info =>
        path
          .relative(paths.appSrc, info.absoluteResourcePath)
          .replace(/\\/g, '/')
    : isEnvDevelopment &&
      (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
}

optimization

TerserPlugin

new TeserPlugin({
  parse: { ecma: 8 },
  compress: { ecma: 5 },
  mangle: { safari10: true },
  output: { ecma: 5 },
})

resolve

modules

{
  modules: ['node_modules'].concat(
    process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
  ),
}       
  • webpackが探索する node_modules の追加
    • プロジェクトの node_modules を優先するために、2番目に追加される

resolve

plugins

[
  PnpWebpackPlugin,
  new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
]

module

strictExportPresence

{ strictExportPresence: true }
  • エクスポートが不足している場合にエラーにする

module.rules

requireEnsure

{ parser: { requireEnsure: false } },
  • 標準仕様にない require.ensure を無効化

module.rules

eslint-loader

{
  test: /\.(js|mjs|jsx)$/,
  enforce: 'pre',
  use: [
    {
      options: {
        formatter: require.resolve('react-dev-utils/eslintFormatter'),
        eslintPath: require.resolve('eslint'),
        baseConfig: { extends: [require.resolve('eslint-config-react-app')] },
        ignore: false,
        useEslintrc: false,
      },
      loader: require.resolve('eslint-loader'),
    },
  ],
  include: paths.appSrc,
}

mdoule.rules

babel-loader (src内のファイルを対象)

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: paths.appSrc,
  loader: require.resolve('babel-loader'),
  options: {
    customize: require.resolve(
      'babel-preset-react-app/webpack-overrides'
    ),
    babelrc: false,
    configFile: false,
    presets: [require.resolve('babel-preset-react-app')],
    plugins: [
      [
        require.resolve('babel-plugin-named-asset-import'),
        {
          loaderMap: {
            svg: {
              ReactComponent: '@svgr/webpack?-svgo![path]',
            },
          },
        },
      ],
    ],
  },
}

mdoule.rules

babel-loader (src内のファイルを対象)


babel-preset-react-app

  • babel-preset-react-app/create.js で定義が行われている
  • 以下のパッケージがデフォルトで含まれる
    • @babel/preset-env / @babel/preset-react / babel-plugin-macros / @babel/plugin-transform-destructuring / @babel/plugin-proposal-class-properties / @babel/plugin-proposal-object-rest-spread / @babel/plugin-transform-runtime / @babel/plugin-syntax-dynamic-import
  • TypeScriptが有効な時に追加されるもの
    • @babel/preset-typescript / @babel/plugin-proposal-decorators
  • flowが有効な時に追加されるもの
    • @babel/plugin-transform-flow-strip-types
  • ビルド時に追加されるもの
    • babel-plugin-transform-react-remove-prop-types
  • テスト時に追加されるもの
    • babel-plugin-dynamic-import-node

mdoule.rules

babel-loader (src外のファイルを対象)

{
  test: /\.(js|mjs)$/,
  exclude: /@babel(?:\/|\\{1,2})runtime/,
  loader: require.resolve('babel-loader'),
  options: {
    presets: [
      [
        require.resolve('babel-preset-react-app/dependencies'),
        { helpers: true },
      ],
    ],
  },
}

mdoule.rules

css (グローバル)

{
  test: cssRegex,
  exclude: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction && shouldUseSourceMap,
  }),
}
  • cssRegex/\.css$/
  • cssModuleRegex/\.module\.css$/ に該当するファイルは除外
  • getStyleLoaders 関数でローダを取得

mdoule.rules

css (モジュール)

{
  test: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction && shouldUseSourceMap,
    modules: true,
    getLocalIdent: getCSSModuleLocalIdentm
  }),
}
  • cssModuleRegex/\.module\.css$/ に該当するファイルに適用される
  • css-loaderの modules オプションを有効にしている
  • getLocalIdentcssのクラス名の生成方法を指定するオプション

module.rules

sass

{
  test: sassRegex,
  exclude: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 2,
      sourceMap: isEnvProduction && shouldUseSourceMap,
    },
    'sass-loader'
  ),
},
{
  test: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 2,
      sourceMap: isEnvProduction && shouldUseSourceMap,
      modules: true,
      getLocalIdent: getCSSModuleLocalIdent,
    },
    'sass-loader'
  ),
}
  • 基本的にcssと同様でpreProsessorとして sass-loader を指定

plugins

InterpolateHtmlPlugin

new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw)

plugins

ModuleNotFoundPlugin

new ModuleNotFoundPlugin(paths.appPath)

plugins

WatchMissingNodeModulesPlugin

isEnvDevelopment &&
  new WatchMissingNodeModulesPlugin(paths.appNodeModules)

plugins

WorkboxWebpackPlugin

isEnvProduction &&
  new WorkboxWebpackPlugin.GenerateSW({
    clientsClaim: true,
    exclude: [/\.map$/, /asset-manifest\.json$/],
    importWorkboxFrom: 'cdn',
    navigateFallback: publicUrl + '/index.html',
    navigateFallbackBlacklist: [
      new RegExp('^/_'),
      new RegExp('/[^/]+\\.[^/]+$'),
    ],
  })

plugins

ForkTsCheckerWebpackPlugin

useTypeScript &&
  new ForkTsCheckerWebpackPlugin({
    tsconfig: paths.appTsConfig,
    formatter: typescriptFormatter,
  })

PIXI.jsでのWebGLRendererでsubtractする

プロジェクトで利用しているPIXI.jsで必要になったので。

PIXI.jsはHTMLのcanvasを操作するライブラリ。
利用可能な環境であればWebGLで動作し、利用できない環境であればCanvasにフォールバックしてくれる。
グラフィックの操作やマウスイベントをハンドリングをいい感じに行なってくれるけれども、subtract、描画したグラフィックで背面のグラフィックを消すこと、canvasdestination-out がサポートされていないし、以下のような制約がある。

The WebGL renderer only supports the NORMAL, ADD, MULTIPLY and SCREEN blend modes.

PIXI.BLEND_MODES

ただ、内部的には当然ブラウザのAPIを利用しているので、少しの工夫で実現が可能。

mapWebGLBlendModesToPixi ここでPIXI.jsブレンドモードとWebGLの設定のマッピングが行われている。

renderer.state でアクセスできる WebGLState にドキュメントにはないけど、 blendModes というプロパティが存在してさっきの mapWebGLBlendModesToPixi を保持している。
mapWebGLBlendModesToPixi は配列で numberである BLEND_MODES の各値をインデックスとして取得するので、配列の最後に任意の設定を追加して、 GraphicsblendMode に直接その値を指定すれば、その設定を利用して描画してくれるようになる。

具体的には以下のコード。

app.renderer.state.blendModes[20] = [0, 0x0303];

設定している値はここで呼び出している blendFunc(sfactor, dfactor) の引数。
直接設定してしまっているけれど、 WebGL constants で設定した方が行儀がいい。
描画元は alpha を 0 に、描画先は元のままとすることで、描画しようとしたところだけ透明になる。

今回はWebGLのみのサポートで決め打ちしてしまっているけれど、Canvasも対象とするのだったらWebGLが使えるかを判定した後に、それぞれ設定する方がいいと思う。

Cognito User PoolのJWTトークンを検証する

Cognito User Poolへログインした際に返却されるAccessTokenやIdTokenを検証する方法。
API Gatewayの認証でCognitoを使いたいけど、いろいろ処理を入れたいときなどに。

const request = require('request-promise-native')
const jwt = require('jsonwebtoken')
const generatePEM = require('rsa-pem-from-mod-exp')

const verifyToken = async token => {
  const { header, payload } = jwt.decode(token, { complete: true })

  if (!payload) {
    throw new Error('invalid token')
  }

  if (payload.iss !== YOUR_COGNITO_IDP_ISSUER) {
    throw new Error('invalid token')
  }

  const jwks = await request({
    method: 'GET',
    uri: `${payload.iss}/.well-known/jwks.json`,
    transform: body => JSON.parse(body)
  })

  const { n, e } = jwks.keys.find(c => (c.kid === header.kid))
  const pem = generatePEM(n, e)

  return new Promise((resolve, reject) => {
    jwt.verify(token, pem, (error, decoded) => {
      if (error) {
        reject(error)
        return
      }
      resolve(decoded)
    })
  })
}

流れとしては、

  1. 自分のCognitoのトークンか検証
  2. キーを取得してPEMを生成
  3. 生成したPEMを用いてトークンを検証

必要であれば、token_useの確認を。

OpenID Provider(OP)をServerlessで提供する

OpenID ConnectOpenID Provider(OP)をAWSを利用したサーバレスアーキテクチャで提供するためのserverless-oidc-providerを開発しました。

serverless-oidc-providerはnode-oidc-providerAWS Lambdaで実行し、ユーザ管理・認証をCognito User Poolに移譲、データ永続化にDynamoDBを利用することで、サーバを準備することなく迅速に、OpenID Provider(OP)を提供することができます。

詳しい利用方法はREADME.mdやソースコードを確認していただくのが早いとは思いますが、コンセプトや、どのようにユーザ管理を切り離しているのか、またAPI Gatewayのカスタムオーソライザーでの利用について、説明をしていきます。

コンセプト

OAuth 2.0 / OIDC 実装の新アーキテクチャーで述べられている、「ユーザー認証と認可の分離」、「アイデンティティー管理と認可の分離」と「API管理と認可の分離」といった考え方に大きな影響を受けています。
それらを達成しつつ、サービスごとの個別の要件に対応できる柔軟性の確保、スケーラビリティや耐障害性といったサーバレスのメリットを享受できることを目指しています。

OpenID Provider(OP)としてのコアの機能

node-oidc-providerに全面的に依存する構成を取っています。
エンドポイントごとにLambdaに最適化した実装を行った方が、パフォーマンス上有利ではあるのですが、OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語るで述べられているように実装難易度が高いこと、多くの機能についてnode-oidc-providerがOpenID Certificationの認証を受けていることから、Lambda上でnode-oidc-providerをWebアプリとして実行する選択をしました。

なお、node-oidc-providerはnode 8.0以上、内部で利用しているkoaはnode v7.6以上を要求します。
Lambdaで利用可能なnodeは6.10であるため、babel-registerを利用する必要があるのですが、現行のbabel-registerはLambda上で動作しません。参考
そのため、現在β版のv7系のbabelを利用しています。
新しいバージョンの@babel/registerはシンプルにrequire()を拡張可能なpiratesを利用するように実装の変更が行われており、Lambda上でも利用することが可能です。

require('@babel/polyfill')

require('./shims/url')
require('./shims/util')

require('@babel/register')({
  presets: [
    ["@babel/preset-env", {
      "targets": { "node": "6.10" }
    }]
  ],
  ignore: [filename => {
    if (filename.match(/node_modules\/oidc-provider/)) {
      return false
    } else if (filename.match(/node_modules\/koa/)) {
      return false
    } else {
      return true
    }
  }],
  cache: false,
  babelrc: false
})

@babel/registerは通常、node_modulesに対しては変換を行わないようになっています。そこで、上記のコードのように、oidc-providerとkoaに関連するモジュールに対して、require時に変換を行えるようにしています。

ユーザ認証

Cognito User Poolを利用していますが、Adminとしてのユーザログイン、ユーザ情報の取得のみの機能を利用しています。
ユーザの登録や管理は、各種SDKを利用したアプリケーションで行う想定としています。これは、可能な限りユーザ管理に関わる固有の実装を分離することを意図したものです。
具体的な実装はsrc/app/oidc/account/index.jsであり、同じインタフェースを備えるクラスを実装すれば、Cognito User Pool以外のユーザ認証の仕組みを利用することが可能です。
また、このクラスを利用するsrc/app/oidc/actions/interaction.jsと合わせて変更することで、任意の方法でのユーザの認証・特定の仕組みを利用することができます。

API Gateway カスタムオーソライザー

src/services/authorizeは、API Gatewayのカスタムオーソライザーとして指定可能なLambda関数のサンプルです。
RFC7662: OAuth 2.0 Token Introspectionに準拠したイントロスペクションエンドポイントを用いたカスタムオーソライザーの例であり、もちろんnode-oidc-providerの提供するエンドポイントでも利用することが可能です。
具体的な実装はsrc/app/authorize以下のコードとなります。

const { getToken } = require('./helpers/get-token')
const { generatePolicy } = require('./helpers/generate-policy')
const { introspect } = require('./introspection/introspect')

const authorizerHandler = async (event, context, callback) => {
  const token = getToken(event.authorizationToken)

  if (!token) {
    callback('Unauthorized')
  }

  const introspectionResult = await introspect(token)

  if (!introspectionResult.active) {
    callback('Unauthorized')
  }

  const { principalId, effect, resource, authContext } = await confirmPermission(introspectionResult, event.methodArn)

  const policy = generatePolicy(principalId, effect, resource, authContext)

  callback(undefined, policy)
}

/**
 * From the introspection result, confirm the authority to the access target.
 * If you are doing your own permission management, use this method to verify.
 */
const confirmPermission = async (introspectionResult, resource) => {
  // Currently it is an implementation that allows access if it is a valid token.
  return {
    principalId: (introspectionResult.sub) ? introspectionResult.sub : introspectionResult.client_id,
    effect: 'Allow',
    resource: resource,
    authContext: {
      // The value that can be set in the context is limited to the primitive value, so serialize it.
      introspection: JSON.stringify(introspectionResult)
    }
  }
}

module.exports = {
  authorizerHandler
}

confirmPermission()でイントロスペクションエンドポイントの結果とリソースに基づき、トークンの利用者がリソースにアクセス可能かを判断します。
現在のサンプルは、トークンが有効であればアクセスを許可する実装となっています。
また、保護されたリソースで再度のトークン情報の問い合わせが不要なように、カスタムオーソライザーのレスポンスのcontextに、イントロスペクションの結果をシリアライズしてセットしています。
実際には、イントロスペクションのレスポンスに含まれる、ユーザを一意に特定するsub(Client Credentials Grantでは含まない)とトークンを払い出されたclient_id、要求したscope等の情報を元に、リソースに対するクライアントアプリケーションに対するアクセス制御、ユーザに対するアクセス制御を行います。

lamproxとlambda-utilities

AWS Lambdaのアプリケーション開発で利用している自作のフレームワークがそれなりにこなれてきたので、README.mdの日本語訳をのせて紹介します。

Lamprox

AWS Lambdaのlambda-proxy機能を使った開発のためのフレームワークです。

セットアップ

$ npm install lamprox

概念と使用方法

Lamproxは、AWS Lambdaのlambda-proxy機能のための最小限かつ柔軟なフレームワークです。
AWS Lambdaを使用して複数のエンドポイントを構築する場合の、認証、応答処理、およびエラー処理のための処理を、横断的に適用することができます。

Process

Lamproxはハンドラを複数のProcessで定義します。
Processは以下に示すような関数です。

interface Process<T, U, E> {
  (ambience: ProcessAmbience<T, E>): Promise<U>
}

interface ProcessAmbience<T, E> {
  /** Variables that pssed lambda function. */
  lambda: {
    event: APIGatewayEvent
    context: Context
    callback: ProxyCallback
  }
  /** Result that preceding process. */
  result: T
  /** Shared variables accross that processes. */
  environments: E
}

Processor

Processorは複数のプロセスを保持して順番に実行するクラスです。
Processorはbefore、main、after、response、onErrorの各プロセスを保持し、ハンドラとして実行します。

/** Preparing before main process. */
type BeforeProcess<T, E> = Process<void, T, E>
/** Main process for request. */
type MainProcess<T, U, E> = Process<T, U, E>
/** After process. */
type AfterProcess<U, E> = Process<U, U, E>
/** Process that creating proxy result. */
type ResponseProcess<U, E> = Process<U, ProxyResult, E>
/** Process that called when error occured. */
type OnErrorProcess<E> = Process<Error, ProxyResult, E>

interface IProcessor<T, U, E> {
  before: BeforeProcess<T, E>
  main: MainProcess<T, U, E>
  after: AfterProcess<U, E>
  response: ResponseProcess<U, E>
  onError: OnErrorProcess<E>

  toHandler: () => LambdaProxyHandler
}

関数群

一般に、Processorを直接生成する必要はありません。
Lamproxは、ハンドラを作成するためのいくつかの関数を提供します。

lamprox()

単純なラムダプロキシハンドラを作成します。
レスポンスボディを生成するメソッドを書くだけで、lambda-proxyのハンドラを作成できます。

lamprox: <U>(main: MainProcess<void, U, void>) => LambdaProxyHandler

buildHandler()

各種プロセス - befire、after、response、onError - とEnviromentsでラムダ関数を作成します。
Enviromentsはプロセス間で共有される値です。

namespace IProcessor {
  interface Params<T, U, E> {
    main: MainProcess<T, U, E>,
    environments: E,
    before?: BeforeProcess<T, E>
    after?: AfterProcess<U, E>
    response?: ResponseProcess<U, E>
    onError?: OnErrorProcess<E>
  }
}

buildHandler: <T, U, E>(params: IProcessor.Params<T, U, E>) => LambdaProxyHandler

prepareHandlerBuilder()

prepareHandlerBuilder()は、buildHandler関数を生成するための関数です。
多くのラムダ関数があると場合に、共通のプロセスを定義されたbuildHandler関数を生成することができます。

namespace PrepareHandlerBuilder {
  interface Params<T, U, E> {
    before?: BeforeProcess<T, E>
    after?: AfterProcess<U, E>
    response?: ResponseProcess<U, E>
    onError?: OnErrorProcess<E>
  }
}

interface BuildHandler<T, U, E> {
  (params: IProcessor.Params<T, U, E>): LambdaProxyHandler
}

prepareHandlerBuilder: <T, U, E>(preparedParams?: PrepareHandlerBuilder.Params<T, U, E>) => BuildHandler<T, U, E>

ユーティリティ

Lamproxにはnode-lambda-utilitiesが含まれていますが、lambda-proxyのためのいくつかのユーティリティ関数が用意されています。

generateDummyAPIGatewayEvent()

これは、ダミーのAPIGatewayEventを生成するための関数です。
node-lambda-utilitiesのinvokeHandler()と一緒に使用してハンドラをテストすることができます。

generateDummyAPIGatewayEvent: (params?: GenerateDummyAPIGatewayEvent.Params) => APIGatewayEvent

generateProcessAmbience()

Process関数実行時の引数となるProcessAmbienceを生成するための関数です。
これを使用すると、Processそれぞれをテストすることができます。

generateProcessAmbience: <T, E>(params: GenerateProcessAmbience.Params<T, E>) => ProcessAmbience<T, E>

lambda-utilities

AWS Lambdaのユーティリティ関数と型定義

インストール

$ npm isntall lambda-utilities

使用方法

型定義

lambda-utilities@types/aws-lambdaが提供するLambdaの基本な型定義を含みます。
以下のリストは、@types/aws-lambdaで提供されている型定義の一部です。

  • API Gatewayのイベントと応答
  • API Gateway CustomAuthorizerのイベントと応答
  • SNSイベント
  • S3作成時のイベント
  • Cognito User Poolイベント
  • CloudFormation Custom Resourceイベントとレスポンス
  • Context

lambda-utilities@types/aws-lambdaが提供していないDynamoDB Streamsイベントの型定義を提供します。
さらに、lambda-utilitiesは独自のイベントハンドラ用の汎用インタフェースを提供します。

interface Handler<Event, Callback> extends Function {
  (event: Event, context: Context, callback: Callback): void
}

ユーティリティ

Callback

SinonExpectationインタフェースを実装しているCallbackのモックを提供します。
引数callbackcallsFakeで実行されます。

generateMockCallback: (callback?: Callback) => MockCallback

Context

Contextのモックを提供します。
done、failとsucceedは、SinonExpectationインタフェースを実装しています。 パラメータとして関数を設定すると、その関数はcallsFakeで実行されます。

generateMockContext: (params?: GenerateMockContext.Params) => MockContext

export namespace GenerateMockContext {
  export interface Params {
    callbackWaitsForEmptyEventLoop?: boolean
    functionName?: string
    functionVersion?: string
    invokedFunctionArn?: string
    memoryLimitInMB?: number
    awsRequestId?: string
    logGroupName?: string
    logStreamName?: string,
    identity?: CognitoIdentity,

    getRemainingTimeInMillis?: () => number

    done?: (error: any, result: any) => void,
    fail?: (arg0: any) => void,
    succeed?: (arg0: any, arg1?: any) => void
  }
}

InvokeHandler

Lambdaのハンドラを実行する関数です。
モックと組み合わせてテストで使用できます。

const handler: Handler<TestEvet, TestCallback> = (event, context, callback) => {
  setTimeout(() => {
    callback(undefined, { foo: event.foo * 2 })
  }, 1000)
}

const callback = generateMockCallback((error, result) => {
  callback.once()
  assert.equal(result.foo, 42)
  assert.ok(callback.verify())
  done()
})

invokeHandler(handler, {
  event: { foo: 21 },
  callback: callback
})

serverless-import-swaggerの紹介

新規サービス開発のプロジェクトの中で、Serverless FrameworkSwagger形式のAPI定義をインポートする、AKIRA-MIYAKE/serverless-import-swaggerを開発したので、その紹介を行います。

開発の動機

開発中のサービスは、Angular2のSPAをS3に配置してCloudFrontで配信、データ取得などのためのAPIAWS Lambda + API Gatewayで実装、というサーバレスアーキテクチャを採用しています。
フロントエンドとバックエンドの開発を並行してスムーズに行うことができるように、Swagger形式でAPIを定義しそれに基づいて実装するようにしています。

AWS LambdaへのデプロイやAPI Gatewayとの紐付けをを手作業で行うのは考えられないので、Serverless Frameworkを用いてイベントや権限を定義、CIでの自動デプロイの環境を整えています。
Serverlessでは、このような感じYAML形式で関数を定義します。
基本的にSwaggerで定義したAPIのメソッドがLambda関数に対応します。Swaggerの定義からServerlessの関数定義を作れたら嬉しいですよね。
でもそんなプラグインはないらしい…ということで開発しました。

開発の上での考え方

Serverlessにはプラグインを追加できる仕組みがありますが、独立したCLIツールとしています。作り始めた時点では、Serverlessのバージョンが0.5から1.0に変わったすぐあとぐらいで、変更が生じる可能性が高かったこと、ビルドやデプロイのプロセスに処理を追加するわけではなかったためです。
他には以下の点を実現できるようにしています。

  • providerなどの共通項目を別途定義して読み込めること
  • Swaggerのタグを用いて任意にサービスを分割できること
  • 生成されるサービスにプレフィックスを付与できること
  • 関数名がAPIに基づいて自動生成されること
  • 手動で追加した項目が再度実行時に消えないこと

基本的な使い方

  1. npm install serverless-import-swaggerでインストールします。
  2. Swagger定義ファイルの取り込む対象のメソッドにタグを追加します。
  3. Swagger定義ファイルをプロジェクトに配置します。
  4. providerなどの共通項目を定義したYAMLファイルを用意します。
  5. sisコマンドを実行することでインポートが実行されます。Serverlessのサービスが存在しない場合は自動的に作成されます。

オプションは以下の通りです。

# 共通オプション
-i, --input <path>             インポートするSwagger.yamlのファイルを指定します。 (デフォルトは "./swagger.ya?ml" です)
-c, --common <path>            共通項目を定義したYAMLファイルを指定します。 (デフォルトは "./serverless.common.ya?ml" です)
-o, --out-dir <path>           Sserveressサービスの出力先を指定します。 (デフォルトは "./" です)
-f, --force                    このオプションを追加すると、新しい出力で既存の内容を完全に上書きします。

# タグやサービスのプレフィックスに関するオプション
-A, --api-prefix <prefix>      Swaggerのタグにつけるプレフィックスです。 (デフォルトは "sls" です)
-S, --service-prefix <prefix>  出力されるサービスに付与されるプレフィックスです。 (デフォルトはなしです)

# ベースパスの扱いに関するオプション
-B, --base-path                このオプションを追加すると、httpイベントのパスからベースパスが取り除かれます。

# CORSに関するオプション
-C, --cors                     このオプションを追加すると、httpイベントに`cors=true`が追加されます。
-O, --options-method           このオプションを追加すると、GETメソッドの関数には`cors=true`がそれ以外のメソッドを含むパスにはoptionsメソッドの関数が追加されます。

サービス分割時のベースパスについて

デフォルトでは、Swaggerのタグに基づいてサービスが分割され、APIのパスがそのままインポートされます。
CloudFormationの制約のため、API数が一定規模を超える場合サービスを分割する必要がありますが、API Gatewayでカスタムドメインに複数のAPIに紐づける場合、ベースパスの指定が必要となります。
そのため、Swaggerで/foo/barと定義したAPIがカスタムドメインからアクセスすると/{base-path}/foo/barとなってしまい、定義と異なってしまいます。

-B, --base-pathオプションを追加することで、Serverlessのイベントのパスからベースパスを除去するようになります。
先ほどの例だと、serverless.ymlでは/fooが取り除かれて/barとなり、API Gateway/fooのベースパスでマッピングすることで、Swaggerの定義と一致させることができます。
ただし、この機能を使うと、ベースパスを基準にしたサービスの分割を強制されることとなります。

CORSについて

-C, --corsオプションをつけるとAPI GatewayのデフォルトのCORSのためのOPTIONSメソッドが用意されます。単純なAPIアクセスを行う場合であれば、このオプションだけで問題はないと思います。
ただし、Cookieや独自ヘッダを用いた通信を行う場合、Access-Control-Allow-Originワイルドカードを指定することができないため、対象のレスポンスを返す関数だけでなく、preflight requestで呼び出されるOPTIONSも独自に定義する必要があります。
-O, --options-methodオプションを付与すると、SwaggerのAPIのパスの中にGET以外のメソッドが定義されている場合、自動でそのパスのOPTIONSメソッドに対応する関数を追加します。
追加された関数でOPTIONSのヘッダを独自に定義することで、Cookieや独自ヘッダを用いたCORSを実現することができます。


ベースパスやCORSの部分は現在のプロジェクトやAWSの制約によって用意したものなので、もう少しうまい解決方法があるかもしれませんが…。
開発中のサービスがリリースされ、落ち着いた頃に整理ができればと思います。

AWSでサーバレスアーキテクチャで開発する際のいくつかの制約について

現在開発中の新規サービスは、AWSを利用したサーバレスアーキテクチャを採用しています。
Angular2のSPAをS3に配置してCloudFrontで配信、データ取得などのためのAPIAWS Lambda + API Gatewayで実装する、教科書通りの構成をとっています。
そこまで大規模なサービスではないのですが、現時点でのAPI数は約100弱。それを手作業で管理するのはよろしくないということで、Serverless Frameworkを用いて管理しているのですが、その中でいくつかの制約に遭遇したのでまとめておきます。
なお、Serverless Frameworkはv1.5を利用しています。現在の最新版はv1.9.0なので、Serverlessに関するいくつかの項目は解決されている場合もあります。

AWS Lambda

関数名の長さ

関数名の長さが64文字までの制限があります。参考: CreateFunction
Serverlessは関数名にサービス名とステージを組み合わせて、${service-name}-${stage}-${functionName}という名前でLambda関数を作成します。
プロジェクトではSwaggerでAPIの振る舞いを定義し、独自に開発したAKIRA-MIYAKE/serverless-import-swaggerserverless.ymlを自動的に生成するようにしているのですが、APIのパスやパラメータに基づいて自動生成した関数名がたまにこの制約に引っかかってしまいました。
任意の関数名を決める場合なら問題にはならないのですが、関数名を自動生成する場合は(そういう人がどれくらいいるかは不明ですが)一意に識別可能でかつ可能な限り短い名前を生成することを意識する必要があります。

CloudFormation

リソースの最大数

ServerlessはCloudFormationを利用してAWS LambdaとAPI Gatewayへのデプロイを実行するのですが、その際に関数や関連するリソースが多すぎると、テンプレートで宣言できるリソースの最大数200の制約に引っかかります。参考: AWS CloudFormation の制限
例えばHTTPをイベントソースとする関数だとAPI Gatewyのリソースやメソッドの定義が必要となるため、関数が30に近いあたりからこの制約に引っかかる可能性が出てきます。
そのため、ある程度の規模のAPIを構築する場合、サービスを分割する必要が出てきます。

出力の最大数

CloudFormationには出力の最大数が60までという制約があります。
Serverless v1.5までは作成されたスタックを全て出力される設定となっているため、それ以上の規模のサービスを定義することができません。
v1.6以上では、CloudFormationの出力が削除されているため、この制約に引っかかることはなくなったのですが、代わりに次の問題が生じるようになっています。

LogGroup作成時のエラー

Serverless v1.5では発生しないのですが、v1.6以降で遭遇するエラー。

An error occurred while provisioning your stack: IamPolicyLambdaExecution
     - Template error: LogGroup /aws/lambda/my-service-name-dev-functionName
     doesn't exist.

v1.6からロググループの作成方法が変更になったようなのですが、どうやら#2614 (comment)にあるように、CloudFormationレベルのバグに起因するもののよう。
関数名のプレフィックスの命名に注意すれば回避できるようですが、あまり好ましい解決策ではない。。

API Gateway

カスタムドメインへの複数APIマッピング

CloudFormationの制約で、それなりの規模のAPIの場合、サービスを分割することが必要。
ServerlessのサービスがAPI GatewayAPIに対応するのですが、カスタムドメインを複数のAPIマッピングする場合は一意のベースパスの設定が必要となります。
そのため、複数サービスに分割する際はそれを考慮する必要があります。
今回はSwaggerで定義したAPIのパスの最初のディレクトリで機械的に分割してグルーピング、そしてそのディレクトリはserverless.ymlのAPIのパスからは除外するという形で対応しています。
RESTfulなAPIであればそこまで大きな問題にはならないと思いますが、今回のサービスではログイン中ユーザに依存する情報を取得する際のAPIとして/userを複数定義していたため、論理的にはあまり関連のないAPIが同じサービスに含まれるような形となってしまいました。


サーバレスアーキテクチャを採用したAPIを開発する中で遭遇した、AWSのいくつかの制約についてあげてみました。
それなりの規模のAPIをServeress、もしくはCloudFormationで管理する場合、リソースの最大数が200という制約で、必然的にいくつかのグループに分割する必要が生じるというのが、それなりに大きいかと思います。
AWS CLIを利用して直接リソースを作成すればその制約を受けることはありませんが、今度は依存関係の解決が難しくなってきます。

今回のプロジェクトは、サーバレスアーキテクチャを利用したステートフルなバックエンド開発、その過程でserverless-import-swaggerなどいくつかツールを開発したので、ブログで紹介していきたいと思います。