株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

はじめまして nabeo です

今年の6月にヘンリーに SRE として中途入社しました nabeo (id:nabeop) です。前職では toC 向けのコンテンツプラットフォームサービスと toB 向けのテクノロジーソリューションサービスなどを展開している会社で共通の基盤を開発運用している部署で SRE をやっていました。

ヘンリーに入ろうと思った理由

今年の初め頃から以下を軸にしてなんとなく転職活動を開始していました。

  • 自分にとって新しいチャレンジをしたい
  • エンジニアとしての成長ができる場に身を置き続けたい

エンジニアとしての成長はわりとこだわりが強くて、自分よりも若いエンジニアがメキメキ成長をしている様子を目の当たりにしていたので強い危機感をもっていました。そんな中で、カジュアル面談や面接を通じてヘンリーには優秀なエンジニアが在籍していそうだし、一緒に仕事をしたいと思えました。とくにカジュアル面談や面接では同じ SRE として活躍されている戸田さんといくつかの技術的なディスカッションをさせてもらいましたが、ディスカッション自体が楽しく一緒に働きながら組織を成長できそうだという印象をもてたことが大きかったです。

また、会社としても勢いがあるが、SRE 的な文脈ではまだまだ未整備なところがあって、SRE として技術分野だけでなく、文化などより広範囲なところで活躍できそうというイメージがあったことも魅力でした。とくに電子カルテやレセコンという医療分野をターゲットにしたプロダクトを開発/運用するにあたり SRE 的な知見をどのように導入して活用していくのかというのは入社前にはまったく想像がつかずとてもワクワクしたことを覚えています。

ヘンリーのオンボーディング

ヘンリーのオンボーディングでは「初日」「最初の1週間」「最初の1ヶ月」の3つの粒度でヘンリーが開発しているクラウド電子カルテ・レセコンシステムの Henry の概要や開発で必要となってくるドメイン知識だけでなく、ヘンリーの文化やプロダクトの機能などを効率よくキャッチアップできるようにタスクが組まれていてとても助かりました。実際にヘンリーに入るまではレセコン*1という言葉も聞いたことないし、病院やクリニックでどのような業務フローがあるかもわかっていない状態でしたが、必要な知識が効率よく学べるようにチェックリスト形式になっていたのでスムーズにキャッチアップできたと思います。

入社初日の TODO リストとして業務で使用するツールで実施するべき初期設定のリストがある。
入社初日の TODO リスト

また、実務でもこれまでに発生した障害や不具合の対応などをまとめて開発に及ぼした影響などが判別しやすい指標を考えるというタスクを渡されて、開発の進め方や Henry の大まかなシステム構成などを俯瞰して眺めることができました。とくに不慣れな医療という現場でどのようにシステムが使われているかを座学で学んだ医療ドメインの知識と照らし合わせることができたのでコスパが良いキャッチアップタスクだったなと思っています。

これから何をするのか

前述のとおり Henry はこれからも成長を続けていくプロダクトですが、SRE 的な視点だとまだまだ未整備なところがあります。当面はサービスの開発と安定性の維持にバランスよく、かつ、最大の速度を出せるように SRE 的な知見の導入が主なミッションになっていくのかなと思っています。

特に SRE として絶対に欲しい SLI や SLO もまだ十分に整備されている状態とは言えず、Henry のプロダクト特性に応じた SLI や SLO を策定し運用する必要があると考えています。

また、前職では自分自身のアウトプットの他にも組織としてのアウトプットを後押ししていましたが、ヘンリーでも何らかの形で他者のアウトプットを後押しすることで、ヘンリーのエンジニアについてたくさんの人に知って欲しいと思っています。

一緒に仕事をする仲間を募集しています

SRE 職にかぎらずヘンリーでは一緒に働く仲間を募集しています。これからも成長が見込める弊社で一緒に働いてみませんか?興味のある方は以下の採用サイトからぜひコンタクトしてみてください。

*1:レセプト(診療報酬明細書)を作成するときに使用するレセプトコンピューターのことです。

StorybookをReact以外のプロジェクトでも使いたい!

はじめまして。今月から株式会社ヘンリーのフロントエンドエンジニアをしている kobayang です。この記事では Storybook を React や Vue などの UI ライブラリを使っていないプロジェクトでも活用できるかも、という話をしていこうと思います。

Storybook

storybook.js.org

もはや説明不要な気がしますが、Storybook は UI を実装する上で便利な、UI プレビューのためのライブラリです。また、プレビューだけでなく、Jest から Story を呼び出すことで簡単にテストが書けたり、Chromatic と連携することで、VRT ができるようになったりと、開発の生産性をあげるだけでなく、プロダクトの安定性を上げるためにも重要なライブラリになっています。

そんな便利な Storybook なのですが、普段当たり前のように使っているがゆえに、Storybook がないと不安になる、ということに気づいてしまいました。その時に触っていたコードは、HTML のテンプレートエンジンで書かれていたため、プレビューできないのは仕方がないかなと思っていたのですが、そこで、あることを思い至りました。

HTML で動いているんだから Storybook で動かないはずはない、と。

Storybook for HTML

ここから本題です。先に結論を言ってしまえば、HTML を Storybook で動かすことができます。新規プロジェクトの場合は、Storybook 初期化時に、 —type html を指定することで、HTML を動かすための最小限のセットアップが行えます。

// pnpm の場合
pnpm dlx storybook@latest init --type html
// npm の場合
npx storybook@latest init --type html

Storybook v7 からは Vite でも動くようになったので、Vite or Webpack のどちらを使用するか、という選択が出ます。今回は Webpack を選択します。

初期化が完了すると、Storybook の設定と、stories フォルダにサンプル用のプロジェクトが追加されます。

Storybook の設定

.babelrc.json
.storybook/main.js
.storybook/preview.js
package.json

サンプルファイル

stories/Button.js
stories/Button.stories.js
stories/Header.js
stories/Header.stories.js
stories/Introduction.mdx
stories/Page.js
stories/Page.stories.js
stories/assets/code-brackets.svg
stories/assets/colors.svg
stories/assets/comments.svg
stories/assets/direction.svg
stories/assets/flow.svg
stories/assets/plugin.svg
stories/assets/repo.svg
stories/assets/stackalt.svg
stories/button.css
stories/header.css
stories/page.css

stories に出力されたサンプルファイルは邪魔なので全部消してしまいます。

さて、もう少し変更されたファイルを見てみましょう。まずは package.json から。

"devDependencies": {
  "@babel/preset-env": "^7.22.5",
  "@storybook/addon-essentials": "^7.0.20",
  "@storybook/addon-interactions": "^7.0.20",
  "@storybook/addon-links": "^7.0.20",
  "@storybook/blocks": "^7.0.20",
  "@storybook/html": "^7.0.20",
  "@storybook/html-webpack5": "^7.0.20",
  "@storybook/testing-library": "^0.0.14-next.2",
  "react": "^18.2.0",
  "react-dom": "^18.2.0",
  "storybook": "^7.0.20"
}

上記のパッケージが追加されました。 HTML を選択しているのに React が追加されているのが気になりますね。

今回、Storybook を動かすのに本当に必要なのは、以下のパッケージになります。

  • storybook
  • @storybook/html
  • @storybook/html-webpack5
  • @babel/preset-env

さて、ここで、 storybook/main.js をみてみると以下のような設定がされています。

framework: {
  name: "@storybook/html-webpack5",
  options: {},
}

Storybook v7 から導入された Framework によって隠蔽されてしまっていますが、どうやら、この設定によって、 HTML を Storybook が起動できるようになっていそうです。

HTML を Story として読み込んでみる

試しに、適当な index.html を作って Story に読み込ませてみましょう。

<h1>test</h1>
import html from "./index.html";

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉

ではこれはどうでしょう?Story に直接 HTML を書いてみます。

export default { title: "index" };
export const Preview = () => `<h1>test</h1>`;

これも無事に動きました 🎉

適当に Story を試しただけですが、このことから、ファイルを HTML として出力さえすれば Storybook で動かすことができることが分かりました。

index.html が読み込めているのは、 @storybook/html-webpack5 が HTML を Webpack のローダーとして設定しているためです。ということは、ローダーさえセットできれば、任意のプロジェクトを Storybook で動かすことができるはずです。

実際にやってみましょう。

テンプレートエンジンを Storybook で動かす

UI ライブラリではないプロジェクトを Storybook により動かせるようにしたいと思います。今回は例としてHandlebars というテンプレートエンジンを使います。詳細は割愛しますが、以下のように、テンプレートを記述することができます。

<h1>{{title}}</h1>

Handlebars.js によってテンプレートをコンパイルできます。コンパイルするためにパッケージをプロジェクトに追加します。

pnpm add handlebars

以下のようにコードを書くことで、 Handlebars で書かれたファイルを HTML に変換することができました。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });
// => <h1>Test</h1>

Story に直接書いてみる

コンパイルすることで HTML を生成することができるところまで分かったので、このコードを Story に記述してみます。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きそうですが、これは残念ながらエラーになってしまいました。

handlebars モジュールを読み込む際に、 Module not found: Error: Can't resolve 'fs' というエラーになります。本質的なエラーではないですが、Storybook を起動するために修正する必要があります。fs module を何らか mock してあげれば回避できそうです。

