Rubyがマジョリティな会社でC#を使ってAWS Lambdaの本番運用を開始した話

こんにちは!freeeでエンジニアをしている @toshi0607 です。アイコンよろしくnyanchuと呼ばれてい ます。

Microsoft PlatformというチームでC#、WPF、Xamarinなどを使ってデスクトップアプリを開発しています。

この記事はfreee develpers Advent Calendar 2017の21日目です。

デスクトップアプリから送信するログファイルの処理をAWS Lambdaを使って行うようになりました。

この記事では導入の経緯や工夫したことについて紹介します。

段階的導入

最終的にLambdaを導入することを目指しつつ、ストレージへのログファイルのアップロードを非同期化することから始まりました。

まずはアーキテクチャの変遷をご覧ください。

Phase0

f:id:s_toshi0607:20171212230919p:plain

アプリからAPIサーバに複数のログをzipで固めて送信しています。

最初zipの中身をAPIサーバで1ファイルずつAmazon S3にアップロードしてからアプリにレスポンスを返していました。

そのため、ファイル数によってはタイムアウトしてしまうことがありました。

クライアント、サーバ両面から様々なアプローチを行いましたが、zip展開処理の非同期化がもっとも効果的でした。

Phase1

f:id:s_toshi0607:20171212230949p:plain

既にRailsサーバーでの一部処理でResqueを利用していたため、当面Resqueで様子を見ることにしました。

ただ、下記の理由からよりスケーラブルなサービスとして切り出したいという思いがありました。

  • Resqueは弊社の色々な機能での非同期処理に利用されており、優先度をつけながら運用している
  • アプリからに限らずログファイル展開で活用する未来がなんとなく見えていた

Phase2

f:id:s_toshi0607:20171212231007p:plain

11月末にこのアーキテクチャで本番運用を開始しました。

メインタスクではなかったので、合間時間や開発合宿などで開発してきましたがとても楽しかったです!

zipの非同期展開処理ができるのは変わりませんが、誤解を恐れずに言うとイベント駆動でスケーラブルなアーキテクチャになりました。

S3にzipファイルをアップロードするイベントでLambdaを起動させています。

Resqueでの運用にまつわる悩みもいくつか解消できていたりします。

はい。

なぜC#(.NET Core)か?

実はインフラ周りだけではなく、サービスでLambdaをnode.jsで運用してたりします。 Real World Serverless

それでもC#で書こうとしたのには理由があります。

メンテナンス

僕が所属しているチームはMicrosoft Platformチームです。

普段C#でクライアントアプリを開発しているので、メンテナンスやレビューの観点から極めて親和性が高いと考えました。

Visual Studio

Windows

強い。AWS Toolkit for Visual StudioをインストールすればVisual Studio上でデプロイ可能。

Lambda用のプロジェクトテンプレートを使用すればローカル実行もテスト(xUnit)を介して可能です。

環境構築にサーバーレスなフレームワークを使わずよしなにやってくれます。楽です。

Mac

Windows版ほどのサポートはありませんが、Visual Studio for Macでもローカルデバッグ環境を楽に構築することができます。

  • Visual Studio for Mac(エディションは注意)
  • .NET Core 1 系(現在VS4Macインストール時に2系がインストールされる)
  • xUnit.NET 2 testing framework support(拡張機能から有効化)

を使用することで、xUnitをFunctoionのドライバとしてデバッグすることができます。

弊社ではMac使いが多いため、Mac向けのLambda環境構築や.NET Coreの出自など詳しめにドキュメントを準備しました。

いざ本番運用

.NET周りのサポートが手厚いLambdaですが、心配なことがありました。

実際本番で運用されている情報はほとんどなく、リリースしたらどんなことが起こるのだろう…?というものです。

しかし、実行環境がどうであれやることは変わらないはず。基本に忠実に次のようなテストや仕組みで準備を行いました。

リトライ

処理に失敗したときはAWS側で自動的に2回リトライしてくれます。

完全に委ねました。

通知

リトライに2回失敗したとき、エンジニアはそれを知る必要があります。

これもAWS側で準備されている仕組みですが、次の図のような構成にしました。

f:id:s_toshi0607:20171219204803p:plain

2回失敗時のDLQとしてAmazon Simple Notification Service(SNS)を選択しました。

通知先はシンプルにメールです。

