ytake blog

Web Application Developer

DDD入門とLaravel

DDDとLaravelについて

先日、主催しているコミュニティで久しぶりに20分トークさせてもらいました。
タイトルはこのエントリと同じようなもので DDD入門とLaravelアプリケーション です。

laravel-meetup-tokyo.connpass.com

資料だけ公開してもミスリードになりそうなため、公開はしませんが内容を詳しく書いていきます。

あくまで対象はDDDに入門してみたい、という方やDDDって実装パターンでしょ、と思っている方向けです。
すでに実践、導入している方向けではありませんのであしからず。
複雑にならないようにわかりやすい文言だけで書いていますが、そうじゃないかもしれません。

前置き

タイトルからわかるようにLaravelなどを使ってある程度のOOP的なテクニックを学び、
ある程度不自由なく実装ができるようになると
アプリケーション設計などのソフトウェア的な探究心が強くなります。

そこでDDD(ドメイン駆動設計) というキーワードを目にすることが多くなり、
そこで実装例としてあげられるレイヤードアーキテクチャやさまざまなOOP的なテクニックに興味が沸き、
習得しようと多くの方が思うわけですが、
このドメイン駆動設計という言葉が一人歩きをしてしまい、言葉のインパクトや
エリックエヴァンスの書籍のイメージ、ネット上にあるさまざまな記事から実装パターンだけにフォーカスしてしまい、
DDDパターンで実装LaravelでDDDを実装
またはリポジトリパターンを取り入れればDDDというような話がよく出てきます。

必ずしもLaravelというわけではありませんが、世間一般的なアプリケーションフレームワークだと思ってください。

これは全く本質的なことではなく、
間違った理解で止まってしまうのは非常にもったいないポイントです。

DDDが全てにおいてずば抜けて素晴らしいもの、というわけではありませんが、
どういうものなのか、というのは知っておく必要があります。
という背景があるなかで、少しでも入門のための知識と間違った理解をしないようにということで。

DDDってよく聞くけどなに?

まずはDDDについてよく聞かれる質問だったり、ネット上でよくみるものですが
以下のようなものがあります。

  • DDDってアーキテクチャで層に分かれていればいいんだよ!
  • DDDで実装するんだけどデータベース肥大化
対策どうやるの?
  • MVCでDDDはできないのでは?
  • DDDで実装したけど全然楽になりません

上記のような内容をよく聞かれたりしますが、
これらは実はDDDについての認識が間違っているために出てくる質問です。

まずDDDとは、日本語でドメイン駆動設計と訳されるものですが、
早い話、実装パターンやアーキテクチャといったレイヤの話ではなく、
ソフトウェアの開発スタイルの一つ です。

問題解決領域つまりドメインに対してどう分析していくか、というところが主になるものです。

では、この問題解決領域とは何を指しているのでしょうか?
ビジネスロジックを指す、という方もいますが
開発者のほとんどの方が会社に所属していたり、
もしくは特定のサービス/アプリケーションの開発に参加しているフリーランスの方や
SIなどの形態だったりと様々だったりしますが、
会社(所属していたり派遣だったりで参加している企業) は世の中に対してどういう問題解決を行いたいか、
という大きな目標だったり思想が必ずあると思います。

たとえば自分が今所属している会社でもあるスターフェスティバルでは

スターフェスティバルは、
「ごちそうで 人々を より 幸せに」を企業理念に掲げ、レストランの中食ビジネス参入支援、
および、フードデリバリー事業を展開する会社です。
レストランや製造工場などの製造パートナー、また、配送パートナーと連携し、
「製造」以外の部分にあたる、「商品開発」「販売促進」「受注」「配達」「料金回収」 までのすべてを引き受けるビジネスモデル

とありますが、
手短にいうと、これが解決したい問題領域(ドメイン)であり、
これに基づいてサービスがいくつか展開、その中にアプリケーションが存在します。
問題解決をするにあたって、これらを 実現するために登場する利害関係者や概念などを整理し、
分析して共通で理解できる概念モデルを導き出す、そしてそれらを起点にして開発に入っていく
わけです。

こういった話は例えば半期に一度全社員集会などがあって社長などから共有されたり、
ビジネスチームから聞くこともあるでしょう。
実はそのタイミングで、「長い話をきくのはウザいな、どうでもいいや」的な姿勢でいると
DDDを実践していくためのヒントや概念が欠落してしまうので、
しっかりと聞き、理解することが非常に大事なわけです。
逆にそこに興味が湧かないと入門・実践していくのが難しい、ともいえます。

DDDに含まれないもの

人によっては違う、というものもあるかもしれませんが、
多くの企業などが掲げる問題の中に非機能要件はありません。
つまり実装言語やフレームワーク、データストレージ、キャッシュやアプリケーションアーキテクチャ
といったものは問題解決領域外のものになります。
解決のために開発者が利用するものではありますが、これらは通常含まれません。

が、例外として言語やミドルウェアやソフトウェア自体をサービスとして提供していたり
それらの領域で活動している団体・企業は除きます(Apache Foundationとか)

DDDに入門するならば

どういうものかざっくりとした概念的なものは多少理解できたと思いますが、
実際に取り組んでいくときの考え方などは後述するとして・・
まずは、どんな言語とどんなフレームワーク、データベースを使って、どういうアーキテクチャで実装するか、
という考えをまずは捨てましょう。

分析したものを実装に落とし込みやすい実装パターンや、言語というのは確かにありますが、
これは本質的なものではありません。

チームでできる範囲の実装方法を採用してもいいでしょう。
エリックエヴァンスの書籍をひたすら読み、
自分たちのアプリケーションをそれに寄せすぎるのも
ドメイン駆動とは遠くなる可能性があります。
(内容を理解してからの話ですよ。まずは読んでおきましょう)

多くの方が経験あると思いますが、新しい機能やサービスの概念などの話をきくと
データベースがここにこういう状態である、だったりこういうテーブル構造にして、
APIがこういうレスポンスで、というように実装するときにどうするか、
ということが頭に浮かぶと思います。

全く考えないというのは難しいかもしれませんが、ここから始まってしまうと
ドメイン駆動ではなく、データベースファーストやデータベース駆動設計だったり、
もしくは他の概念だったりが先行してドメインが主ではなくなっていきます。

なので浮んだとしても全くの別物だと認識しておいた方がいいでしょう。
ここが一番難しいポイントかもしれませんが。

そして書籍やネット上の記事などで目にすることが多いと思いますが、
会社のビジネスモデルやサービス仕様、
カスタマーサービスやビジネスチームの話す内容を理解することです。
アプリケーションレベルの仕様ではなく、マネタイズなども含めて実現したいことを知るのが良いでしょう。

これを実践する方法はいくつでもあります。
カスタマーサービスに加わって業務を体験したり話をよく聞いたり、

企画会議に参加したりといったことができます。

DDDを実践していくには

時代の流れで利用者などが変わっていき課題解決対象が多少変わったり、
当然退職や編成などでチーム構成が変わるため、継続的にコミュニケーションや分析を行う必要があります。
一度やればいい、というものではありません。

分析した結果実装、というフェーズになるわけですが
残念ながらいくら本やネット上で様々記事を読んでも、完璧にコードなどにうまく反映できるものではありません。
当然みなさんの所属する会社のビジネスモデルは本や記事にある例で済む単純なものではありませんし、
ビジネスモデルに変化があれば変わっていきます。

コードに落とし込むときに、実はこうではないか?ということも日常茶飯事であります。
とにかくトライアンドエラーを繰り返し、洗練させていくしかありません。
これはどんな達人であっても絶対にそうだと思います。
一度作ったら終わり、というものでもありません。

入門して実践していくには
とにかく関わるチーム全体で企業理念などの概念からドメインモデルを導き出し、
全員で課題に対して同じ認識と同じ言葉で会話できるようになること
です。

やらないように意識したいこと

これらの概念の分析などをすっ飛ばして、実装パターンだけに飛びついてしまうということは
概念にそったクラス設計やカプセル化にならず、実装者都合のものになってしまいます。
抽象レイヤが導入されたことによってテストがしやすくなる、かもしれませんが、
これはDDDではなくソフトウェアの一般的な問題解決方法の一つを採用しただけにすぎません。

所謂軽量DDDと呼ばれるもので、ビジネスモデルにも沿っておらず、
インターネットなどで見聞きしたカッコ良さそうなパターンを採用し、
短期的にはテストが書きやすいなどのメリットはあるかもしれませんが、
チームでレイヤの分け方くらいしか共通認識がないため簡単に破綻してしまいます。
ドメイン貧血症などもあります。

とはいえこれじゃダメだ!と体験することができるのもこの軽量DDDと言われるものでしょう。
失敗して得ることの方が大きいです。
おそらく5回くらい失敗すれば身を持って学ぶことができます。

こうした実装をすることで発生してしまう例を、境界付けられたコンテキストで紹介しましょう。

境界付けられたコンテキスト

言葉だけで見るとなかなか難解な境界付けられたコンテキストですが、
理解してしまえば怖くはありません。
目の前にある複雑なものも分析がしやすくなるでしょう。

