Hygenを使ってアプリケーションコードの雛形を自動生成している話
こんにちは、@ikkitangです。
この記事はスターフェスティバル Advent Calendar 2023の17日目の記事です。
昨日はnano72mknさんのdivで作るボタンはボタンぽい何か
でした。
初心者なので、良くやった記憶がありますねぇ・・(白目。
nano72mknさんはフロントエンドで困ったらだいたいnano72mknさんに聞けば何か解決するのでは、とめちゃくちゃ皆に頼られてる方ですが、フロントエンドにおいてアクセシビリティや開発のメンテナンス性など色々な観点でフロントエンドと向き合われているのですが、今回もそのような話で良かったですね。
余談ですが、今回弊社からフロントエンドカンファレンス沖縄に3名のエンジニアが登壇してくれたのですが、昨日・明日・明々後日とその登壇したエンジニアの記事が続くので是非ウォッチしてみてください。
今回の記事の内容
- コードの自動生成の活用事例と導入した背景
- Hygenというコードの自動生成ツールの使い方を紹介
コードの自動生成の活用事例と導入した背景
アプリケーションの概要
今回、コードの自動生成を適用したのは、Apache Kafka(以下、Kafkaって書きます!)によるイベント駆動開発における、Consumer
アプリケーションに対してです。まずは、より理解を深めていただく為にこのConsumerアプリケーションについて少し説明していきます。
前提として、弊チームではシステムリプレースをやっていて、その中でKafkaやDebeziumを採用しています。より詳しい背景を知りたい方はリンクを貼っておくので、参考にしてください。
全体像をシュッと説明すると、DebeziumによってMySQLのbinlogが監視されており、Insert/Update/Deleteイベントが発火するとKafkaにメッセージが書き込まれ、更にそのメッセージは Consumer
という役割が与えられてるアプリケーションによって購読され、新たにリモデリングしたデータベースに書き込まれる。といった構造になっています。
Consumer
のアプリケーションの構造についても触れると、各役割ごとにレイヤー化して実装をしています。
レイヤー | 役割 | 目的 |
---|---|---|
Commandレイヤー | 購読するTopic、メッセージをどのParser・EventHandlerに渡すかを管理する | ・ Consumerアプリケーションのエントリーポイントとして独立させる |
Parserレイヤー | KafkaのメッセージをJavaScriptが扱えるオブジェクトに変換する | ・ メッセージの構造(型)を保障する ・ メッセージの前方・後方互換があればこのレイヤーで解決させる |
EventHandlerレイヤー | Parseされたオブジェクトを使ってドメインロジックを実装する | ・ ドメインロジックをこのレイヤーの中で純粋に表現する |
Repositoryレイヤー | データストア層とのやり取りを実装する | ・ データのリモデリングを実施していくにあたって、旧テーブル(現行動いてるテーブル)の影響をこの内部で留めることを目的としている。 なので interface も save(record: 新モデル) と切って、内部で新モデルを旧テーブルのDBに保存するみたいなことをやる。 |
例えば、Product
トピックの 商品登録イベント
を購読するConsumerを作る場合は ProductRegisteredParser
がメッセージをパースして、その後 ProductRegisteredEventHandler
が ProductRepository
などを呼び出してデータの永続化をやるような流れとなります。
上記にように切ることの一つの恩恵は各レイヤー間の独立性を高めることです。ParserレイヤーやEventHandlerレイヤーは単体テスト(EventHandlerはRepositoryレイヤーをInMemoryのもの=テストダブルに置き換える)を通じて動作保証ができ、Repository層では実データベースとの結合テストを行うことで動作保証をすることができます。各レイヤーの独立性により、労力を掛け過ぎないバランスで本来のレイヤーの役割や目的をテストする事ができるようになっています。
実務におけるアプリケーションの実装の実体
さて、実務におけるConsumerの実装に目を向けてみると、9割方最初に購読したいイベントが決まっています。そこから、イベント名を元にParserクラス・EventHandlerクラスのクラス名を機械的に決めてある程度のお作法に乗っ取り、ガワを整えてから(継承する基底クラスとかそういうの)実装...という形で進んでいきます。
お作法でいうと上記の通りですが、 僕は基本既にあるConsumerを丸々コピペして調整してました!!!! Consumerにおいて一番時間を掛けたい部分はドメインロジックを記述するEventHandlerなので、サボれる所はサボろうと思ってたという開き直りがあります。
そんな折に、フロントエンドでのアプリケーションでは Hygen というコードの自動生成を使ってコンポーネントを作成する取り組みがあることを知ることができました。
これに乗っかることで「ちゃんとサボる」ことができるのでは?と思って調べた所、ざっと眺めただけでもやりたいことが出来そうだったので、導入してみることにしました。
実際に導入したとしても初回のコード生成のフローに組み込むだけなので捨てやすかろう、また大体の雛形が作られるので上で長々と説明したアーキテクチャを具体的なコードとして取り込みやすいという点も採用しやすかったポイントです。
実務のアプリケーションにHygenを適用する
当初の想定通り、導入は非常に簡単でした。実際の使用感としては以下のような感じです。(取り敢えず、伝えるベースで改変してますが...!)
$ npm run new:consumer
✔ Consumerが処理をする関心ドメインを指定してください(ex: product ) · {ユーザー入力}
✔ Consumerが処理するkafkaのトピックを指定してください(ex: product) · {ユーザー入力}
✔ Consumerが処理するkafkaのイベント名を指定してください(ex: product-registered) · {ユーザー入力}
Loaded templates: _templates
added: src/product/commands/ProductRegisteredCommand.ts
added: src/product/parsers/ProductRegisteredParser.ts
added: src/product/parsers/ProductRegisteredParser.test.ts
added: src/product/eventHandlers/ProductRegisteredEventHandler.ts
added: src/product/eventHandlers/ProductRegisteredEventHandler.test.ts
npm run new:consumer
は hygen new consumer
というコマンドのaliasで、以下の _templates
配下のディレクトリ構成と対応しています。(hygen new consumer
のnew
の部分をgenerator
, consumer
の部分をaction
と説明されているのですが、Hygenの思想から反してますね...! hygen consumer new
とかの方が正しそう. )
{root}/
_templates/
new/
consumer/
index.js
commandsのテンプレート
parsersのテンプレート
parsersのテストのテンプレート
eventHandlersのテンプレート
eventHandlersのテストのテンプレート
src/
アプリケーションファイル置き場
ちなみにこのディレクトリ構成はテンプレートのジェネレーターを使用することで、シュッと作ってくれます。便利。
hygen new consumer
が起動された時にプロンプトを表示するようにしたい場合は、 _template/new/consumer
の中に index.js ファイルを置くことでそれがエントリーポイントとして動作するようです。 index.jsの中身は以下のような感じになっています。
module.exports = {
prompt: async ({ prompter }) => {
const { domain } = await prompter.prompt({
type: 'input',
name: 'domain',
message: 'Consumerが処理をする関心ドメインを指定してください(ex: product)',
});
const { topic } = await prompter.prompt({
type: 'input',
name: 'topic',
message: 'Consumerが処理するkafkaのトピックを指定してください(ex: product)',
});
const { eventName } = await prompter.prompt({
type: 'input',
name: 'eventName',
message: 'Consumerが処理するkafkaのイベント名を指定してください(ex: product-registered)',
});
return {
domain,
topic,
eventName,
};
},
};
最後に return していますが、これらの変数をテンプレートファイル内で扱うことができます。
実例をみていきましょう。質問の回答を行った段階で以下のようなテンプレが手に入ります(説明の為に余計なライブラリの情報を消したりしてます)。 ToDo
を書いておけば開発時にエディタが検知してくれてコミット時に警告してくれたりもするので、自動生成出来ない部分は諦めてエディタに解決してもらうという点で妥協しています。
import { ConsumeEvent } from '@/foundation/event';
import { Handler } from '@/foundation/event/handler';
import { ProductRegisteredEvent } from './ProductRegisteredParser';
export class ProductRegisteredEventHandler implements Handler<ProductRegisteredEvent>
{
constructor() {}
async handle(event: ConsumeEvent<ProductRegisteredEvent>): Promise<void> {
try {
// ToDo: ドメインロジックの処理
// 例:
// await this.repository.save(event);
// この場合は、constructorにrepositoryをDIする処理を追加してください
} catch (e) {
throw e;
}
}
}
Hygenはテンプレートによってコードを自動生成することを可能にしており、上記のEventHandlerを生成したEventHandlerのテンプレートを見るとこのような感じです。
---
to: src/<%= domain %>/event/<%= h.changeCase.pascal(eventName) %>EventHandler.ts
---
import { ConsumeEvent } from '@/foundation/event';
import { Handler } from '@/foundation/event/handler';
import { <%= h.changeCase.pascal(eventName) %>Event } from './<%= h.changeCase.pascal(eventName) %>Parser';
export class <%= h.changeCase.pascal(eventName) %>EventHandler implements Handler<<%= h.changeCase.pascal(eventName) %>Event>
{
constructor() {}
async handle(event: ConsumeEvent<<%= h.changeCase.pascal(eventName) %>Event>): Promise<void> {
try {
// ToDo: ドメインロジックの処理
// 例:
// await this.repository.save(event);
// この場合は、constructorにrepositoryをDIする処理を追加してください
} catch (e) {
throw e;
}
}
}
Hygenの構成要素
テンプレートは以下のようにFrontmatterセクション
とBodyセクション
を---
で分けて記述します。
---
Frontmatter セクション
---
Bodyセクション
Frontmatterセクションは、テンプレートのメタデータを定義する為のセクションです。配置先のディレクトリの指定などで良く使います。
Bodyセクションでは、実際にコードのテンプレートを記述していきます。ここでは、ejsというJavaScriptのテンプレートエンジンが採用されています。 Change case helpers
というのがとても便利で、入力値をPascalCaseやsnake-caseなどの色んな形式に変換することができます。(参考)
先程のEventHandlerの例に立ち返ると...
✔ Consumerが処理をする関心ドメインを指定してください(ex: product ) · {ユーザー入力}
npm run new:consumer
を実行した時に上記のような質問がありましたが、その入力値が domain
という変数の中に入るようになっていて、 Frontmatterセクションでディレクトリ指定 to: src/<%= domain %>
に使用しています。 product
と入力すると src/product
と展開されて保存されるようになります。
他の要素としては、イベント名の質問が eventName
という変数名に入るのですが <%= h.changeCase.pascal(eventName) %>Event
というように記述をすることで product-registered
という入力がPascalCaseに変換されて ProductRegisteredEvent
と展開されます。 こういった形である程度機械的に決まるものについては、大体のケースをHygenにより対応することが出来ます。
今回はEventHandlerを例にしましたが、Parser、Parsetのテスト、EventHandlerのテスト も同じ粒度でコードが自動生成されて、書き手(特に自分)はそのイベントに特化したコードの記述に集中することができるようになりました。
その他、使えそうなHygenのユースケース
Hygenのドキュメントを見ていると、 expressのRoutingとHandlerの追加も自動で出来るような例がありました。
ExpressのAPIのルーティングを app/routes.js
で定義していて、今回と異なる点として 既存ファイルにコードを追加する ということをされているようです。ドキュメントでいうとInjection
というのがそれです。
これを実際にやってみたんですが、中々ハマりどころも多かったので補足をしておきます。例えば、先程のExpressの例のように まずは、以下のようなコード追加をしたいとします。
const health = require('./handlers/health')
+ const shazam = require('./handlers/shazam')
app.get('/health', health)
+ app.post('/shazam', shazam)
module.exports = app
この場合、まずrequireとapp.postの部分のそれぞれの行に対応する2つのHygenのファイルが必要になります。
まず、 inject-require-route.ejs.t
といったファイルに書くのがこれです.
---
inject: true
to: app/routes.js
skip_if: "require('./handlers/<%= name %>')"
prepend: true
---
const <%= name %> = require('./handlers/<%= name %>')
続いて、 inject-add-routing-route.ejs.t
といったファイルに書くのがこれです.
---
inject: true
to: app/routes.js
skip_if: <%= name %>
before: "module.exports = app"
---
app.<%= method %>('/<%= name %>', <%= name %>)
Frontmatterセクションに、inject:true
のキーワードを追加することで、挿入するモードになります。これと to:app/routes.js
のどのファイルに上書きをするかという指定が必須で他のはオプション値ですね。
skip_if や before , prepand といった記述があるかと思いますが、これが どの辺りに記述を追加するか といった指定となります。なぜ、2ファイルが必要だったかというと、requireを追加する場所とRoutingを追加する場所は違っていて、一個のFrontmatterセクションではそれぞれに対応出来ないからです。
inject-require-route.ejs.t
で使ってる prepend: true
という指定は、ファイルの先頭に追加するオプションです。const health = require('./handlers/health')
の下に追加したい所ですが、app/routes.js
からhealthのHandlerが消えたら速やかに動かなくなります。ので、取り敢えず妥協として一番上に追加しています。末尾にするには append: true
とします。実際に適用すると以下のようになります。
+ const shazam = require('./handlers/shazam')
const health = require('./handlers/health')
app.get('/health', health)
module.exports = app
一方、inject-add-routing-route.ejs.t
の方では before: "module.exports = app"
という記述が使われています。これは指定した記述の前行に追加する命令になります。後の行に追加をする場合は、after
を使うことで指定ができます。 例えば、after: "app.get('/health', health)"
と記述することで今回でいうと同じ場所に追加されます。実際に適用すると以下のようになります。
const shazam = require('./handlers/shazam')
const health = require('./handlers/health')
app.get('/health', health)
+ app.post('/shazam', shazam)
module.exports = app
実際に色々デバッグしてて、Injectionの処理で困ることが多かったです。ファイルがそれぞれの行ごとに必要だったりするのが一瞬不気味だったりしますね。皆、どうやってるんだ??と思って、GitHubを漁ってたんですが、Next.jsに使用されている例でinject_xxxxが5ファイルぐらいある所もありました。後は、before
の指定でうまく見つからなかった時にエラーも吐かずに処理をスキップしてたりとか、コードを追加する時に追加したコードの行の更に下に空白行が追加されたりとかもあって、今は自動生成後に微調整しなくちゃいけないとかもあるんですよねぇ・・。これは今後の改善に期待したい所です。
おわりに
これを導入して良かった所は、力を入れたいドメインロジックの実装部分に力を入れることが出来るようになった所と、また 自動生成出来るように命名規則を整えた所
にあると思います。人によって、同じ設計になる状態、良いですよね。
明日は YAhiru さんの フロントエンドカンファレンス沖縄で話しきれなかった HTTP/3 の話でもするか!
です。 していただきましょう!
それでは!
Discussion