Lambdaのトリガーになったイベントそのものがつぎのような形式で通知されます。

{"Records":[{"eventVersion":"2.0","eventSource":"aws:s3","awsRegion":"us-east-1","eventTime":"2017-12-22","eventName":"ObjectCreated:Put","userIdentity":{"principalId":"AWS:xxx"},"requestParameters":{"sourceIPAddress":"xx.xx.xx.xx"},"responseElements":{"x-amz-request-id":"1234567","x-amz-id-2":"xxxxx"},"s3":{"s3SchemaVersion":"1.0","configurationId":"ZipUploadedEvent","bucket":{"name":"xxxxx","ownerIdentity":{"principalId":"xxxxx"},"arn":"arn:aws:s3:::xxxxx"},"object":{"key":"xx/xx/54321-xxxx.zip","size":12345,"eTag":"abcde","sequencer":"12345"}}}]}

監視 / ログ

リトライに失敗したときには通知された内容をもとにスタックトレースが見たいです。

CloudWatch Logsには通知内のx-amz-request-idやzipのファイル名に含めたログのID(上述のログでは54321)を吐き出し、検索できるようにしました。

移行 / 切り戻し

もし大量エラーが起これば手段を変えて処理を継続する必要があります。

Lambdaの処理の有効・無効はLambdaのポータルでアップロードイベントの発生の有効・無効で制御できます。

それと同様、元々のResque処理のコードも残し、Redisへのenqueueの有効・無効も管理画面で切り替えることができるようになっています。

代替処理

代替処理でもResqueを使用します。

RedisにenqueueするためだけのRailsタスクを作成し、ログのIDとzipファイルのパスを渡せばいつでもzipの展開処理が可能です。

段階的に移行を行った際の資産を有効活用できています。

ステージング環境での検証

色々と備えたものの、本番環境に出す前に本番に近い環境でのテストはしたいものです。

本番の疑似環境にリリースし、数日間運用してみました。

その結果、次のようなエラーに未然に対処することができました。

一時ディスク容量オーバー

実行環境の/tmpは512MBまで使用することができます。

ただし、同一のコンテナは(できる限り)再利用されます。

今回は/tmp下に展開したzipファイルの中身を置いていたため、タイミングによっては容量をオーバーしてしまっていました。

実行時に/tmp下にファイルがあれば消すようにしました。

容量0のファイルのアップロードでこける

S3のAWS SDKでファイルをアップロードするとき、容量0のファイルのアップロードに失敗したため、容量のチェック処理を入れました。

CI

いくらローカルで簡単にデプロイ環境まで構築できたとしても、個人の環境に依存するのは避けたいところです。

そこでCIサービスとしてVisual Studio Team Servicesを活用しました。

2017年の8月にAWS Tools for Visual Studio Team Servicesが発表され、Lambdaについてもビルドパイプラインが数分で構築できるようになりました。

f:id:s_toshi0607:20171212231622p:plain

ビルド・デプロイの設定はたったのこれだけです。

もともと設定項目少ないのはありますが…!

まとめ

以上を経て今のところは安定稼働しています。

f:id:s_toshi0607:20171221095246p:plain

たまにS3へのアップロードでこけますが(We encountered an internal error. Please try again.)、リトライの範囲内なのでまぁよしとしています。

これまでAWSはfreeeを支える重要なインフラとして部分的に触れてきたものの、特定のサービスをしっかり触ったことはありませんでした。

これを機に他のサービスにも親しんでいけたらと思います。

また、運用は開始したものの、つぎのような課題があります。

  • 通知
    • スタックトレースをいちいち探しに行かなくても、通知されたときに直接確認したい
  • CI
    • プルリクエストやタグでビルド・テスト・リリースをトリガーしたい
  • イベントの設定状況

今回のアーキテクチャを突き詰めても直接的にユーザに価値を届けることにはなりませんが、他に活かせるところを探しつつうまいこと付き合っていきます。

通知あたりはAWS Lambda Advent Calendar 2017の23日目に書けるようにがんばります。

さいごに

ここまで読んでいただきありがとうございました!

freeeではプロダクトを一緒に成長させてくれる仲間を募集しています。

興味を持ってくださった方はぜひお願いします!

www.wantedly.com

jobs.freee.co.jp