あまりよい例えではありませんが、わかりやすくいうと
これがわからないと分析して導き出す主役たち、
つまりエンティティやバリューオブジェクトを区別して見つけることができないと思います。

ユビキタス言語などにも通じますが、
みなさんは同じ言葉を使っているようで
微妙にあの人とは認識が合わない
そんな経験ありませんか?

例えば昔の話などで構いませんが、友人たちが昨晩TVでみたアニメの話をしていたとします。
自分は原作を読んでいて大体の話を知っているわけです。
このためTVで観なくても大体のことは理解しています。
友人たちの会話に参加して問題なくその話題について話ができました。
ただどうやら若干原作と設定や背景が異なるものがあるようです。
自分自身は原作の知識で話、友人たちはTVで観た知識で話をして通じることは通じますが若干の違和感があります。
後日TVなどで観てみると同じ名前でちょっと違う、実は違うキャラだったことが判明しました。

アニメなどはほとんど観ないのでこうしたケースはあまりないのかもしれませんが、 こういった些細なことは日常にたくさんあります。

仕事でもあると思います。
例えばユーザーについて話しているビジネスチームがいて、
話に参加したところ、エンジニアとしてはDBに存在するユーザーの行のことを想像して聞いていると、
実は特定のステータス(有料課金者だったり)を持っている人のことを指していた、だったり
ある言葉がエンジニアチームと共通したキーワードがありますが、
違う意味で他のチームが使っていてそれをdisるみたいなことをして、意味が違っていてもずっとその言葉を使ってしまう、
などもあるかと思います。

繰り返しになりますが、これらは全て言葉自体が同じでも指しているものが異なるもので、
どこか似ているかもしれないけど、別物として認識しておかなければならないもの、となります。
これは違う、となる境界線がどこかにあるはずで、それらを見つけることが分析のポイントになるわけです。
これが境界付けられたコンテキストです。

実装前に近い段階の話を例にすると、
EC的な通信販売を扱うサービスの開発に参加し、その中で商品という言葉があったとします。

あるビジネスチームはこの商品という言葉を構成するものとして

  • 商品名

  • 扱っている店舗
  • 値段

  • 販売期間

をあげたとします。
これをエンジニア側が一つのクラスとして表現しようとします。

次に配送などを担当しているチームと話します。
このチームはどうやら

  • 商品名
  • 
個数
  • 配送先

を商品という言葉に内包しているようです。

商品を提供している店舗に話を聞いてみましょう。
ここではどうやら商品という言葉は以下のようでした。

  • 商品名

  • 値段

これらは共通した言葉なのでそのままクラスに落とし込んだとします。

  • productName / 商品名
  • storeName / 扱っている店舗
  • price / 値段

  • salesPeriod / 販売期間
  • quantity / 個数
  • shippingAddress / 配送先

全ての要求を満たす商品クラスが完成しました!

ちょっと待って!!!

共通化されましたが、果たしてどの商品のことを指しているのでしょうか?
全てを指しているのであれば、さらに違うチームと会話をして新たな要素が追加されたら
ここにも追加されるのでしょうか?

こうなってしまうと実装レベルでも障壁が生まれてきます。
ある一方では特定の概念を表現できますが、
ある一方では不要な要素がたくさんあり、表現するためには無理して使わなければなりません。
こうなってしまうとおそらくセッターだらけであったりnullableな要素ばかりだったり・・。
所謂神クラスなどと何も変わりません。

これは境界付けられたコンテキストについて認識をせずに、
実装上都合が良いからと共通化してしまうことで発生してしまうよくある例です。
(自分も当然あります)

これらを防ぐには、同じ言葉でも少し意味が違う、というものに沿って
別なものとして表現した方が問題にたいしての表現が良くなります。

これはアプリケーションの特定箇所だけの話ではなく、
アプリケーション全体を俯瞰したときにいろんなところで見つかるものです。
利害関係者を知り、様々な体験と分析をすることでこれらを見つけて落とし込んでいくのが必要不可欠なわけです。

当然上記の例にも実は不十分な点があります。
値段が差すものは税込なのか?税抜なのか?
配送先は都道府県から?それとも市内?区内?
といった些細なものに見えて実はインパクトがあるものだったりが隠されています。

これらを見つけるのは特定の開発者だけ、というレベルの話ではないことがわかると思います。
(例外として一人で企画・運営・マネタイズ・開発などをしてるよ!というケースはありますが)

各チームのバックグラウンドに基づく重要な知識が
隠されていることが多々あるので、これらを見つけ出すのもDDDの一環です。

当然これらはみなさんのアプリケーションによって主としてみるところが変わりますので、
明確にコレさえやれば完璧!問題なし!みたいなものは存在しません。

例えばお酒が好きな人とビールが好きな人を考えても、それぞれ主にする場所が変わるわけです。
極端な話でお酒が好きな人は、特定の好きなお酒というよりも酔えれば良い!というアルコール度数を重要視するかもしれませんし、
ビールが好きな人はアルコール度数よりも、どこの国で作られたIPAなのか、ということを重要視しているかもしれません。
一般的にみると酒でも違うわけです。

こうしたことから書籍などで完璧に導き出せるものがないというのはわかると思います。
どんな名著を読んでその例をそのまま自身が携わっているアプリケーションにそのまま適用しても
(例えばEメールについて本にこう書かれていたのでこうだ!みたいな)
何にもならず、自分たちの注力外の概念が無理矢理結合されるだけとなります。
実装パターンだけ追っても複雑さの解決はされないのです。

エンティティ、モデル

分析の話ばかりで飽きたところに少しだけ開発に関する知識の話をしましょう。
DDDについて少し理解したり、書籍を読んだりすると出てくる言葉にエンティティとモデルという言葉があります。

実はこれは大きくミスリードしてしまう要素でもあり、
開発における境界付けられたコンテキストの代表みたいなものです。

タイトルにもあるLaravelだと、例えばデータベースアクセスを表現する「モデル」と呼ばれるものがあります。
実はこのモデルという言葉はさまざまな意味があります。

ドメインモデル、データモデル、Eloquent
モデル(他にもありますよ!)などをモデルと指すことが多いですが、
どれも違うものを指しています。

どちらか片方の知識だけで片付けてしまうのは、これまでの文章にあったように
実は大変危険で複雑化してしまう原因になります。
それぞれのモデルを正しく理解して一緒に考えるのは止めるのをお勧めします。

例えばDoctrineやtsのtypeORMでもありますが、エンティティ。

DDDにおけるエンティティとは、

ドメインにおける識別しなければいけない特別な存在のことでで、
データモデルにおけるエンティティ
とは、
情報収集する対象のこと
、データベースの行に近いものではありますがそれ自体ではありません。

まったく同じ言葉ですが全くの別物なわけです。
言葉が同じということでエンティティをひとまとめにしてしまうと、
簡単にデータベースなどの入出力と結合してしまったものになってしまいます。

同時にモデルという言葉が同じであっても全く意味が異なっています。
どちらかの知識に寄せて片付けてしまうのは大きな誤解を産んでしまいますので注意が必要です。

これまで述べてきた内容にもありますが、
データベースなどの知識や考えを排除して考えましょう。
データの入出力やAPIのコールなど現実的な事柄がいくつもあるのは当然なんですが、
これらについては後で考えましょう。
むしろ実装でも一番最後くらいでちょうどいいです。

とはいえ実装に落とし込む方法を知りたいんだ・・

この話をしてしまうとそこにフォーカスしてしまうので、
複雑なものにたいする具体的な実装コードサンプルを載せるということはしませんが、
ヒントのような形で雑に載せておきます。

Laravelの機能が楽なので使いたいんですがどうしたら?

使いましょう。
ただDDD入門についてこれまで書いたようにきちんと分析し、
その分析した世界に極力それ以外の知識、つまり非機能要件を持ち込まない工夫が必要です。
とはいえ高度な抽象化やかっこいいパターンを無理に真似する必要はありません。
便利な機能を使ったメソッドなどをインターフェースに含めるなどするだけで問題ないです。

Eloquent使ってもいいの?

どうぞ!
他上記と同じ

リポジトリパターンが巨大で・・

データベースのテーブルなどと対になった作りになっていませんか?
抽象レイヤではなく、まずは特別視しなければならない存在の塊を操作するもの、
そしてこの特別な存在が微妙に違うのであればそれを操作するリポジトリも分けてみましょう。
仕様パターンなどの解決方法がありますが、難しければ無理に取り入れなくても良いでしょう。
まずは少しでも中身がちがう存在を操作するものが混ざっているのであれば分けましょう。

DDDをもとに導かれたコードを
引き継いだがわからない

分析したチームなどが存在しないのであれば、再度分析をしてください。
今のチームの認識とそのときのチームの認識が異なっていて当たり前です。

分析した結果、今の概念と異なるのであれば
新しい知識に基づいて実装し直すなど適度なリファクタリングを重ねましょう。

全てでDDDで題材にされるパターンを用いなければなりませんか?

他のシステムですでにドメインが表現されているのであれば必要ありません。
特別なロジックもなければ、取得して適当に成形するだけで十分、
といった場合はDAO、DTOなど用いましょう。