ググって見つかった stackoverflow の回答に従って、以下のように Storybook の main.jsconfigwebpackFinal を記述します。

webpackFinal: async (config) => {
  config.resolve.fallback.fs = false;
  return config;
},

これで、Storybook が起動しました 🎉

ファイルから Import する(その1)

上記の直接 Story に書いていくやり方は、さすがに古典的すぎるので、せめてファイルからインポートできるようにしたいです。というよりファイルからインポートできないと、プレビューとしての意義を果たせません。察しの良い方はすでにお分かりの通り、この辺りから Webpack のローダーをカスタマイズしていきます。

先ほど修正した webpackFinal のフィールドがありますが、ここから Storybook で動かす Webpack の設定を変更できるようになっています。

とりあえずファイルから読み込めるようにするために、raw-loader を使ってみましょう。 raw-loader は名前の通り、ファイルをテキストとして読み込むローダーです。

まず、 raw-loader をパッケージに追加します。

pnpm add -D raw-loader

.storybook/main.jswebpackFinal に、 .handlebars 拡張子のファイルに対して raw-loader を適用するようにセットしてみましょう。

webpackFinal: async (config) => {
  config.module?.rules?.push({
    test: /\.handlebars$/,
    loader: "raw-loader",
  });
  config.resolve.fallback.fs = false;
  return config;
},

次に、 index.handlebars を作成し、そこにテンプレートを記述します。

<h1>{{title}}</h1>

これで準備が整いました。先ほど書いた source の部分を index.handlebars から import するように変更してみましょう。

import Handlebars from "handlebars";
import source from "./index.handlebars";

const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きました 🎉

ファイルから Handlebars で書かれたテンプレートを Story に import できるようになりました。ここまでで必要最小限のプレビュー機能が達成できたことになります。

しかし、テンプレートが一つのファイルで収まることは稀でしょう。実際には、テンプレートは別のテンプレートに依存します。Handlebars においても、テンプレートから別のテンプレートを呼び出すことが可能です。

以下のように記述します。

<h1>{{title}}</h1>
{{>subtitle}}

subtitle.handlebars

<h2>{{subtitle}}</h2>

上記で記述した Story だと、 subtitle を読み込むことができないので、以下のようなエラーになってしまいます。

これを解決するには、 registerPartial を使って、コンパイルの前に、依存する Partial Template を登録しておく必要があります。

import Handlebars from "handlebars";
import source from "./index.handlebars";
import subtitle from "./subtitle.handlebars";

Handlebars.registerPartial("subtitle", subtitle);

const template = Handlebars.compile(source);
const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

これで、動くには動きますが、依存ファイルが増えるたびに Story にそれを追加しなければいけないのは何とも筋が悪いです。次はこれを何とかしましょう。

ファイルから import する(その2)

Handlebars には幸いにも Webpack のローダーが存在します。

github.com

このローダーを設定することで、先ほどの依存をいい感じに解決してくれるようになります。

設定してみましょう。

まずは、handlebars-loader をパッケージに追加します。

pnpm add -D handlebars-loader

Storybook の Webpack のローダーを変更します。

config.module?.rules?.push({
  test: /\.handlebars$/,
-  loader: "raw-loader",
+  loader: "handlebars-loader",
});

変更はこれだけです。この設定により、 Handlebars で書かれたテンプレートがローダーによって解決されるようになりました。

以下のような Story を記述すれば先ほどのテンプレートも動くようになります。

import template from "./index.handlebars";

const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉  これで、Handlebars によって記述された HTML をいい感じに Storybook で確認できるようになりますね!

まとめ

HTML を Storybook で動かす方法について簡単に紹介しました。また、Handlebars というテンプレートを例にとって、コンパイル可能であれば raw-loader を使ってテンプレートファイルをプレビューできること、さらに Webpack のローダーがあれば、より良い感じに Story を記述できることを示しました。

ぱっと思いつく適用例としては、 erbhaml の Webpack ローダーが存在していそうなので、Rails のプロジェクトにも Storybook が適用できるかもしれません。(注: 試してないので分かりません)

github.com

github.com

今回例に出した Handlebars は、Vite 用のローダーがなかったため、Vite では起動できませんでしたが、設定さえしてあげれば Vite でも同様なことができるはずです。

また、実際のプロジェクトは、より複雑だったり、依存するファイルがプログラムによって制御されていたりと、ローダーを直接使うことが難しかったりするかもしれません。

しかし、Webpack をいい感じに設定して HTML さえ出力できれば Storybook を使えることが分かってもらえたと思うので、もし、Storybook が入ってないプロジェクトがある場合には、ぜひ一度導入をチャレンジしてみて欲しいなと思います。

皆様がより良い Storybook ライフを送れますように。敬具。

参考

今回説明で使った成果物は以下のリポジトリにあります。

github.com

【Scala 3 macroがすごい】Compiletime API編

株式会社ヘンリーでメタプログラミングに没頭しているgiiitaです。

突然ですが皆さんはメタプログラミングに触れたことがあるでしょうか? プログラミング言語によって様々なメタプログラミングの機能があります。Javaやその派生言語では馴染み深い「リフレクション」はその代表例ですが、LispやRust, Scalaなどにはマクロと呼ばれる、compile前にコードを操作する仕組みがあります。 そんな中でも最近急激な変化を遂げている Scala3 のMacroがとんでもなくすごいんですが、なかなかまだ情報が出回っておらず、手を出すにはあまりにも敷居が高くなっているため、皆さんに面白さを知ってもらうべく紹介します。

マクロって何ができるの?

そもそもマクロに手を出しづらい背景として、何に使えるのか、いつ使うべきなのかよくわからないからというのが大きいのではないでしょうか? 現に私自身、手を出すまでイメージがつかず、わざわざ難解でExperimentalな機能 (Scala2系当時) を使ってまで何かをしたいというモチベーションがありませんでした。 とは言いつつも、今なら明確にこういうケースで使うべきですと言えるわけではないので難しいところですが、基本的には コードの変換自動生成 といったところが主な目的です。 そして何より、それら成果物を compilerによって静的に検査できる というのが旨味なわけです。

使ってみよう

今回はひとまず先日の3.3.0リリースを祝って、3.3.0でいろいろやっていきたいと思います。

Setup

まずはModule構成です。マクロを使用したmoduleは、利用するmoduleのbuild時にはbuildが完了していないといけないので、macroを定義するmoduleと呼び出すmoduleが必要です。 (確かこれもScala3で変更があったような気も...

build.sbt

lazy val Scala3_3  = "3.3.0"
scalaVersion in Scope.Global := Scala3_3

lazy val root = (project in file("."))
  .aggregate(
    macroModule,
    runtimeModule,
  )
lazy val macroModule = (project in file("macro-module"))
  .settings(
    libraryDependencies ++= {
      Seq(
        "org.scala-lang" %% "scala3-compiler" % scalaVersion.value,
      )
    }
  )
lazy val runtimeModule = (project in file("runtime-module"))
  .dependsOn(macroModule)

もはや何も特別な事はありません。 さて、何を作ろうか悩むところですが、わかりやすくここは誰もが通る道という事で、JsonParserを作っていきたいと思います。 String => JsonJson => String が必要なのでI/Fを切りましょう。この時、マクロから利用するものはmacro以下のmoduleに定義する必要があります。

trait Read[T] {
  def read(json: Json): T
}
trait Write[T] {
  def write(t: T): Json
}
trait Both[T] extends Read[T] with Write[T]

ここでは変異境界は無しでやっていきます。

trait Json extends Serializable {
  def nameOf(key: String): Json
}
case class JsonString(value: String) extends Json {
  override def toString: String = s""""$value""""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonAny(value: String) extends Json {
  override def toString: String = value
  override def nameOf(key: String): Json = JsonNull
}
case class JsonArray(values: Seq[Json]) extends Json {
  override def toString: String = s"""[${values.mkString(",")}]"""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonObject(values: Map[JsonString, Json]) extends Json {
  override def toString: String = s"""{${
    values.map { case (key, value) =>
      s"$key:$value"
    }.mkString(",")
  }}"""

  override def nameOf(key: String): Json = values(JsonString(key))
}
case object JsonNull extends Json {
  override def nameOf(key: String): Json = this
}

Jsonの型としては一旦こんなところでしょう。エスケープ処理は面倒なので考慮していません。 性能面では話にならないのであまり参考にしないでください。String to Jsonの処理もちゃんとやると結構面倒なのでイメージです。

さて、ここからいよいよマクロを書いていきますが、Scala3のマクロには2種類あります。

一つはお馴染み、AST(抽象構文木: Abstract Syntax Tree) を直接的に操作する方法です。 もう一つはScalaのコードがCompileされる前段のフェースでPrecompileされる、半マクロ的なものです。これは、scala3-libraryの scala.compiletime パッケージにAPIがあります。

Compiletime API

後者は近しいものがScala2にもありましたが、非常に強化されました。非常に簡単かつ、安全に使用できるAPIなので、出番も多いかもしれません。これはCompiletime APIと呼ばれています。 まずはこれを用いて、CaseClassのJson変換器を導出していきます。

// runtimeModule
object CodecGenerator {
  inline final def CaseClass[T]: Both[T] = InferCodecs.gen[T]
}

実行module側からinline functionを呼び出しています。 InferCodecs.gen は単なるinline関数であり、マクロ展開はされませんが、前述の通りPrecompileによってinline化されます。

// macroModule
object InferCodecs {
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

  inline def derivedCodec[A](using inline A: Mirror.ProductOf[A]): Both[A] =
    new Both[A] {
      override def write(value: A): Json = Writes.inferWrite[A].write(value)
      override def read(value: Json): A = Reads.inferRead[A].read(value)
    }

  trait ProductProjection {

    transparent inline def inferLabels[T <: Tuple]: List[String] = foldElementLabels[T]

    transparent inline def foldElementLabels[T <: Tuple]: List[String] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          constValue[t].asInstanceOf[String] :: foldElementLabels[ts]
      }
  }

  object Reads extends ProductProjection {

    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }
  }
  object Writes extends ProductProjection {
    inline def inferWrite[A]: Write[A] = {
      summonFrom[Write[A]] {
        case x: Write[A] => x
        case _: Mirror.ProductOf[A] => Writes.derivedWrite[A]
        case _ => error("Cannot inferred")
      }
    }

    inline def derivedWrite[A](using A: Mirror.ProductOf[A]): Write[A] =
      new Write[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemDecoders: List[Write[_]] =
          inferWrites[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemDecoders).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def write(t: A): Json = {
          val entries = t.asInstanceOf[Product].productIterator.toArray
          JsonObject(
            (0 until elemCount).map { i =>
              JsonString(elemLabels(i)) -> elemDecoders(i).asInstanceOf[Write[Any]].write(entries(i))
            }.toMap
          )
        }
      }

    private inline def inferWrites[T <: Tuple]: List[Write[_]] = foldWrites[T]

    private inline def foldWrites[T <: Tuple]: List[Write[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferWrite[t] :: foldWrites[ts]
      }
  }
}

複雑に見えますが、順に見ていきます。

InferCodecs.gen
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

summonFrom というのはcompiletime APIで、scala2における implicitly[T] に近いものです。 パターンマッチによって、 given Both[A] がimplicit scopeに見つかればそれを返して終わります。

case _: Mirror.ProductOf[A] もpackageこそ変わっていますが馴染み深いのではないでしょうか?要するにケースクラスであり、メンバの型情報が導出できれば、という分岐で、 given Both[A] は見つからないけど、型情報導出できそうなのでReadとWriteをそれぞれ導出するぜ!という処理です。

Reads.inferReads
    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

これも一緒ですね、さっきのは Both[T] の導出だったのに対し、今回は Read[T] の導出になっているだけです。

Reads.derivedRead
    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }    

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }

さて、いよいよcompiletime APIの本領発揮です。

inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

これはシグネチャから想像できる通り、任意のTupleのサブタイプ T の要素から Read[T] を導出してListで返しています。

inline erasedValue[T] これはまぁ見ての通りですが、 T <: Tupletypeunapply 的な操作である事は想像できます。 ※リファレンス

EmptyTuple か、 _: (t *: ts) かのパターンがあるという事は、 (A, B, C)(A *: (B, C))unapply されるという事ですね。そして A, B, C が順次導出されていきます。WritesもReadと同じですね。

        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

この辺はなんとなく想像しやすいと思います。型情報からラベル (変数名) とそれに対応する再起的に導出された Read[_]を持ち、 read(value: Json) が呼び出された際にはマッピングして各メンバをdeserializeしますよ、という事ですね。

さて、これでケースクラスのCodecは導出できるようになりました。後は末端のプリミティブ型のCodecが事前に定義されていて、implicit scopeに見つかれば動きそうです。 サンプルとして、末端がStringかIntであればいいとしましょう。

object Codecs {
  given Both[Int] with {
    override def read(json: Json): Int = json.toString.toInt
    override def write(t: Int): Json = JsonAny(t.toString)
  }

  given Both[String] with {
    override def read(json: Json): String = json.toString

    override def write(t: String): Json = JsonString(t)
  }
}

object Main extends App {
  export Codecs.given

  case class Root(child: Child)
  case class Child(str: String, num: Int)

  val codec = InferCodecs.gen[Root]
  val raw = codec.write(
      Root(
        Child("A", 1),
      )
  )
  println(raw)

  val value = codec.read(raw)
  println(value)

}

出力結果はこんな感じになりました。

{"child":{"str":"A","num":1}}
Root(Child("A",1))

雑な作りではありますが、雰囲気はつかめたのではないでしょうか? このサンプルでは、 given Both[T]Mirror.ProductOf[A] のいずれかしか処理していないので、間に Seq[T] などが入ってきてしまうとcompileできなくなります。 そして、マクロの呼び出し元はcompileの時点で異常に気付くことができるようになるわけです。

※本内容には、一部個人的な解釈を含んでいます。

最後に

面白い反面、マクロの運用にはなかなかリスクも伴います。 Scala3はまだまだガンガン開発が進んでいるので、3.0 ~ 3.3にかけて結構APIが変わっていたり、それだけならまだしもシグネチャのシンボルがこっそり変わったりして、OSSによってはScala3.xごとにsrcクラスパスを分けていたりします。 すでに、cross buildという観点では、同一のsrcクラスパスで各マイナーバージョンでのマクロmoduleのビルドはなかなか難しいかもしれません。

Scala2と比較して、構文literal直書き (scala.reflect.api.Quasiquotes) ができなくなった分マシにはなりましたが、結構めちゃくちゃなことをやっているところも少なくないので、適当なOSSのマクロ関数を読む事はあまりお勧めできません。 何より一番厳しいのは情報の少なさで、実装を読むか、公式 に聞く以外お手上げなんてことも... そんな中でも少しでも情報を集めている方は、私がメンテナンスしている DI framework のコードでも参考にして見てください。少しは助けになるかもしれません。

次回はもっと自由度の高いMacro APIについてお話ししようと思います。 Techオタクなあなた、是非私たちとお話しましょう!

jobs.henry-app.jp

プロダクト開発における認知負荷と戦う知見を共有するイベント「認知負荷バスターズ」を開催します!

こんにちは、リードエンジニアの @agatan です。

今回は、6/16に開催予定のイベント「認知負荷バスターズ」についてのお知らせです! このイベントは、株式会社タイミーとヘンリーが共同で開催するもので、我々プロダクト開発者にとって興味深いテーマ、「認知負荷」に焦点を当てています。

henry.connpass.com

認知負荷とは

認知負荷とは、一般的には情報処理能力を超える情報や要求によって生じる精神的な負荷を指します。

プロダクト開発に関わる全ての人々が、認知負荷と日々対峙していると言っても過言ではありません。 難解なコードベース、複雑な仕様、膨大なドメイン知識、etc...
認知負荷を高める要素は至る所に存在しています。 認知負荷との戦いは、プロダクト開発の根幹を成すものともいえるのではないでしょうか。

このイベントで話すこと

一方で、認知負荷との戦い方は、プロダクト・組織のフェーズや性質、辿ってきた歴史によって様々です。 このイベントでは、それぞれの抱える認知負荷にまつわる課題を共有し、それぞれの企業がどのように対処しているのかをLT形式で発表します。

タイミーとヘンリーは、それぞれ求人と医療という異なる業界でビジネスを展開していますが、共通して難易度の高いドメインを扱っています。(そして、みなさんのいる業界も!)

それぞれの開発チームが抱える課題には、共通点もあれば相違点もあるはずです。それらの経験を共有することで、登壇者だけでなく、参加者全員が新たな学びを得ることができるとよいなと思っています。

LT終了後は、懇親会も開催します。私たち登壇者と直接話す機会はもちろん、参加者同士で意見を交換したり、議論したりする時間も設けています。今回のイベントのテーマの性質上、懇親会での議論もなかなかに盛り上がるのではないかと楽しみにしています!(そのためのオフライン開催です!)

こんな人に参加してほしい!

  • 「認知負荷」について深く理解したい方
  • 自社の認知負荷軽減のための実践的な事例を探している方
  • タイミーやヘンリーのエンジニアと交流したい方

まとめと参加方法

以上、株式会社タイミーとヘンリーの共催イベント「認知負荷バスターズ 〜複雑なドメインをやっつけろ〜」のご案内でした。複雑なドメインに取り組む我々にとって、認知負荷の軽減は非常に重要な課題です。一緒にこの課題に立ち向かい、認知負荷をバスターしませんか?

興味を持っていただけた方は、ぜひイベントに参加してみてください!お待ちしています!

↓参加登録はこちらから

henry.connpass.com

フロントエンドをViteからNext.jsに書き換えた話 〜パフォーマンス編〜

こんにちは。4月にヘンリーに入社したSWE / アーキテクト / SETのsumirenです。
弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

今回、HenryのフロントエンドをReact + ViteからNext.jsに書き換えました。
この記事では、最初にNext.jsへの切り替えによってもたらされたユーザー体験の向上について説明します。次に、このユーザー体験の向上がどうして生じたのか、その背後にある技術的な要素をエンジニア向けに詳細に解説します。最後に、フロントエンドアーキテクチャに対する我々の長期的なビジョンについて述べます。

