メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

フロントエンドのコードを書いている時に考えていること - まず状態から始めよ編

椅子に甘えないと心に決めて最近はスタンディングメインで仕事してます小宮山です。

実は私はペアプロ・モブプロ好き人間です。なぜ好きかというと、単にワイワイコードを書けるというのもありますが、何よりもそのときに考えていることをリアルタイムに共有できるからです。

メドピアのCTO室フロントエンドグループ(最近正式にグループ化されました)は、CTO室という何やら凄そうな名前の部署に所属している通り、メドピア社内のフロントエンド開発を幅広く支援するという役割を持っています。その一環としてペアプロ歓迎ムードを漂わせているわけです。

そして先日久しぶりにペアプロに誘われたのでほいほい承って参戦してみて、やはりペアプロという場はいいなと感じてこんな記事を書いています。

で、何をテーマにするかというとタイトルの通りです。おそらく近頃のフロントエンド開発に慣れた方なら特に意識しなくともそういう考えをしているのではと思うので、それほど目新しく斬新な考え方というわけではありません。

ただ世の中の開発者全員が全員、近頃のフロントエンド開発に慣れているわけでもないはずで、特に普段はバックエンドメインで片手間にフロントエンドも触るけどよく分からんという方もいるのではと思います。私は近頃のフロントエンド開発に慣れた側に立てているだろうということを最上の謙虚な心を持って認めると、残念ながら慣れていない方が感じている、何が分からないか分からないという気持ちを汲むのはなかなか難しかったりします。

なので役に立つのかは分からないけれども、もしかしたら誰かの役に立つかもしれないということで、どんなことを考えながらコードを書いているを紹介してみようというのが今回の趣旨です。

実装タスク

概要

  • 既にテーブル形式でデータ一覧を表示する機能がある
  • そのテーブルにて、行を選択できるようにしたい
  • 複数行選択を可能にしたい
  • 表示されている行全ての選択を切り替える全選択機能も欲しい

要するにこれをこうしたいというタスクです。選択して何をしたいんだということは気にせずいきます。

f:id:robokomy:20201008124711p:plain
Before 👉 After

1stステップ - 機能の境界を意識する

選択して何をしたいんだということは気にせずいきます。

さらっと書きましたが実はこれも考え方として重要かもしれないということで1stステップです。例えば大元の要件が「まとめて選択して削除したい」という場合、「まとめて選択して削除する」機能と考えるのではなく、「まとめて選択する」機能と「削除する」機能を分けて考えた方がいいケースがほとんどです。

せっかくなのでなぜかを説明すると、今後新たに「まとめて選択して移動したい」という要望が来ても対応しやすいからです。そしてフロントエンドという領域にDBはないので、「削除する」「移動する」という機能はそれぞれAPIに処理を委ねることになります。ではフロントエンド側に何が残るのかというと、「まとめて選択する」という機能と、「選択したものをAPIに投げる」という機能です。APIに投げるのは多少のインタフェース調整が必要かもしれませんが、実質ただの関数実行です。つまり「まとめて選択する」という機能さえ作れてしまえば、「まとめて選択して○○したい」という要望の大半は叶ったようなものです。

以上を踏まえて、選択して何をしたいんだということは気にせず、「まとめて選択する」という機能をこれから実装しようと一目散に考えます。

2ndステップ - 状態から考え始める

選択して何をしたいかは気にしませんが、「選択して何かをする」が控えていることを忘れてはいけません。もし本当に「まとめて選択する」という機能だけが欲しいのであれば、テーブルに<input type="checkbox />"をまぶした時点でもう実装は完了ということになります。

ここでふと思ったのですが、もしかしたらjQuery時代であればこれは正しかったのかもしれません。なぜなら「選択する」という機能はチェックボックスを設置するだけで実際に満たされるからです。そしてチェックボックスのDOMをそれぞれ取得して選択状態を調べてその後の「何かをする」に引き渡せば終了です。「全選択」機能はもう少し追加の実装が必要になるものの、まぁ適当にイベントハンドラを設定して適当な処理を適当に書けばおそらくなんとかなるでしょう。

当然ですが現在は(少なくとも観測範囲内では)jQuery時代ではないのでこういう考え方はしません。

「選択して何かをする」が控えている

スタート地点はここです。処理的にはゴール地点ですが設計的にはスタート地点です。選択した後に、その選択したという状態を必要とする後続処理が控えています。つまり、「選択したという状態」が欲しいわけです。

「選択したという状態」がある。ここが全ての基点となります。

何度でも言いますが「選択したという状態」が基点です。「選択する」という機能は二の次です。とりあえず機能を作り出すのではなく、真っ先に状態を定義します。

3rdステップ - 状態を形にする

基点となる状態が見えてきたのでそろそろ手を動かします。先に状態以外も含めた全体像を設計しきってしまうというのもありですが、ペアプロ想定ということで手を動かして抽象度を下げていきます。

「選択したという状態」を表現します。今回選択対象となるデータはそれぞれユニークキーidを持っていると想定します。特段難しく考えるまでもなく、選択したデータのidArrayObjectで持てば良さそうです。ぱっと見シンプルな気がするのでArray でいきましょう。どちらでも大した違いはないのでお好みで。

let selectedIds:number[] = []

主役が完成しました。結局のところ、後続の処理に回すために興味がある情報はこれだけです。

型が付いている方が視認性が良いと思うのでTSで書きます。JS原理主義者のみなさんごめんなさい、私はTSに屈しました。

ちなみですがパフォーマンスをシビアに求めるならObject方式がオススメです。

let selectedIds:{ [key: string]: boolean } = {}

そこまでのシビアさが求められるケースはあまりないのでお好みでどうぞ。あるいはAPIがどちらを採用しているかで判断するのがいいかもしれません。

4thステップ - 状態を変化させる

4rdと書きたい気持ちを抑えて4thステップです。

主役となる状態が作れたので、次はその状態に対してどんな操作をしたいか考えます。今回の操作はシンプルで、「選択する」と「選択を外す」です。2種類の操作ということですが、どちらの操作を行いたいかは状況によって変わります。状況とは、上で定義した状態のことです。

改めて説明するまでもなく、「選択済」なら「選択を外す」、そうでなければ「選択する」が実現したいことです。

function toggleSelected(selectedIds:number[], id: number): number[] {
  if (TODO 選択済) {
    return selectedIds.filter(_id => _id !== id)
  } else {
    return [...selectedIds, id]
  }
}

特定フレームワークに依存しない考え方がテーマなので、なるべく簡素に書いていきます。

「選択済」かを判定して「選択を切り替える」関数を作りました。ただどうやら関数を完成させるには、「選択済」かの判定が必要なようです。ではそれはどうすれば得られるのか。もちろん、主役である「選択したという状態」から求めることができます。

function isSelected(selectedIds:number[], id: number): boolean {
  return selectedIds.includes(id)
}

「選択を切り替える」関数も完成させます。

function toggleSelected(selectedIds:number[], id: number): number[] {
  if (isSelected(selectedIds, id)) {
    return selectedIds.filter(_id => _id !== id)
  } else {
    return [...selectedIds, id]
  }
}

この時点で、「選択されたという状態」、「選択済」かの判定、「選択を切り替える」という操作が揃いました。なんだかもう選択機能が作れたような気がしてきませんか?気のせいではないです。個別の選択機能はもうこれで完成です。まだUIがないだけです。

5thステップ - 状態をUIと繋げる

選択するための状態とその状態を切り替えるための関数は既に作成しました。あとはそられをUIと連携させれば完成なのですが、この連携というのがなかなか厄介です。というのはかつての話で、今はそれほど厄介ではありません。

なぜ厄介ではなくなったかというと、それは昨今のフロントエンドに関わる方なら耳にタコができるくらい聞かされたであろう宣言的なUI構築を売りにした各種フレームワークのおかげです。宣言的というのが重要です。だからこそ先ほども状態ありきで処理を作っていました。そして状態に関する処理を既に作ってしまったので、あとはフレームワークのお作法に沿ってUIを組み立てていくだけです。細かく気を使う箇所は都度あるにせよ、状態さえ定まってしまえば、宣言的なUIの構築は最早シンプルな作業です。

というわけでここから先は単に今まで作ったものをフレームワークと合わせて組み立てていくだけなので、特筆すべきことはあまりありません。

というわけで終わりますと投げっ放しにする度胸もないので続きます。せっかくなので今回はみなさん大好きSvelteでいきます。

svelte.dev

先に完成形を張ってしまいます。公式サイトにさくっと触れる砂場を用意してくれているので、そこでも遊んでみてください。

<script>
  const dataList = [
    { id: 1, name: 'メドピア1号' },
    { id: 2, name: 'メドピア2号' },
  ]
  let selectedIds = []
  $: isAllSelected = selectedIds.length === dataList.length
  
  function toggleAllSelected() {
    if (isAllSelected) {
      selectedIds = []
    } else {
      selectedIds = dataList.map(data => data.id)
    }
  }
</script>

<p>
  選択中: {selectedIds.join(', ')}
</p>