繰り返しになりますが、なにがビジネスロジックでみたいな判断基準は
みなさんの分析結果や解決しないといけないものは何か、
などの知識基準の話でこういうのは実装しなくていい、みたいな答えはありません。
かならずチームなどで導き出した共通認識の中で判断してください。

最後に

いくつかポイントなどを書きましたが、
この内容のうちどれかは必ずやっていることだと思います。
例えば開発チームとビジネスチームで話を聞く、みたいなことも良くあると思いますし、
アジャイルなどを採用しているところはまさにそういう毎日だと思いますし、
社内ツールを作っている方などはカスタマーサポートチームと話をするということは
日常的なことでしょう。
これらは全て自分たちのアプリケーションをどうやって要件に合わせていくか、
というところがベースになっているはずです。

つまりDDDという名前がついていますが、
ある程度のことは意識せずに大体みなさんやっているわけです。
それをどこまで認識合わせして落とし込んでいくか、というところを主として開発を進めているわけです。
分析の精度を上げるための手法だったり、会話の認識合わせ方法だったりはいくつかありますので、
チームだったり個人に合わせて導入するといいでしょう。
この辺りは非常に有益な本がたくさんありますので是非読んでください。

会話だったり、多少の抽象的なものの考え方だったりに慣れやセンス的なものが必要だったりはします。
この辺りは開発から少し遠いと感じるかもしれませんが、ロジカルシンキングなどの本を読むといいでしょう。

冒頭の例に挙げたいくつかの質問は全くの違うレイヤの話で
DDDの話ではないということが少しでもわかっていただければ幸いです。
(冒頭の質問に対する答えはネット上に素晴らしい記事がたくさんありますのでそちらを参考に)

  • 実装方法起点ではなくビジネス、利害関係者を起点に表現する
  • データの入出力を考えるのは一番最後、分析時などには考えない
  • 流行りモノではなく当たり前なことをやるだけ

こうしたことを意識しておけば入門して実践することがおそらくできるはずです。
(実装方法は別の話ですよ)

DDDに限らず、自身のチーム合わせた開発スタイルを取り入れて問題解決に取り組んでいきましょう。

実践ドメイン駆動設計

実践ドメイン駆動設計

エリック・エヴァンスのドメイン駆動設計

エリック・エヴァンスのドメイン駆動設計

  • 作者:Eric Evans
  • 発売日: 2013/11/20
  • メディア: Kindle版

PHPを使ってEvent Sourcing + CQRSをざっくり理解してみよう(Laravel)

これはさりげなく スターフェスティバル Advent Calendar 2020の20日目です。

PHPカンファレンス2020

2019年は登壇などを控えて一休みの期間としていたので一年振りくらいの
と登壇となりました。

発表の内容としてはここ3、4年注力しているデータ処理まわりから、
PHPにおけるWebアプリケーションなどでも活用することができる題材を取り上げてお話させていただきました。

要するに事業に関わっている開発は年々要件も複雑になっていき、
問題解決するためにはいろんな手法があるけど、きちんと分析して
開発しやすいよう、フレームワークにべったり依存してつくるのではなく、
数年先を見越してつくったり、改善する方法の一つにES+CQRSもありますよ、という話です。

お話したように、全てのアプリケーションでペイできるものではありませんし、
ある程度大きな規模だったりある程度複雑な機能だったり、
または周辺サービスや事業自体の構想によって初めて導入するかしないかという話になります。

なんかカッコ良さそうな方法があるから採用しよう!では失敗しますので
よく見極めて導入するのが良いと思います。

さてアドベントカレンダーということもあり、実際に自分自身が手がけてきたものを取り入れて、 ES+CQRSをざっくり理解してみよう ということで概念だけではなく、
PHPのコードも交えて簡単に解説します。

スタフェスではある部分をES+CQRSに置き換える途中だったり、
データ基盤的なものをこれらの手法の発展形ともいえるラムダアーキテクチャなどを用いていろんなものを
作り上げようとしている段階です。

ではざっくりといきましょう。

仮のお話

*ブログか、レビューか、何かそういうものを想像してください。

ユーザーが記事にキーワードを投稿して、頻度の高いキーワードをサジェストしたり、
ワードクラウドみたいなものを実現したいんですよね!
難しいかもしれませんが、キーワードのサジェストはリアルタイムに近いくらいの速度で変えたいです。 もちろんデータ集計や将来的にはレコメンデーションで使いたいです!

すごい雑な内容ですがこんな要望を実現しようという場合に、
キーワードはおそらくタグ的な用途だと思われますが、
記事などのコンテンツと同時にキーワードが投稿されるらしいものというのはざっくりわかります。

おそらくキーワードと、それを投稿したユーザー情報が分ければ良さそうです。
大きくするとキリがないのでここでは例としてユーザーIDだけにします。

ワードクラウドみたいなものやサジェストはどう実現したらよいでしょう?
ワードクラウドみたいなものやサジェスト はどうやら投稿したユーザー向けというわけではなく、
UIやAPIを含めて、不特定多数のユーザー向けの機能なようです。

ユーザーが投稿する時のキーワードとは違う概念になりそう(利用数の概念などが加わります)で、
サジェストはさすがにRDBMSでは無理でしょう。
ElasticsearchやSolrを使えば実現できそうです。

ざっくりとこういう内容だとします。

RDBMSでも近しいことはできなくはないと思いますが、
情報取得にはLIKE検索と集計を多用することになり、アナライザーなどはないため
日本語サジェストに対応するにはかなり厳しいものがあります。

ワードクラウド的なものは集計するだけでできそうですね。
ただし上記のサジェスト、ワードクラウドはユーザーIDは不用そうです。

同じキーワード、という文字でもアプリケーションに関わるユーザーの角度から
多少コンテキストが違うのがわかります。

例としてかなり簡単ですので、ES+CQRSを使うほどでもないですが
どういう風に作っていくか簡単に見ていきましょう。

コマンド実装例

今回の内容のコードは下記で公開しています。

github.com

CQRSについては、取り入れる環境やアプリケーションによって広義の意味だったりすることもありますので、
まずはCQRS Documents by Greg Youngを参照してください。

ベースの考え方としては、副作用のある書き込み処理と副作用のない読み込み処理を分離しましょう。
というものです。
当然DDDとシステムを利用するUI的な問題もともに解決しなければならないため、
スマートUI的な解決方法も取り入れなければなりません。
画面を構成する要素と要件分析は必ずしも一致しません。
(データベースの物理的な設計もデータモデルも異なります。)

このあたりはTask Based User Interface考察の下記の記事もわかりやすいと思います。

qiita.com

現実的にはうまくいろんな要素を取り入れなければ、
アプリケーションの規模によってはパフォーマンス面で深刻なボトルネックがあったり、
多様性のないデータベースなどが溢れてしまいます。

さてこの例のアプリケーションでは、
書き込みとしてはキーワード投稿(通常はブログかなにかの一部ですが、それだけを抜き出したものとして)が、
該当することがわかります。
読み込みとしてはサジェスト、ワードクラウドが該当します。

まずこれらを分割します。

まずはユーザーが投稿するキーワードです。

<?php
declare(strict_types=1);

namespace SampleDomain\Keyword\Entity;

use SampleDomain\User\ValueObject\UserId;

final class Keyword
{
    /**
     * @param UserId $user
     * @param string $word
     */
    public function __construct(
        private UserId $user,
        private string $word
    ) {
    }

    public function getUserId(): UserId
    {
        return $this->user;
    }

    public function getWord(): string
    {
        return $this->word;
    }
}

実際に必要なのはこれだけです。
これがアクションからユースケース(アプリケーションサービス)が実行されます。

<?php
// 省略
    /**
     * キーワードを登録する
     * @LogExceptions()
     * @param int $id
     * @param string $text
     */
    public function register(
        int $id,
        string $text
    ): void {
        $keyword = new Keyword(new UserId($id), $text);
        $this->dbRepository->save($keyword);
    }

書き込みを実行する実装で、簡単に実装できました。
ですが、ここに一つの問題があります。
それは書き込みの処理です。

サジェストがあるので RDBMS(ここではMySQLを使います)とElasticsearchに書き込まなければいけません。
ここで2フェーズコミットの問題が出てきます。

<?php
// 省略
        $this->dbRepository->save($keyword);
        $this->esRepository->save($keyword);

この二つに書き込む場合、MySQLへの書き込みが失敗した場合に
次のElasticsearchへの書き込みを停止することはまだ簡単です。

Elasticsearchが失敗した場合、MySQLへ書き込んだ内容を削除しなければなりません。
この順序を逆さまにした場合でも同じです。

2つとも無事に書き込めたことを確認し、正常終了したものとしてコミット扱いにしなければなりません。
これを回避するにはどれか一つにだけ書き込んで、
バッチ処理か何かでデータを同期してあげれば良さそうです。

ただリアルタイムに近しい頻度で更新して欲しい、という話がありました。
そのあたり要件の調整をしろ、というのもありますが、
そこで止めては今回のサンプルの意味がありません。

<?php
// 省略
    /**
     * キーワードを登録する
     * @LogExceptions()
     * @param int $id
     * @param string $text
     */
    public function register(
        int $id,
        string $text
    ): void {
        $keyword = new Keyword(new UserId($id), $text);
        $this->dbRepository->save($keyword);
        $this->dispatcher->dispatch(new KeywordRegistered($keyword));
    }