対象読者

  • エンジニアの方
  • ビジネスサイドの方
  • Henryを導入されている医療機関様の方

Next.js導入によるユーザー体験向上

私たちは数年前からHenryを開発しており、IT業界のトレンドに沿って技術をアップデートしています。
今回、業界のトレンドとしてNext.jsという技術が進化を遂げており、こちらで画面表示をリプレイスすることにしました。

以前にブログ記事でも紹介したとおり、ヘンリーでは様々なツールを用いてソフトウェアとエンジニアリングのステータスを定量化しています。 dev.henry.jp

医療機関様の環境での画面の性能は、以下のようになっていました。右下のFCP Distributionという箇所を見ると、200ms〜1秒程度のFCPがボリュームゾーンとなっていました。

ツール上で表示される画面表示のパフォーマンス

大部分が200msで済むならそこまで低い数値でないようにも思えるのですが、定性的にパフォーマンスを深掘りしていくと、ページをリロードしたときに半分以上の方が0.5秒〜2秒程度の待ち時間を感じていることが分かりました。

ツール上で表示される画面表示のパフォーマンス - 定性

この数値が高い(性能が低い)と、ユーザーがページを読み込み直すときに時間がかかり、ユーザー体験を悪化させます。例えば患者様の会計をするために急いでいるときに、Henryのページが高速で表示されなければ、医療事務の方々はストレスを感じることになります。

Next.js採用は、長期的なビジョンに基づいています(それについては主にエンジニアの方々向けに記事の最後のほうで述べます)。それに加えて、短期的なユーザー体験向上のメリットもあり、特に上記のパフォーマンスについても改善できると見込んで導入を決定しました。

デモ

以下2点のパフォーマンスが向上しています。

  • ヘッダーなどのレイアウトが表示されるまでの圧倒的に速くなっている
  • 最終的なデータ取得完了までの時間がやや速くなっている

上がNext.js版(新しい)、下がVite版(古い)の比較になっています。同時にページを読み込んでいます。 少し分かりづらいかと思うのですが、上記2点に注目して見ていただけると幸いです。

www.youtube.com

定量的なパフォーマンス比較

Next.js版のHenryは本番環境にリリースしたばかりで、まだ医療機関様の環境でのパフォーマンスは集計できていません。そのため、検証環境でのパフォーマンスの比較を記載します。
以下が、LighthouseというIT業界で最もスタンダードなツールでの計測結果です。

  • 総合パフォーマンス:56点82点
  • FCP(初期表示):4.0秒0.5秒
  • LCP(データ表示):5.4秒2.9秒
  • CLS(カクつき):0.050.001

FCPが圧倒的に速くなり、LCPもそれにつられてやや速くなっていることが分かります。 CLSというのはデータが表示されるにつれて画面がずれたりするカクつきのことで、これも改善しています。 その結果、総合パフォーマンスが56点から82点まで改善しました。toB バーティカルSaaSとしては先進的な数値ではないかと思います。

計測結果はツールによって異なるので、先述のダッシュボード上の数値と噛み合ってない部分もありますが、本番環境でも同様に高速化すると見込んでいます。

※3画面に対して複数回実施した平均点数を記載しています。
※キャッシュはオフで計測しています。

ユーザー体験がどう向上したか

上記の通り、Next.jsに書き換えたことで、ブラウザのリロード時や初回アクセス時のページ表示が速くなりました。

Henryにアクセスすると、ナビゲーションや画面のレイアウトなど、データ以外の部分が非常に速い速度で表示されます。 これにより、ユーザーが別のタブを見たい場合やデータを新規作成したい場合には、即座に操作を行えます。

また、ページの初期表示が速くなったことで、結果的に最終的なデータ取得までの時間も短くなっています。 加えて、データ以外のレイアウトが先に高速表示されるため、ユーザーは「画面上のこのあたりにデータが出そうだ」というのを事前に認識でき、データ表示後すぐに操作ができます。

ユーザー体験向上の図解
ユーザー体験向上の図解

FCP高速化の技術的な仕組み

この章では、主にエンジニアの方向けにNext.jsを導入するとFCPが高速化する理由を解説します。

元々のHenryは以下のようなアーキテクチャでした。

  • 素のReact + Vite
  • SSR

今回、上記からNext.jsに置き換えるだけで、自然とFCPが向上しています。 いくつか工夫しているポイントもあるため、併せて説明します。

以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。
また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。

まずはNext.jsとViteの基本的なアーキテクチャの違いを説明した後、高速化を後押しする要因である以下2点について説明します。

Next.jsとViteの基本的なアーキテクチャの違い

Vite版とNext.js版のヘンリーでページをリロードしたときの初回レスポンスを見比べてみます。

ViteとNext.jsの初回レスポンスの比較

左側がVite版です。Viteのような通常のSPA構成では、Reactコンポーネントレンダリングは全てブラウザで実行されます。
そのため、サーバーからのレスポンスは、Reactが書かれたJavaScriptを読み込むための<script>タグを含む最小限のhtmlであり、ほとんど何も表示されていません。
通常のSPA構成の経験がある方なら、<div id="root"></div>は馴染みがあるのではないでしょうか。

一方、右側がNext.js版です。Next.jsは、サーバーサイドでページをHTMLとJSに変換します。
そのため、サーバーからのレスポンスの時点で中身のあるHTMLが返されています。

次はChromeのPerformance Insightsで、ブラウザの処理の流れを見比べてみます。
以下のとおり、Next.js版のHenryでは初回のレスポンスでレイアウトの表示が始まっています。

Next.jsのPerformance Insights

一方、Vite版のHenryでは、初回のレスポンスでほとんど空のHTMLが返ってきた後、JSの読み込みがあり、その後JSの処理が行われています。
その結果、初回のレスポンスが返ってきてから、いくつものステップを挟まないと画面上には何も表示されていません。

ViteのPerformance Insights
ViteのPerformance Insights

このアーキテクチャの違いがNext.jsのFCPを高速化しています。
Vite版の場合は、ブラウザに初回レスポンスが来た後、JSの読み込み、ブラウザでのJS処理をして、ようやく画面を表示できます。
一方のNext.js版の場合は、ブラウザに初回レスポンスが来た時点で画面にレイアウトを表示できます。

この基本的なアーキテクチャの違いを踏まえたうえで、高速化を後押ししているNext.jsの2つの機能を見ていきます。
上記のPerformance Insightsの比較は処理の流れにフォーカスするためにキャッシュありで比較していましたが、この後はターンアラウンドタイムの最適化やトラフィック量に関わる話になるため、キャッシュを無効化したPerformance Insightsも見ていければと思います。

補足:工夫しているポイント

HenryはtoB SaaSであり、大部分の画面に認証がかかっています。認証には、クライアントサイドでFirebaseを利用しています。
Vite版のHenryでは、認証の状態が得られてから画面上に出すべきコンポーネントが決まりレンダリングしていました。

今回Next.jsに移行するにあたり、認証できている場合のコンポーネントレンダリングして返しています。これにより、Firebaseの処理待ちやコンポーネントの再レンダリングなしで画面のレイアウトが表示できます。
※データはあくまで認証後に取得するため、セキュリティ上の問題はありません。

const PageRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // ...

  // Firebaseによる認証は副作用として行う
  useEffect(() => {
    if (currentUser.loading) {
      return;
    }
    if (requiredAuth(router.pathname) && !currentUser.membership) {
      setShowLoginPage(true);
      return;
    }
    setShowLoginPage(false);
  }, [currentUser, router]);

  // ...

  return (
    <div>
      {showLoginPage ? (
        // ...
      ) : (
        // サーバーサイドレンダリング時点では、認証できている前提でレンダリングしておく
        <RootLayout showGlobalNav>
          {children}
          <WindowsPresenter />
        </RootLayout>
      )}
    </div>
  );
};

プリレンダリング

前章で説明したとおり、Next.jsは初回のレスポンスさえ受け取れれば、データを必要としない画面の一部を表示できます。
しかし、単純にリクエストが来てからサーバーサイドでReactをHTMLに変換すると、ブラウザでのJS処理の代わりにサーバーでのJS処理に時間がかかり、初回のレスポンスが遅くなってしまいます。

そこで、Next.jsではビルド時に大部分のHTMLとJSを生成してキャッシュしています。これをプリレンダリングと言います。具体的には、動的なサーバーサイドでのデータ取得を含まないページがプリレンダリングの対象となります。
この機能により、Next.jsは初回リクエストに対するレスポンスのターンアラウンドタイムを最小限に押さえています。

キャッシュなしでのVite版HenryとNext.js版Henryの初回リクエストに対するレスポンス速度を比較してみます。

まず、Vite版のHenryは次のとおりです。
オレンジの部分はJSの読み込みで、かなり気になる長さになっていますが、これは次の章で解説するので一旦見なかったことにしてください。
ここで見たいのは青色の初回リクエストに対するHTMLのレスポンスで、15〜20ms程度に収まっています。これは、Viteでは画面表示にJSが必須となる代わりに、HTMLはほとんど空に近いもので、サーバーサイドでの処理も必要ない静的なものだからです。