明日はCISO(Chief Information Security Officer:最高情報セキュリティ責任者)(長い)のtosaさんです。お楽しみに!

社内での技術発表会を定期的に行うことで心がけていること

freee 株式会社 松崎 啓治です。 人事労務freeeのアプリケーション開発を担当しています🔥

この記事は、freee developers Advent Calendar 2017 20日目の記事です。

社内の技術発表会 真剣.js

真剣.jsという名前からしてJavaScriptを連想しますが、特に関係なく、技術的な発表を行う会です。 社員数が少ない時代から続いています。私がfreeeに入る前から、初期に入社した社員によって運営が切り盛りされていました。

f:id:syanbi:20171218200146j:plain
人数が少なかった頃

初期担当者が忙しくなり、切り盛り具合が大変になったため、私側で真剣.jsの名前を借り、技術勉強会を行うようになりました。

無理のない発表体制

技術発表会では、ざっくりと5分〜10分枠、15分枠を設けて、 発表したい人が居たらどのぐらいの時間枠で発表できるか選択した上で エントリーしてください、とお願いしています。

f:id:syanbi:20171219000042p:plain
社内で利用しているQiita::Teamに募集の投稿をした様子

話したい人がある程度居ないと技術発表会は会として成り立たないので、 4半期に1度のペースで技術発表会の開催を行えるように調整しています。

エンジニアの交流

2017年に入った後、エンジニアの人数も100名まで近づき、担当しているプロダクトや、得意分野、興味、家族構成、出身などかなりのバラつきが出るようになりました。 新しく入社した人と前から働いている人の技術的な興味に関するつながりはきっかけがなければ交わることがありません。

技術発表会、真剣.jsを通じて「ああいうことに技術は応用できないか」「私は運用を知っている」「もっとより良い手段を見つけている」など、 1名の技術興味、視野では追いづらいのを補完する作用があります。実際に機械学習系の発表をエンジニアが行ったところ、聴衆側のエンジニアが 自身の知識や、興味を元に質問をし、発表に出した抱え込んでいた課題の解決策を話し合っていたりしました。

開くことを定常にせず。なんとなくやっている技術発表会

開くことを義務とし、定常とし、発表者に義務を課しつつ開催し続けるスタイルにできるかもしれません。 が、それではなかなか長続きしないし、リラックスした形で発表はできないと思うし、 聴衆も聞くことが義務になるけど、興味のないことに耳は傾けづらいだろうし、レビューもしづらい。 モチベーションを維持しつつ、面白そうな発表や、質問があったらその都度気にしてもらえればいい、 そんな思いで続けています。

低コスト運用

f:id:syanbi:20171218201046j:plain

酒を発注したり、食べ物を発注したりして、コストをかけようとがんばったことも数回あったのですが、 ここ最近はやめ、場所を押さえる・発表者を集める・時間を予定する・リマインドする 以外のことには手を出さなくなりました。お酒があったり、食べ物があると人は集まりそうなのですが、 お酒があったら正しい判断しづらそうだし、発表を聞いた上で話が盛り上がったらより良い酒を飲みに行くとか、 そんな流れになったほうが良いかなという思いが強いためです。(本当は合ったほうがいいかなと最近思っては居ます)

コストが高くなると、開催する意図を細かく説明し、コストが係る理由を正当化していかなくてはなりません。 お酒・食べ物の消費も見積もらなければならないし、そうなると運営として1名ではなく複数名となるようになります。 そうすると適当な開催がしづらくなり、打ち合わせのためのミーティングが開かれ、ミーティングのためのミーティングが... となり技術発表会の開催の目的が業務の匂いが強いものになってしまいます。

なかなかそうすると紋切り型の発表が増えそうだし、一発ネタや時事ネタが流行りそうで、 交流や思わぬ発見があった、などのよくわからないおもしろイベントが発動する機会が減りそうかな〜とか そんなことを思うため、低コスト運用を心がけています。

長く、定期的に続ける

勉強会で得たノウハウや、知見共有や、そこで生まれた場や出会いなどがあるので、 もったいないので長く続けていきたい気持ちを持ってやっています。 真剣.jsをやる、という呼び込みをするとエンジニア以外の方からも真剣.jsにて発表したい、 ということで申し込みがある状態になっていて、非常にうれしいです。🙏