リアルタイムに近しい速度を実現させるためにpubsubのメッセージブローカーを使うことにしました。
dispatchを使ってメッセージブローカーへの通知をするように実装してみました。

<?php
declare(strict_types=1);

namespace App\Listeners;

use App\DataAccess\Kafka\KeywordCreatedParameter;
use App\DataAccess\KeywordProducerInterface;
use SampleDomain\Keyword\Event\KeywordRegistered;

class KeywordRegisteredListener
{
    /**
     * @param KeywordProducerInterface $producer
     */
    public function __construct(
        private KeywordProducerInterface $producer
    ) {
    }

    /**
     * @param KeywordRegistered $event
     */
    public function handle(
        KeywordRegistered $event
    ): void {
        $this->producer->add(
            new KeywordCreatedParameter(
                $event->getKeyword()
            )
        );
    }
}

リスナーで受け取ってpublishしているだけです。 これならいけそうです。

とはなりません。
結局、MySQLかElasticsearchのどちらかに書き込んだあとに、
メッセージブローカーへの書き込みが失敗したら戻さなければなりません。
ということで、ここではメッセージブローカーへのpublishに注力すれば良さそうです。

<?php
declare(strict_types=1);

namespace App\AppService;

use Psr\Log\LoggerInterface;
use SampleDomain\Keyword\Event\KeywordRegistered;
use Illuminate\Contracts\Events\Dispatcher;
use SampleDomain\Keyword\Entity\Keyword;
use SampleDomain\User\ValueObject\UserId;
use Ytake\LaravelAspect\Annotation\LogExceptions;

/**
 * Usecase
 */
class KeywordRegistration
{
    /**
     * @param LoggerInterface $logger
     * @param Dispatcher $dispatcher
     */
    public function __construct(
        private LoggerInterface $logger,
        private Dispatcher $dispatcher
    ) {
    }

    /**
     * キーワードを登録する
     * @LogExceptions()
     * @param int $id
     * @param string $text
     */
    public function register(
        int $id,
        string $text
    ): void {
        $keyword = new Keyword(new UserId($id), $text);
        $this->dispatcher->dispatch(new KeywordRegistered($keyword));
        $this->logger->info('publish', ['object' => $keyword]);
    }
}

DebeziumやDynamoDB ストリームを使うことで、この問題を回避することができますが、
ここではどこかの環境に依存していないアプリケーションのアプローチとします。

このアプローチを採用する場合に、当然重要なのがメッセージブローカーに何を使うか、になると思います。
PHPに焦点を当てたこの例では、有力なのはApache Kafkaとなります。
大きな問題としてKafkaへの送信失敗時の挙動です。

PHPの場合、librdkafkaを使ったrdkafkaを利用するわけですが
このlibrdkafkaは通信失敗時のリトライがサポート、
それに加え通信のトランザクションもサポートされています。

<?php
declare(strict_types=1);

namespace App\DataAccess;

use App\DataAccess\Kafka\ParameterInterface;
use App\Foundation\Serializer\SerializerInterface;
use RdKafka\Producer;
use RdKafka\ProducerTopic;
use function is_null;
use const RD_KAFKA_PARTITION_UA;

final class KeywordProducer implements KeywordProducerInterface
{
    /**
     * @param Producer $producer
     * @param ProducerTopic $topic
     * @param SerializerInterface $serializer
     */
    public function __construct(
        private Producer $producer,
        private ProducerTopic $topic,
        private SerializerInterface $serializer
    ) {
    }

    /**
     * to Kafka
     * @param ParameterInterface $parameter
     */
    public function add(
        ParameterInterface $parameter
    ): void {
        $this->producer->initTransactions(10000);
        $this->producer->beginTransaction();
        $this->topic->produce(
            RD_KAFKA_PARTITION_UA,
            0,
            $this->serializer->serialize($parameter->toArray())
        );
        $this->producer->poll(0);
        $error = $this->producer->commitTransaction(10000);
        if (!is_null($error)) {
            throw new \RuntimeException('Kafka Transaction Error.');
        }
    }
}

もちろん選択肢としてApache Pulsarもあるかと思います。
この辺はみなさんのアプリケーションによって最適なものを採用できればいいかと思います。
サンプルコードではjsonで送信していますが、
より強固なアプリケーションにする場合はApache Avroを利用します。
ほかにもKafka Streamsを使うことでメッセージがsubscribeされる前にフィルターしたり(バリデーションなど)、
メッセージの中身によって振り分けたりさまざまなことができます。
この辺りもアプリケーションに合わせて採用するといいでしょう。

これでメッセージブローカーへの送信が確かなものになりました。 このメッセージをサブスクライブしてそのまま補完していけばイベントを再発行することもでき、
遡ることもできます。
またKafka自体にもメッセージを消失させずに30日まで補完する機能がありますので、
その辺りもうまく利用できます。
また手抜きをするならば、kafkaで受け取ったメッセージをそのまま他のデータベースに保存することもできます。
(Kafka Connect)
これでイベントソーシングと組み合わせることができました。

読み込みモデル更新処理

次に読み込みモデルの更新です。
ここではメッセージブローカーをsubscribeしたプロセスの処理が該当します。

PHPではこれを行うにはsystemdかsupervisorなどで常駐プロセスにするしかありません。

ここでLaravelならQueueがあるのに、という話になりますが、
今回はMySQLとElasticsearchを使って二つのデータベースに書き込まなければいけません。
つまり一つのQueueに異なるプロセスが同時にアクセスできる必要があります。
これを一つのプロセスで実装すると2フェーズコミットの問題がそのままくっついてきます。
(同じデータベースであれば問題ありません)

ラウンドロビンというよりも並行して処理が走る、ということになります。
これはもうフレームワークの機能でどうにかなる話ではありません。
それにパフォーマンス面でPHPの読み込み処理をGoやScalaなどに変更する、というのは
事業サービス形の会社ではよくある話だと思います。
この場合、LaravelのPHPシリアライズしたQueueを使い続けていては足枷になってしまいます。

こういった処理をする場合はフレームワーク依存の機能を使わないことが一番です。

MySQLとElasticsearchに書き込むということで、MySQLの方は簡単ですので、
サンプルを見るなり自身で作りなりしてもらえれば良いですが、
問題はElasticsearchです。

サジェストなどを実装しなければなりませんので、LIKE検索の延長で使うだけではできません。
これを解決するにはアナライザーを利用することです。
kuromojiやicu、ngramなどを組み合わせると対応できます。
今回のサンプルでは最低限の構成になっていますので、下記のようなmappingで十分です。