Vite版 キャッシュなし時のHTMLのレスポンス速度

次に、Next.js版を見てみます。
一見Vite版より遅くなっていますが、すでにレンダリング済みのHTMLでファイルサイズで多少大きさがあるため、通信完了までの時間が伸びるのは自然です。
特筆すべきはレスポンスが始まるまでのリードタイムです。0msから数えると、わずか5ms程度でレスポンスが始まっているのが分かります。

Next.js版 キャッシュなし時のHTMLのレスポンス速度

何もデータフェッチを行わない動的フェッチ(getServerSideProps)を含むNext.jsのページをローカルで動かすと、このようになりました。Reactのレンダリングで30ms程度ロスしていることが分かります。

Next.js版 非プリレンダリング時のHTMLのレスポンス速度

これがプリレンダリングの威力です。サーバーサイドでReactレンダリングすることでかかってしまう数十msのリードタイムすらなくし、SPAアーキテクチャとの性能差を圧倒的なものにしています。

補足:工夫しているポイント

Vite版のHenryのデータフェッチは、Apollo Clientを用いたクライアントサイドフェッチでした。
今回、マイグレーションにあたり影響を最小限にするために、データフェッチ手段は変えずにNext.jsに書き換えました。そのため、ほぼすべての画面がプリレンダリングの対象となりました。

個人的には、Streaming SSRでない通常のサーバーサイドフェッチはFCPを落としてしまうため、プリレンダリング可能なクライアントサイドフェッチのほうが望ましいと考えています。

コード分割

加えて、Next.jsには、フロントエンドへのJS転送量も大きく軽減する仕組みもあります。
※正式な機能名がドキュメント上で見つけられなかったため、便宜上コード分割としています。

Viteの場合、基本的には全てのReactコンポーネントが1つの巨大なJSファイルにバンドルされます。Lazy Loadingにより個別のコンポーネントを動的に読み込むことは可能ですが、そうした工夫をしなければ全ページ分のコンポーネントの情報がクライアントサイドで読み込まれます。

先ほど少し目に留まったかと思いますが、改めてVite版のHenryのJS読み込み時のPerformance Insightsを見てみます。
キャッシュヒットなし時には1秒経っても読み込みが終わっていません。このindex.jsは、4.0MBもの大きさになっていました。かつ、前の章で説明したとおり、SPAアーキテクチャではこのJSが読み込みとJSのブラウザ側での処理をしないかぎり、画面には何も表示できません。

Vite版のキャッシュあり時となし時のindex.jsの読み込み比較

記事の最初にあるLighthouseの計測結果でVite版のHenryのFCPが4.0秒もかかる一番の要因は、このキャッシュヒットなし時のJSの読み込みです。

続いてNext.js版のHenryのキャッシュヒットなし時のJS読み込みを見てみます。
細かくて分かりづらいかもしれませんが、下記の図から300ms程度でJSが読み込まれていることが分かります。
このように高速なのには2つの理由があります。
1点目の理由は、Next.jsは細かくJSを分けて並列での読み込みを可能にしていることです。これはこの図からも分かりやすいかと思います。

Next.js版 キャッシュなし時のJS読み込み

より重要な2点目の理由は、Next.jsはサーバーサイドでページ単位でReactにレンダリングしているため、そのページに必要なJSだけを返せることです。
実際、上記の図で読み込まれているJSは、数は多く見えますが、1つ1つは10〜300kBほどで、合計すると(この画面では)1MB未満になっています。

以下はNext.jsのビルド実行時のログです。ビルド時点で、ページ単位のFirst Load JSが計算できています。
これによると、多くの画面が500kB〜1MBに収まっており、4.0MB以上を一括で読み込まなければいけないVite版のHenryに対して大きなアドバンテージがあります。
実際にはこれでもNext.js的には大きいということで、First Load JSのログが真っ赤に染まってしまっています。今後、よりFirst Load JSを削減できるように精進していきたいです。

Next.js ビルド時ログ

このように、Next.jsでは個別の最適化を行わずとも、JSを小分けにする仕組み・デフォルトでページに必要なJSしか返さない仕組みがあり、高速化とトラフィック削減が実現されます。

補足:工夫しているポイント

Next.jsでも、Vite同様、Lazy Loadingによる個別の最適化が可能です。
今回は、もともとルートに近いコンポーネントでインポートしていた大量のFloatingWindowなどのコンポーネントをLazy Loadingするようにしました。
まずはマイグレーションを完了させることが最優先のため、手早くできるボリュームゾーンのみを対応しましたが、それでもFirst Load JSを300kBほど削ることができました。

補足:JS読み込みとFCP

Next.jsにとって、このJS読み込みは、厳密にはFCPと直接関係がありません。
ここまでに説明してきたとおり、初期表示のためのHTMLはサーバーサイドでレンダリング済みだからからです。

それではこのJSは何に使うかというと、ざっくり言うとユーザーが画面を操作できるようにするために使います(ハイドレーションと言います)。
あくまで初回レスポンスで返ってくるHTMLで、clickなどのイベントは一切登録されていないため、ブラウザ側にReactコンポーネントを正しく紐付ける必要があるということです。

つまり、このJSの読み込みや処理が遅いと、画面は高速で表示されても、ユーザーはデータを作成したり画面遷移するといった操作ができないことになります。
FCPには直接関わらないものの、広い意味では初期表示の高速化に大きく関わるものです。

長期的なビジョン

今回、Next.jsを導入したことで自然とページロードが高速化しました。
しかし、実のところ、Next.jsを導入したのは、単に短期的なページ高速化のメリットを享受するためではありません。
むしろ、Next.jsを導入できた今、フロントエンドエンジニアリングの可能性がさらに広がったのだと思っています。

この章では、フロントエンドアーキテクチャに関して、どのような長期的なビジョンを持ってNext.jsを導入したのか解説します。
この章では、これまでの章に比べ、主観や推測、判断が多くなります。予めご理解いただければと思います。

フルスタックReactというトレンド

まず、最も長期的な観点として、フルスタックの波に乗ることで、トータルでより先進的なユーザー体験や開発者体験が実現できる可能性が高いと考えています。

背景として、フロントエンド界隈全体の流れとして、Web標準フルスタックに寄せていくことが開発生産性やパフォーマンスを高めることにつながるという動向があるように思います。
Reactチームもそうしたトレンドを推し進めている当事者であり、今後Reactを始めるうえではNext.jsやRemixを採用することを推奨しています。

React公式のスクリーンショット

この記事で解説してきたように、こうしたフルスタックフレームワークはサーバーサイドレンダリングにより初回レスポンスからHTMLを返せます。
また、最近はPOSTについてもServer ActionsやAction Functionのように、個別のinputから値を集めてJSONに詰める今までのやり方に比べ、よりWeb標準に近い形を模索しています。

一方、ViteのようなSPAアーキテクチャはというと、空のHTMLが返ってくることも、巨大なJS読み込みが発生することも、そのJSで画面全体を構築することも、全てがWeb標準フルスタックのトレンドとは真逆です。
そのため、少なくとも今後盛り上がりや進化を見せる可能性は低く、最悪の場合はエコシステムの豊富なライブラリやホスティングサービスとの統合が難しくなっていく懸念もあると判断しています。

こうしたことを踏まえ、フルスタックフレームワークの波に乗ることで、ますます革新的なユーザー体験や開発者体験が可能になり、未来が明るくなると判断しました。

ユーザー体験

より短期的な観点として、Next.jsを導入したことですぐに始められるアプリケーションレベルのアーキテクチャ施策もたくさんあります。

例えばApp Routerを有効化すれば、React Server Componentsを使ってJSバンドルサイズを削減できます。
Steaming SSRによりFCPを保ちながらGraphQLリクエストをサーバーサイドに集約するなどすれば、ページリロード時だけでなくクライアントサイドナビゲーションによる遷移時のパフォーマンスも向上することができる期待があります。

また、Server Componentsの導入と併せてstyled-componentsからゼロランタイムCSS in JSへの置き換えを行うことで、iPadスマートフォンでも高いINPを実現できる見込みもあります。
その他、細かい機能も含めると、フォントやイメージの最適化の活用などもあります。

開発生産性

加えて、開発者体験についても、Next.jsを導入したことで今後向上する見込みです。

Next.jsの規約に従うことで、ルーティングやバンドラの設定を自前で管理する必要がなくなり、アプリケーションに集中しやすくなります。 機能開発自体も、レイアウトやメタデータを簡単に共通化できるようになるなど、配管コードの量が削減できる見込みです

より積極的なアプローチとして、ソフトウェアのアーキテクチャを見直す余地も出てきます。
現状はフロントエンド - GraphQL BFF - gRPC Backendという構成ですが、例えばコンポーネントにServer Actionsを書いて直接gRPCを呼んだり、Server Componentsから直接gRPCを呼んでデータを取得するアーキテクチャに変更できます。
このようにすれば、Reactだけでフロントエンドの開発が完結し、より開発を加速できるかもしれません。

まとめ

