kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

インフラチームで導入しているflowの話

この記事はChatWork Advent Calendar 2017 - Adventarの、12日目の記事です。

こんにちは。インフラマネジメント部の @cw-ozaki です。 弊社のフロントエンドではTypeScriptを導入していたり、一部のサービスでScala.jsを導入しようとしていますが、インフラ側ではflowを導入しています。 わざわざ利用するツールを変えてまで何故flowを導入するのか。その魅力をご紹介したいと思います。

どこでflowを使うの?

インフラ側での主な利用用途はAWS Lambdaになります。 どうしてflowをAWS Lambdaで利用するのかというと、AWS Lambdaは動かすのに労力がかかるので、動かす前に問題を発見したいという思いが強いからです。 というのも個人的な経験則ですがAWS Lambdaでの開発で大体問題になるのは下記の3つです。

  1. 凡ミス
    • プロパティ名間違えたとか
  2. IAMの権限付与漏れ
  3. 他サービスとLambdaやStepFunctionsでのLambda間の結合ミス

これらの問題は一度動かしてみれば解決するのですが、AWS Lambdaはコードをアップロードする必要があるため、どうしても高速に開発のサイクルを回すことができません。 もし、依存した他のサービスがある場合はそちらの準備や破棄も必要になるため、さらに開発サイクルの速度は落ちてしまいます。

そこで動かす前に静的型付けチェックを行えるflowを導入することで1.と3.の問題は解決できます。 ただし、2.に関してはflowでも解決できないので別の方法を模索してください。(chaliceのAutomatic IAM policy generationをNodeで実現するツールが欲しいです)

ちなみに、それなら元々静的型付けを行う言語を利用すれば良いのでは?と思う方もいらっしゃるかもしれませんが、そこはコールドスタート問題があるためNodeを採用しています。(他にもエコシステムの豊富さなどの理由もあります)

flowとAWS Lambda

先ほど上げたflowの導入理由はそのままTypeScriptやScala.jsでの導入理由にもなります。 それでは他のAltJSと比べてflowのどこが良いかというと

  • AltJSとしてではなくチェック用のツールとして提供されている
  • 利用する方法がいくつかある
    • 生のJSで利用
    • JSを拡張した特殊な型の構文での利用
      • この場合はトランスパイルして型の除去を行える
    • コメントに型定義を付与しての利用
  • トランスパイルする場合はメジャーなbabelを利用できる
    • つまり今あるbabelプロジェクトをそのまま再利用できる
  • flowの機能として型の網羅率や、型のないコードへの型定義の付与、型定義の自動生成などもある

といったところで、今あるJavaScriptの知識をそのまま利用できる導入のしやすさがflowの魅力です。

中でもAWS Lambdaでflowを利用するにあたって最も重要なのがコメントに型定義を書けるComment Typeを利用できることです。 AWS Lambdaでトランスパイルしたコードを利用するとManagement Consoleで修正がしにくくなり、エラー時のスタックトレースの行数がわかりにくくなるなどの問題が発生します。 そこを解決するにはトランスパイル必須なTypeScriptやScala.jsでは解決できず、トランスパイルをしないという選択をできるflowでしか解決できない問題です。

creators-note.chatwork.com 弊社の @eielh が書いたGASの話でも同じような結論に至っているので興味ある方はご参照ください。

flowの始め方

flowの導入方法はとても簡単です。

$ npm install --save-dev flow-bin
$ $(npm bin)/flow init
$ $(npm bin)/flow check

上記のようにNodeプロジェクトでflow-binをインストールして、flow checkコマンドを実行すればそれだけで静的型付けチェックを行えます。 準備が出来たら簡単なコードを例にどのようにして型付けを行っているのか解説したいと思います。

/* @flow weak */
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = function(event, context, callback) {
  let promises = event.Records.map((record) => {
    return new Promise((resolve, reject) => {
      let params = {
        CopySource: `/${record.s3.bucket.name}/${record.s3.object.key}`,
        Bucket: `${process.env.TARGET_BUCKET}`,
        Key: `${record.s3.object.key}`
      };
      s3.copyObject(params, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data);
        };
      });
    });
  });
  Promise.all(promises).then((results) => {
    callback(null, results);
  }).catch((err) => {
    callback(err);
  });
};

今回は簡単にS3 Event NotificationのPut Eventを受け取って、別のバケットにPutされたオブジェクトをコピーするAWS Lambdaです。 型の適用具合をわかりやすくするためにAtomのNuclideでflowのカバレッジを表示すると下記のように表示されます。