{
  "settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 0
    },
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        },
        "kana_to_romaji": {
          "type": "mapping",
          "mappings": [
            "あ=>a",
            "い=>i",
            "う=>u",
            "え=>e",
            "お=>o",
            "か=>ka",
            "き=>ki",
            "く=>ku",
            "け=>ke",
            "こ=>ko",
            "さ=>sa",
            "し=>shi",
            "す=>su",
            "せ=>se",
            "そ=>so",
            "た=>ta",
            "ち=>chi",
            "つ=>tsu",
            "て=>te",
            "と=>to",
            "な=>na",
            "に=>ni",
            "ぬ=>nu",
            "ね=>ne",
            "の=>no",
            "は=>ha",
            "ひ=>hi",
            "ふ=>fu",
            "へ=>he",
            "ほ=>ho",
            "ま=>ma",
            "み=>mi",
            "む=>mu",
            "め=>me",
            "も=>mo",
            "や=>ya",
            "ゆ=>yu",
            "よ=>yo",
            "ら=>ra",
            "り=>ri",
            "る=>ru",
            "れ=>re",
            "ろ=>ro",
            "わ=>wa",
            "を=>o",
            "ん=>n",
            "が=>ga",
            "ぎ=>gi",
            "ぐ=>gu",
            "げ=>ge",
            "ご=>go",
            "ざ=>za",
            "じ=>ji",
            "ず=>zu",
            "ぜ=>ze",
            "ぞ=>zo",
            "だ=>da",
            "ぢ=>ji",
            "づ=>zu",
            "で=>de",
            "ど=>do",
            "ば=>ba",
            "び=>bi",
            "ぶ=>bu",
            "べ=>be",
            "ぼ=>bo",
            "ぱ=>pa",
            "ぴ=>pi",
            "ぷ=>pu",
            "ぺ=>pe",
            "ぽ=>po",
            "きゃ=>kya",
            "きゅ=>kyu",
            "きょ=>kyo",
            "しゃ=>sha",
            "しゅ=>shu",
            "しょ=>sho",
            "ちゃ=>cha",
            "ちゅ=>chu",
            "ちょ=>cho",
            "にゃ=>nya",
            "にゅ=>nyu",
            "にょ=>nyo",
            "ひゃ=>hya",
            "ひゅ=>hyu",
            "ひょ=>hyo",
            "みゃ=>mya",
            "みゅ=>myu",
            "みょ=>myo",
            "りゃ=>rya",
            "りゅ=>ryu",
            "りょ=>ryo",
            "ぎゃ=>gya",
            "ぎゅ=>gyu",
            "ぎょ=>gyo",
            "じゃ=>ja",
            "じゅ=>ju",
            "じょ=>jo",
            "びゃ=>bya",
            "びゅ=>byu",
            "びょ=>byo",
            "ぴゃ=>pya",
            "ぴゅ=>pyu",
            "ぴょ=>pyo",
            "ふぁ=>fa",
            "ふぃ=>fi",
            "ふぇ=>fe",
            "ふぉ=>fo",
            "ふゅ=>fyu",
            "うぃ=>wi",
            "うぇ=>we",
            "うぉ=>wo",
            "つぁ=>tsa",
            "つぃ=>tsi",
            "つぇ=>tse",
            "つぉ=>tso",
            "ちぇ=>che",
            "しぇ=>she",
            "じぇ=>je",
            "てぃ=>ti",
            "でぃ=>di",
            "でゅ=>du",
            "とぅ=>tu",
            "ぢゃ=>ja",
            "ぢゅ=>ju",
            "ぢょ=>jo",
            "ぁ=>a",
            "ぃ=>i",
            "ぅ=>u",
            "ぇ=>e",
            "ぉ=>o",
            "っ=>t",
            "ゃ=>ya",
            "ゅ=>yu",
            "ょ=>yo",
            "ア=>a",
            "イ=>i",
            "ウ=>u",
            "エ=>e",
            "オ=>o",
            "カ=>ka",
            "キ=>ki",
            "ク=>ku",
            "ケ=>ke",
            "コ=>ko",
            "サ=>sa",
            "シ=>shi",
            "ス=>su",
            "セ=>se",
            "ソ=>so",
            "タ=>ta",
            "チ=>chi",
            "ツ=>tsu",
            "テ=>te",
            "ト=>to",
            "ナ=>na",
            "ニ=>ni",
            "ヌ=>nu",
            "ネ=>ne",
            "ノ=>no",
            "ハ=>ha",
            "ヒ=>hi",
            "フ=>fu",
            "ヘ=>he",
            "ホ=>ho",
            "マ=>ma",
            "ミ=>mi",
            "ム=>mu",
            "メ=>me",
            "モ=>mo",
            "ヤ=>ya",
            "ユ=>yu",
            "ヨ=>yo",
            "ラ=>ra",
            "リ=>ri",
            "ル=>ru",
            "レ=>re",
            "ロ=>ro",
            "ワ=>wa",
            "ヲ=>o",
            "ン=>n",
            "ガ=>ga",
            "ギ=>gi",
            "グ=>gu",
            "ゲ=>ge",
            "ゴ=>go",
            "ザ=>za",
            "ジ=>ji",
            "ズ=>zu",
            "ゼ=>ze",
            "ゾ=>zo",
            "ダ=>da",
            "ヂ=>ji",
            "ヅ=>zu",
            "デ=>de",
            "ド=>do",
            "バ=>ba",
            "ビ=>bi",
            "ブ=>bu",
            "ベ=>be",
            "ボ=>bo",
            "パ=>pa",
            "ピ=>pi",
            "プ=>pu",
            "ペ=>pe",
            "ポ=>po",
            "キャ=>kya",
            "キュ=>kyu",
            "キョ=>kyo",
            "シャ=>sha",
            "シュ=>shu",
            "ショ=>sho",
            "チャ=>cha",
            "チュ=>chu",
            "チョ=>cho",
            "ニャ=>nya",
            "ニュ=>nyu",
            "ニョ=>nyo",
            "ヒャ=>hya",
            "ヒュ=>hyu",
            "ヒョ=>hyo",
            "ミャ=>mya",
            "ミュ=>myu",
            "ミョ=>myo",
            "リャ=>rya",
            "リュ=>ryu",
            "リョ=>ryo",
            "ギャ=>gya",
            "ギュ=>gyu",
            "ギョ=>gyo",
            "ジャ=>ja",
            "ジュ=>ju",
            "ジョ=>jo",
            "ビャ=>bya",
            "ビュ=>byu",
            "ビョ=>byo",
            "ピャ=>pya",
            "ピュ=>pyu",
            "ピョ=>pyo",
            "ファ=>fa",
            "フィ=>fi",
            "フェ=>fe",
            "フォ=>fo",
            "フュ=>fyu",
            "ウィ=>wi",
            "ウェ=>we",
            "ウォ=>wo",
            "ヴァ=>va",
            "ヴィ=>vi",
            "ヴ=>v",
            "ヴェ=>ve",
            "ヴォ=>vo",
            "ツァ=>tsa",
            "ツィ=>tsi",
            "ツェ=>tse",
            "ツォ=>tso",
            "チェ=>che",
            "シェ=>she",
            "ジェ=>je",
            "ティ=>ti",
            "ディ=>di",
            "デュ=>du",
            "トゥ=>tu",
            "ヂャ=>ja",
            "ヂュ=>ju",
            "ヂョ=>jo",
            "ァ=>a",
            "ィ=>i",
            "ゥ=>u",
            "ェ=>e",
            "ォ=>o",
            "ッ=>t",
            "ャ=>ya",
            "ュ=>yu",
            "ョ=>yo"
          ]
        }
      },
      "tokenizer": {
        "kuromoji_normal": {
          "mode": "normal",
          "type": "kuromoji_tokenizer"
        }
      },
      "filter": {
        "readingform": {
          "type": "kuromoji_readingform",
          "use_romaji": true
        },
        "edge_ngram": {
          "type": "edge_ngram",
          "min_gram": 1,
          "max_gram": 10
        },
        "synonym": {
          "type": "synonym",
          "lenient": true,
          "synonyms": [
            "nippon, nihon"
          ]
        }
      },
      "analyzer": {
        "suggest_index_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize"
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [
            "lowercase",
            "edge_ngram"
          ]
        },
        "suggest_search_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize"
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [
            "lowercase"
          ]
        },
        "readingform_index_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize",
            "kana_to_romaji"
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym",
            "edge_ngram"
          ]
        },
        "readingform_search_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize",
            "kana_to_romaji"
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "word_field": {
        "type": "keyword",
        "fields": {
          "suggest": {
            "type": "text",
            "search_analyzer": "suggest_search_analyzer",
            "analyzer": "suggest_index_analyzer"
          },
          "readingform": {
            "type": "text",
            "search_analyzer": "readingform_search_analyzer",
            "analyzer": "readingform_index_analyzer"
          }
        }
      }
    }
  }
}

この辺りはこれを解説するだけで数記事になりますので、
興味ある方は下記のものをはじめとして色々読み込んでみてください。

www.elastic.co

これを利用すれば集計と利用頻度順のサジェストをうまく活用できます。
ここまでデータベースだけの世界で解決できました。

これを読み込む、クエリの処理は単純に問い合わせてDTO(Data Transfer Object)だけあれば十分でしょう。
例えばクエリの対象がUIや複雑なAPIであれば、それに合わせたデータを構築すれば済みます。
また対象のインデックスの中身のデータを更新したければ、
それ用のメッセージをpublishし、subscribeした処理で書き換えれば済みますので、
複雑な処理がWebアプリケーション側からも排除できます。
またチームで開発をする場合は、開発者の分担もうまくできるようになります。
(どこかの層にビジネスロジックが集中しすぎないように配慮する必要はあります。)

<?php
// 省略

    /**
     * @param HandlerInterface $handler
     * @param Consumer $consumer
     * @param int $offset
     */
    public function handle(
        HandlerInterface $handler,
        Consumer $consumer,
        int $offset = RD_KAFKA_OFFSET_STORED
    ): void {
        $consumer->addBrokers($this->broker);
        $topic = $consumer->newTopic($this->topic, $this->topicConf);
        $topic->consumeStart($this->partition, $offset);
        while (true) {
            $message = $topic->consume($this->partition, 120 * 10000);
            if ($message instanceof Message) {
                match ($message->err) {
                    RD_KAFKA_RESP_ERR_NO_ERROR => call_user_func($handler, $message),
                    RD_KAFKA_RESP_ERR__TIMED_OUT => throw new SubscriberTimeoutException('time out.'),
                };
            }
        }
    }

今回の例ではsubsrcibeの仕組みと、そのデータを受け取って処理するクラスを分離するようにしました。
この辺りは単純にデータ処理だけになりますので、特に難しいものはありません。
なにかのビジネスロジックを解決するわけでもありませんので、
シンプルな仕組みになっています。

<?php
declare(strict_types=1);

namespace App\DataAccess\Elasticsearch;

use DateTime;
use App\Foundation\Kafka\HandlerInterface;
use Elasticsearch\Client;
use RdKafka\Message;

class RegisterKeyword implements HandlerInterface
{
    /**
     * @param Client $client
     * @param string $index
     */
    public function __construct(
        private Client $client,
        private string $index
    ) {
    }

    /**
     * @param Message $message
     * @throws \JsonException
     */
    public function __invoke(
        Message $message
    ): void {
        $decoded = json_decode($message->payload, false, 512, JSON_THROW_ON_ERROR);
        $word = '';
        if (isset($decoded->body)) {
            $word = $decoded->body;
        }
        if ($word === '') {
            return;
        }
        $d = new DateTime();
        $this->client->index([
            'index' => $this->index,
            'body' => [
                'word_field' => $word,
                'created' => $d->format('Y-m-d\TH:i:s')
            ]
        ]);
    }
}