HenryをViteからNext.jsに書き換えたことで、フロントエンドエンジニアリングの可能性が広がり、直近ではFCPも向上しました。
引き続きHenryフロントエンドアーキテクチャをよくして、ユーザー体験と開発生産性を高めていきたいと思います。

この記事は比較的一般論に近い解説記事となりました。実際、パフォーマンスに寄与しているのはほとんどNext.jsのビルトインの仕組みです。
一方、今回行ったViteからNext.jsへのマイグレーションプロセスには、より実用的な洞察がたくさん含まれていると考えています。
最も面白い点としては、今回Vite版を破壊せずに、ほとんどのコードを共通化した状態でNext.jsを導入し、開発を止めずにマイグレーションしたことが挙げられます。
こうしたマイグレーションを実現したプロセスやコードベース設計について、別途記事として公開したいと思いますので、よければそちらもご覧ください。

最後まで読んでいただき、ありがとうございました。

修正ログ

  • 2022/06/02 12:57
    • 記事の内容がVite自体の性能について述べるものと誤解を与えうる内容になっていたため、補足を追加しました。
      • 以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。

      • また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。

医療系スタートアップのバックエンドをモノレポ化した話 〜技術編〜

こんにちは、ヘンリーの SRE の戸田と Wildcard Engineer の岩永です。 弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

前編の Henry のバックエンドをモノレポ化した戦略やプロセスに続いて、後編のこちらの記事ではモノレポ化の技術的手法を解説します。 dev.henry.jp

実際のモノレポ化の流れに沿って、ポイントを3点説明します。

  1. 2つの git リポジトリのマージ
  2. アプリケーション・ワークフローのモノレポ対応
  3. モノレポへの切り替え当日に向けた手順書の作成

1. 2つの git リポジトリのマージ

今回のモノレポ化においては、もともと存在していた henry-general-api と henry-receipt-api という2つのマイクロサービスのリポジトリを、1つのリポジトリにマージし、それぞれのマイクロサービスがサブディレクトリとして管理できることを目指していました。

henry-general-api/
├── .git/
└── ...

henry-receipt-api/
├── .git/
└── ...

↓↓↓

henry-backend/
├── .git/
├── general-api/
|   └── ...
└── receipt-api/
    └── ...

コアコンセプト

1つのリポジトリにマージするといっても、その後のコード変更のトレーサビリティを確保するために git の履歴が失われれないようにするための方法を考えなければいけません。

今回は git-filter-repo というツールを使用しました。これは、単一リポジトリの履歴を書き換えるためのツールです。例えば、条件に一致するファイルを履歴から完全に抹消したり、逆にそのファイルに関連する履歴だけを抽出したりすることができます。

このツールを使うと、前述したようなリポジトリのマージは大きく3ステップで実現できます。

  1. git-filter-repo を使い、マージ元の各リポジトリ (henry-general-api, henry-receipt-api) で、すべてのファイルをサブディレクトリのパスに移動させる

     cd /path/to/henry-general-api
     git filter-repo \
       --path-rename ":general-api/" \ # すべてのファイルをサブディレクトリに移動する。
       --tag-rename ":general-api-"    # Tag にもプレフィックスを付ける。
                                       # どちらも「(書き換え前):(書き換え後)」という記法。
    
     cd /path/to/henry-receipt-api
     git filter-repo \
       --path-rename ":receipt-api/" \
       --tag-rename ":receipt-api-"
    
     henry-general-api/
     ├── .git/
     └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── (application code)
    
     ↓↓↓
    
     henry-general-api/
     ├── .git/
     └── general-api/
         └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── receipt-api/
         └── (application code)
    
  2. 1 で履歴を書き換えたリポジトリを remote としてマージ先のリポジトリ (henry-backend) に登録する

     cd /path/to/henry-backend
     git remote add general-api /path/to/henry-general-api
     git remote add receipt-api /path/to/henry-receipt-api
    
  3. マージ先のリポジトリ (henry-backend) で、2 で登録した remote の branch を両方1つの branch に取り込む

     cd /path/to/henry-backend
     git merge --allow-unrelated-histories general-api/develop
     git merge --allow-unrelated-histories receipt-api/develop
                                         # ^^^^^^^^^^^ ここは remote の名前
    
     henry-backend/
     ├── .git/
     ├── general-api/  # general-api/develop から merge されたもの
     |   └── ...
     └── receipt-api/  # receipt-api/develop から merge されたもの
         └── ...
    

こうするとサブディレクトリ単位で見たときは元の履歴と全く同じ、親ディレクトリで見れば2つの履歴が混ざったようなものになります。

cd /path/to/henry-backend
git log               # 2つのリポジトリの履歴が混ざった1つの履歴になる
git log ./general-api # マージ前の henry-general-api の履歴と同じものが見れる

いきなりモノレポにすると困ること

コアコンセプトはシンプルなものですが、git-filter-repo は不可逆操作であるという問題があります。git-filter-repo はすべての commit を新しく作り直すので、前後で commit sha1 が完全に変わってしまいます。

つまり一度リポジトリをマージしてしまうと、マージ元のリポジトリで追加の commit があったときにその差分をマージ先のリポジトリに取り込むことができなくなってしまうのです。

これではモノレポ化に伴う変更 (アプリケーション・CI/CD も含めて) をすべて一気にやりきるまでは、普段のプロダクト開発を止めるしかなくなってしまいます。

モノレポ化対象のリポジトリは変更頻度や規模がかなり大きいリポジトリであり、変更がそれなりに大変なことが分かっていましたが、プロダクト開発が長期間止まることは許されません。

submodule から始める

そこで、普段のプロダクト開発を止めることなくモノレポ化に伴う変更を管理できるように、まずは2つのリポジトリを submodule にした状態で、アプリケーションや CI/CD がすべて動くようになることを目指すようにしました。

henry-backend/
├── .git/
├── .gitmodules
├── general-api/ (submodule)
|   ├── .git
|   └── ...
└── receipt-api/ (submodule)
    ├── .git
    └── ...
  1. まずマージ元の2つのリポジトリにそれぞれ、モノレポ化用のブランチを作ります

     cd /path/to/henry-general-api
     git checkout develop
     git checkout -b develop-monorepo
    
     cd /path/to/henry-receipt-api
     git checkout develop
     git checkout -b develop-monorepo
    
  2. そして、マージ先のリポジトリに 1 のブランチにチェックアウトした submodule を追加します

     cd /path/to/henry-backend
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-general-api.git general-api
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-receipt-api.git receipt-api
    

こうすることで以下のことが実現できるようになりました。

  • プロダクト開発で入る変更差分にいつでも追従することができる。
    • 履歴の書き換えは行っていないため、開発ブランチからモノレポ化用のブランチに git merge することができます。
  • モノレポ化のための変更差分 (diff) が確認しやすい。
    • 実際マージ元にも PR を作り差分をレビューしていました。

submodule からモノレポに切り替える (finalize script の作成)

すべての準備が整った最後には、submodule をやめて3つのリポジトリ (henry-backend, henry-general-api, henry-receipt-api) を1つにマージする作業が残っています。

基本的には「コアコンセプト」で示した内容をやることになるのですが、この際 submodule の存在が邪魔になります。例えば、submodule のディレクトリは普通のディレクトリとは git 内部の管理の仕組みが異なるため、submodule のディレクトリと同じパスに対して、普通のディレクトリへの変更 commit がある状態は作れません。(commit 一つ一つで conflict の resolve が必要になってしまいます)

そのため、まずはマージ先のリポジトリ (henry-backend) の履歴から submodule の存在を抹消する必要があります。具体的には以下のことをやりました。

  1. 各サブディレクトリを submodule の管理から外して git rm したコミットを作成する
  2. git-filter-repo を使い submodule のパスに一致する過去の commit (submodule の更新など) をすべて書き換えてなかったことにする
  3. 「コアコンセプト」の手順を行う

全体のフローはそこそこ複雑なので、いつどういう状態でも、誰でもできるように、初期の段階でスクリプトを作成しました。処理が途中で失敗したり止まってしまっても、再度実行したときに失敗したところから再開できるような工夫もされています。

具体的な処理を解説すると長くなってしまうので、gist に実際のスクリプトを上げていますので良かったら見てみてください。

Merge two git repositories while perfectly preserving all commit histories · GitHub

2. アプリケーション・ワークフローのモノレポ対応

前編でも説明したとおり、今回モノレポにする目的は、マイクロサービス間をまたがる作業を生産的に行えるようにするためでした。そのため、一般的なモノレポ導入でも同様かと思いますが、アプリケーション・ワークフローをモノレポに適応させることが重要でした。

  • 1つのIDEでモノレポが管理する全てのコードベースで快適に作業ができるようにする
  • モノレポ化以前のデプロイプロセスを踏襲する

これらの要件を実現するために、大きく3つの変更を行いました。

  1. ビルドシステムを multi-project 構成に変える
  2. IDE やエディタの設定を共通化する
  3. CI/CD を再構築する

ビルドシステム (Gradle) を multi-project 構成に変える

Gradle にはネストした複数のプロジェクト(モジュール)を持つ「マルチプロジェクト」という仕組みがあり、モノレポ化対象のプロジェクトは以下のように、既にマルチプロジェクトとなっていました:

  • henry-general-api (root project)
    • app (sub project)
    • utils (sub project)
  • henry-receipt-api (root project)
    • app (sub project)
    • utils (sub project)