f:id:cw-ozaki:20171212113358p:plain

見事に真っ青ですね。これはflowの型で言うとanyまたはemptyになっているためこのような表示になります。anyは何の型としても扱えるため型を利用しているとみなされず、emptyは型が未定義だったり、flowが型を推論することができないときに使われます。(ちょっと複雑な型を書くと結構あっさり推論できなくなるのでカバレッジは常に表示していることをお勧めします)

今の状態ではflowを導入したありがたみは全くないので、段階を追ってカバレッジで青くなっていくところを減らしていきます。

Lambda Functionの入出力の値に型を付与する

まずは/* @flow weak */と警告を弱めていたのを/* @flow */と通常の警告に戻します。

f:id:cw-ozaki:20171212113500p:plain

そうすると画像のようにLambda Functionの入出力のところで型定義がないとエラーが発生します。 そこで、このLambda Functionの引数と、戻り値に型アノテーションを付与します。

/*::
// http://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/notification-content-structure.html
type S3_Notification_Event = {
  // ...
};
// https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-context.html
type LambdaContext = {
  // ...
};
// https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-handler.html#nodejs-prog-model-handler-callback
type LambdaCallback<T> = (error: ?Error | ?mixed, result?: T) => void;

export type LambdaEvent = S3_Notification_Event;
export type LambdaResult = []; // FIXME: 後で適切な型に直す
*/

exports.handler = function(
  event/*: LambdaEvent */
  , context/*: LambdaContext */
  , callback/*: LambdaCallback<LambdaResult> */): void {
  // ...
};

細かい定義は長いので省略しています。 今回の場合はS3 Event NotificationのEventのため型を固定にしていますが、例えばAPI Gatewayのようにユーザーの値を受け取るような場合など型を信用できない場合はmixedという型にしてType Refinementsを用いて適切な型に変換するのが定石です。逆に言えば何かしらの要因で型が決定できないところを固定にすると型と実際の値がズレてしまいます。多少手間でも適切な型に変換するというのはflowを使っていく上で重要なことなので注意するようにしてください。

export type LambdaEvent = S3_Notification_Event;
export type LambdaResult = []; // FIXME: 後で適切な型に直す

また、このLambdaEventLambdaResultはわざわざexportして外部から読み出せるようにしていますが、これはStepFunctionsでAWS Lambdaを連鎖させるときに役立つので、よくこういう書き方をしています。 例えば、このLambda Functionの後に別のLambda Functionを繋ぐ場合に

/*::
import type {LambdaResult as S3CopyResult} from './s3-copy.js';

export type LambdaEvent = S3CopyResult;
export type LambdaResult = [];
*/

というように先ほど定義したLambdaResultを読み込んで、次のLambda FunctionのLambdaEventとして扱うことができます。

AWS SDKに型を付与する

次は外部モジュールのAWS SDKに対して型を付与します。 AWS SDKの場合はflow-typedには型定義がないため自分で用意する必要があります。(TypeScriptなら型定義があるのに・・・とよく言われますが、型定義の質が低いことが多いのであんまり使えないことの方が多いです。全てを諦めて型を書くマシーンになった方がいろいろ幸せです)

外部モジュールの型を定義するには.flowconfig[libs]セクションに指定したディレクトリでxxx.js.flowといったファイルを作成します。

// http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#copyObject-property
declare type S3_CopyObject_Params = {
  // ...
};
declare type S3_CopyObject_Result = {
  // ...
};

declare module 'aws-sdk' {
  declare class S3 {
    copyObject(params: S3_CopyObject_Params, callback: (err: Error, data: S3_CopyObject_Result) => void): void;
  }
}

このように外部モジュールを型定義することで、いつも通り利用するモジュールに型を付与することができます。 これでS3.copyObjectの結果の型もできたので、さきほど定義したLambdaResultの型を正しい型に変更します。

export type LambdaEvent = S3_Notification_Event;
export type LambdaResult = S3_CopyObject_Result[];

これによって、このLambda FunctionはS3 Event Notificationを受け取って、S3 CopyObjectの結果の配列を返すということが明示され、よりわかりやすくなりました。

このあたりの型定義とOpaque Type Aliaseを使ってS3_CopyObject_ResultS3.copyObjectを呼び出さないと作れない型にすれば、このLambda Functionでは必ずS3.copyObjectを呼び出し、その結果を返すというさらに制約を持たせれそうだと妄想しています。本当にできるのかは知らないのでチャレンジャーな方がいればぜひ結果を教えてください。