クエリ実装例

クエリとしては、特に何かに注力しなければいけないことはありません。
コマンドと同じクラスを使わない、なにかを変に汎用化させないなどができていれば良いでしょう。

<?php
declare(strict_types=1);

namespace App\DataAccess\Elasticsearch;

use App\DataAccess\GetSuggestKeywordInterface;
use Elasticsearch\Client;
use function array_merge;

final class SuggestKeyword implements GetSuggestKeywordInterface
{
    use AggregateQuery;

    /**
     * @param Client $client
     * @param string $index
     */
    public function __construct(
        private Client $client,
        private string $index
    ) {
    }

    /**
     * @param string $word
     * @return array
     */
    public function findByWord(
        string $word
    ): array {
        $params = [
            'index' => $this->index,
            'body' => $this->aggsQuery(),
        ];
        if ($word !== '') {
            $params['body'] = array_merge($params['body'], [
                'query' => $this->suggestMatchQuery($word)
            ]);
        }
        $result = $this->client->search($params);
        return $result['aggregations']['keyword']['buckets'];
    }

    /**
     * @param string $word
     * @return array
     */
    private function suggestMatchQuery(
        string $word
    ): array {
        return  [
            'bool' => [
                'should' => [
                    [
                        'match' => [
                            'word_field.suggest' => [
                                'query' => $word
                            ]
                        ]
                    ],
                    [
                        'match' => [
                            'word_field.readingform' => [
                                'query' => $word,
                                'fuzziness' => 'AUTO',
                                'operator' => 'and'
                            ]
                        ]
                    ]
                ]
            ]
        ];
    }
}

Elasticsearchへの問い合わせは上記のような形になります。
ここで取得したものをインターフェースを挟んで
DTOに変換してあげると要件を実現できます。

<?php
declare(strict_types=1);

namespace App\QueryProcessor;

use App\Transfer\Keyword;
use Generator;
use App\DataAccess\GetSuggestKeywordInterface;

final class SuggestKeywordQueryProcessor
{
    /**
     * @param GetSuggestKeywordInterface $suggestKeyword
     */
    public function __construct(
        private GetSuggestKeywordInterface $suggestKeyword
    ) {
    }

    /**
     * @param string $word
     * @return Generator
     */
    public function run(string $word): Generator
    {
        foreach ($this->suggestKeyword->findByWord($word) as $row) {
            yield new Keyword($row['key'], $row['doc_count']);
        }
    }
}

気になる方は実際のサンプルコードを見るなどすると理解できると思います。

最後に

一つ一つの処理は単純になることがわかると思います。
ただし作るものや関わるミドルウェアが増えるのは事実ですので、
利用するミドルウェアについてもしっかりと理解をする必要があります。

またこのKafkaのデータハブとして作用する仕組みを使って、
データ基盤系のデータパイプラインやSparkを使ったより高度なアプリケーションを開発することができます。
その辺りになると本格的な分散処理や、Webアプリケーションとは全く違う知識やテクニックが必要となりますので、
エンジニアとしてのスキルセットをさらに増やしたい方にはいい入り口になると思います。
これらはES+CQRSではなくてイベント駆動になりますが、その先にUIがあることも多々ありますので考え方は踏襲できます。

もうちょっと丁寧に図解とかしようと思いましたが力尽きたので今回はこの辺で。

会社のアドベントカレンダーということで、
こういった処理を作ったり考えたり、改善をしまくりたい!というTSをメインにして
GoやPHPをやりたい!というエンジニアの方や、
KafkaやSparkなどを使ったストリーム処理(Scala、Java)とかやりたいというエンジニアの方、
それらを支えるインフラをやりたい!SREみたいなことしたい!というインフラエンジニアの方を

募集しております!!!!!

お気軽に@ex_takezawaなどにご連絡ください。

エンジニア的3ヶ月で16キロダイエット!

久しぶりのブログ投稿ですが、
今回は健康的なダイエット生活についてお届けします。
8月から10月末まで、一応期間を作ってストイック気味にダイエットをしていました。
エンジニアの自分が実際にこの3ヶ月でやってきたことを記念に残しておこうと思います。

結論からいうと、
3ヶ月で16キロ減量し(84キロから68キロ)、
BMIは5マイナス(26.8から21.7)、体脂肪は7.5%ダウン(25%から17.3%)、
骨格筋率は5%アップ(33%から38%台へ)、体年齢は15歳若返る(48から33)

という成果になりました。

ちなみにジムには通いませんでした。
やってしまえば気合でなんとかなる、という性格なので自宅とランニングでなんとかしました。

コロナで変わってしまった生活環境

コロナなどの影響で外出する機会が減ってしまい、
太ってしまった方、逆に痩せた方、様々な方がいると思います。

自分は去年ヘルニアの手術(ヘルニコア注射ですが)をやったり、
調子が悪くなったりで肉体的にも外に出る機会が少し減ったりしていました。

今年は年明けから間も無くコロナということでそれまでの生活から大きく変わってしまいました。

その一つに出勤の移動がなくなった、という方も多いと思います。
外に出る機会が減ったため、仕事をする上では大変楽なのですが、
千葉北部の隅っこに住んでる自分としてはこの通勤が、意外といい運動になっていたことがわかりました。

そう、年末から今年の7月中旬にかけて10キロ以上太ってしまいました。

自分は身長177センチなんですが、10キロ以上太って84キロ台に。
BMIでいうと26.8になるので、肥満1度となります。

オフラインで久しぶりに会う方には、「あれ、ytakeさんひとまわり大きくなった?!」と
ほぼ必ず言われていました。

はい、ひとまわり大きくなりました。
Lサイズの服を着るとお腹がだいぶ気になる状態だったのでXLを着るという状態でした。

ダイエットするぞ!

大きくなりつつ、たまたま病院で血液検査などをしていた時に
脂肪肝になりつつあるので気をつけてと言われることも多くなり、
減量しなきゃまずいな、と思いつつなかなかできませんでした。

そんな中、ダイエットするぞ!となった決定的なものは、
昔から聴いていたアーティストの現在の姿をYoutubeでたまたま観ていた時に、
当時スマートでカッコ良かったのに、今はこんなデカい体に・・・・
と。
(ちなみに観ていたのは、元Sepulturaのマックス・カヴァレラ)

歳を取ると大型化する人は海外アーティストに多く、
年齢や健康を考えたらやるしかない!ということで
区切りの良い8月からダイエットをすることにしました。

痩せるために大事なこと

個人的にやると決めたら絶対にやらなきゃ気がすまない性格なので、
痩せると決めたら目標を決めて絶対にやり遂げる、というのが
一番良かったのかも、と思っています。

明日できることは明日でいいやぁ、と思うこともありますが、
人間は1キロ痩せるのに7000kcal必要と言われるので、
明日に回したところで何もプラスにならないので、
とにかく毎日義務的にやる、ということを常に意識して取り組んでいました。
幸いにしてコロナ禍ということもあって、呑みに行ったり通勤で疲れてやる気がー、
などということは一切ありませんでした。

途中から義務的にやっていたものが生活習慣へと変わっていく意識があり、
1ヶ月半超えたあたりから毎日体を動かすのが楽しくて仕方ありませんでした。
ランニングなんかは今後も継続して行うと思います。

それでは以下、この3ヶ月でやったことを記載していきます。

食生活を変える

減量の基本といえる食生活。
今回は炭水化物抜きダイエットとか、
所謂食事制限だけでするダイエットはしませんでした
明らかにリバウンドすることもわかっていたのと、
体重減少とともに筋肉も基礎代謝も落ちていくので、
筋トレは必ずやる、と決めていたので食事は最低限のものは取る様にしました。

食生活で大きく変えたのは、
摂取カロリーを意識した食事メニューにする、でした。
意識したのは炭水化物は1日に一食は取る様にして、
とにかく基礎代謝以上には絶対に取らない、ということでした。

その中でも晩飯のメニューはダイエット、筋トレの定番メニューにすることが多かった...。

ただ食事制限だけのダイエットにするつもりはなかったんですが、
結果的に食事制限していた、という形になっていました。

1日の食事は

  • 朝はオイコス一個と小さいアンドーナッツ(子供向けの)1個
  • 昼はオイコスとウィダーinゼリー(エネルギー)
  • 夜は野菜、サラダチキン、茹でたまご またはご飯とおかずの何か

という具合でした。
食事のみのカロリーを計算すると大体食で800kcal以下という日がほとんどでした。
当然腹が減るんですが、
お腹が減ると脂肪を使ってエネルギーに変える、という体の仕組みを使って、
空腹になったら筋トレをして空腹感を紛らわせて食事量を少し減らす、という具合です。

筋トレを毎日欠かさず

体重とともに筋肉と基礎代謝が落ちていくので筋トレは必須でやっていました。
上記の食生活改善もこの筋トレのおかげで改善しやすかった、というのもあります。