f:id:syanbi:20171218235539j:plain
社内への広報や宣伝に力を入れすぎなくても、自主的に集まり、技術発表が始まる

そして...

社内での技術発表会に参加したい、企画したいという方。 一度弊社まで遊びに着てください。ご案内いたします🔥 最近大阪オフィスでの採用も始まっているようですよ。

www.wantedly.com

エンジニアに限らず、ビジネス、サポートも募集しています。

jobs.freee.co.jp

次の担当は

明日、12/21のfreee Developers Advent Calendarはfreee エンジニア C#の要(かなめ)、@toshi0607 が担当します。 お楽しみに👏👏

ReactComponent のリファクタリング指針

エンジニアの id:t930 です。
freee Developers Advent Calendar 2017 19日目いきます。

React はその名前を聞くようになってから3年以上が経過し、Webアプリケーション開発の文脈においてはもはや枯れた技術と言えるでしょう。会計freeeでも2015年ごろに Backbone.js から React へのリプレースを行い、現在では Reactコンポーネントだけでも900近いファイルが存在しています。当然このような規模でやっているとリファクタリングも必要になってくるわけで、本記事ではそんな中で得られたReactコンポーネントにおけるリファクタリングの指針について紹介していきます。1

適切な単位に分割する

React に限った話ではないですが、巨大で見通しの悪いコンポーネントはメンテナビリティや再利用性の低下を招きます。表示領域、責務、意味付けに応じて適切な単位に分割します。

Bad

// state, method, rendererの肥大化したコンポーネント
class BigComponent extends React.Component {

  constructor() {
    super(...arguments);
    this.state = {
      //...
    }
  }

  handleClickFoo() {
    //...
  }

  handleClickBar() {
    //...
  }

  handleClickBaz() {
    //...
  }

  //...

  render() {
    <div className="big-component">
      <div className="header">
        <div className="header-content-1" />
        // ...
        <div className="header-content-99" />
      </div>
      <div className="body">
        <div className="body-content-1" />
        // ...
        <div className="body-content-99" />
      </div>
      <div className="footer">
        <div className="footer-content-1" />
        // ...
        <div className="footer-content-99" />
      </div>
    </div>
  }
}

Good

// Header, Body, Footerを別コンポーネントに分割
class BigComponent extends React.Component {
  render() {
    <div className="big-component">
      <Header />
      <Body />
      <Footer />
    </div>
  }
}

内部要素の抽象化

あるコンポーネントをラップするようなコンポーネントは内部要素を props.children として扱い、責務を分解します。これにより Props のバケツリレーも減らすことができます。

Bad

class ParentComponent extends React.Component {
  return() {
    <div className="parent">
      <ChildComponent1 title={this.props.title} value={this.props.value} />
      <ChildComponent2 title={this.props.title} value={this.props.value} />
    </div>
  }
}

class ChildComponent1 extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      <GrandChildComponent type="foo" value={this.props.value} />
    </div>
  }
}

class ChildComponent2 extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      <GrandChildComponent type="bar" value={this.props.value} />
    </div>
  }
}

Good

class ParentComponent extends React.Component {
  return() {
    <div className="parent">
      <ChildComponent title={this.props.title}>
        <GrandChildComponent type="foo" value={this.props.value} />
      </ChildComponent>
      <ChildComponent title={this.props.title}>
        <GrandChildComponent type="bar" value={this.props.value} />
      </ChildComponent>
    </div>
  }
}

class ChildComponent extends React.Component {
  return() {
    <div className="child">
      <h3>{this.props.title}</h3>
      {this.props.children}
    </div>
  }
}

Stateless Functional Componentを使う

State を持たないコンポーネントは Stateless Functional Component(SFC) として関数の形で書くことができます。State を持たない純粋関数とすることでそれだけ副作用を減らせるので、プレゼンテーショナルなコンポーネントはなるべく Stateless にすることをおすすめします。SFC の形で書くことでチーム開発においても Stateless であるという意図が伝わりやすくなるでしょう。

Bad

class Foo extends React.Component {
  render() {
    return (
      <div className="foo">{this.props.value}</div>
    );
  }
}

Good

function Foo(props) {
  return (
    <div className="foo">{props.value}</div>
  );
}

継承ではなく Higher Order Component を使う