<table>
  <thead>
    <tr>
      <th><input type="checkbox" checked={isAllSelected} on:change={toggleAllSelected} /></th>
      <th>ID</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    {#each dataList as { id, name }}
      <tr>
        <td><input type="checkbox" bind:group={selectedIds} value={id} /></td>
        <td>{id}</td>
        <td>{name}</td>
      </tr>
    {/each}
  </tbody>
</table>

悲しいお知らせがあります。まずはパッと見てもらえれば分かるように、さすがに砂場だとTypeScriptは非対応でした。心の目で型の補完をお願いいたします。

さらに悲しいお知らせが続きまして、なんと先ほど意気揚々と作成したisSelectedtoggleSelectedという関数は出番がありませんでした。

<input type="checkbox" bind:group={selectedIds} value={id} />

なんとこれだけでSvelteがチェックボックスと配列の双方向バイディングを完成させてくれました。選択判定やトグル処理は自作する必要すらなかったです。

せっかく書いたコードが不要になりましたが、無駄な時間を過ごしたと嘆く必要は全くありません。むしろその不要なコードを順を追って作ったからこそ、フレームワークが提供してくれる機能に対してより深い理解が得られるというわけです。たとえcommitログに何も残らなくともすべてを血肉に変えていきましょう。そしてペアプロ・モブプロに慣れてくるとcommitの量なんて無意味です。より大事なのはコード品質です。

もしSvelteにこんな便利な機能がなかった場合にどういう展開にしようとしてたかを一応解説しておきます。

<input type="checkbox" checked=選択されている? onchange=選択をトグルする />

正しいHTMLになっていませんがイメージとしてはこのような形です。選択状態の判定とトグル処理は既に用意したので、あとはこの形を各々のフレームワークでどう表現するか探すだけです。ここから先はフレームワークの表面的な作法や記法の問題です。

まとめ

かなり単純化してしまいましたが、コンポーネントの実装に取り組むときの流れは大概このような流れです。早く見た目を作りたい欲はぐっと抑え、とにかく真っ先に状態を設計して形に落とします。状態さえ定めてしまえば後はUI実装を思う存分楽しむだけです。

レビューだけではどうしても完成した後のコードを見るというケースが多く、こうした実装の考え方はなかなか共有が難しいものです。一方でペア・モブプロをしてみると、まさにこういった実装者の思考と共にコードを追っていくことができます。

ペア・モブプロが好きな理由もこのあたりだったりします。他人の作業風景を覗き見て取り入れることで自らの作業効率を改善できるという副次作用もあったりします。普段そういった機会があまりない方々も、ここまで読んでいただけた縁と思ってぜひとも周囲の方を誘ってペア・モブプロを試してみてください。

そしてなんと、メドピアではエンジニアを絶賛募集中です。集合知というキーワードの元にチーム開発を楽しみたい、そんな方は是非ともお声がけください。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html`

メドピアのECSデプロイ方法の変遷

f:id:satoshitakumi:20201118153720p:plain

CTO室SREの侘美です。好きなLinuxディストリビューションはLinux Mintです。

メドピアでは現在多数のサービスを運用しており、そのほとんどがAmazon ECSを構成の中核として利用しています。

ECSに対してデプロイを行う方法としては、CodeDeploy、CodePipeline、Copilot(ecs-cli)等があり、CloudFormationやTerraform等のIaCツールで何をどこまで管理するかも合わせて検討する必要があります。 どの方法にもメリット・デメリットがあり、Twitterや技術ブログを観測している範囲ではデファクトスタンダードと呼べる方法は未だに無いように思われます。

メドピアで最初にECSを利用し始めたのは2018年ころであり、これまで試行錯誤しながらECSのデプロイ方法とタスク定義の管理方法を模索してきました。 今回はメドピア社内で試してきた、ECSへのデプロイ方法とその課題や解決方法をご紹介したいと思います。

前提

各デプロイ方法に共通する運用は次の通りです。

  • インフラはTerraformでコード化
  • RailsとTerraformでGitリポジトリは別
  • Railsはサービス担当のエンジニアが実装
    • SREが実装を変更する場合もある
  • Terraformは横断して複数のサービスを担当するSREが実装

1. ecs-cli

社内でECSを採用し始めた当初はecs-cliを利用していました。 現在は後継のCopilotというツールがリリースされています。

ecs-cliの公式ドキュメントでは次のように紹介されており、簡単にECSを構築することが売りのツールです。

ローカルの開発環境からクラスタやタスクの作成、更新、監視を簡素化するための高レベルのコマンドを提供します。

メドピアでは、ecs-cliを使い次のようなデプロイパイプラインを構築していました。

f:id:satoshitakumi:20201117180938p:plain
ecs-cliによるデプロイ

  • CodePipelineでデプロイパイプラインを構築
  • リリース対象ブランチの場合CircleCIからCodePipelineをスタート
  • CodeBuild上でCapistranoでイメージビルド
  • CodeBuild上でCapistrano + ecs-cliでデプロイ
  • 上記Capistranoのタスクやecs-cliで利用するタスク定義用のファイルはRails側リポジトリで管理

ecs-cliによるデプロイの課題

この構成でいくつかサービスを運用していくうちに以下の課題があがってきました。

  • ecs-cliの仕様で一部ECSの機能を利用できない。
    • ECSサービスを複数のTargetGroupに所属させることができない。(現在は複数のTargetGroupに対応しています)
  • タスク定義の管理がRails側リポジトリで行われている。
    • タスクのパラメータ変更等をSREが実装する際に、Rails側リポジトリにPRを作成しRails側リポジトリのリリースフローに則る必要がある。
    • →RailsエンジニアとSREの責任の境界が曖昧になってしまっていた。
  • ALBやECSタスクに付与するIAMロール名やSSMパラメータストアのパラメータ名など、Terraform側リポジトリで管理されているリソースのARN等をRails側リポジトリにハードコードしなければならない。

そこで、これらの課題を解決する構成として、次のCodePipelineによるECSへのデプロイへと移行することにしました。

2. CodePipeline

ecs-cliの利点はあくまでも「簡単にECSを管理できる」というものであり、Terraformを利用して細部まで管理を実施したい場合には不向きと判断しました。

ECSに関する設定の大半をRails側リポジトリで管理していた従来の構成を見直し、それぞれのリポジトリで管理する対象を次のように変更しました。

  • Rails側リポジトリ
    • Dockerfile
  • Terraform側リポジトリ
    • デプロイパイプラインと各CodeBuildで実行するスクリプト
    • ECSタスク定義

CodePipelineは通常CodeBuildやCodeDeployと組み合わせてデプロイパイプラインを構築するサービスですが、CodePipeline自体もECSへデプロイする機能を持っています。(よくCodeDeployを利用していると勘違いされがちです)

CodePipelineによるECSへのデプロイの特徴としては、CodeDeployよりも簡単な代わりに、機能はCodeDeployよりも少ないといった感じです。
参考:https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-ECS.html

terraformの実装例

resource "aws_codepipeline" "deploy" {

  # 他の設定項目は省略

  stage {
    name = "Build"

    action {
      name             = "BuildRails"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["input"]
      version          = "1"
      output_artifacts = ["imagedefinitions"]

      configuration = {
        ProjectName = aws_codebuild_project.rails.name
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name            = "DeployApp"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ECS"
      input_artifacts = ["imagedefinitions"]
      version         = "1"

      configuration = {
        ClusterName = aws_ecs_cluster.app.name
        ServiceName = aws_ecs_service.app.name
        FileName    = "imagedefinitions.json"
      }
    }
  }
}

ビルドステージでは、イメージビルドを行いECRにイメージをpushし、 imagedefinitions.json というファイルを作成しデプロイパイプラインのアーティファクトに登録します。

デプロイステージでは上記の imagedefinitions.json を指定することで、既存タスク定義を流用しイメージのみを新しいイメージに変更したタスク定義のリビジョンが作成され、デプロイされます。

ecs-cliでのデプロイと同様の図で表すと以下のようになります。

f:id:satoshitakumi:20201117180949p:plain
CodePipelineによるデプロイ

このような実装にすることでシンプルにECSへデプロイを行うパイプラインを構築できます。

テクニック:TerraformとCodePipelineの両方からタスク定義を更新可能にする

CodePipelineでECSへのデプロイを行うと、ECSタスク定義に新しいリビジョンが追加されます。 このリビジョンは1つ前のリビジョンのタスク定義を、CodePipelineで指定した imagedefinitions.json に記載されたイメージで置き換えた設定となります。

# デプロイ前のタスク定義のリビジョン
{
  "family": "prd-rails",
  "cpu": "1024",
  "memory": "2048",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "元のイメージのURL",
      "environments": [...略]
    }
  ]
}

# これに↓のimagedefitions.jsonを使ってCodePipelineでデプロイする

# imagedefinitions.json
{
  "name": "web",
  "imageUrl": "新しいイメージのURL"
}

# すると、imageUrl以外が同じである次のようなタスク定義のリビジョンが追加される

# デプロイ時に追加されるタスク定義のリビジョン
{
  "family": "prd-rails",
  "cpu": "1024",
  "memory": "2048",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "新しいイメージのURL",
      "environments": [...略]
    }
  ]
}

このように、イメージ以外はデプロイ時に変更することができません。 そのため、CPUやメモリ、各コンテナの環境変数等はterraformで変更する必要があります。

terraformはtfstateに現在のリソースのARN等を記録し、コードとリソースの関係を保持しています。 そのため普通に実装してしまうと「デプロイするたびに新しいタスク定義のリビジョンが作成され、tfstateとAWS上のリソースに乖離が発生する」という問題を引き起こしてしまいます。

これを解決する実装方法が、terraformの公式ドキュメント中にかかれています。
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ecs_task_definition

resource "aws_ecs_service" "mongo" {
  name          = "mongo"
  cluster       = aws_ecs_cluster.foo.id
  desired_count = 2

  # Track the latest ACTIVE revision
  task_definition = "${aws_ecs_task_definition.mongo.family}:${max(aws_ecs_task_definition.mongo.revision, data.aws_ecs_task_definition.mongo.revision)}"
}

ECSサービスに指定するタスク定義を data を用いて最新のタスク定義を参照することで、CodePipelineでデプロイ時に作成された最新のタスク定義を参照することができます。

また、terraform側でタスク定義のメモリ等の設定を変更した際は、terraform apply時に新たにタスク定義のリビジョンが作成されます。 data で取得した存在する最新のリビジョンと、メモリの変更でこれから作成されるリビジョンをmax関数にわたすことで、以下を実現できます。

  • terraform側のタスク定義に変更なし
    • CodePipelineがデプロイしたリビジョンが最新なので、apply時はそのまま最新のリビジョンが使われる
  • terraform側でタスク定義の属性を変更
    • terraformがこれから作成するリビジョンが最新なので、作成されたリビジョンで置き換えられる

この実装を利用することで、メモリを変更し terraform apply を実行すれば、新しいタスク定義のリビジョンが作成され、それがECSサービスに指定されることで新しいタスクに入れ替わります。

CodePipelineによるデプロイの課題

ecs-cliを利用していた際の課題はだいぶ解決できたのですが、CodePipelineを利用したECSへのデプロイにも課題がありました。

CodePipelineによるECSへのデプロイはローリングアップデートで行われます。 Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。

f:id:satoshitakumi:20201117183217p:plain
ローリングアップデート時に404エラーが発生する仕組み

メドピア AWS勉強会 ECS編より

この課題を解決する方法はいくつかありますが、ロールバック方法まで含めて検討した結果、次のCodeDeployによるデプロイを採用することとなりました。(CodeDeploy以外の案に興味がある方は上記勉強会資料をご参照ください)

3. CodeDeploy

CodeDeployの ECSAllAtOnce デプロイ設定を利用することで、ローリングアップデートではなく、Blue/Greenデプロイメント方式でECSタスクを更新することができます。
参考:https://docs.aws.amazon.com/codedeploy/latest/userguide/deployment-configurations.html

また、CodeDeployのロールバック機能を利用すればシンプルな操作でECSタスクのロールバックを実施することも可能です。

CodeDeployを利用した場合のデプロイパイプラインは次のようになります。

f:id:satoshitakumi:20201117183711p:plain
CodeDeployによるデプロイ

ちなみに、CodeDeployによるECSへのデプロイの場合はデプロイ毎に完全なタスク定義の設定をCodeDeployにわたすので、前述したようなテクニックを利用してTerraformとデプロイによるタスク定義更新を考慮する必要はなくなります。
タスク定義はTerraformの aws_s3_bucket_object リソースを使いTerraform側のリポジトリでテンプレートファイルとして管理しています。ビルドステージのCodeBuild上でS3からタスク定義のテンプレートを取得し、ビルドしたDockerイメージのURLを埋め込み、アーティファクトとして次のステージのCodeDeployへ伝える実装としています。

いろいろとデプロイ方法、タスク定義の管理方法を試してきましたが、結局はスタンダードなCodeDeployの利用に落ち着きました。

今後の改善予定

現状のデプロイにもいくつか課題があるため、まずは下記の点を改善していこうと計画しています。

SREとRailsエンジニアの責任境界の明確化

現状ではDockerfileをSREが用意し、CodeBuild上で実行するイメージビルドコマンドもSREがメンテしています。 DockerfileとDockerイメージビルドはRailsエンジニア管理に変更し、SREとRailsエンジニアの責任の境界を下記のように変更したいと考えています。

  • Railsエンジニア:Dockerイメージをビルドし、pushするまで
  • SRE:Docker Registryにpushされたイメージをデプロイする

PRのCIでイメージビルドを行いデプロイパイプラインを短縮

現在イメージビルドに約10分程度時間がかかっています。 デプロイパイプラインをさらに短縮するため、リリースPRの段階でイメージビルドをCI上で実行できないか検討しています。

これらの改善がある程度進んだ後には、また本ブログで内容を公開したいと思います。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

API認証基盤の改善について

今月の一日でメドピアに入社してちょうど1年になったCTO室の内藤(@naitoh) です。

主にやっていることはAPI認証基盤の改善です。 この1年でやってきた事を技術ブログで紹介させて頂きます。

背景

メドピア で採用されているバックエンドの言語(フレームワーク)は本 blog のタイトルにもあるように PHP から Rails に移行が行われているのですが、 実は上記以外に Golang(以下 Go) も使用しています。

このあたりの当時の開発背景は下記の記事に書かれておりますのでご参考にして頂ければと思います。

tech.medpeer.co.jp

私が昨年入社した時点でこのAPI認証基盤(API ゲートウェイ)の保守が難しく、Go のわかる開発者がほぼいなかったため機能追加が行えない状態になっていました。 このAPI認証基盤へのユーザーログイン処理時のアクセス負荷が朝方に集中し、メドピアのサービスに影響が出ないか懸念されていたため、まず安心して保守できる状態に持っていく事から取り組み始めました。

安心して保守できる状態に持っていくためにやった事

  • 開発環境でコンパイルできるようにする。
  • CIが停止していたので、動くようにする。
  • テストコードを追加する。
  • テストコードを使って動作を理解する。エラーメッセージを修正する。
  • 未使用のライブラリを削除する。
  • 社内の非公開リポジトリ(ライブラリ)の2重管理を排除する。

開発環境でコンパイルできるようにする。

2015年の開発当時は Go 1.5 を使っていたため、入社時点で開発環境として支給された macOS Mojave ではコンパイルしたバイナリを実行すると実行バイナリの応答が無い状態になり、30分程待っても何も起らず正しくコンパイルできてない事が判明しました。(本番への deploy 環境は Linux なので問題なし。)

Go のコンパイラのバージョンを変えいくつか試したところ macOS 10.12 Sierra 以降では go 1.7以上必須 である事がわかったため、Go のバージョンを当時の最新の 1.13 まで上げ、かつ、struct 周りの記述ミスでコンパイルエラーが発生していたのを修正する事で開発環境のmacOS Mojaveで無事動作するようになりました。

CIが停止していたので、動くようにする。

2015年の開発当時は社内の Circle CI 1.0 (Enterprise) で CIが動作していたようなのですが、社内開発環境のクラウド移行時に、メンテナー不在の影響でクラウド環境のCircle CI 2.0 移行が行われずCIが停止していました。 Circle CI 1.0 当時の設定が残っていたので、Circle CI 2.0 で動作するように対応を行い、カバレッジ情報の出力を追加するなどCI環境を整備しました。

テストコードを追加する。

ある程度のテストコードは書かれていたのですが、メインロジック部分のテストコードが一部しか存在しなかったため、処理を理解するためテストコードのカバレッジを上げる事に取り組みました。 ただ、期待値となるドキュメントの場所がわからず、社内メンバから教えてもらった内容も記述が2015年のまま古い箇所が散見されたため、本当にこれが当時の情報なの?と恐る恐るテストコードを書きながら正常系のテストコードを書き上げました。(結果的に正しい情報でした。)

テストコードを使って動作を理解する。エラーメッセージを修正する。

エラー発生時のエラーコードが毎回同じ値を返したり、エラーメッセージが同じ箇所が複数あったり詳細エラーメッセージが出力されないバグがあった事が原因で、どこでエラーが発生したかソースコードを見ても判断ができず、クライアント側も最初の処理からリトライせざるを得ないなど利用者にわかりづらいAPIとなっていました。

そのため、異常系のテストコードを追加し、詳細エラーメッセージが正しく出力されるように修正、エラー時の挙動を理解することで、本来意図している内容にエラーメッセージの修正を行い合わせてエラーコードの整備など使い勝手の改善を行いました。

未使用のライブラリを削除する。

2015年の開発当時に組み込まれた未使用のライブラリがそのままになっており、(vendor 配下のリポジトリにはあったのですが)元のリポジトリが残っていないものもありました。 削除してテストが通る事を確認し、メンテナンス対象を整理しました。

社内の非公開リポジトリ(ライブラリ)の2重管理を排除する。

上記 vendor 配下の整理を行なったのですが、社内開発ライブラリ(非公開リポジトリで別管理)もvendor配下に登録されていたため2重管理になっており、両方のソースコードに修正が入るような状態になっていました。(glide で 管理は行われていたのですが、Go 1.5 で vendoring が始まったばかりで当時はこのように管理するようにしたようです。)

社内ライブラリは vendoring 不要なため vendor配下での 2重管理を廃止し、Circle CIで glide がうまく動作しなかったため(Go 1.13 では正式版ではなかったため Go Module への移行は見送り) dep に移行し、コンパイル時に dep ensure で社内ライブラリをvendor配下に展開する対処を行いました。

安心して保守できる状態に持っていくためにやった事のまとめ

これらの対処により約4ヶ月ほどでメンテナンスが可能な状態に改善、無事リリースする事ができました。 懸念となっていた朝のログイン時の負荷問題は不要なToken再発行処理である事が判明し、別途行われたクライアント側の改修作業で無事問題は解消されました。

現在は次のステップとしてAPI認証基盤がもっと活用されるようにする取り組みを行なっています。

現在の取り組み

上記対処を実施するまでAPI認証基盤の機能追加ができなかったためか、メドピアの各サービスはサービス単位に機能を作成する事が多く、各サービス間の連携が弱いという課題がありました。

また、メンテナンスが行える状態になったのですが、利用を広げるにあたり下記の課題がありました。

  • API開発をGoで行う必要があり、社内の Rails 開発者にとって敷居が高い。
  • JSON RPC API ゲートウェイ として実装されていたため一般的な REST APIではなく利用がしづらい。

これを解決するために、APIゲートウェイのバックエンドを Go ではなく Rails での実装を追加、新規にREST API のサポートを行なう事でこの課題を解消し、各サービス間の連携を強化する新APIの追加を行なっています。

f:id:ju-na:20201109163946p:plain
API Gateway

(API ゲートウェイそのものは負荷が集中する部分で、機能追加が少ない部分のため、Go での実装を継続しています。)

おわりに

以上のような施策を実施し、社内サービスのボトルネックを一つずつ改善していく取り組みを行なっています。

前職は組み込み系のテストプログラム開発を行なっていたため Go は触った事がなかったのですが、Go は言語仕様が比較的小さいため2週間程の勉強は必要でしたが無事改善が回せております。 開発分野が前職とはだいぶ異なるため新たに学ぶ事が多いですが、(Goに限らず)日々新しい事に取り組みつつ楽しく開発をしております。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Rails未経験でRailsエンジニアとして入社して感じたメドピアのエンジニア文化

2020年6月付けで入社したフィッツプラス システム開発部の讃岐と申します。 DietPlus Proというアプリや、特定保健指導の進捗管理用のWebアプリケーションを開発するサーバーサイドエンジニアとして働いています。

特定保健指導については、以前に福本さんが使用している技術も含め書いてくださったのでそちらをご覧下さい。

tech.medpeer.co.jp

エンジニアとしてのキャリアはメドピアで2社目となります。Railsは未経験でしたがRailsエンジニアとしての採用でした。

そんなわけでメドピアでRailsエンジニアとしてのキャリアが始まったわけですが、開発環境については芝田さんが他の記事で紹介してくれているので、今回はメドピアのエンジニア文化を紹介していきたいと思います。

1. 新しいことを学ぶ機会が多い

メドピアには「Rails読書会」というRailsやエンジニアリングに関する書籍をwillnetさんと読み合わせできる会だったり、「PR振り返り会」という共有したいトピックがあるPRについてエンジニア間でそれぞれの意見を交換をする会が毎週あります。

そこで経験豊富なエンジニアの方々がチーム関係無しに参加されて、エンジニアリングについて様々な観点で議論されて、自分では得られなかった情報や、知らなかったトピックがぽんぽん出てくるのでかなり多くのことを学べます。

最近のRails読書会ではSRE サイトリライアビリティエンジニアリングを読み合わせていて、メドピアのSREの方の意見を直で聞くことができています。以前はSREというものはフワッとした理解だったのですが、SREの方の意見を直で聞くことで今SREがどのように事業に貢献していて、自分もその恩恵を受けているかをかなりリアルに感じることができています。

僕がエンジニアとしての経験がかなり浅いので得られるものが多いというのもあると思いますが、経験が浅いエンジニアでもそういった会に参加して、いろんなチームの経験豊富なエンジニアの方の意見を直接聞ける機会がある、というのはかなり嬉しいです。

毎週水曜日の決まった時間に上記の会があるので、頻度もいい感じに参加しやすい形になっていると思います。

2. 技術者支援の制度がちゃんと使用されている

メドピアには年間12万円までAWS・Azure・GCPなどのIaaSの使用料や技術書、資格取得などを補助してくれるテックサポートという制度があります。

前職でも一応資格取得費用などは補助してくれる制度とかはあったのですがだれも使ってない&フローがわかりにくかったので利用していませんでした。

メドピアはそういった制度が形骸化してだれにも使われない、ということはなく実際に使用されています。入社後にきちんと制度を利用する際のフローや参考ドキュメントなどを教えてもらえるので入社した方がスムーズに使えるからだと思います。

みなさんかなり有効活用されていて、各種有料IDEやHHKBなどのキーボード、各種書籍を買われている方が多い印象です。

おそらく社内で一番有効活用している先輩の利用額はこんな感じです。笑

f:id:sanuki_tech:20200908134314p:plain

この制度のおかげで高い技術書なんかも気兼ねなく買って勉強できるのでエンジニアが成長しやすい環境だと思います。僕も何冊か買って勉強しています。

また、メドピアで働くエンジニアは裁量労働制なので時間の都合が付けやすく、勉強会のためにすこし早く抜ける、ということなんかもできるのでかなり働きやすい環境でもあります。

こういった制度がちゃんと有効活用されていて学びやすさ、働きやすさとして会社から還元されているのはとても良い文化だと思っています。

テックサポートについては別の記事でも紹介されているので是非読んでみてください。

tech.medpeer.co.jp

3. 技術顧問がwillnetさん、Matzさん

「1. 知見を得られる機会が多い」でもお話させて頂きましたがwillnetさん、そしてMatzさんが技術顧問としてメドピアをサポートしてくれています。

willnetさんは毎週の勉強会やSlackのtimesチャンネルで参加してくださっていて、その知見で様々な疑問などを解決してくださっています。以下のような感じでtimesチャンネルに質問され、それに回答を頂けます。

f:id:sanuki_tech:20200908134339p:plain

そしてMatzさんは月一でWeb会議を開いて頂いているので、そこで毎月疑問などをぶつけられます。Matzさんに質問したい内容がまとめられているスプレッドシートがあってそこにずらっと質問が並べられています。

f:id:sanuki_tech:20200907194621p:plain

言語開発者に直で意見をぶつけられたりする機会なんて滅多にあるものではないと思っているので、この環境を作って下さった弊社CTOの福村さんには「Matz」の文字列をみる度に感謝しております。

(弊社CTOの福村さんとMatzさんがこの記事でMatzさんが技術顧問になる経緯などを対談しているので是非みてみてください。)

www.wantedly.com

こう言った著名な技術顧問の方がいるおかげで会社全体の技術力の底上げになっていると思います。 Railsの実装やRuby内部のことが気になった際にすぐに質問できるという環境はエンジニアにとってものすごく良い環境だと思っています。

4. コミュニティを大事にしている

RubyKaigiは毎年エンジニアの方が殆ど総出で行く恒例行事だそうです。 (残念ながら今年はオンライン開催なので現地には行けないですね。)

他にもいろんなコミュ二ティ、カンファレンスのスポンサーになっていたりもします。

以下はRubyKaigiのスポンサーになった記事です。 tech.medpeer.co.jp

以下は前述した弊社CTOの福村さんのRuby,Railsコミュニティとの向き合い方についてのスライドです。 speakerdeck.com

僕はこういったカンファレンスなんかにあまり参加する機会がなかった(単にぼっち参加にビビってた)のですが、メドピアで働くうちに自然と興味も湧いてきて今年はいろいろ参加してみる予定です。

他にも先輩がGotanda.rbのOrganizerだったり、銀座Railsに登壇してたり、社内でコミュニティを盛り上げようという雰囲気があるのでかなりコミュニティに貢献したいモチベーションが上がります。

かなり直近ですがRubyKaigi Takeout 2020のスポンサーにもなってました!

f:id:sanuki_tech:20200907195302p:plain

おわりに

メドピアでRailsエンジニアとしてのキャリアが始まったわけですがいままで紹介してきたメドピアのエンジニア文化のおかげで毎日様々なことを快適に学べています。

こういった文化を作ってきたメドピアのエンジニアの方に感謝しながら僕もよい文化作りに貢献していきたいと思います。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html`

薬局向けサービス”kakari”にruby-vipsを導入した話

こんにちは。
外出自粛が続き、大胸筋の育成が疎かになっているエンジニアの宮原です。

ruby-vipsという画像処理用のGemを、かかりつけ薬局化支援サービスの「kakari(かかり)」で導入してみました。
今回は、ruby-vipsとkakariに実装した画像処理の内容について紹介させていただきます。

ruby-vipsとは

ruby-vipsは、画像処理ライブラリであるlibvipsのRubyバインディングになります。 こちらのGemを利用することで、Ruby on RailsのWebアプリケーションに画像処理の機能を追加することができます。 実際にruby-vipsの導入方法や、簡単な使い方は下記スライドにて紹介しておりますので、ご参照いただければと思います。

※昨年の11月に、鹿児島Ruby会議01にてruby-vipsの使い方を紹介させていただきました。

どのような機能で利用しているのか

kakariには、「FAX同時受信」機能というものがあります。 患者さんが送信した処方せんを、薬局が「kakari」上で確認できると同時に、薬局内のFAXにも自動送信される機能になります。
こちらの、「薬局内のFAXにも自動送信される」箇所で、ruby-vipsを利用して画像処理を行っています。

処理の流れとしては以下のとおりになります。

  1. S3から患者さんがアップロードした画像をダウンロード
  2. ダウンロードした画像をグレースケール画像に変換
  3. グレースケール画像をモノクロ画像に変換
  4. バイナリ画像を保存
  5. バイナリ画像や患者さんの情報から、FAX送信用のPDFを作成
  6. PDFファイルをFAX送信

上記手順の、2番から4番の箇所でruby-vipsを利用して画像処理を行ってます。

なぜ画像処理する必要があるのか

FAXは、非常に古くから利用されている画像伝送方式で、以下のような課題があります。

  • フルカラー対応の機器が少数
  • 伝送可能容量の限界

それでは1つずつ見ていきましょう。

フルカラー対応の機器が少数

フルカラーで出力できる機器が少なく、ほとんどの機器は白黒の2階調もしくは中間調を含む階調でしか出力できません。 このため、患者さんがアップロードした処方せん画像をそのままFAXで送ってしまうと、読みづらい処方せんが薬局さん側で出力されてしまいます。

f:id:nyagato_00_miya:20200727194500j:plain ※ カラー画像をそのままFAX送信した際の例

患者さんがアップロードした画像を、グレースケールかモノクロ画像に変換することが必須であることが分かりました。

伝送可能容量の限界

昨今のスマートフォンでは、いとも簡単に4032 x 3024 pxの高解像度な写真を撮影できます。 しかしながら、この画像をそのままFAXで送信することはできません。
そうです。FAXの伝送規格では、こんなに大きな解像度の画像を送ることは想定していないのです。 なので、適切なサイズにリサイズして上げる必要があります。

これらの理由から、患者さんがアップロードした処方せん画像をFAXで送信できる画像に加工する必要があるのです。

なぜruby-vipsを選んだのか

Rubyの世界で画像処理を行うには、いずれかの選択肢があります。

  • ImageMagickを使う
  • GraphicsMagickを使う
  • OpenCVを使う
  • libvipsを使う

kakariでは、「処理性能の高さ」と「Rails Wayに乗る」という2つの理由から、libvipsを使う方法を選択しました。

libvipsの処理性能

下記のグラフは、画像処理ライブラリ毎の処理速度とメモリ使用量を比較したものになります。 この比較の結果から、libvipsは処理速度・メモリ使用量共に優れていることがわかります。

f:id:nyagato_00_miya:20200721210343p:plain

f:id:nyagato_00_miya:20200721210402p:plain

Rails Wayな画像処理

Active Storageで利用する画像処理用のGemが、MiniMagickからImageProcessingに変更されました。 github.com

ImageProcessingは、ImageMagick/GraphicsMagickまたはlibvipsライブラリのいずれかの方法で、画像を処理する機能を提供するGemになります。 つまり、Rails Wayな画像処理ライブラリにruby-vipsが加わったということです。

ruby-vipsは、高い処理性能を持ったRails Wayな画像処理用のGemということになりますね。

2値化手法の選定

患者さんからアップロードされる画像は、様々な照明条件で撮影されており、非常にバラエティーに富んだものになります。 このため、画像毎に適切に処理を行わないと、きれいなモノクロ画像を作成できません。
下記の表に示した例では、出力画像の約半分が黒つぶれしており読むことができない状態になります。

Input Image Output Image
f:id:nyagato_00_miya:20200727190552p:plain f:id:nyagato_00_miya:20200727190644p:plain

※ 処方せんの内容が読み取れないように、画像はサイズを小さくしております。
※ モザイク処理はあとから追加したものです。

特定の閾値を設定する方法や、大津の2値化では、適切にモノクロ画像を作成できないことがわかりました。

適応的閾値処理

大津の2値化でうまく処理できない場合があることが分かったので、適応的閾値処理を試してみることにしました。

OpenCVのドキュメントでは、以下のような説明がされています。

先の例では、ある画像に対して一つの閾値を与えて閾値処理をした。しかし、撮影条件により画像領域で異なる光源環境となるような画像に対しては期待する結果が得られない.そういう状況では「適応的閾値処理」を使うと良い.適応的閾値処理では,画像の小領域ごとに閾値の値を計算する.そのため領域によって光源環境が変わるような画像に対しては,単純な閾値処理より良い結果が得られる.

「光源環境が変わるような画像に対しては,単純な閾値処理より良い結果が得られる」との記載があったので、OpenCVを利用して実験してみます。
下記のサンプルは、入力画像を2つの方法(大津の2値化・適応的閾値処理)を利用してモノクロ画像に変換しています。

// 画像読み込み
cv::Mat image;
image = cv::imread("test.png", 1);

// グレースケール画像へ変換
cv::Mat gray_image;
cv::cvtColor(image, gray_image, CV_RGB2GRAY);

// モノクロ画像へ変換    
cv::Mat otsu_image, adaptive_image;
// 大津の2値化を利用して、モノクロ画像を作成
cv::threshold(grayImg, otsu_image, 0, 255, cv::THRESH_BINARY|cv::THRESH_OTSU);
// 適応的閾値処理を利用して、モノクロ画像を作成
cv::adaptiveThreshold(grayImg, adaptive_image, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY, 11, 15);

※パラメータ値は適当な値です。
※C++のサンプルコードです。

下記の表に示した画像が、大津の2値化と適応的閾値処理を利用して作成したモノクロ画像になります。
適応的閾値処理を利用することで、きれいなモノクロ画像を作成できることがわかりました。

Input Image Mono Image(Otsu) Mono Image(adaptiveThreshold)
f:id:nyagato_00_miya:20200727190552p:plain f:id:nyagato_00_miya:20200727190644p:plain f:id:nyagato_00_miya:20200727190819p:plain

※ 処方せんの内容が読み取れないように、画像はサイズを小さくしております。
※ モザイク処理はあとから追加したものです。

ruby-vipsで適応的閾値処理を実装する

OpenCVを利用した実験で、適応的閾値処理が有効であることが分かったので、ruby-vipsを使って実装していきます。

  1. 注目画素と周囲の画素の平均値を求める(閾値を求める)
  2. 求めた閾値を利用して、注目画素を2値化する

注目画素と周囲の画素の平均値を求める

入力画像が、以下のような3×3の画像で、注目画素が中央の画素だとします。 f:id:nyagato_00_miya:20200727152745p:plain

例えば、注目画素t(1, 1)に対して平均値を求める場合は、以下のような式で求めることができますね。

\displaystyle{
t(1, 1) = 1/9(110 + 125 + 200 + 100 + 120 + 110 + 255 + 255 + 120)
}

実際には、左の画像(入力画像)に対して、右のフィルタを適応させていきます。 f:id:nyagato_00_miya:20200727155357p:plain ruby-vipsには便利な#convメソッドがあるので、こちらを利用しました。
下記コードは、入力画像に対して平均化フィルタを適応させた例になります。

# 入力画像(二次元配列から作成)
image = Vips::Image.new_from_array [[110, 125, 200], [100, 120, 110], [255, 255, 120]]
# 平均化フィルタ
averaging_filter = Vips::Image.new_from_array [[1/9, 1/9, 1/9], [1/9, 1/9, 1/9], [1/9, 1/9, 1/9]]

# 入力画像に平均化フィルタを適応(畳み込み演算)
# 画素毎に求めた閾値を配列に格納する
thresholds = image.conv(averaging_filter, precision: :float).to_a

求めた閾値を利用して、注目画素を2値化する

前段の処理で作成した閾値配列を利用して、注目画素の2値化を行います。 #new_from_arrayメソッドを利用して、2値画像用の配列からVips::Imageのオブジェクトを作成します。

width, height = input_image.size

(0...height).each do |y|
  (0...width).each do |x|
    mono_pixels[y][x] = input_image[y][x][0] < thresholds[y][x][0] ? 0 : 255
  end
end

# mono_image配列から、Vips::Imageのオブジェクトを作成する
Vips::Image.new_from_array mono_pixels

実装結果

下記の表に、OpenCV・ruby-vipsを利用して作成したモノクロ画像の例を示します。 ruby-vipsで実装した適応的閾値処理は、独自実装ですがOpenCVと遜色ない結果の画像を生成することができました。

Input Image Output Image(OpenCV) Output Image(ruby-vips)
f:id:nyagato_00_miya:20200727190552p:plain f:id:nyagato_00_miya:20200727190819p:plain f:id:nyagato_00_miya:20200727190920p:plain

※ 処方せんの内容が読み取れないように、画像はサイズを小さくしております。
※ モザイク処理はあとから追加したものです。

これで、無事薬局さんにきれいなFAXで処方せんを送信できるようになりました!

サーバーサイドに画像処理の機能を組み込んでみて

今回は、ruby-vipsを使った画像処理について紹介させていただきました。

僕がサーバーサイドエンジニアになる前は、前職で生産技術開発の領域で画像検査用のアプリケーション等を作ってました。
画像検査するときは、照明条件や撮影機器(光学フィルタ・レンズ・カメラ)を厳密に定めることができます。
例えば、接着剤の塗布量を検査する時は、光源にUV照明、特定の波長帯のみ通過する光学フィルタを利用するなど、後続の画像処理がやりやすい条件で撮影してました。

しかしながら、今回のFAX同時受信機能では、多種多様な画像が入力されるリアルワールドの画像処理です。
どのような画像が入力されても、適切に処理を行う必要があり、それが難しさでもあり面白さでした。

おわりに

最後までお付き合いいただきありがとうございました。画像処理やruby-vipsの使い方など、皆さまになにか得るものがありましたら幸いです。

今回はkakariにフォーカスした内容で解説させていただきましたが、メドピアはメインサービスである「MedPeer」を中心に、さまざまな医療領域をカバーするため新しい事業をつぎつぎと立ち上げています。

メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

特定保健指導"フィッツプラス"事業を支えるモノリシック Rails + VIPER Swift アーキテクチャ

みなさんこんにちは。フィッツプラス開発エンジニアの福本(@terry_i_)です。
早いもので入社して半年が経ちました。普段はRailsを中心に色々と書いてます。

リモートワークが長く続いていることもあって、最近は自宅の開発環境を(過剰に)整備するのがマイブームです。先日はlogicoolのPCスピーカーを買いました。所得がゴリゴリ削られていってツラい。

さて今回は、これまで忙しくて紹介する機会のなかったフィッツプラスの事業概要や、アーキテクチャおよび使用する技術についてお話しします。

アーキテクチャに悩むエンジニアの方の参考になったり、皆さんのフィッツプラスへの事業理解が深まれば幸いです。


特定保健指導とは?

いきなり技術の話に入る前に、タイトルの”特定保健指導”という事業ドメインについて簡単にご説明します。

www.mhlw.go.jp

この”特定保健指導”という単語で、すぐピンと来るエンジニアの方は多くないでしょう。というのも、特定保健指導は健康保険に加入している40歳以上の方を対象に実施されているためです。

私も例に漏れずピチピチの若エンジニアですので、あまりよく知りませんでした。今は若い方もその内お世話になることと思います。

特定保健指導は、特定健康診査という定期検診で対象となった(要するに”引っかかった”)方の生活習慣病の予防および改善を目的に行われています。

具体的な内容としては、有資格者が対象者と最初に面接をし、その後一定期間継続的にサポートするプログラムです。それを”管理栄養士”という国家資格を有する専門職の方が、食生活を中心としたアドバイスを行って、生活習慣改善のサポートを行っています。

フィッツプラスはその”特定保健指導”を行うtoB向けのWebサービスを中心に、一般の方向けにも食事のアドバイスを行うアプリを開発・運営しています(後述)。つまり、フィッツプラスはメドピア内で”食”の観点から予防医療をケアする立ち位置で事業を推進していることになります。

メドピアグループはヘルステック企業として、幅広い医療領域を技術でサポートしています。中でも「予防領域」は、高齢化社会により高騰した医療費の削減などの社会的背景から、昨今とても重要視されております。

アーキテクチャ

f:id:terryyy:20200620202230p:plain

さて、この章から具体的な技術の話をしていきます。上記の図は、先ほど説明したフィッツプラス事業のサービスの中核であるRailsアプリケーション(dietplus-serverと呼んでいます)と、関連するアプリやサービスとの関係を図にしたためたものです。

「関連する」という表現ですが、この図には記載されていないWebサービスが複数稼働しています。モダンなプロジェクトで言うと、Nuxt.js + Rails6 でのSPA構成のサービスを絶賛開発してたりします。完成した暁には、そちらの担当エンジニアが記事を書いてくれると思うのでマァ首を長くして待っていてください。

上記の図をすべて解説すると薄い本が1冊書けてしまうので、中心となるRailsサービス『dietplus-server』と、上部オレンジ色の領域にあるiOSアプリケーション『DietPlus』のふたつに的を絞って今回はお話します。

以降では、まず裏側を支えているdietplus-server(Rails)について、その後にDietPlus(iOS)について解説します。そうすることで、現状のアーキテクチャ全体でのトータルなメリットや課題感をお伝えできればと考えています。

そういった目的上、RailsとSwiftの両方について触れています。「Swiftの話だけ聞きたいんだ俺は」という方は、お手数ですが、”VIPER Swift”の章から読んでいただけると幸いです。

モノリシック Rails

f:id:terryyy:20200620193739p:plain

中核となるRailsですが、先ほどの図では詳細が分かりづらいので、今回お話したいAPIと管理画面に関わるライブラリを記載した図を別途作ってみました。特徴的な部分について説明していきます。

ちなみに、2020年6月1日時点のrails stats では以下のような結果となりました。Rails のサイズ感が伝われば幸いです。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |  11144 |   9420 |     223 |     840 |   3 |     9 |
| Helpers              |    212 |    175 |       0 |      27 |   0 |     4 |
| Jobs                 |    447 |    364 |      14 |      18 |   1 |    18 |
| Models               |  13809 |   8639 |     167 |     799 |   4 |     8 |
| Mailers              |    586 |    502 |      26 |      66 |   2 |     5 |
| Channels             |      8 |      8 |       2 |       0 |   0 |     0 |
| JavaScripts          |     67 |     21 |       0 |       4 |   0 |     3 |
| Libraries            |   1019 |    909 |       7 |       9 |   1 |    99 |
| Mailer specs         |      8 |      6 |       1 |       0 |   0 |     0 |
| Decorator specs      |     67 |     60 |       0 |       0 |   0 |     0 |
| Loyalty specs        |    205 |    147 |       0 |       0 |   0 |     0 |
| Model specs          |   6153 |   5438 |       0 |       0 |   0 |     0 |
| Request specs        |  10968 |   9586 |       0 |       0 |   0 |     0 |
| System specs         |   6937 |   5931 |       0 |       0 |   0 |     0 |
| Lib specs            |    659 |    560 |       0 |       0 |   0 |     0 |
| Job specs            |    154 |    123 |       0 |       0 |   0 |     0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                |  52443 |  41889 |     440 |    1763 |   4 |    21 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 20038     Test LOC: 21851     Code to Test Ratio: 1:1.1

ActiveModelSerializers

APIでJSONを返すオブジェクトの作成はActiveModelSerializersで行っています。いちいちviewファイルを作ってレンダリングさせる必要がなく、関連オブジェクトの指定もRailsっぽく書けます。メドピアでは過去に他のチームでも採用情報があり、かつ一般的にもよく使われるgemなので特に違和感なく使えています。

tech.medpeer.co.jp

OpenAPI

各APIの定義はOpenAPI仕様のドキュメントをSwagger Editorで書き、Swagger UIで閲覧しています。

特徴としては、当初のアーキテクチャ図の通りAPIのレスポンスを返す先のアプリケーションが2つ(図のオレンジと緑の領域)ある点です。幸い2つのAPIは互いに独立しているので、各レスポンス先ごとに spec.yml の参照パスを分けた docker-compose のコマンドを、以下のようにMakefileを作って運用しています(一部改変しています)。

# Makefile
## Swagger-ui
### DietPlus
dietplus/api/docs:
    docker run --rm -p 8591:8080 -v $(CURDIR)/${DIETPLUS_API_SPEC_PATH}:/usr/share/nginx/html/spec.yml -e API_URL=spec.yml swaggerapi/swagger-ui
### DietPlus Pro
dietpluspro/api/docs:
    docker run --rm -p 8591:8080 -v $(CURDIR)/${DIETPLUS_PRO_API_SPEC_PATH}:/usr/share/nginx/html/spec.yml -e API_URL=spec.yml swaggerapi/swagger-ui

OpenAPIについては、私が後からSwaggerを導入したため、モック環境など一部整っていない部分があります。引き続き徐々に環境整備を進めていきたいという気持ちです。気持ちはあります。

Houston(プッシュ通知)

RailsからiOSアプリに対してのプッシュ通知(いわゆるAPNs)は、HoustonというgemをActiveJobと併用して行っています。Houston::Notificationをインスタンス化するだけで、iOSアプリに送る通知のバッヂや音声を簡単に設定し送信できます。

問題としては、執筆時点でmasterがiOSのバージョン13のプッシュ通知に対応していない点が挙げられます。

詳細は以下のIssueに記載されていますが、Apple Developersが要求するheaderの情報をgemで設定できないことが原因です。

github.com

幸いにもこのIssueに対応するPRが上げられているので、現在はGemfileのgitオプションを使用し該当するcommitを取り込む形で対処しています。実際には以下のように記述しています。

# ios push notification
# TODO: マージされたら git オプション外す
gem 'houston', git: 'https://github.com/ab320012/houston', ref: 'efbeb6c'

上記の対応には若干懸念が残っていて、オプションでcommit hashを直接指定している関係上、ハッシュ値が変わってしまった場合にbundle installできなくなります。rebaseforce pushなどが行われると、参照しているcommit hashの値が変わってしまう危険性があるようです。*1

Banken(権限管理)

管理画面にログインするユーザー権限の管理手法として、Bankenを採用しています。

github.com

前提として、後述の『DietPlus』を含む複数のスマホアプリを同じ管理画面を用いて管理しています。そして、アプリごとにメニューから画面を切り替えて操作するようになっており、(当然ですが)他のアプリの管理栄養士や管理者がユーザーの個人情報を見られないようにしています。また、同じアプリ内の画面でもセンシティブな情報(例: ユーザーとのチャットのやり取り)が含まれるものがあったりするため、画面ごとの細かい権限の制御が必要です(詳細は後述)。

アプリごとの namespace (実際はmodule)が複数存在し、画面ごとに権限を定義する必要があるため、Controllerベースで権限を付与するBankenは違和感なく使えています。RSpecでテストコードを書く際は、Request Spec内で権限ごとにループでテストを回しています(以下例)。

shared_examples_for "アプリの管理者と開発者のみアクセスできる" do
  [
    { name: 'アプリ管理者', trait: :app_admin },
    { name: '開発者', trait: :developer },
  ].each do |user_value|
    context user_value[:name] do
      let!(:user) { create(:admin_user, user_value[:trait]) }

      it { expect(response.status).to eq 200 }
    end
  end
end

context '各権限でアクセスする' do
  before { get admin_app_index_path }

  it_behaves_like "アプリの管理者と開発者のみアクセスできる"
end

VIPER Swift

f:id:terryyy:20200620194007p:plain

ここからは、クライアントサイドであるSwiftコードの設計と使用するライブラリについてお話できればと思います。

今回スポットを当てるアプリ『DietPlus』ですが、食事の写真を投稿すると管理栄養士の方がアドバイスをしてくれるサービスです。2019年10月にiOSアプリをフルリニューアルしてリリースし、その後いくつかの機能追加や改善を行いました。

medpeer.co.jp

こちらもコードのサイズ感をお伝えすると、2020年5月時点でのcloc の実行結果は以下のとおりです。

$ cloc --include-lang=Swift,Objective\ C --exclude-dir=Pods,Carthage ./
    6401 text files.
    6269 unique files.
    5721 files ignored.

github.com/AlDanial/cloc v 1.86  T=4.17 s (168.1 files/s, 13199.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Swift                          697           9707          11423          33492
Objective C                      5             86             37            363
-------------------------------------------------------------------------------
SUM:                           702           9793          11460          33855
-------------------------------------------------------------------------------

DietPlusのSwiftコードにおける特徴は、ピュアなVIPERアーキテクチャで構築されている点でしょう。VIPERについて詳細に書くと薄くない本が数冊書けてしまうので割愛しますが、いわゆるClean Architectureの一種です。

qiita.com

Rubyエンジニア的に言うと、フレームワークの『Hanami』に近いイメージがあります。Actionごとにクラスを切って1画面≒1クラスになる点や、ViewsとTemplateファイルが独立している点などが、VIPERのViewやPresenterの仕組みと似通っていると感じました。*2

Entityがキモ

f:id:terryyy:20200616231704p:plain

さて、VIPERにおいて最も設計が難しい点のひとつ(諸説あり)は、Entityに何を置くかでしょう。Clean ArchitectureではEntityを「アプリケーションに依存しないドメインおよびビジネスロジック(を示すデータの構造やメソッドの集合)」だとしています。*3

このEntityをインターフェースやDBから完全に切り離し、依存の方向を一方向にすることで(図参照)UIなどの変更が多い部分を変更しやすく、そうでない部分に影響を与えないようにします。この設計をいかに維持できるかで、プログラムの変更を容易にできるかが決まります。

DietPlusにおけるEntity

DietPlusにおけるEntityですが、結論から言うと「ユーザーの食事」と「食事の日付」に関わる部分が、最も中心的なドメインロジックとなっています。

人間の食事習慣や日付といった概念は普遍的なものですが、その食事や日付に対して「どうコメントを返信するか」「どういう」は、サービスを提供する私たち側の問題です。これをしっかり分けて考えることで、変更の多い部分をできる限りInteractorPresenterに切り出せています。

具体的にはこんな感じのコードが、ユーザーの食事投稿を表示するPresenterに書かれていて、Entityである食事(Meals)をUIで表現するデータに変換しています。

// MARK: - MealRecordPresenterProtocol
final class MealRecordPresenter: MealRecordPresenterProtocol {

    struct Constant {
        // 反映可能食事枚数の上限
        static let maxMealPhotoCount: Int = 4
    }

    
    struct InitialState {
        var date = Date(second: nil)
        var memo = ""
        var selectedCategory: MealCategory = .breakfast
        var selectedStyle: MealStyle = .home
        var tags: [MealTag] = []
    }
    
    // 取得可能枚数の上限
    private var maxAddableCount: Int {
        return Constant.maxMealPhotoCount - photos.count + deletePhotos.count - addedImages.count
    }
    private(set) var date = Date(second: nil)
    private(set) var photos = [Photo]()
    private(set) var deletePhotos = [Photo]()
    private(set) var addedImages = [UIImage]()
    var memo: String = ""
    var mealTags = [MealTag]()
    var selecetedCategory: MealCategory = .breakfast
    var selectedStyle: MealStyle = .home
    
    private(set) var mealDetail: MealDetail? // Editの場合に取得
    private(set) var initialState: InitialState?
    private var completionHandler: (() -> Void)?
    
    weak var view: MealRecordViewProtocol!
    var interactor: MealRecordInteractorProtocol!
    var router: MealRecordRouterProtocol!
    
    init(completionHandler: (() -> Void)?) {
        self.completionHandler = completionHandler
    }
}

一方、食事を表すEntityであるMeal.swiftはシンプルに書かれています。

ここにすべては記載できませんが、ファイル内のSwiftコードはExtensionの拡張を含めて66行でした。主要なEntityとしては薄い部類だと感じます。

struct Meal: Codable {
    
    let id: ID
    let time: Date
    let category: MealCategory
    let style: MealStyle
    var content: String?
    var memo: String?
    let createdAt: Date
    let updatedAt: Date
    var photos: [Photo]
    var mealTags: [MealTag]
    
    struct ID: Identifiable {
        let rawValue: Int
    }
    
    enum CodingKeys: String, CodingKey {
        case id
        case time
        case category = "categoryCode"
        case style = "styleCode"
        case content
        case memo
        case createdAt
        case updatedAt
        case photos
        case mealTags
    }
 
}

Embedded Frameworkによるマルチモジュール構成

また、VIPERのレイヤーの分割や依存関係の構造を守るために、UIコンポーネントや拡張メソッドを別のモジュールに切り出して管理しています。具体的には、以下の3つにモジュールが分かれています。

# DietPlus(App)
    - アプリ本体のコード
    - 画面に関するModule(View, Interactor, Presenter, Router)
    - Entityおよびサービスクラス(API、Database, Keychain, UserDefaultsなど)
# UIComponent
    - 各種UIパーツの格納
    - Color Asset, Image Assetも基本的にはこっちで管理
    - UITableViewCell, UICollectionViewCellといったCellクラスもUIComponentに追加
    - アプリ本体のモジュールはImportしない(依存は一方向のみ)
# Common
    - Extensionメソッド(UIは除く)
    - Standard Libraryに関するUtilityクラス
    - UIに限定されない各種定義値

アーキテクチャの設計を遵守できるのはもちろん、依存の方向性をある程度強制できるので「UIComponent →アプリ本体」という依存を作り循環参照が起きてぐちゃぐちゃになるそしてしぬ…ということが防げます。他にもnamespaceをきっちり分けることで、呼び出すモジュールやクラスを明確にできるという利点があります。

qiita.com

まだ実施していませんが、EntityやAPIは他のアプリのコードと比較して変更の頻度が速くないため、これも別モジュールに切り出して良いかもしれません。

現状のメリット/課題

さて、冒頭から偉そうに解説していますが、RailsとSwiftのどちらも私が設計したものではなく、過去に在籍したエンジニアの方が設計したものです(そのため私の解釈がある程度混ざっています)。私はその恩恵に預かっているわけですが、これまで半年間の開発で感じたアーキテクチャの「メリット」と「課題」についてお話します。

メリット

少人数のエンジニアリソースで開発できる

個人的にはこれが最も大きなメリットだと感じるのですが、コードベースの大きさと比較すると、人数の少ないチームで開発を進められます。

著書『人月の神話』の中で、フレッド・ブルックスは基本的な原則を明らかにしました。小さなチームなら、どんな方法論もうまくいくのです。―ケイト・トンプソン 『ZERO BUGS シリコンバレープログラマの教え』*4

実際に2020年5月の執筆時点で、フィッツプラスはサーバサイド4名とアプリエンジニア1名の計5名で開発を行っています。今回ご紹介したサービス以外にもRailsアプリケーションが2つにPHPのサービスやPythonスクリプトなどがあり、それらの存在も考えると少ない人数ではないでしょうか。小さなチームではコミュニケーションコストを低く抑えられ、サービスの前提やコードの変更状況などの共有がとてもラクです。

また、一般的な話をすると、そもそもベンチャー企業では物理的に大量のエンジニアを採用しづらいパターンもあるかと思います。まず最初はサービスをモノリシックに作り、市場に必要とされる機能を開発していくスタイルは、オーソドックスですがひとつの解ではあると思いました。

拡張性が高く複数のアプリケーションを展開しやすい

これはVIPERのくだりでドメインを定めたおかげですが、Entityで閉じ込めたロジックが複数のアプリケーションで共有されやすい状態だと感じます。

社内にはDietPlusに近いドメインを持ったiOSおよびAndroidのアプリ(冒頭アーキテクチャ図参照)が他にも存在していますが、アプリやバックエンドともに既存アプリと同じようにコードを書くことで再現できる部分が多く、後から入った身としては助かります。

また、テストを書く際に、テストケースを豊富に書くべき部分が明確になります。具体的には、日付に関しては境界値テストを必ず書いたり、APIリクエスト時のパスが想定しない日時だった場合の異常系のテストなどを増やしケースを充実させています。一方で、管理画面上(View)では課金などのクリティカルな処理を行っていないので、System Specは薄くて済みます。

実はサーバサイドと連携するアプリを他にも増やす予定があり(まだ喋れないやつ)、現在私が担当者としてモリモリとコードを書いているのですが、こういったことを簡単にできるのはひとつの強みです。

課題

アカウントや権限の管理が複雑になる

Bankenの章でピンと来た方がいるかもしれませんが、複数の権限が必要なアプリが複数存在しているため権限が複雑になってきています。

それぞれユーザーの権限を各Modelのenumで判断しているため、権限の説明やコンテキストをコードで表現・管理するのが難しいです。マイグレーション時にDBにコメントを残すことができますが、そこに盛り込むのに権限の説明は少し長すぎます。Model内に長文でコメントアウトを残すのが妥当なラインでしょうか。

長期的には、太ってきた権限を他のテーブルに分割していく等の改善方法があるかと考えています。権限の説明をコードで把握するのを諦めて、しっかりドキュメントを残すことも大切でしょう(視線を泳がせながら🐟)。

Swiftのファイル数が多くなる

VIPERに限らず、Clean Architectureでは”ファイル数が多くなりがち”です。責務を分割すればひとつのファイル(あるいはレイヤー)あたりのコード数が少なくなるので、その裏返しと考えれば当然です。

ひとつの画面を作るために、VIPERの頭文字(E除く)とStoryboardがひとつ(不要な場合もある)の合計5つのファイルを作成する必要があります。RailsならViewファイルを作成して、Controllerとrouteファイルに追記するくらい(諸説あり)なので、比べるとやはり多いと感じます。

これについては、コードとファイルの自動生成gemのGenerambaを用ることで工数を削減しています(アプリ開発のライブラリにRuby製のgemが使われていると、Rubyistとしては少し嬉しい気持ちになります)。工数の削減以外にも、Module構成やクラス記述などを統一できるメリットもあります。

github.com

他の自動生成ツールとしては、SwiftGen でリソースと型の作成を自動で行ったりしています。

一部のコードがmodule間でDRYにならない(しづらい)

A::UserB::Userといった別moduleの類似クラス(AやBはアプリ名)が数多く存在するのですが、共通化すべきコードとそうでないコードの見極めが難しいと感じます。各アプリでグロース速度が異なるのでなおさらです。普段コードを書いていて「あっ、このscopeってBの方には生えてなかったのか...」ということがよくあります。共通化するにも「3つ以上のmodule間で共通して使われ続けるであろう処理」かどうかの判断は容易ではありません。

個人的には、ヘンに共通化して罠にハマるくらいなら、メンテナンスするコード量が多少増えても、影響範囲をmodule内に閉じ込めておく方が無難なのではないかと考えます。

さいごに

長くなりましたが以上です。最後までお付き合いいただきありがとうございました。アプリケーションのアーキテクチャや使用するgemなど、皆さまになにか得るものがありましたら幸いです。

冒頭で事業について触れましたが、メドピアはメインサービスである「MedPeer」を中心に、さまざまな医療領域をカバーするため新しい事業をつぎつぎと立ち上げています。事業やプロダクトが社内にたくさんある現状から学べること・経験できることはとても多く、エンジニアとしてとても魅力的な環境だと思います。

ステマみたいになりました しかし、プロダクトをより良くするために、私たちにはエンジニアの力がもっと必要です。というか一生「足りない」って言い続けてる気がしますが、そんな中でも一緒に走りながらお互いを高めあえるエンジニアの方はぜひメドピアへ!

■募集ポジション

medpeer.co.jp

■開発環境

medpeer.co.jp

*1:リポジトリをforkしprotect branchすればケア可能: 参考

*2:実際、HanamiはClean Architectureに影響を受けているそうです: 『Hanamiフレームワークに寄せる私の想い(翻訳)』https://techracho.bpsinc.jp/hachi8833/2018_03_28/54381

*3:『クリーンアーキテクチャ(The Clean Architecture翻訳)』https://blog.tai2.net/the_clean_architecture.html

*4:"37.象の多くの側面"より引用

Terraform用のGitHub Actionsをterraform-github-actionsから後継のsetup-terraformに移行する

SREの侘美です。

最近はfirst call for オンライン診療の開発でRailsのコードを書いてました。

hashicorp/terraform-github-actions から後継である hashicorp/setup-terraformへ移行した際にいくつか設定でハマったので、そのことについて書いていきたいと思います。

背景

メドピアではterraformでAWSのインフラを管理しています。
terraformのリポジトリでは、レビューがスムーズに行えるようにGitHub Actions上で terraform planterraform applyterraform fmt 等を実行できる hashicorp/terraform-github-actions を利用し、下の画像のようにplan結果をPRに自動で投稿するようにしていました。

f:id:satoshitakumi:20200520154554p:plain

新サービスをリリースし仕事も一段落した先日、TerraformのGitHub Actionsに関するとあるドキュメントが更新されていることを発見しました。

Teraform GitHub Actions - Terraform by HashiCorp

なんと hashicorp/terraform-github-actions のメンテナンスが終了されていました!

メンテナンスされていないActionsを利用したままでは、最新のterraformのバージョンに対応できない日がやってきそうなので、さっそく後継の setup-terraform でGitHub Actionsの設定を書き換えることにしました。

準備

まずGitHub Actionsの設定を修正する前に、 terraform-github-actions と 後継である setup-terraform の特徴と terraform plan の実行例を比較してみました。

terraform-github-actions

リポジトリ

github.com

特徴

  • 各ステップで uses: hashicorp/terraform-github-actions@master を指定して、 initplan などのサブコマンドを指定して使う
  • tf_actions_comment: 'true' を指定することで、plan結果をPRに投稿してくれる
  • planの差分が無い場合はPRに投稿はしない

Actions上でterraform planを実行するサンプル

steps:
  - uses: actions/checkout@v2

  - name: Terraform Init
    uses: hashicorp/terraform-github-actions@master
    with:
      tf_actions_version: ${{ env.TF_VERSION }}
      tf_actions_subcommand: 'init'

  - name: Terraform plan
    uses: hashicorp/terraform-github-actions@master
    with:
      tf_actions_version: ${{ env.TF_VERSION }}
      tf_actions_subcommand: 'plan'
      tf_actions_comment: 'true' # PRへplan結果を投稿する設定

setup-terraform

リポジトリ

github.com

特徴

  • 文字通りterraformをsetupし、 terraform コマンドが利用できるようにする
  • GitHub Actionsのoutput等に対応するようにscriptでwrapされている
  • plan等は run: terraform plan で実行する
  • その他の機能は無く、plan結果のPRへの投稿などは自前で設定する必要がある

Actions上でterraform planを実行するサンプル

steps:
  - uses: actions/checkout@v2

  - uses: hashicorp/setup-terraform@v1
    with:
      terraform_version: ${{ env.TF_VERSION }}

  - run: terraform init

  - run: terraform plan -no-color

setup-terraformへの乗り換え

特徴が把握できたところで、setup-terraformを利用してPRにplan結果を投稿する設定をしていきます。 基本的には terraform-github-actions の仕様を再現する形としています。

具体的には下記の3点です。

  • PRへのplan結果の投稿
  • 差分が無い場合は投稿を抑制
  • 差分以外の余計な出力の削除

PRへのplan結果の投稿

setup-terraform の README に記載されている設定を参考にします。

下記のように actions/github-script を使い createComment 関数でコメントを投稿します。
secrets.GITHUB_TOKEN はActions上で自動で定義される変数です。

- uses: actions/github-script@v1
  env:
    # id: planのステップの出力を参照
    STDOUT: "```terraform\n${{ steps.plan.outputs.stdout }}```"
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      const output = `<details><summary>tf plan:</summary>\n\n${process.env.STDOUT}\n\n</details>`;

      github.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: output
      })