そんな毎日の筋トレメニューは以下のものをやっていました。

  • 腹筋100回
  • ダンベルカール(左右100)
  • サイドレイズ(20 2セット)
  • ダンベルショルダープレス(20 2セット)
  • 背筋100回
  • バックランジ(20 5-10セット)
  • プランク(30-60秒)
  • レッグレイズ -> オルタネイトレッグレイズ -> リバースクランチ -> V字クランチ(30秒やって20秒休んで最後まで)
  • 腕立て伏せ(80回)

回数があるのはその日の限界くらいまでやるので、多少可変させてやっていました。
これを毎日やり、ある程度体が慣れた1ヶ月半後くらいにHIITを追加しました。

「HIIT」とは「High-Intensity Interval Training(ハイ・インテンシティ・インターバル・トレーニング)」の略。高強度インターバルトレーニングのこと

HIITは以下のものを毎日やっていました。

  • バーピー -> スクワット -> スクワット&ジャックス -> マウンテンクライマー

上記のものを「30秒全力でやって30秒休んで最後まで」を4セットやります。

筋トレだけで消費できるカロリーは高が知れているので、
HIITを追加することで室内でできる筋トレと有酸素運動を加えた形になるので、
4セットくらいで大体150kcal消費できる様になります。

これらを在宅ワークという環境を使って、昼休みにやります(やっています)。

有酸素運動も毎日欠かさず

大きく体重を減らせたのは有酸素運動を毎日やっていたから、というのも一つ理由としてあるかなと思います。
有酸素運動といえばランニングですが、最初からランニングはやりませんでした。
というか最初にランニングをやっていましたが、肺活量も持久力も最初からあるわけではなく、
途中で走れなくなることも多かったので、
まずは最初に肺活量と持久力を鍛えることにしました。

どうやって肺活量と持久力を鍛えたのかというと、
それはエアロバイク!
エアロバイクはながらでやることができる有酸素運動として有名ですが、
ながらでやることは辞めました。

カロリー消費と肺活量、持久力を鍛えたかったので、
例えば動画を観ながらやると心拍数は大して上がらず、消費カロリーも大したことありません。
効率よくやせるには心拍数の維持と、時間です。

そこでエアロバイクは週ごとに心拍数と時間を上げていく、
というストイックな形式でやることにしました。
最初の1週間は心拍数150で30分続けることから始めました。
2週間後は心拍数150で40分、
その翌週は心拍数150で50分、
8月末あたりは心拍数150で60分になっていました。

心拍数150で60分やると、60分で大体600kcal以上は消費できる様になります。
最初の頃は音楽を聴きながらやっていましたが、
自分の呼吸ペースと合わなくなるとあまり調子が良くない、ということがわかり
無音で60分やることに。

9月は心拍数167くらいで60分漕ぐ、というペースに。
所謂ハードペースというペースになり、60分で大体800kcalくらい消費になり、
体力的にも余裕がでてきて60分以上を毎日やり、
1日エアロバイクだけで1000kcalくらい消費する様になりました。

このくらいまでいくとランニングに切り替えてももう問題ありません。

そこで、ランニングに移行する前の9月の後半は
エアロバイク50分とウォーキングに切り替えました。
ウォーキングといっても心拍数140台で歩くため、ほぼ競歩みたいな感じになります。

ウォーキングをしながら走るコースの下見をする、という具合ですね。
エアロバイクは天気に左右されずにできるので大変良いんですが、
部屋の中でしかも集中してやるので途中から飽きてしまいます。
9月末からランニングにしたのは丁度良かったかもしれません。

Apple Watchはワークアウト時に心拍数が常に確認できるのでかなり重宝しました。
ダイエットする方は購入しましょう!

10月からはランニングに移行し、雨の日だけエアロバイクにすることにしました。
ただ2ヶ月経過すると、体重がすでに10キロ以上減っていたこともあり、
予定よりも痩せすぎてしまうので少し消費カロリーをコントロールしました。

ランニングは1キロ7分ペースからはじめ、
徐々に早くしていき、今現在は1キロ5-6分くらいのペースになっています。
走る距離は6キロからはじめ、現在は10キロに。
減量目的でランニングする方は、はぁはぁする手前のペースを維持するのがいいかなと個人的に思います。
自分はエアロバイクを毎日2ヶ月やってからランニングに切り替えたので、
かなり早めに走っても全然息が上がらなくなりました。

毎日やっているとこの辺の体の変化がわかるので、モチベーションにも繋がります。

プロテイン

プロテインは9月中盤から飲むことにしました。
飲んでいたのはSAVASのアスリート ウェイトダウン ヨーグルト風味!

ヨーグルト風味とありますが、実質きな粉みたいな感じです。
ソイプロテインは吸収がゆっくりということもあり、
寝る前30-60分前に飲むように。
これは就寝時の成長ホルモンが分泌されるピークに合わせた時間になっています。

プロテイン飲んだら太る!という方もいるかも知れませんが、
1日の摂取カロリーによります。

たくさん食べながら痩せる、というそんな虫のいい話はありません。
諦めて食事制限と有酸素運動と筋トレをしましょう。

所謂チートデイはつくらない

ダイエットの記事を見るとチートデイというものについて書かれていることが多いですが、
基本的に体重が減らなくても食事に気をつけて筋トレと有酸素運動を欠かさずやっていれば、
停滞期が来てもその後減っていきます。

10日間ほど所謂停滞期らしいものがありましたが、
体重は毎日減っていくものではなくて、一週間くらいの期間で減少傾向にある、
という感じでわかるものなので、
一週間くらい減らなくても焦らず、そしてチートデイというものには頼らない方がいいです。
食べすぎてリバウンドしてしまった、というのはよくある話です。

自分はかなり速いペースで体重を落としていったので、
9月中盤から後半にかけて減りにくい期間がありましたが、
ちょうどこの時期あたりにHIITを導入した期間でもあり、
減りにくい期間が過ぎたら一気に落ちていきました。

結果

上記の食生活の見直し、
1ヶ月あたり大体5キロくらい減量し、
結果3ヶ月で16キロ減量になりました。

現在コロナ禍ということもあり、運動不足はいろんな方が感じていることだと思います。
エンジニアという職種についているので、
在宅ワークの時間を使うことで効率的に運動不足を解消することができる!
という事がわかった3ヶ月でした。

体全体の肉が1週間ごとに減っていくのがわかるので大変楽しかったです。
減量中に取り組んでいたものは習慣化しているので(ランニングなど)今後も継続してやりながら
健康的で元気なエンジニア生活を送っていこうと思います。

ダイエットしたい!という方は是非参考にしてみてください。

スターフェスティバル株式会社にジョイン! &仲間を募集中!

f:id:ytakezawa:20200906102102p:plain

近況のお話です。

スターフェスティバル株式会社

9月より、
元メルカリCTOでもあるsotarokが現在CTOとして務めている
スターフェスティバル株式会社にジョインさせていただくことになりました。

プロダクト開発に舵を切ったばかりのところで、
且つエンジニアリング組織としては刷新したばかり、
様々な文化ややり方、環境、技術的な部分もまだまだこれから大きく変わろうとしている段階で、
これまでのCTO経験やテックリード的な動きや経験が
活かせる職場でもあり、
システム的にもいろいろこれから整えていく、まさにそんなフェーズで面白そうと思い
ジョインさせていただきました。

自身は事業ドメインについて現在理解を深めているところです。
(ドメイン駆動開発をするため)

直近では大きな課題をいくつか抱えているので、
そう云った問題解決にコミットしつつ、エンジニアをもう1段階、2段階へと底力を上げながら、
強い組織作りにもコミットしようと考えています。

アイスタイルでCTOとしてやってきたこととあまり変わりませんが、
エンジニア組織としてはまだまだかなり小さいので、
伸び代がかなり大きい段階です。
こうしたフェーズに参加できるのはとても楽しくもあり、
数年後を考えるとどう云った形に成長しているのか、ワクワクする環境です。

仲間を募集中!

さてさて、そんなスターフェスティバル社ですが、
当然これからプロダクト開発へ力を入れていきますので、
インフラ(現AWS)や、フロントエンドエンジニア(Vue、TSなど)、
バックエンドエンジニア、プロダクトマネージャー、モバイルなど
様々なエンジニアを募集しています。

バックエンドは現在PHPやNode.js(TS)がメインではありますが、
言語にこだわるよりも、適材適所で技術選択ができるエンジニアの方は
かなり活躍できる場だと思います。
(GoやRust、もう少しシステムが大きくなればScalaなど(?))

一緒に文化作りやチームビルディング、自動化、改善などを行いながら
楽しくエンジニアリング組織を盛り上げてくれる、そんな仲間を強く求めています!

ちょっと興味がある、カジュアルに聞いてみたい、詳しく知りたい、などありましたら
竹澤まで気軽に連絡いただければと思います!

是非一緒にやりましょう!

現場からは以上です。

近況のご報告

一年の半分が過ぎました。

近況の話

ヘルニアの治療に続き、
体にガタがきてしまいしばらく大人しくしていました。
しばらく休ませてくれた会社、関係者に感謝致します。

完全に近いくらい復活しまして、
知人の会社の手伝いをしながらいろんなことをやってました。