あるコンポーネントに共通の振る舞いを持たせたい時、継承ではなく Higher Order Component(HoC) パターンを使う方が依存関係などの見通しが良くなります。HoCは他のコンポーネントをラップして機能を追加したコンポーネントです。

Bad

class InputWithHandler extends React.Component {
  constructor() {
    super(...arguments);
    this.state = {
      value: null
    };
  }

  handleChange(e) {
    this.setState({ value: e.target.value });
  }

  render() {
    return (
      <input
        value={this.state.value}
        onChange={this.handleChange.bind(this)}
      />
    );
  }
}

class SelectWithHandler extends InputWithHandler {
  render() {
    return (
      <select
        value={this.state.value}
        onChange={this.handleChange.bind(this)}
      >
        <option value="foo">foo</option>
        <option value="bar">bar</option>
        <option value="baz">baz</option>
      </select>
    );
  }
}

Good

上記の継承を HoC を返すファクトリ関数で置き換えると以下のようになります。

function handlerHoC(BaseComponent) {
  return class extends React.Component {
    constructor() {
      super(...arguments);
      this.state = {
        value: null
      };
    }

    handleChange(e) {
      this.setState({ value: e.target.value });
    }

    render() {
      return (
        <BaseComponent
          value={this.state.value}
          handleChange={this.handleChange.bind(this)}
        />
      );
    }
  }
}

const InputWithHandler = handlerHoC((props) => {
  return (
    <input
      value={props.value}
      onChange={props.handleChange}
    />
  );
});

const SelectWithHandler = handlerHoC((props) => {
  return (
    <select
      value={props.value}
      onChange={props.handleChange}
    >
      <option value="foo">foo</option>
      <option value="bar">bar</option>
      <option value="baz">baz</option>
    </select>
  );
});

recompose

recompose はHoCを定義するための関数群で、これを使用することでHoCをより簡潔に定義することができます。

const enhance = compose(
  withState('value', 'setValue', null),
  withHandlers({
    handleChange({ setValue }) {
      return (e => setValue(e.target.value));
    }
  })
);

const InputWithHandler = enhance(props => {
  return (
    <input
      value={props.value}
      onChange={props.handleChange}
    />
  );
});

パフォーマンスチューニング

実際のところ ReactComponent そのもののパフォーマンスイシューに遭遇した機会はそこまでありませんが、例えば無限スクロールする ListView や、スプレッドシートのようなセルベースの入力インターフェースの実装といったケースでは、VirtualDOM といえどその差分計算時のコストが原因でパフォーマンスが悪化することがありました。推測するな、計測せよということで、まずは react-addons-perf2 を使ってパフォーマンスの計測を行います。

import * as React from 'react';
import Perf from 'react-addons-perf';

class ParentComponent extends React.Component {
  constructor() {
    super(...arguments);
    this.state = {
      label: 'button'
    };
  }

  onClick() {
    Perf.start();
    this.setState({ label: 'clicked' }, () => {
      Perf.stop();
      Perf.printWasted(Perf.getLastMeasurements());
    });
  }

  render(): any {
    return (
      <ChildComponent
        label={this.state.label}
        onClick={this.onClick.bind(this)}
      />
    );
  }
}

function ChildComponent(props) {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
}

クリックすると一度だけラベルを変更するボタンです。2回目のクリック以降は再レンダリングの必要はなさそうですが、react-addons-perf を使用したプロファイリング結果を見ると2回目以降のクリック時にも ChildComponent まで VirtualDOM のレンダリングが走っていることがわかります。

f:id:t930:20171219183259p:plain

そこで、React のライフサイクルメソッドである shouldComponentUpdate で現在の Props と新しく受け取る Props を比較して再レンダリングが必要か否かを判定するようにします。

compose(
  lifecycle({
    shouldComponentUpdate(nextProps) {
      return this.props.label !== nextProps.label;
    }
  })
)((props) => {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
});

これで2回目以降のクリックイベント発火時に ChildComponent の renderer が走ることはなくなりました。

f:id:t930:20171219183310p:plain

しかし、この形式では Props 毎に比較する必要がある上、列挙している Props に漏れがあった場合再描画されるべき時にされないといったバグを生み出してしまうリスクが生じます。 React v15 から導入された React.PureComponent では shouldCOmponentUpdate をオーバーライドして自動的に Props を比較してくれるので、そちらを使うようにします。