これを以下のように既存のプロジェクトルートを新プロジェクトのサブプロジェクトにする形を採りました。

  • henry-backend (root project)
    • general-api
      • app (sub project)
      • utils (sub project)
    • receipt-api
      • app (sub project)
      • utils (sub project)

この判断は前述の git submodule を利用した移行プロセスともよく噛み合い、人間にとっても理解しやすい利点がありました。今後同じようなことをやる場合も、この手法を採るでしょう。

なお buildSrc ディレクトリと settings.gradle.kts ファイル、Gradle wrapper スクリプトはプロジェクトルートに置く必要があるため、これだけは各サブプロジェクトから移動しました。gradle.properties ファイルも各プロジェクトの設定をマージしたファイルを作成しています。

Version Catalogによる依存バージョンの管理

各サブプロジェクトでばらばらにバージョン管理をする必要が無かったため、モノレポ化にあわせて Version Catalog による依存の中央管理を導入しました。

もともと弊社ではRenovateを使い、ライブラリなどの依存を極力最新に保つようにしています。Renovate は Version Catalog もサポートしていますので、特に運用に変更は生じませんでした。

モノレポ化によるビルド時間の変化

モノレポ化によってビルド時間はどう変化したでしょうか。

CIに限った話としては、サブプロジェクトをビルドする際に他のサブプロジェクトのビルドを実行しないように注意すればほとんど影響はありません。Gradleは -pオプションでプロジェクトディレクトリを指定できますので、例えば ./gradlew -p general-api shadowJarとすれば general-api 以外のサブプロジェクトによる影響は最小化できます。

実際に移行前は約20分で終わっていたデプロイが、移行後も約20分程度で終えられています。とはいえこのプロジェクトにおけるデプロイのボトルネックがもともと Gradle ではなくデータマイグレーションにあったことには注意が必要でしょう。

IDE やエディタの設定を共通化する

IntelliJ の設定ファイルは、Gradle プロジェクトができていれば IntelliJ が自動的に生成してくれます。ここでは自動生成では補えない部分について述べます。

.gitignore

.gitignoreには gitignore.io で配布されている IntelliJ 用ものをほとんどそのまま利用しています。さらにGradleによるプロジェクト管理をしているため、Gradle が自動作成してくれるファイルも加えています。

コードスタイル設定

移行前のプロジェクトあった.idea/codeStyleディレクトリは手動で移行する必要がありました。弊社では *を使った import を抑制したり、Trailing Comma を許容したりといったカスタマイズを施していたため、 .idea/codeStyles/Project.xmlファイルをコピーして対応しました。

Detekt プラグイン

弊社では Detekt プラグインを利用しているため、その移行も行いました。

IDEA プラグインの移行自体は .idea/detekt.xmlに必要な情報を書くだけで済みますが、Detekt の設定は丁寧にマージする必要があります。今回は Detekt ルールはゆるい方に合わせ、モノレポ化を終えてから順次ルールを追加していくことにしました。

なおルールさえマージしてしまえば、baseline ファイルは ./gradlew detektBaselineで自動生成できます。

CI/CD を再構築する

CI/CD には CircleCI を使っていました。Docker image を build して、CloudRun + CloudFunction へのデプロイを行う流れです。

Henry のマイクロサービスは git-flow に従ったブランチ管理・デプロイ戦略を採用しています。デプロイ環境が4つあり、それぞれのブランチへの push で自動デプロイがされる仕組みです。

ブランチ デプロイ環境 存続期間
develop staging, sandbox 永久的
release/YYYYMMDD qa 一時的
master production 永久的

モノレポにおいてもこのデプロイフローを再現できる必要がありました。

もともと大きな機能変更をテストするための場として sandbox 環境が用意されていたので、モノレポ化に関しても、一時的にこの環境を専有することで実験を進めました。

モノレポ対応に際しては、なるべく移行時や切替時の設定ミスが少なくなり、長期に渡ってもメンテナンスしやすくなるように設定の共有化やリファクタリングを並行で行いました。具体的には以下のようなものが含まれます。

  • Reusable Config Reference Guide にあるプラクティスに従い、commands や parameters を活用し、なるべく可読性が確保できるようにした
  • Dynamic Configuration を活用し、設定ファイルを分割した。
    • constants.yml — Anchor/alias を使った定数系の定義
    • common.yml — 共有の設定や再利用可能な command や job の定義
    • {general,receipt}-api.yml — 各サービス固有の job と workflow の定義

それ以外にも Gradle Project の構造が変わったことによる変更や、キャッシュ効率を良くするために jar の生成を docker build 内でやっていたものを CI 上でやるように変えたりと、細かいところでいろいろな調整・改善をしましたが、長くなるのでここの知見はまた別の機会に共有したいと思います。

3. モノレポへの切り替え当日に向けた手順書の作成

モノレポ化の PoC が sandbox 環境で動くところまでできたところで、当日の手順書を作成しました。

マージ元リポジトリでの変更差分の反映や、設定が正しくモノレポ移行できているかの最終確認など、finalize script を実行する前後でやることがそれなりありました。

手順書を作るにあたっては、以下の点に気をつけながら3人でレビューを繰り返しました。

  1. 当日何も考えなくて良いように実際に使うコマンドや確認方法も含め詳細に記載する
  2. ステップを踏んで範囲を広げて行き、途中で問題があっても切り替えと顧客への影響が最小になるようにする

    実際には以下のようなの順に確認をしていきました。

    1. master ブランチ + sandbox 環境 (finalize 前 = submodule の状態)
    2. master ブランチ + sandbox 環境 (finalize 後 = モノレポの状態)
    3. develop ブランチ + staging 環境
    4. master ブランチ + qa 環境
    5. master ブランチ + production 環境
  3. 当日の作業だけでなく、切り替え後の開発体験を想像する
    • branch protection を有効化する等、設定や考慮漏れを事前に潰すことができました

実際の手順書の一部はこのようなものです。

  • [ ] general-api と receipt-api で master 追従
  • [ ] 差分を検知して反映して sandbox deploy
    • [ ] general-api と receipt-apimaster-monorepo branch に master を取り込む
    • [ ] monorepo repo で submodule を update する
    • [ ] monorepo repo 側で変更に追従
    • [ ] 反映漏れがないか一通りチェック
      • [ ] .github/workflows
      • [ ] groovy
      • [ ] lib versions 系
    • [ ] master に merge
  • [ ] general-api, receipt-api の repo で develop-monorepo branch を作る

      git checkout develop
      git checkout -b develop-monorepo
      git merge master-monorepo
      # conflict 解消
      git push origin develop-monorepo
    
  • [ ] ./poc-tools/finalize して sandbox deploy

    • 手順
      1. git checkout master
      2. ./poc-tools/finalize
      3. 目視で確認
        • submodule が消えているか
        • receipt-api / general-api が統合され、履歴も統合されているか
        • receipt-api / general-api と monorepo で最新のコミットが一致しているか
      4. CI を確認
        • sandbox デプロイが実行されること
        • 本番デプロイが動いていないこと
  • [ ] deploy branch 変更して staging deploy
    • {general,receipt}-api/develop-monorepomaster branch の3つをマージした develop branch を作成

        git checkout master
        git checkout -b develop
        git merge remotes/general-api/develop-monorepo
        git merge remotes/receipt-api/develop-monorepo
      
    • デプロイのブランチ設定を変更: staging (develop に変える), sandbox (develop に変える)

    • develop に push
    • CI を確認
      • staging デプロイが実行されること
      • 本番デプロイが動いていないこと

まとめ

プロセスの検討、アプリケーション・ワークフローのモノレポ対応、手順書作成、といった準備には、3人の Working Group による週1の活動でトータル2ヶ月間ほどかかりました。

しかしその入念な準備の甲斐あって、切り替えの作業とそれに伴うコードフリーズはたったの5時間で済みました。また、細かいミスはあったものも、顧客影響は一切なく、開発者もみんな翌日からすぐにストレスなくモノレポでプロダクト開発を再開できたことはとても素晴らしい結果でした。


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

ヘンリーでは一緒に働く仲間を絶賛募集中です。興味がある方はぜひお気軽にご連絡ください。 jobs.henry-app.jp

医療スタートアップのバックエンドをモノレポ化した話 〜戦略・プロセス編〜

こんにちは、ヘンリーの Lead Architect の @kohii です。

弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

最近 Henry のバックエンドをモノレポ化したので、その戦略やプロセスについて書きたいと思います。

こちらは前編となっており、モノレポ移行の手法やテクニックの話は後編で説明します。

dev.henry.jp

Why モノレポ?

ざっくり説明すると、既存のマイクロサービス/チームの分界点を抜本的に見直し、ドメイン(業務の領域)による分割を目指すため、一旦モノレポにまとめて、理想的な構造の切り出しをやりやすくするという目的です。

モノレポ化前のシステム/チームアーキテクチャ

バックエンド