差分が無い場合は投稿を抑制

実装方法としては2通りあります。

1つはterraformの -detailed-exitcode オプションを使うやり方です。
このオプションを付与することで、コマンドのexit codeが下記のようになります。

  • 0: 成功かつ差分なし
  • 1: エラー
  • 2: 成功かつ差分あり

GitHub Actionsではexit codeが0以外の場合はエラーとなりworkflowで利用するには continue-on-error: true をplanのステップに追加する必要があります。

steps:
  # 他のstepは省略
  - name: terraform plan
    id: plan
    run: terraform plan -detailed-exitcode
    continue-on-error: true # 0以外のexit codeでもworkflowを継続する
  
  - name: comment on PR
    if: ${{ steps.plan.outputs.exitcode == 2 }}
    # 以下PRにコメントする処理

2つめは単純に出力内容の文字列から取得する方法です。
こちらはあまりロバストではないですが、 continue-on-error: true を利用しなくてすむため、今回はこの方法を採用しています。

steps:
  # 他のstepは省略
  - name: terraform plan
    id: plan
    run: terraform plan
  
  - name: comment on PR
    if: ${{ !contains(steps.plan.outputs.stdout, 'No changes.') }}
    # 以下PRにコメントする処理

差分以外の余計な出力の削除

terraform-github-actions ではterraform planを実行した際に大量に出力される <resource_id>: Refreshing state... のような出力を削除した上でPRにコメントしてくれます。