// FYI: recomposeを使用しない場合はReact.PureComponentを継承します
pure((props) => {
  return (
    <button onClick={props.onClick}>
      {props.label}
    </button>
  );
});

PureComponent では shallowEqual で Props の比較を行うため、ネストした Props を扱う場合は差分を正確には検知できません。lodash の deepEqual や Immutable.js などを使って比較することはできますが、あまり複雑になってくると比較そのもののコストがレンダリングのコストを上回る・・・といった事態になりかねないので、その点は天秤にかけながら実装していくのが良いでしょう。

Jestでスナップショットを取る

React と同じく facebook が公開しているテストフレームワーク Jest では通常のアサーションに加えてコンポーネントのレンダリング結果をスナップショットとして保存し次回実行時に比較することができます。

import * as React from 'react';
import renderer from 'react-test-renderer';

test('snapshot', () => {
  const component = renderer.create(
    <div>foo</div>
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
$ yarn jest

 PASS  sample.test.js
  ✓ snapshot (15ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        1.328s, estimated 3s

一度実行し、スナップショットが作成されました。元のコンポーネントに意図的に変更を加えた上で再度実行してみます。

import * as React from 'react';
import renderer from 'react-test-renderer';

test('snapshot', () => {
  const component = renderer.create(
    <div>bar</div>
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
$ yarn jest

 FAIL  sample.test.js
  ✕ snapshot (18ms)

  ● snapshot

    expect(value).toMatchSnapshot()
    
    Received value does not match stored snapshot 1.
    
    - Snapshot
    + Received
    
      <div>
    -   foo
    +   bar
      </div>
      
      at Object.<anonymous> (sample.test.js:11:16)
      at process._tickCallback (internal/process/next_tick.js:103:7)

 › 1 snapshot test failed.

Snapshot Summary
 › 1 snapshot test failed in 1 test suite. Inspect your code changes or run with `yarn run jest -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        4.245s

前回実行時のスナップショットと比較し、diff が生じていることが検知されました。これにより意図しないレンダリング結果の変更を防ぐことができます。3

Storybookでドキュメントを生成する

チーム開発の中で新しいメンバーがどのコンポーネントを利用すべきかわからなかったり、それにより似たようなコンポーネントが生まれてしまう状況を防ぐために、Storybookを使用してコンポーネントのドキュメントを生成すると良いでしょう。

import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text, boolean } from '@storybook/addon-knobs';
import DateInput from 'components/input/date_input';

storiesOf('components/input/date_input', module)
.add('with date range', () => {
  return (
    <DateInput
      value={text('value', '2018-01-01')}
      onChange={action('change')}
      minDate={text('minDate', '2017-12-15')}
      maxDate={text('maxDate', '2018-01-15')}
      disabled={boolean('disabled', false)}
    />
  );
})

f:id:t930:20171219183336p:plain

Storybookを使うことで、コンポーネントのユースケース別の解説や、Props変更時の振る舞いについて確認することができます。

最後に

実際の開発ではここに flux によるデータフロー、flowtype による型付けといった要素が加わってきますが、今回はReactComponentそのものにフォーカスして、普段設計やリファクタリング時に気をつけていることなどを紹介させていただきました。少しでも参考になれば幸いです。

明日は社内勉強会を主催されている futoase さんです!お楽しみに〜


  1. 本記事で使用しているReactのバージョンはv15.6.2となります。

  2. React v16以降は非対応となり、ブラウザのデベロッパーツールで計測することが推奨されています。

  3. 意図的に変更がある場合は --updateSnapshot でスナップショット自体を更新することができます。

運用中のサービス間の堅牢性をあげるために、Pact導入を試みた話

この記事は、freee Developers Advent Calendarの18日目です。

はじめまして! freee株式会社のエンジニアの id:Maco_Tasu です。最近の趣味はゲーム実況をYoutubeでみることです。今日は私が所属しているチームで行ったHackweekという取り組みの中で、運用中のサービス間における堅牢性向上を試みた話について書きたいと思います。

Hackweekとは

Hackweekとは私が所属するチームで11月に行った取り組みの一つで、各エンジニアがそれぞれ課題に感じていることに対して各自のアプローチで一週間それだけに集中して行う取り組みです。

私が取り組んだこと

freeeにはマイクロサービスが存在します。いくつかのマイクロサービスでは通信を容易に行えるようにするための専用のクライアントgemがあります。クライアントのテストコードでは、マイクロサービスとの通信箇所の処理はwebmockでマイクロサービスから返ってくるであろう値を返すようにstubしています。このような方法のテストの場合、

  • マイクロサービス側のみ修正して、クライアント側でstubしている箇所の修正漏れなど起こっていても気づきにくい
  • マイクロサービス側のレスポンスの値を変更すると、マイクロサービス側のテスト、クライアント側のstubしている値の二箇所を修正する必要がある

以上のような事が起こりえます。ここで一番問題なのは、マイクロサービス側の修正を行った際に、クライアント側の修正漏れが起こり得ることです。現在は念入りな動作確認やQAさんのチェックを通しているので、そのタイミングで問題には気付けるのですが、ここもより早いタイミングで機械的に検知できるようにすることでサービス間の堅牢性につながるのではないと考えていました。そこで思いついたこととしては、クライアントがリクエストを正しく処理できることを正だとみて、それにマイクロサービスが期待するレスポンスを返せるかという部分をテストで検知できたらこの問題解消されるのでは?と考えていました。これを実現するためのいい感じのツールがないかと色々なテスト方法を検索していて調べたところ、Cookpadさんが執筆されていた実践 Pact:マイクロサービス時代のテストツールという記事をみつけて「これだ!」となりPactの導入を試みてみました。

Pact とは

Consumer-Driven Contract testing を実現するためのツールです。クライアント駆動でテストを作成し、クライアントが期待しているリクエストを実際にサーバーが返せるかの検証をすることができます。実際に使う際には、大きく以下のような手順を踏むことになるかと思います。

  1. consumer(クライアント) にPactのgemを導入して、rspecなどで作ったAPI呼び出し処理に対するテストケースを作成します
  2. 1を実行した際のリクエストとレスポンスがPact fileに記録され吐き出されます。
  3. privider(マイクロサービス) にPact fileのテストを実行するのに必要な初期データを作成します。
  4. 初期データが作成できたらprivider側でbundle exec rake pact:verifyを実行します
  5. この際consumerが期待するレスポンスをprividerが返せるか pact fileにそって検証されます

参照: https://github.com/pact-foundation/pact-ruby

こうやってみると意外にすぐできそうです。今回はこれを運用中のサービスに導入を試みました。

運用中のサービスへの導入

運用中のサービスに導入しようとした場合、クライアント側はwebmockを既に使っていたら少しの設定の追加とwebmockからの置き換えの対応が必要です。

クライアント側

1. まずPactがテスト時にリクエストが受け付けるためのmock serverが起動できるように設定を追加します

spec_helper以下のような設定を追加します

require 'pact/consumer/rspec'

Pact.service_consumer "client" do
  has_pact_with "microservice" do 
    mock_service :microservice do
      port 3001 # 適当なポート
    end
  end
end
2. 次にwebmockを使用している箇所はPactのmock serverを参照するように以下のように変更します

例えば以下のようなwebmockの設定があったら次のような感じで書き換えます。

before: mock_server

stub_request(method, "#{host}#{path}?#{string_query}").
      to_return(status: status, body: body.to_json)

after: pact

string_query = "#{params.to_query}"
description = "#{self.class.it.full_description}"
escape_path = URI.escape(path)
mock_service.given(description).
      upon_receiving(description).
      with(method: method, path: escape_path, query: string_query).
      will_respond_with(
        status: status,
        headers: {
          'Content-Type' => Pact.term(generate: 'application/json', matcher: %r{application/json}),
        },
        body: body
      )

webmockからPactに置き換えた場合でも、Pactがテスト中にmockしたAPIはコールされたかなど検証してくれます。 ここで記述されているupon_receiving(description)の箇所は、マイクロサービス側でテストを実行する時にクライアント側のどのテストと結びついているかを表す識別子的な役割を果たします。そのためここはユニークな値でなければいけないのですが、私の場合、"#{self.class.it.full_description}"を渡すここでとりあえずうまくいっています。

3. Pactを適用させたいテストケースに以下を追加
describe 'test', pact: true do
  ...
end

例のようにpact: trueの書いてあるテストケースのみPactが有効になり、テスト実行時にファイルに書き出されるようになります。この設定があるおかけで運用中のサービスのテストケースに少しずつテストケースを反映することができ、導入のハードルが下がりました。ここで気をつけないといけないのは、一つのテストファイルでwebmockを使いつつPactもつかいたいケースで、Pactを適用させたテストケース実行時にはwebmockを無効にしないとwebmock側で、「そんなリクエストはモックしてないのでしらないぞ!」って怒られてしまいます。そこで柔軟に対応できるように私はspec_helperは次のような設定に変更しました。

RSpec.configure do |config|
  require 'webmock/rspec'
  include WebMock::API

 config.before :suite  do
    WebMock.enable!
  end

 config.before :all, pact: true do
    WebMock.disable!
  end

 config.after :all, pact: true do
    WebMock.enable!
  end
end


def mock_request(method: :get, host: '127.0.0.1:9292', path: '/', params: {}, body: {}, status: 200)
  if self.class.metadata[:pact]
    # pactを使ったmock
  else
    # webmockを使ったmock
  end
end

以上のような設定を追加することで、一つのファイルでwebmockもPactも共存できるようになります。

マイクロサービス側

こちらはクライアント側で作成されたテストケースに対しての初期データ設定のみ対応が必要です。基本的にはControllerのテストが正しく書かれていたらそこで作成されているテストデータを、Pactから使われる箇所へ移植する作業になります。導入にあたる手順は以下になります。

1. クライアント側が生成したファイルをPactが読み取れるように設定を追加します

ruby Pact.service_provider 'microservice' do honours_pact_with 'client' do pact_uri 'jsonファイルが置かれている場所を指定' # 手元で動かすには相対パスでクライアントが吐き出したファイルを指定する感じです。 end end

2. ファイルに書かれたテストケースと対になるテストデータを追加します

基本的にはよくあるFactoryGirl(FactoryBot)、DatabaseCleanerを使ったテストデータの作成方法と同じです。

# FactoryGirlの設定
Pact.configure do | config |
  config.include FactoryGirl::Syntax::Methods
end

# 各テスト時にデータがクリアされるようにする
Pact.set_up do
  DatabaseCleaner.strategy = :transaction
  DatabaseCleaner.start
end

Pact.tear_down do
  DatabaseCleaner.clean
end


Pact.provider_states_for 'client' do
  provider_state 'クライアントで設定しているテストケース名' do # 先述した upon_receiving(description) の名前
    set_up do
       # テストデータセットアップ
    end

    tear_down do
       # セットアップデータ破棄
    end
  end

  # ... 以下テストが続く
end

以上で準備完了です。あとはマイクロサービス側で

bundle exec rake pact:verify

を実行するとクライアント側が吐き出したファイルに沿ってマイクロサービス側でリクエストを受けて、期待するレスポンスを返せるか検証されます。今回は手元で確認するところまでしか導入できてませんが、これをCIなりにのせて自動化していくのが次のスッテプかなというところです。

導入してみて感想

初見のツールだったので仕様の把握に少し時間はかかったものの、一度仕組みさえ整えてしまえばPactを意識しないで今まで通りテストがかくことができました。導入して他に気づいたことや感想は以下になります。

  • [気づき] 現状stubしている箇所で、requestまたはresponseも追従できないものがいくつか見つけることができた
  • [気づき] 現状追従できていない箇所があるものので問題になっていないのは、入念な動作確認やQAの皆様のチェックのおかげが大きいことに改めて気づけた
  • [感想] 実際にクライアント・マイクロサービス間のリクエストを機械的に検証できるのはすごく安心感ある
  • [感想] テストケースごとにPactを使うことができるので、徐々に導入していけて運用中のサービスからでも使いやすかった
  • [感想] Pactを使ったクライアント側のテストがしっかりしていたら、マイクロサービス側のControllerのテストで意味が重複してくるのでテスト自体なくてもいいのではないかと感じた

以上のような感想をいだきました。

最後に

いかがでしたでしょうか。今回はクライアント・マイクロサービス間の堅牢性向上のために、Hackweekを通して運用中のサービスにPactを導入する時に実際にどうやったかという点と、気をつけないといけない箇所について触れました。少しでも同様の問題でお悩みの方への参考になりましたら幸いです。

明日19日目は、freeeのエンジニアで多彩な趣味をお持ちなtakumiさんです!お楽しみに!