Lambda Environmentの型を定義する

これで、一通りカバレッジを網羅して、型の警告を消すことができましたが、実はまだ適切に型を付与できていない部分があります。

Bucket: `${process.env.TARGET_BUCKET}`

実はこのLambda Environmentで注入される値の型は?stringになっています。仮に注入を漏らした場合にBucketの値は''となってしまい、実行時エラーになってしまいます。意外にこの注入漏れというのはくせ者で、今回は実行時エラーになりますが、ものによってはそのまま正常に動作してしまうパターンがあります。 そのため、Lambda Environmentの値を適切な型に変換します。

function _env() {
  if (!process.env.TARGET_BUCKET) {
    throw new Error('Undefined environment variables `TARGET_BUCKET`.');
  }

  return {
    TARGET_BUCKET: process.env.TARGET_BUCKET
  };
}

const env = _env();

このようにType Refinementを適切に使うことで、env. TARGET_BUCKETには必ず文字列が入っていることを担保することができます。

最終的なLambda Function

// http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#copyObject-property
declare type S3_CopyObject_Params = {
  // ...
};
declare type S3_CopyObject_Result = {
  // ...
};

declare module 'aws-sdk' {
  declare class S3 {
    copyObject(params: S3_CopyObject_Params, callback: (err: Error, data: S3_CopyObject_Result) => void): void;
  }
}
/* @flow */
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const env = _env();
/*::
// http://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/notification-content-structure.html
type S3_Notification_Event = {
  // ...
};
// https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-context.html
type LambdaContext = {
  // ...
};
// https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-handler.html#nodejs-prog-model-handler-callback
type LambdaCallback<T> = (error: ?Error | ?mixed, result?: T) => void;

export type LambdaEvent = S3_Notification_Event;
export type LambdaResult = S3_CopyObject_Result[];
*/

exports.handler = function(
  event/*: LambdaEvent */
  , context/*: LambdaContext */
  , callback/*: LambdaCallback<LambdaResult> */): void {
  let promises = event.Records.map((record) => {
    return new Promise((resolve, reject) => {
      let params = {
        CopySource: `/${record.s3.bucket.name}/${record.s3.object.key}`,
        Bucket: `${env.TARGET_BUCKET}`,
        Key: `${record.s3.object.key}`
      };
      s3.copyObject(params, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data);
        };
      });
    });
  });
  Promise.all(promises).then((results) => {
    callback(null, results);
  }).catch((err) => {
    callback(err);
  });
};

function _env() {
  if (!process.env.TARGET_BUCKET) {
    throw new Error('Undefined environment variables `TARGET_BUCKET`.');
  }

  return {
    TARGET_BUCKET: process.env.TARGET_BUCKET
  };
}

最終的なLambda Functionは上記のようになります。実際には型定義部分は別ファイルに分離したり、S3.copyObjectPromise化するための関数を用意したりしますが、だいたいこんな感じです。

ここまできっちりと型を書けばだいたい動作してくれます。 もし、何か問題がおきるとすれば

  1. 仕様を満たしてない/仕様と違う
    • 例えばS3.copyObjectで保存するKeyが仕様と違うとか
    • 型定義を間違えてるとか
  2. IAMの権限付与漏れ
    • S3 Event NotificationでEventを発行したS3 BucketでGetObjectの権限がないとか

ぐらいかなと思います。 1.に関しては例えばSAM LocalLocalStackを使ったテストで解決できると思います。 2.に関しては実際にAWS上で動かすしかないのでSAMServerlessApexなどのLambda Frameworkを使って、E2Eテストとしてデプロイ→検証→破棄を回せる状況を作るしかないと思います。

個人的にはSAM LocalやLocalStackを使ったテストを動かせるようにするぐらいなら、E2Eテストに集約してしまった方が手っ取り早いとは思っていますが、このあたりはバランスかなぁと思います。例えば今回みたいに簡単なLambda Functionが複数ある場合はE2Eテストに集約した方が早いですし、ちょっと複雑なLambda FunctionがあるならSAM LocalやLocalStackでテストを書いた方がいいです。

まとめ

いかがでしたでしょうか。 AWS Lambda界隈では最近テスト手法が盛り上がっていますが、flowを使うことでテストの前に問題を検出するというのも一つの手かなと思います。 flowはなかなか使い勝手の良いツールなので、本記事でもし興味が出たらぜひ触ってみてください。

ChatWork Advent Calendar 2017、明日は @cw-nishiguchi です。