Henryのバックエンドはマイクロサービスになっていますが、以下の2つのサービスが大部分を占めています。

  • henry-general-api電子カルテ「Henry」のバックエンド。基本的にはフロントエンドから呼び出される形で処理を行い、必要に応じて receipt-api を呼び出します。
  • henry-receipt-api … 「レセコン」と呼ばれる医事会計システムの領域を担当するサービス。ステートレスな計算エンジンとなっていて、general-api から呼び出され、診療報酬制度に乗っ取った処理を行い結果を返します。(ちなみに診療報酬制度はたぶんみなさんが想像するののだいたい50倍くらい膨大かつ複雑です。)

どちらも Kotlin で記述されていて、3年半に渡り50人の開発者が関わってきました。

マイクロサービス SLOC (Kotlin のみ) 累計コミット数 月間 PR マージ数
henry-general-api 13.9万 12,557 154
henry-receipt-api 14.6万 12,561 170

Henryの開発チーム

バックエンドの開発を行うのは以下の2チームでした。

  • Accomplishment Team … ユーザストーリーのデリバリーとインクリメントの最大化を追求する。general-api のオーナー。
  • Receipt Committee … レセプト機能を専門的に扱う。receipt-api のオーナー。

これら以外にも Platform Group(技術基盤の開発などを通じ組織全体の生産性を上げる)というチームもあります。

課題

プロダクトを作り始めて数年経ち、チーム/システムを分割した当初の意図と現状にギャップが生まれるようになってきました。

1. チーム構造とシステム構造の不一致

receipt-api は general-api のために存在する計算エンジンなので、Receipt Committee は Accomplishment Team から依頼を受けることを起点に receipt-api をインクリメントする想定でした。(Team Topologies で言うところの Complicated-subsystem Team)

しかし、実際には Receipt Committee はユーザー体験を含めた医事会計業務全般の開発や問い合わせ先を期待されることが多く、general-api やときにはフロントエンドの開発も担う必要がありました。(Team Topologies で言うところの Stream-aligned Team)

  • 開発フローやコミュニケーションパスの複雑化
  • 1つの機能のデリバリーがチームで完結せず時間がかかる
  • オーナーシップを持たないコードベースへ手を加えたり調査したりすることが多くある
    • 認知負荷の問題
    • 各チームの方針やコンテキストを理解しないコミットが入りやすい

2. チームの分界点の問題

チームの境界付近に非常に難しい問題が落ちていることが多く、そもそも分けてはいけない問題を分けているのでは?みたいな感覚がありました。

  • チームをまたがる開発の手戻りが多く、仕様確定までに時間がかかる
  • 仕様の認識齟齬が残ったままデリバリーされてしまう
  • チーム/システム間をまたいで相互に理解していないと適切なプロダクト設計にたどり着けない

3. 分散した密結合

API 呼び出しの方向は general-api → receipt-api なので、この方向の依存が発生するのは当たり前ですが、逆方向の依存も発生していました。具体的には「general-api は receipt-api を呼び出す時に、自身のドメインモデルをそのまま渡す」ということをやってしまっていたため、receipt-api は general-api のモデルを知っているということが起きていました。

この双方向の依存のため、コードベースやサービスは別れているのに、実質的には1つの密結合な塊として扱わねばならず、サービス境界面を含む変更容易性や理解容易性が損なわれ、アーキテクチャの改善そのものを難しくしていました。

戦略

これらの課題に対して、既存のアーキテクチャの延長線上でいくら捏ねくり回しても根本的な解決に近づくのは困難だと判断し、ゼロベースで理想のチーム・システムを考えました。そしてその理想を目指すトランジションの初手として、バックエンドのコードを一旦モノレポにまとめ、サービス境界の見直しを含めた変更をやりやすくするという戦略を取りました。

モノレポ化までの道

1. モノレポ化を決めるまで

開発体制再考ワーキンググループ発足

2023年2月頃に「チーム/システムの境界を跨ぐ機能に関して、開発がうまく進まない問題が頻発している」ということから「開発体制再考ワーキンググループ」が発足しました。(ヘンリーにはワーキンググループ(WG)という仕組みがあり、特定の課題を解決するために作られる一時的なプロジェクトのようなものが有志によって組成されます。)

その WG の中で議論を重ねた結果、既存のチーム構造を見直し、ドメインによるチーム分割を目指すということで方向性が決まりました。

  • 各チームは、担当する領域について顧客へ価値を提供するために必要なすべての機能/権限を持つ
  • 極力チームをまたがずに自律して意思決定し行動できる

これは「現状で正しそうに見える分割」であり、先に進む中で知見や洞察が深まり、徐々に実践的な正解が見いだされていくものだと思っています。

なお、実際には人数や役割の問題から、移行期として一旦は2チームに分け、それぞれ複数ドメインを受け持っています。

次にシステムアーキテクチャに関する議論が行われました。チームの構造とシステムの構造は一致しているべきということに異論はなく、そこにたどり着くまでのトランジションを議論しました。

いろいろなプランが挙がりましたが、結論としては既存のマイクロサービスを一旦モノレポにすることで理想の構造に移行しやすくするという方針に決めました。

  • 既存の構造の境界部分を含む変更がやりやすくなる
  • 正しいドメイン境界を特定するための試行錯誤がやりやすい

モノレポの具体像

まずは各サービスのデプロイメント単位は変えずに、コードベースを1つにするということを目指します。

Before:

  • henry-general-api (ルート)
  • henry-receipt-api (ルート)

After:

  • henry-backend (ルート)
    • general-api (Gradle のサブプロジェクト)
    • receipt-api (Gradle のサブプロジェクト)
    • utils (ユーティリティを共有。Gradle のサブプロジェクト)

これが終わった後は、モノレポの中でドメインによるコンポーネントの再構成を進める作戦です。

ADR (Architecture Decision Records) を記述

意思決定を ADR という形でまとめ、開発者間で共有しました。ADRアーキテクチャ決定を記述したもので、弊社の場合は Notion のデータベースに書いています。

どのようなアーキテクチャも様々な背景やトレードオフ、方針の上に成り立っていて、それをすべての開発者が理解することは重要です。また将来アーキテクチャに関する検討を行う時に、「なぜ今こうなっているのか」の経緯を後から遡って理解できるようにしておくという意味もあります。

2. モノレポ化の準備

モノレポ分科会の発足

「開発体制再考 WG」から、モノレポへのトランジションを実行する分科会が発足しました。

分科会では次のようなことをやりました。

  • Slack チャンネルを作成
    • 分科会のメンバー以外も任意で参加
  • スケジュールと計画の立案と周知
  • モノレポの具体像の決定
  • モノレポ化のためのバックログの作成・管理
  • 週次の定例ミーティングを設置
    • 週次のスプリントみたいな形で各々作業を進め、1週間後に結果や状況を確認し、また次週のプランを行う

作業日の決定とアナウンス

実施日は最も顧客業務への影響が少ない日曜日の夜間に決めました。

混乱なく進められるように、その週の初めごろに開発者向けのアナウンス Slack で周知しました。

手順書の作成

リスクを極力減らし、当日のコードフリーズ時間を最短にするため詳細な手順書を作成しました。可能な場合は具体的なコマンドまで記述しておきます。

3. モノレポ化当日

日曜日の17時から始めました。Google Meet で1人の作業者の画面を共有しながら行い、途中で夕食を食べたりしながら作業しました。一部想定通りに行かなかったところもありましたが、入念な準備のおかげで大凡滞りなくすべての作業を完了しました。

作業が完了した旨をアナウンスし、開発者が行うべき作業を伝えてこの日はおしまいです。

当日の技術的な内容は後編で説明してますので、よかったら読んでみてください。

dev.henry.jp

4. モノレポ化後初日

特に混乱なくモノレポ化後のコードベースで開発を始められました。

IDEのコードスタイルの設定漏れ等細かいものはありましたが、開発メンバーの協力によってすぐに解消されています。

やってみて

モノレポの Pros / Cons

モノレポ化はアーキテクチャの目的地ではなく、これからの変遷のための初手なので、これ自体に関する良し悪しはあまり本質的ではありませんが、だいたいこんな感じの感想です。

  • Pros:
    • general-api / receipt-api をまたがる変更をアトミックにできる
    • 設定やスクリプトの重複が排除され、一箇所で管理できるようになった
  • Cons:
    • Intelllij のサイドバーごちゃつきがち
    • general-api / receipt-api で同じ名前のクラスがあったため「今開いているこのファイルはどっちの持ち物のやつだっけ?」みたいな確認が必要なときがある

今後

具体的な次の一手としては、API の I/F の定義(proto ファイル)はまだモノレポの外に存在するので、これもモノレポに取り込みたいと考えています。これができれば general-api / receipt-api の境界面に関する変更がさらにやりやすくなるはずです。

一方でモノレポ化はドメインによるサービス分割を行うための一歩目であり、その方向に向かってアーキテクチャを移行していく旅は続きます。その先にあるゴールがマイクロサービスなのかモジュラーモノリスなのかは明確に見えているわけではありませんが、進んでみて、進んだ先にまた新たなことが見えてくる、の繰り返しだと思っています。


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

ヘンリーでは一緒に働く仲間を絶賛募集中です。興味がある方はぜひお気軽にご連絡ください。

jobs.henry-app.jp