f:id:satoshitakumi:20200520154554p:plain

terraform-github-actionsの実装 を確認してみると、 sed コマンドで ------(略 の区切り線を基準に行を削除していました。

terraform-github-actions 同様に sed コマンドで消しても良いのですが、PR投稿のgithub-script内でついでに整形する実装にします。

steps:
  - uses: actions/github-script@v1
    env:
      STDOUT: "${{ steps.plan.outputs.stdout }}"
    with:
      github-token: ${{ secrets.GITHUB_TOKEN }}
      # NOTE: 区切り文字で囲まれた範囲のみを出力する
      script: |
        const lines = process.env.STDOUT.split('\n')
        const separator = '-'.repeat(72)
        let index = lines.indexOf(separator)
        let outputLines = lines.slice(index + 1)
        index = outputLines.indexOf(separator)
        if (index) {
          outputLines = outputLines.slice(0, index)
        }
        const planOutput = '```' + outputLines.join('\n') + '```'
        const output = `<details><summary>plan:</summary>\n\n${planOutput}\n\n</details>`;

        github.issues.createComment({
          issue_number: context.issue.number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: output
        })

最終的なGitHub Actionsの設定

最終的な設定は下記のようになりました。

steps:
  - name: Checkout Repo
    uses: actions/checkout@v2

  - name: setup Terraform
    uses: hashicorp/setup-terraform@v1
    with:
      terraform_version: ${{ env.TF_VERSION }}

  - name: terraform init
    run: terraform init

  - name: terraform plan
    id: plan
    run: terraform plan -no-color -lock=false

  - uses: actions/github-script@v1
    if: ${{ !contains(steps.plan.outputs.stdout, 'No changes.') }}
    env:
      STDOUT: "${{ steps.plan.outputs.stdout }}"
    with:
      github-token: ${{ secrets.GITHUB_TOKEN }}
      # NOTE: 区切り文字で囲まれた範囲のみを出力する
      script: |
        const lines = process.env.STDOUT.split('\n')
        const separator = '-'.repeat(72)
        let index = lines.indexOf(separator)
        let outputLines = lines.slice(index + 1)
        index = outputLines.indexOf(separator)
        if (index) {
          outputLines = outputLines.slice(0, index)
        }
        const planOutput = '```' + outputLines.join('\n') + '```'
        const output = `<details><summary>tf plan:</summary>\n\n${planOutput}\n\n</details>`;

        github.issues.createComment({
          issue_number: context.issue.number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: output
        })

所感

terraform-github-actions から比べるとPRへplan結果を投稿する付近の処理を自前で用意しなければならず、難易度は上昇したように思えました。
ですが、無事に後継の setup-terraform へ移行することができたので、terraformの最新バージョンへの追従する際のActions関連の懸念を減らすことができました。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html