自分で機械学習の処理をイチから全部実装したり、
CQRSを応用した様なものを多数作ったり、
相変わらず複数言語を多用した開発スタイルを取りながらEKSでのサービス運用だったり、
なぜかPythonからScalaへの移行をやったり etc...

そんでもって一年の半分が過ぎたということで、
働き方を大きく変えることにしました。

アイスタイルのCTOを退任させていただき、また社員としてではなく、
ちょっと異なるポジションで技術面で関わらせていただくことになっています。

という近況の報告でした。

今後どこかに社員として転職するのかとかは今時点では特に決まっておりません。

しばらくは好き勝手に何かしようと思っていますが、
何か面白そうな話などがありましたらTwitterやFacebookなどでお気軽に話しかけてください。

最近Twitterの方でも呟きましたが、
最近おもしろいダイレクトリクルーティングが減ってきているのでお待ちしております。

なお干し芋リストはありませんので、呑みにでも誘ってくださいませ!!!!!!

HHVM 4.50リリース!

HHVM 4.50

現地時間3/24に最新のHHVM4.50がリリースされました。
これに伴い4.44のサポートが停止に。
現在サポートされるバージョンはHHVM 4.45–4.49です。
なおLTSは4.8、4.32です。

$ hhvm --version

でHipHop VM 3系が表示される方はもうそのバージョンを使うのは辞めましょう。
かなり別物になっています。

変更点ハイライト

HHHVMのphp.iniに以下のものが追加されました。

hhvm.hack_arr_is_shape_tuple_notices

use function var_dump;

<<__EntryPoint>>
async function mainAsync(): Awaitable<void> {

  $dict = dict['a' => 'c'];
  var_dump($dict is shape());
}

今まではfalseが返却されていましたが、これを実行するとnoticeが発生する様になります。

Notice: Hack Array Compat: dict is shape in path/to/file.hack

もう一つは、Enumについて。
というかこの機能初めてしりました・・・

use function var_dump;

<<__EntryPoint>>
async function mainAsync(): Awaitable<void> {
  $l = new Latest();
  $l->builtinEnum();  
}

class Latest {
  protected vec<arraykey> $vec = vec[];
  public function builtinEnum(): void {
    $this->vec = vec[FOOBAR::BAZ];
    var_dump($this->vec);
  }
}

/* HH_FIXME[2053] */
class FOOBAR extends HH\BuiltinEnum<arraykey> {
  const BAZ = 4;
}

自分でEnumのクラスを作れたんですね・・・。
covariantになった様です。

Breaking Changes

PHPに詳しい方はよりPHPっぽさがなくなるように感じる変更がいくつか。

マジックメソッドの__get, __set, __isset, __unsetがTypecheckerから削除されました。
Typecheckerの特性を考えると当然かもしれません。
これらのマジックメソッドは将来ランタイムからも削除されるそうです。
つまり使えなくなります。

Future Changes

array() リテラルが削除されるそうです。
以前からHack Arrayにないもの(PHP array特有のもの)は削除します、とありましたので、
いよいよ削除されます。

hhvmのpjp.iniに hhvm.hack.lang.disable_array=trueを指定するか、
.hhconfigに disallow_array_literal=true でその挙動に変更できます。

したがって4.50で.hhconfigは以下のモノにしておくと良さそうです。

assume_php = false
enable_experimental_tc_features = no_fallback_in_namespaces
ignored_paths = [ "vendor/.+/tests/.+" ]
safe_array = true
safe_vector_array = true
disallow_assign_by_ref = true
unsafe_rx = false
disallow_array_literal=true

コードを記述してみましょう。

use function var_dump;

<<__EntryPoint>>
async function mainAsync(): Awaitable<void> {
  $l = new Latest();
  $l->disallowPHPArray();  
}

class Latest {

  public function disallowPHPArray(): void {
    var_dump(array(1 => 32));
  }
}

上記のコードを実行すると以下のエラーで実行できなくなります。

Naming[2083] Array literals are no longer legal; use varray or darray instead
   --> index.hack
 15 |     var_dump(array(1 => 32));
    |     

警告に出る様にarrayではなく、darray, varrayなどのHack Arrayにすると回避できます。

    var_dump(darray[1 => 32]);
    var_dump(dict[1 => 32]);

この場合、コレクション的な操作を行ったりするのでdictにするのが一番いいでしょう。

現場からは以上です。

腰椎椎間板ヘルニアで入院とヘルニコア

現在進行形で入院中です。

 

f:id:ytakezawa:20190622163108j:image

 

一昨年の年末にも椎間板ヘルニアで10日間程入院していましたが、

結果、今回は前回ヘルニアになった椎間板と別な椎間板でした。

坐骨神経痛は痛い

2019年4月頃から左のケツの下から  

太腿の横から前、

左のふくらはぎ、足首が痛み出し

前回の坐骨神経痛よりも痛くなる

ヘルニア持ちでない方はわからないかもしれませんが、

筋肉痛のちょっと質が違うやつがずっと続く感じ

 

そんななか

6月頭にデータセンターで物理層と闘いがあった。

(コレはまた今度どこかの機会で)

 

重いものは持っていないのに、

何故か翌日から歩くのも立つのも無理なくらい激痛に。

思えばこの時に救急車呼んでおけばよかった…が、

前回入院時に痛み止め服用しながら安静にしてたなと、

だがしかし一向に痛み引かずに1週間続く。

 

手すり等につかまりながら歩けるようになり、

日常生活を送るのに不自由ないというレベルではないけど少し回復。

 

前回入院時と違って痛みの引きが遅いと思い、

近所の総合病院に行こうと思っていたところ

車に乗ろうにも痛みがひどく、

動けない事から救急車で運ばれる事に。

 

搬送先に近所の総合病院を希望しましたが、

ベッド空きなし、ということで

前回と同じ総合病院へ

そしてそのまま入院となりました。

 

前回と違うヘルニア

入院後直ぐにMRI。

痛過ぎて20分耐えれるかどうか、自分自身との戦い

MRIの中でイテーイテー言ってましたw

 

結果、前回のヘルニアは靱帯を突き破ったもので、

痛いけど痛み止め飲みながら安静(保存療法)にしていると、

マクロファージ(白血球の一つ)がよしなに対応して回復するタイプでした。

 

が、今回は靱帯を突き破らないタイプのヘルニアで、

安静にしててもあまり良くならないタイプでした。

オブジェクト指向的にいうと開放/閉鎖原則に違反したもの

開放/閉鎖原則 - Wikipedia

 

リファクタリング不可!という事で、

手術しかないかも、という結果に

手術以外の選択肢

手術でもよかったんですが、

コレ以外に去年からできるようになった、

ヘルニコアっていう注射をやってみますか?

という話に。

https://www.seikagaku.co.jp/ja/news/news-8922458318854098739/main/0/link/20180731.pdf

 

靱帯を突き破っていないタイプしかできなく、

一度しか投与できないものだそうで、

コレでダメなら手術ですねぇ、というものですが

試しにやってみる事にしました。

 

椎間板への注射は痛い

手術室ではなく、レントゲン室というか、

CTを取りながら背骨の横のあたりから

長い注射針を刺して投与する様でした。

 

うつ伏せになり注射の対象箇所に

マーキングされていました。

この時うつ伏せで足を伸ばす為、坐骨神経痛が痛かった…

 

そして背中に局部麻酔の注射。

噂には聞いていたけど、奥に差し込んでいくほどに

痛くなり 思わずイタタタタと声が

注射等にはかなり強いタイプなんですが、痛かった。

その後ヘルニコアの注射を刺して対象の椎間板の髄核へ、

つまり椎間板の真ん中に到達する様に

CT取りながら徐々に針を入れていくわけですが、

麻酔は皮膚表面くらいにしか効かないため、

背骨付近にまで到達する頃にめちゃくちゃ重い痛み!!!!!!

 

予想していた痛みとベクトルが違う痛みに襲われ、

局部麻酔の注射よりも痛い!

 

ずっとイタタタタタ

全く我慢できない痛み、というわけではないんですが、

重い痛みが背骨、腰付近に発生し、

下半身は坐骨神経痛でひたすら痛い!

 

痛いしかありませんが、

投与後少しして針が抜かれ一安心。

その後ベッドに戻り横になり昼飯を完食。

投与後は安静以外特にないので、

全身麻酔の手術よりは体への負担がありません。

効果は個人差がありますが、

1-2週間後に効果が出始めて数ヶ月後に完治に向かっていくそうなので、

しばらくは通常の痛み止めを飲みながら安静にする予定です。

 

ヘルニコアはどこでもできるの?

実は全国どこでもできるわけではないようです。

ある程度の設備があるとかがあるようで、

救急車で希望した最初の搬送先ではできませんでした。

それどころか比較的近所の総合病院は全部ダメ!

エンジニアでヘルニア持ちは結構多いので、

手術ではなくて注射で済むかもしれない、

それを試してみよう!という方は

事前に調べるか、医師に相談してみましょう!

 

あとこのヘルニコア、結構高額なので

限度額適用認定証を忘れずに。

 

限度額適用認定証をご利用ください | お役立ち情報 | 全国健康保険協会

 

現場からは以上です。