食べチョク開発者ブログ

食べチョクエンジニアによるプロダクト開発ブログ

食べチョクのプロダクトチームとチームトポロジー

食べチョクのプロダクトチームとチームトポロジー

この記事はビビッドガーデン Advent Calendar 2021 最終日の記事です。

皆さんこんにちは、エンジニアの西尾です。

2019年、食べチョクのプロダクトチームは数名だけでした。 2020年から少しずつ、2021年頭からは一気にメンバーが増加し、12月現在は30名近いメンバーが所属しています。

プロダクトチームはもともと大きな1チームでしたが、2021年からチーム分割を検討し、6月頃から本格的に分割を始め、12月現在は7つのチームでプロダクト開発をすすめています。

組織設計、チーム分割にあたり参考にしたのが、チームトポロジーの概念です。 今回はチームトポロジーの一部を食べチョクでの実例を踏まえて紹介しつつ、運用してみての感想や今後の課題について紹介します。

本記事を書くにあたり、日本語版チームトポロジーを大いに参考にしています。

チームファースト

食べチョクのプロダクトチームは、もともと1つだけでした。 徐々にメンバーが増えていき、分割前には10〜15名ほどが所属する、大きな1チームとなっていました。

大きすぎるチームでは以下の問題が発生していました。

  • プロダクトが1つのチームで把握するには大きく、メンバーのプロダクトに対する認知負荷がとても高い状態になっていた
  • プロダクトの端から端まで把握できるメンバーは極めて少ない。そのため仕事がその分野に詳しい個人に張り付く状態となっていた
    • 例えば、定期便の担当はMさん、商品検索はKさん、注文画面はFさん担当といった具合になっていた
  • 仕事が個人に張り付くことにより、計画がメンバー個人に左右される状態になっていた
    • 今週中に最優先の施策、機能のデリバリーができるかどうかは、担当する個人の能力や状況に左右される
  • チームとしてサポートできず、大きなプレッシャーが個人にのしかかる状態であった
    • 今週中に最優先の施策、Aさんどうか体を壊さずに無理して実装してくれ!

プロダクトチームのメンバーは続々と増えている状態でした。 増員したのだから、その分チームとしてのケイパビリティを高め成果を出していくことが求められます。 メンバー数名でうまくいっていたときの開発方法や構成のまま増員しても、チームのパフォーマンスはどんどん落ちていってしまいます。

プロダクトが大きく複雑化したことにともない、個人が気合でプロダクトを開発するフェーズは終わりを迎えました。 組織がスケールするために、より大きな成果をだしていくために、2021年はチームとして成果をだしていかなければならない、すなわち物事をすべてチームファーストで考えるフェーズに変わりました。

チーム分割のアンチパターン

チームとして成果を出していくためには、チームサイズは重要です。 効率よく動くためには、大きな1チームから複数の5〜9名が所属する小さなチームに分割する必要があります。

どう分割したらよいのでしょうか。 職能別に分ければよいのか、顧客別に分ければよいのか、機能ベースでわければよいのか、はたまたミッションベースでチームをわければよいのか。

いろいろなアプローチが浮かび、どれも一長一短だと思います。ただし、アンチパターンは存在します。

チームトポロジーでは、頻繁にメンバーが入れ替わるようなプロジェクトベースのチームは良くないと紹介しています。 機能A開発プロジェクトが立ち上がり、メンバーがプロジェクトごとに集められ、プロジェクトが終了したら解散するようなチームはよくありません。 それはチームに知識が蓄積されず結局は個人に作業が紐付いてしまいます。また、メンバーは頻繁にコンテクストスイッチを求められパフォーマンスが出せない恐れがあります。

場当たり的で無策のままチームを分割するのもよくありません。単に情報が分断されただけの開発のしにくい組織になってしまいます。

変更のフローに最適化した組織

チーム分割のアンチパターンはわかりました。ではどうやって分割すればよいのでしょうか。 チームトポロジーでは、変更のフローに最適化した組織を作るのがよいと紹介しています。

なかなか難しい言葉ですが、要はチーム間の引き継ぎを極力少なくし、自分たちのチームの中でできるだけ仕事が完結するチームにするのが良いとのことです。

エンジニアであれば、ときにはすべて自分ひとりで考えて作って運用したほうが速いのでは、と感じたことはないでしょうか。 なぜ速いと思うのか。それは仮説出し、機能検討、デザイン、設計、開発、テスト、デリバリー、運用保守とそこからのフィードバックがすべて自分だけで完結し、 他のメンバーとの調整が発生しなくて楽だからではないでしょうか。

チームにも同じことが言えます。 企画は企画チームが考えて、開発チームに引き継ぐ。開発チームは機能を作りテストチームに引き継ぐ。テストチームの許可がでたらデプロイし運用チームに引き継ぐ。 運用チームは気になる点がありフィードバックはしたいけど他のチームと調整するのは面倒だな。 このように引き継ぎが多いとプロダクトを素早く改善しチームとしてのパフォーマンスを出すことはできません。 重要なフィードバックや気付きも得にくく、良いプロダクトを作ることはできません。

ストリームアラインドチーム

チームトポロジーでは、モダンなソフトウェアの開発と運用に必要なのは、たった4つのチームタイプであると紹介しています。 ストリームアラインドチーム、イネイブリングチーム、コンプリケイテッド・サブシステムチーム、プラットフォームチームの4つです。

このうち、組織の根幹となるのがストリームアラインドチームです。

ストリームとはなんでしょうか。 チームトポロジーでは、「ビジネスドメインや組織の能力に沿った仕事の継続的な流れ」と呼んでいます。 またまた難しい言葉ですが、例えばプロタクト開発においては、顧客にどんな価値を届けるかを考え、 企画検討からデリバリー、その後のフィードバックをもとにした改善までの一連の仕事の流れのことをストリームと呼びます。

ストリームアラインドチームは、単一のストリームに沿って動くチームです。 例えば、「カート機能を通じて、顧客がほしいと思った複数の商品を送料を押さえつつ購入できる体験を提供する」といった価値ストリームに沿って動くチームです。 そもそも顧客の課題は何なのか、何をしたら改善するのか、どういう設計にしたらよいか、本番運用はどうしたらよいか、 ストリームアラインドチームは自分たちの中で作業が完結する能力を備えている必要があります。

モダンなソフトウェア組織では、ほとんどのチームがストリームアラインドチームとなるとのことです。 食べチョクのプロダクトチームでは、上記の考えを踏まえつつ、2021年12月現在は3つのストリームアラインドチームを作っています。

ストリームアラインドチームを支える3つのチーム

ストリームアラインドチームはすべての根幹となるチームです。 このチームタイプの他に、ストリームアラインドチームを支える以下3つのチームタイプが定義されています。 今回はチームタイプの紹介は割愛します。詳しくはチームトポロジーを参照ください。

食べチョクのプロダクトチームでは、2021年12月現在、以下3つのチームを設置しています。

  • イネイブリングチーム
    • 1つのチーム、5名のメンバーが所属しています。内訳は開発全般を担当するメンバーが2名、フロントエンドエンジニアが2名、QAエンジニアが1名です。
  • プラットフォームチーム
    • 1つのチーム、4名のメンバーが所属しています。
  • コンプリケイテッド・サブシステムチーム
    • 1つのチーム、3名のメンバーが所属しています。

チームインタラクションモード

チームトポロジーでは、チーム構成について述べているだけでなく、チーム間の関わり方、インタラクションについても紹介しています。 インタラクションモードは、コラボレーション、X-as-a-Service、ファシリテーションの3つを定義しています。この概念も詳しくはチームトポロジーを参照ください。

感想と今後の課題

ここまではチームトポロジーの基本的な概念を、プロダクトチームの実例を踏まえつつ紹介しました。 ここからは実際に採用してみて直面した問題や感想、今後の課題について紹介します。

チームインタラクションはまだまだこれから

インタラクションモードに関しては、食べチョクではうまく実践できてはいません。

コラボレーションについては、2021年12月現在は1つのストリームアラインドチームとコンプリケイテッド・サブシステムチームが行っています。 これはまあ2つのチームが密に協力して機能開発に取り組んでいるというだけです。 朝会や日々の開発、レトロスペクティブは2つのチームで合同実施していますが、プランニングはそれぞれのチームで行っています。

ファシリテーションに関してはイネイブリングチームが行ってはいますが、うまく機能しているのかと聞かれるとそんなことはなく、まだまだこれからです。 ファシリテーションという概念になじみがなく、そもそも何をすればよいのか、チーム内でもうまく認識はとれていないのが実情です。

X-as-a-Serviceでのインタラクションは今の所できていません。

X-as-a-Serviceモデルがうまく機能するのは、サービス境界が正しく選択、実装され、サービスを提供するチームが優れたサービスマネジメントを実践している場合に限られる

とありますが、食べチョクではサービスの境界がまだまだ曖昧なために、運用はできていません。

イネイブリングチームは概念がわかりづらく、運用が難しい

イネイブリングチームは設置していますが、まだ名前だけという状態です。運用が難しい理由は2つあります。

  • ファシリテーションがそもそも難しい
  • イネイブリングチームとはどういうチームなのか? チーム内にもチーム外にもうまく理解されていない

ファシリテーションについては前述したとおり、まだまだこれからです。

イネイブリングチームとはそもそも何なのか、組織内で学習ができていないというのもあります。 2021年12月に日本語版チームトポロジーが出るまでは、英語の文献と一部の日本語サイトでの紹介しかされていなかったことも大きいと思っています。 今は日本語で読めるようになったため、12月からはチーム内でのTeam Topologies読み合わせ会をすすめて、少しずつ概念の学習をすすめています。

今はファシリテーション先のチームでは、手が回らない施策や機能実装、テストを変わりに担当する、便利なお助けチームになってしまっていることが多いのが実情です。

ストリームアラインドチームが複数のストリームを持っている

ストリームアラインドチームは、単一の仕事のストリームに沿って動くと定義されていますが、現状は複数のストリームを1つのチームが持ってしまっています。

これはストリームの境界がまだまだ曖昧であること、 すべてのストリーム分のチームが用意できるほどメンバーがいないこと、 やることが多くてフォーカスが絞れていないなどのプロダクトマネジメント上の課題など、さまざまな要因が重なっての結果です。

チームの認知負荷を下げるアーキテクチャになっていない

これからの技術課題です。 個人だけでなくチームの認知負荷に注意し、チームが扱うコードベース、アーキテクチャも扱いやすいよう適切に整理していく必要があります。

食べチョクは、今はまだ大きなモノリシックアプリケーションです。 「会員登録の機能を修正したら、なぜか注文画面が壊れた」といった問題が発生することもありました。 ドメインの境界が曖昧で、今は幅広くコードベースを把握していないと怖くて手を入れられない状態にあります。

チームがデリバリーしやすいように、認知負荷を下げて安全に手を加えられるようにするために、チームに合わせてアーキテクチャを整理していかなければなりません。

おわりに

わたしたちはまだチームトポロジーの概念を参考に、チームを整理しただけという段階です。 道半ば、チームとして組織としてスケールし成果を出していくためには、まだまだ改善の余地があります。

チームトポロジーは銀の弾丸ではないので、導入したから組織がうまくまわるというものではありません。 チームのあり方を変えるだけでなく、健全な組織文化の構築、技術やアーキテクチャの整理、プロダクトロードマップの整理やマネジメントの強化などもあわせて行っていく必要があります。

2022年も、よりよい価値のあるプロダクトを生産者・ユーザーに届けられるよう、そして食べチョクがさらなる成長を遂げられるよう、これからも精進していく次第です。

「あ、面白そう」と思ってもらいたい。カジュアル面談の中身をザクッと公開します

f:id:vividgarden-tech:20211206111602p:plain この記事はビビッドガーデン Advent Calendar 2021 の4日目の記事です。

こんにちは。ビビッドガーデンのプロダクト開発チームで採用と組織開発を担当している平野です。 簡単に自己紹介をすると、「webエンジニア歴8年」「ビビッドガーデンには3年前に入社してここ1年半は採用にコミット」「好きなものはビールとハロプロとパデル」という人です。

ビビッドガーデンではプロダクト開発に関わる人の採用において、候補者の方との相互理解というのを重視しています。 すなわち候補者の方にはビビッドガーデンの良いところと課題となっているところを「そんなとこまで?」というレベルまで知ってもらおうと思っており、その起点となる場がまさにカジュアル面談だと捉えています。

この記事ではプロダクト組織のすべてのカジュアル面談に出席している私がカジュアル面談の弊社における立ち位置実際にどんな話をしているのかという部分を書くことで、応募者の方にとってカジュアル面談がより価値のある時間になる手助けができれば・・・と思っています。

カジュアル面談にかけてる思い

我々としては応募者の方に「ビビッドガーデンで働くとどんな価値が得られるか」を理解してもらうこと、さらに砕けた言い方をすると「あ、面白そう」と思ってもらうことがカジュアル面談のゴールだと定義しています。

応募者の方に時間を割いてもらっているので基本は応募者の方が聞きたい話を丁寧にさせて頂くのですが、「そもそもどんな働き方をしたい方なのか」「どのくらいビビッドガーデンのことを知っているのか」ということ応募者の方にお聞きして、それに合わせて弊社からさまざまな情報をお伝えしています。

しかし今やプロダクト開発に関わるメンバーの採用はどんな職種でも「選考する側」「選考される側」というものはなく「お互いがお互いを選考している」という状況です。 その上選考フェーズにおいては候補者の方とお話できる時間は非常に限られています。

限られた時間の中で弊社で働くことに価値を感じて頂く為に、ついついポジティブな面だけを強調してしまいがちですが、キャリアの方向性などにミスマッチの懸念があればお互いのために必ずそれをお伝えするようにしています。 相手をリスペクトしながら飾ることなくストレートに話を伝えあう弊社の雰囲気を私自身が体現すべきですし、そのためにカジュアル面談は「あくまでカジュアルなので〜」という感じではなく本気で、誠意を持って取り組んでいます。

こう書いてしまうと堅苦しい印象もありますが、お話自体はカジュアルに楽しくさせていただいております。カジュアル面談への応募は本当に、本当にカジュアルにしてください〜!

カジュアル面談の実際の流れ

ごあいさつ

カジュアル面談は主に私と現場メンバーのペア体制でやっており、最初に我々の自己紹介をさせてもらっています。応募者の方には自己紹介は求めていません。

そのままアイスブレイクを兼ねて「お互いのカジュアル面談のゴール」「お聞きしたいこと」を確認させてもらいます。「転職を考えていて、開発体制などの内情を詳しく知りたい」「転職はあまり考えてないが興味はある会社なので話を聞いてみたかった」「実家が農家でビジネスモデルについて知りたかった」などのお答えをいただきます。なんでもござれです。 あわせて「これは選考ではないので我々から何かしら判断をするということはありません」ということをお伝えしています。

弊社について

応募者の方が「お聞きしたいこと」次第で順番は左右しますが、カジュアル面談が終わったタイミングではすべての応募者の方が弊社に関する理解度が一定レベルになるようにお話するよう心がけています。 基本セットとしては以下内容をお伝えしています。

  • 何をしたい会社なのか
    • 会社のミッションとビジョンについて
  • そのために何を作っているのか
    • 食べチョクというプロダクトの詳細説明
  • それをどう作っているのか
    • 開発組織の話(ユニット制、技術スタック)
  • どんな人が関わっているのか
    • メンバーの紹介
  • 今後どうしていきたいのか
    • 今期と中長期に何をしていきたいのか
  • そのためにどのような人を増やしたいのか
    • 職種に応じてお伝えします

ここでは専用のスライド資料(今月中に公開予定です!!)を使って説明しながら、所々止まって「気になったところありますか?」「もっと深く知りたいところはありますか?」「逆にこちらから聞いてもいいですか?」という形で対話形式で話を進めています。事業の話、開発の話、会社の話、なんでもござれです。

また、具体的に理解してもらうということに重点を置いていて、実際のプロダクトの画面、Slack(社内の雰囲気理解)、GitHub(プロダクトチーム以外も使っています)、ユーザーからの反響(Twitterなど)も画面共有して見てもらうようにしています。(機密情報は伏せています)

多くはこの過程で応募者の方の「聞きたかったこと」をカバーするのですが、必ず「気になっていた点はクリアになりましたか?」と確認させてもらっています。

締め

全体を通じて大体45分で終了となります。最後に聞き逃したことが無いかを確認させてもらいます。面談終了後でもいつでも連絡いただければすぐに回答しますともお伝えしています。

興味がある方には今後の選考フローに関する説明をしますが、この場で選考に進むかどうかの意向確認は行っておりません。持ち帰っていただいて一言「選考に進みたい」とご連絡いただければすぐに次の選考へとご案内しています。

しかしこの場で選考に進む意思を口頭で伝えていただく方、面談終盤に「今チャット送りました」と言ってくれる方、面談終了1分後にチャットを送ってくれる方も多いです。素直に大変うれしい気持ちになります。

お時間頂いたことに感謝して終了となります。

よく聞かれること

ビジネスモデルが農協とバッティングしてそうだけど、大丈夫なの?

提供しているサービスやターゲットには違いがあるものの、生産者の方への価値貢献という側面では同じ方向を向いている同志というお話をしています。

代表秋元のブログに詳しく記載があるので、気になる方は是非そちらも読んでみてください。一部引用します。

・提供しているサービス価値は異なり、すみ分けができていると認識しています。結果として農協さんに呼んでいただいて講演させていただくことも多々あります。

・皆さんが「一次産業を良くしていきたい」という共通の想いを持っています。 note.com

サービスの成長と共にプロダクト組織はどう変わりましたか?

1年半前まではwebエンジニア3-4人という体制でやっていましたが、今となっては以下の各職種のメンバーが在籍しており40名に迫る組織となっています。

  • webエンジニア
  • モバイルエンジニア
  • インフラエンジニア
  • QAエンジニア
  • データアナリスト
  • UIデザイナー
  • PdM

組織体制という観点では1つのチームがすべての開発を受け持っていた形から、プロダクトに対して定められたミッションを複数のユニットがそれぞれ自律して取り組むという体制に。

開発方針という観点では「作る、リリースする」ということが目的になりつつあった状態から、どのくらい価値提供ができたかという軸での分析を元に仮説検証を重ねて探求と学習を進める体制へと変わりました。

この部分は1年前から角谷さんに手伝ってもらっており、一緒に議論し続けて今の形になっています。現在も継続議論中です。 offers.jp

体制に関してはゴールは無いので、今後も常にアップデートし続ける、変化を歓迎するという姿勢を取っていきます。

エンジニアの評価制度はありますか?

昨年末に制定したものがあります。 簡単に説明するとにグレード毎に期待成果と年収が記載されており、メンバー各々がそれに沿った形で目標設定をしています。 半期ごとに評価が行われますが評価者との中間評価と毎月の1on1、私や各チームのリードメンバーとの毎週の1on1で軌道修正を図っています。

しかしこの評価制度はまだまだベータ版だと思っており、エンジニアが出す価値をちゃんと評価できているだろうかという観点ではまだまだ改善の余地があると思っています。 今月に評価制度のアップデートがありましたが、引き続きアップデートを重ねていく予定です。

どんな人がいますか?

プロダクト組織は全員中途入社で、出身企業はベンチャーからSIなどひとくくりにはできないくらい多様です。 しかしマインドという観点では皆下記2つの考えを持っています。

  • 自分の手でプロダクトを作り、それが誰かの課題を解決し、その様子が手にとるように分かる環境で働きたい
  • より良いプロダクトを作るために、慣れた体制や技術に固執せず、変化やアップデートすることを歓迎する

このマインドに加え「生産者の方への価値貢献」が全メンバー自分事化している点がチーム全体が一体感を持って前に進める秘訣だと感じています。

「実家が農家でした」などの農業バックグラウンドがあるメンバーは多くないのですが、 それでも生産者の方に関わるという事が自分事化するのは日常的に生産者さんの声やフィードバックが聞こえるプロダクトという特性のおかげだと思っています。

おわりに

ご覧いただきありがとうございました。 ビビッドガーデンは11月をもって6期目に突入しました。

1年前はエンジニア数人とデザイナーでがむしゃらに作っていた感じでしたが、1年で複数の少人数チームが自律的にデリバリーと仮説検証を進める体制へと大きく進化しました。 6期目はさらなる価値提供のために「より深くより広い領域で仮説検証をする」、「デリバリーを安定的に行う開発基盤を強固にする」という2点を実現させたいと思っています。

会社としても立ち上げ期から拡大期に一気に変わってきた状態です。ドラスティックに環境が変わり続ける現場にてより多くのメンバーと一緒にお仕事を楽しみたいと思っています。

カジュアル面談は以下媒体より申し込みが可能です。

Meetyでカジュアル面談

選考フローも必ず最初にカジュアル面談をはさみますのでこちらでエントリー頂いても大丈夫です。エントリーの時点では履歴書等必須ではありません。 herp.careers

それでは、カジュアル面談にてお会いしましょう!

監査ログをファイルに記録するためのGemを公開しました

こんにちは。
食べチョクの開発を副業でお手伝いしているプログラマーの花村です。

監査ログをJSONL(JSON Lines)のファイルに記録するためのGemのAuditLoggableを作成してrubygems.orgで公開しました。
ソースコードもGitHubで公開しています。 ​ rubygems.org github.com

なぜ開発したのか

食べチョクでは監査ログを記録するためにAuditedを利用していました。
AuditedはActiveRecordのコールバックを利用してモデルの変更を手軽にRDBに記録してくれる大変便利なGemです。

しかしRDBに記録するためテーブルサイズが肥大化しパフォーマンスに影響を与える場合があるというデメリットもあります。
食べチョクでは注文数の増加に伴ってこの問題に直面しました。

これを解決するにあたり以下のようなアプローチが思いつきます。

  1. 監査ログを定期的に消す
  2. 監査ログを記録するDBを分ける
  3. 監査ログをRDBとは別のストレージに記録する

1では過去分のデータが必要になった際に困りますし、2では延命措置にしかなりません。
ということで今回は3を採用しました。

記録したログファイルの扱い

AuditLoggableで記録したログファイルはCloudWatch Agentを利用してCloudWatch Logsに連携しています。
CloudWatch Logsには1か月分ほど保持する設定にしており、毎日CloudWatch LogsからS3へバックアップしています。
このときにCloudWatch LogsのExportTask機能を使ってエクスポートするとJSONのメッセージに余分な情報(CloudWatch Logsに取り込んだ日時の情報)がついてしまうので、S3のイベント通知を使いLambdaで整形処理を走らせています。

CloudWatch Logsに保持し続けずにS3に移しているのはコスト圧縮のためです。 1カ月より過去分を参照したいときはAthenaで検索できるようにしてあります。

なぜログファイルに記録するのか

監査ログの記録先としてRDB以外を使うのであればDynamoDBなどのNoSQLなDBに記録することも考えられます。
しかし以下の理由で記録先はファイルとしました。

  1. 記録の失敗しにくさ
  2. リアルタイムに参照できる必要はない​

Web API経由での記録では失敗することも多いのでそのためのハンドリングが煩雑になることが予想されます。
また監査ログを記録された瞬間から参照したいことはないので収集にラグがあっても構わないという判断がありました。

使い方

config/initializers/audit_loggable.rb など初期化ファイルで出力先のファイル名などを設定します。

AuditLoggable.configure do |config|
  if Rails.env.test?
    config.auditing_enabled = false
  end
  config.audit_log_path = Rails.root.join("log", "audits.log")
end

ApplicationRecord など必要なモデルで AuditLoggable::Extension でActiveRecodのモデルに機能を追加します。

class ApplicationRecord < ActiveRecord::Base
  extend AuditLoggable::Extension
end

監査ログを記録したいモデルで log_audit を呼び出して記録対象に設定します。

class Post < ApplicationRecord
  log_audit
end

ApplicatoinController などで以下のように情報集する機能を有効化することで「操作したユーザーID(とクラス名)」、「リモートIPアドレス」、「リクエストID」を監査ログに記録できるようになります。

class ApplicationController < ActionController::Base
  around_action AuditLoggable::Sweeper.new(current_user_methods: %i[current_user])
end

Auditedとの違い

Auditedと似た挙動をしますが主に以下の違いがあります。

  • 記録先はJSONLのログファイル
    • RDBではなくJSONLのログファイルに記録します
  • ActiveRecordのモデルへのメソッド追加は最小限
    • Auditedでは audit_xxx のようなインスタンスメソッドが複数追加されますが、AuditLoggableでは1組のaccessorの追加のみです
    • またクラスメソッドは機能の有効化用メソッド1つを追加するのみです
  • Associated Auditsは未サポート
    • 食べチョクでは不要であったことと複雑になってしまうので未サポートとしました
  • 条件付き記録は未サポート
    • 食べチョクでは不要であったことと複雑になってしまうので未サポートとしました

おわりに

似たような課題を抱えている方の解決の一助になれば幸いです。 もしバグを見つけたり機能を追加したいなどありましたらGitHubでPull RequestやIssueをお待ちしています。

github.com

map,filter,reduce関数内で状態を書き換えてはいけないのは、なぜですか

皆さんこんにちは、エンジニアの西尾です。

あなたは今、コードレビューをしています。

以下コードに直面したとき、何を指摘しますか。 修正してほしい点を、どのようにレビュイーに伝えますか。

// これはJavaScriptのコードです。
// 商品の在庫を1つ減らし、売り切れになったものを抽出したい、と思っています。

const soldOutProducts = products.filter(product => {
  product.quantity -= 1;

  return product.quantity <= 0;
});

よくないコードレビューの例

問題は表題の通り、filterの中で状態を書き換えているのが、よくありません。

関数型言語を学んだことがある方なら、このコードの違和感に気がつきます。 filterは純粋関数であるべきだ、副作用を起こしてはいけない。そう認識しているからです。

しかし、それをコードレビューで指摘したとして、相手に伝わるでしょうか。 書いている言語は関数型ではないし、副作用とか言われても意味がわからないし、複雑なコードでもないし、動くからいいんじゃないの。

map,filter,reduce内で状態を書き換えるコードは、思いの外よく見かけます。 そのたびに、私は以下のような指摘をしてしまっていました。

f:id:vividgarden-tech:20210123215718p:plain

f:id:vividgarden-tech:20210123215721p:plain

f:id:vividgarden-tech:20210123215725p:plain

問題は何なのか?

filter内で状態を書き換えてはいけません、と伝えるだけでは、レビューを受けた側も意味がわかりません。 このコードが良くない理由を説明しないと、相手には伝わりません。

なぜ状態を書き換えてはいけないのか。 言われてみると、私自身もすんなり理由を説明できませんでした。

そこで、自分なりにこのコードの問題点を、今一度考えてみました。

複数の目的をもったコードは、わかりづらい

UNIXの基本的な考えに、「ひとつのプログラムには、ひとつのことをうまくやらせる」というものがあります。 目的を最小限に抑えた小さなプログラムは、誰にとってもわかりやすく、保守も容易です。

プログラムに限らず、この考えは1つの関数・1つのブロック・1行のコードにも当てはまります。 複数の目的を持った処理は理解しづらいものです。

今回の例では、「在庫を減らす」操作と「売り切れの商品を抽出する」操作が同時に行われているため、 複雑なコードに見えます。

本来の目的と違う使い方をしているから、わかりづらい

filter関数は、与えられた条件に当てはまるデータを抽出(フィルタリング)するために利用します1。 「フィルターをする」処理をお願いしたのに、在庫を減らす処理も同時にされてしまうのは、驚きがあるロジックです。

mapやreduceも同様で、「渡されたリストを別のものにマッピングする」「渡されたリストを畳み込む」以外の挙動をさせるのは、理解しづらいコードです。

広い範囲に影響を及ぼすロジックは、わかりづらい

呼び出すたびに中身が書き変えられるロジックは、わかりにくいです。

例えば、次のようなメソッド呼び出しで中身も書き換えられてしまったら、直感に反する理解しづらいコードではないでしょうか。

console.log(product); // { name: "商品1", quantity: 3 }

const quantity = getQuantity(product);

console.log(product); // { name: "商品1", quantity: 2 } !? まさか中身が書き換わっているとは
function getQuantity(product) {
  product.quantity -= 1; // 渡された引数の中身を破壊している

  return product.quantity;
}

これは極端な例ですが、今回の例にあるfilterの使い方も、同じようなわかりづらさがあります。

じゃあ、どうすればいいの?

どのように修正すればよいでしょうか。 コード断片だけを見て、適切なアドバイスをするのは難しいです。 在庫を減らすという処理は、別のところで、あらかじめしておくべきでしょうか。設計から見直す必要があるかもしれません。

それでも直すとしたら、以下のようになるでしょうか。

products.forEach(product => {
  // どうしても値を書き換えたかったら、eachを利用する
  product.quantity -= 1;
});

const soldOutProducts = products.filter(product => product.quantity <= 0);

値を書き換える必要があるのなら、forやforEachを使います。 もちろん、書き換えずにすむなら、それに越したことはありません。

おわりに

map,filter,reduce関数内で値を書き換えてしまう違和感を、言語化してみました。 これらの関数をfor(each)と同じ感覚で使ってしまう方が、案外多いように思えます。

今回紹介したメソッドは、あくまで一例です。 例えば、RubyのEnumerableにあるようなメソッドで値を書き換えるのは、避けるべき実装です。

この問題は、コードレビューの時点で、私自身もすんなり理由を回答できませんでした。 あたりまえだと思っていることを見直すことで、自分自身の理解もより一層深めることができました。

www.wantedly.com

www.wantedly.com


  1. 余談ですが、この記事を書くためにリーダブルコードを読み返していました。第3章に、filterという名前は「選択する」のか「除外する」のかあいまいだから避けるべき、との指摘をみつけて、確かに!と納得しました。ただ、JavaScriptにselectはないので、しょうがない。

会社の支給PCがMacBook Pro M1なので、新しく開発環境を構築した話

こんにちは。

今年の年始からジョインした遠藤です。

さて、入社したところ会社支給のMacBook ProがM1チップのものでした。

はい、現状は開発環境で苦労するとか色々噂を聞くやつです。

実際に試したのですが、

  • 現状の開発環境構築スクリプト、手順書が一切使えない
  • VitualBox, Vagrantは利用不可
  • Dockerは利用可能ではあるが、一部イメージが対応されてない
  • 古いパッケージは動かす手段がない

などなど、通常ではぶつからない問題にぶつかります。

食べチョクでは、

  • Ruby
  • Node.js
  • MySQL
  • Redis
  • ElasticSearch
  • Kibana

を利用しています。

この辺りをメインに話つつ、Intel版とこんな風に違うのかっていう辺りの雰囲気を感じ取っていただければと思います。

どこに開発環境を構築するか

まず、どこで開発環境を構築するかを考えてみたいと思います。

  • ローカルで開発環境を作る
  • 仮想化ソフトウェア上で作る
    • VirtualBox🙅‍♂️
    • Docker
  • サーバー立ててその中に開発環境を作成して、リモートで作業する

この3種類になると思いました。

支給されたPCだとローカルに開発環境を構築される方が多いのかなって印象が多いのですが、どうなのでしょうか。

私は支給されたPCだとローカルに開発環境を構築したい派なので、ローカルでの開発環境構築をひとまず目標にしました。

今回は「ローカルで開発環境を作る」「仮想化ソフトウェア上で作る」で試しました。

では、開発環境の構築をしていきます。

ローカルで開発環境を作る

ここから実際に行ったことを記述していきたいと思います。

homebrew

みんな大好きのhomebrewですが、きちんと公式のドキュメントを読みましょう。

インストールする場所がm1 macの場合は別になっています。

mkdir /opt/homebrew && curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C homebrew
echo "export PATH=/opt/homebrew/bin:$PATH" >> ~/.zshrc
source .zshrc

However do yourself a favour and install to /usr/local on macOS Intel, /opt/homebrew on macOS ARM, and /home/linuxbrew/.linuxbrew on Linux. Some things may not build when installed elsewhere. One of the reasons Homebrew just works relative to the competition is because we recommend installing here. Pick another prefix at your peril!

Installation — Homebrew Documentation

Intel版ならディレクトリを明示するとかなかったので、いつもと違うなっていうことを、最初から感じさせてもらえます。

rbenv

Rubyのバージョン管理ツールのrbenvです。

こちらがないとRubyのバージョン管理で困るので、導入しましょう。

GitHub - rbenv/rbenv: Groom your app’s Ruby environment

brew install rbenv
rbenv init
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/master/bin/rbenv-doctor | bash

ここまでは問題なくいきます。

さて、待望のインストールをします!?

rbenv install 2.5.7
Downloading openssl-1.1.1i.tar.gz...
-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242
Installing openssl-1.1.1i...
Installed openssl-1.1.1i to /Users/xxx/.rbenv/versions/2.5.7

Downloading ruby-2.5.7.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.5/ruby-2.5.7.tar.bz2
Installing ruby-2.5.7...

WARNING: ruby-2.5.7 is nearing its end of life.
It only receives critical security updates, no bug fixes.

ruby-build: using readline from homebrew

BUILD FAILED (macOS 11.0.1 using ruby-build 20201225)

Inspect or clean up the working tree at /var/folders/np/m8mjm0q52njgyqc6kp0b_zw80000gn/T/ruby-build.20210110220451.39283.BzZl54
Results logged to /var/folders/np/m8mjm0q52njgyqc6kp0b_zw80000gn/T/ruby-build.20210110220451.39283.log

Last 10 log lines:
compiling ../.././ext/psych/yaml/reader.c
3 warnings generated.
compiling ../.././ext/psych/yaml/emitter.c
compiling ../.././ext/psych/yaml/parser.c
linking shared-object date_core.bundle
5 warnings generated.
linking shared-object zlib.bundle
1 warning generated.
linking shared-object psych.bundle
make: *** [build-ext] Error 2

ログファイルを確認します。

compiling qsort.c
linking shared-object -test-/vm/at_exit.bundle
linking shared-object -test-/wait_for_single_fd.bundle
compiling closure.c
compiling nofree.c
compiling conversions.c
compiling zlib.c
installing default libraries
compiling fiddle.c
compiling psych_to_ruby.c
closure.c:264:14: error: implicit declaration of function 'ffi_prep_closure' is invalid in C99 [-Werror,-Wimplicit-function-declaration]
    result = ffi_prep_closure(pcl, cif, callback, (void *)self);
             ^
1 error generated.
make[2]: *** [closure.o] Error 1
make[2]: *** Waiting for unfinished jobs....

なんか今までにあまり起こったことのないエラーに遭遇します。

ffiでエラーが起こっているのですが、libffiを除外すればインストールできます。

Building Ruby on arm64 macOS

この問題が2020/6/30に解決されていることに感動を受けつつ、除外しましょう。

RUBY_CFLAGS=-DUSE_FFI_CLOSURE_ALLOC rbenv install 2.5.7

コマンド自体はissueを参考にさせていただきました。(先人に感謝)

2.6.6 on ARM64 · Issue #1699 · rbenv/ruby-build · GitHub

Ruby3.0の場合はなんの問題もなくインストールできます。

MySQL

brew install mysql@5.7
echo 'export PATH="/opt/homebrew/opt/mysql@5.7/bin:$PATH"' >> ~/.zshrc
brew services start mysql@5.7

正直ここは何も問題がないので、休憩ゾーンになります。

Redis

brew install redis
brew services start redis

これも特に問題はありません。

Node.js

Node.jsは今のところversion 15以上じゃないと対応されてない状態なので、ここは素直にversion 15を使いました。

webpackのコンパイルでしか利用しないので、まぁ、いいかという気持ちもあり。。。

Nodejs 14.x doesn't compile on ARM OSX (M1) · Issue #36161 · nodejs/node · GitHub

弊社ではnodebrewを利用していたので、下記を参考にさせていただきました。

brew install nodebrew
echo "export PATH=$HOME/.nodebrew/current/bin:$PATH" >> ~/.zshrc
vim $(which nodebrew)

sub system_info {
    my $arch;
    my ($sysname, $machine) = (POSIX::uname)[0, 4];

    if  ($machine =~ m/x86_64|arm64/) {
        $arch = 'arm64';
    } elsif ($machine =~ m/i\d86/) {

...

nodebrew compile v15.5.0

M1 Mac を購入して arm64 縛りでインストールしたもの (更新中) - アルパカ三銃士

ElasticSearchのインストール

弊社では検索エンジンにElasticSearchを利用しているので、ElasticSearchをインストールします。

brew install --build-from-source elasticsearch
...

==> Installing dependencies for elasticsearch: openjdk and gradle
==> Installing elasticsearch dependency: openjdk
==> Patching
==> Applying f80a6066e45c3d53a61715abfe71abc3b2e162a1.patch
patching file src/hotspot/share/runtime/sharedRuntime.cpp
Hunk #1 succeeded at 2850 (offset -6 lines).
==> Applying 4622a18a72c30c4fc72c166bee7de42903e1d036.patch
patching file src/java.desktop/macosx/native/libawt_lwawt/awt/CSystemColors.m
==> ./configure --without-version-pre --without-version-opt --with-version-build=9 --with-toolchain-path=/usr/bin --with-sysroot=/Library/Developer/Com
Last 15 lines from /Users/endo/Library/Logs/Homebrew/openjdk/01.configure:
checking for stdlib.h... yes
checking for string.h... yes
checking for memory.h... yes
checking for strings.h... yes
checking for inttypes.h... yes
checking for stdint.h... yes
checking for unistd.h... yes
checking stdio.h usability... yes
checking stdio.h presence... yes
checking for stdio.h... yes
checking size of int *... 8
configure: The tested number of bits in the target (64) differs from the number of bits expected to be found in the target (32)
configure: error: Cannot continue.
/private/tmp/openjdk-20210110-26180-4xtn4y/jdk15u-jdk-15.0.1-ga/build/.configure-support/generated-configure.sh: line 82: 5: Bad file descriptor
configure exiting with result code 1

Do not report this issue to Homebrew/brew or Homebrew/core!

These open issues may also help:
Cassandra 3.11.9_1 crashes in openjdk@8 JVM https://github.com/Homebrew/homebrew-core/issues/66462
openjdk: Add support for Apple silicon https://github.com/Homebrew/homebrew-core/pull/65670
OpenJDK is somewhat broken on newer MacOS instances, console is flooded with errors when using JMeter, AdoptOpenJDK has no issues https://github.com/Homebrew/homebrew-core/issues/66953

案の定エラーになります。

原因はOpenJDKがサポートされてないので、利用できません。

https://github.com/Homebrew/homebrew-core/pull/65670

このPR早くマージしてくれ!という気持ちもありつつ、OpenJDKはZuluがM1対応しているので、そちらを利用します。

Support of Java Builds of OpenJDK for Apple Silicon | Azul

Java Download | Java 8, Java 11, Java 13 - Linux, Windows & macOS

※記事執筆中にマージされていましたので、この問題は解決していましたが、https://github.com/Homebrew/homebrew-core/pull/69029で別の問題が発生しています

ElasticSearchはソースコードからビルドするように変更します。

wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.10.1-darwin-x86_64.tar.gz
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.10.1-darwin-x86_64.tar.gz.sha512
shasum -a 512 -c elasticsearch-7.10.1-darwin-x86_64.tar.gz.sha512 
tar -xzf elasticsearch-7.10.1-darwin-x86_64.tar.gz
cd elasticsearch-7.10.1/ 

Javaの参照先をZuluに追加します。

Set up Elasticsearch | Elasticsearch Reference [7.10] | Elastic

echo "export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-15.jdk/Contents/Home" >> ~/.zshrc

これでbin/elastic searchで動くのですが、xpackがサポートされないので、コンフィグから外しましょう。

org.elasticsearch.ElasticsearchException: X-Pack is not supported and Machine Learning is not available for [darwin-aarch64]; you can use the other X-Pack features (unsupported) by setting xpack.ml.enabled: false in elasticsearch.yml

Kibana

ElasticSearchを入れるので、Kibanaも入れましょう。

homebrew経由でインストールしようとすると、案の定エラーになるのでソースコードからビルドします。

curl -O https://artifacts.elastic.co/downloads/kibana/kibana-7.10.1-darwin-x86_64.tar.gz
curl https://artifacts.elastic.co/downloads/kibana/kibana-7.10.1-darwin-x86_64.tar.gz.sha512 | shasum -a 512 -c - 
tar -xzf kibana-7.10.1-darwin-x86_64.tar.gz
cd kibana-7.10.1-darwin-x86_64/

bin/kibana

ここまで揃うと、なんとかbundle exec rails serverまでは辿りつけると思います。

ここまでがローカル開発環境編です。

仮想化ソフトウェア上で作る

現状はVirtual Boxが対応していないので、Docker Previewが頼みの綱になります。

Docker Previewはrosettaが現状は必要になるので、rosettaをインストールします。

softwareupdate --install-rosetta

Docker Previewのダウンロードは下記にリンクがあるので、そちらかダウンロードします。

Download and Try the Tech Preview of Docker Desktop for M1 - Docker Blog

ここではうまくいきづらいものだけ紹介します。

docker-composeを利用することを前提にしていきます。

MySQL

設定ファイルです。

services:

  mysql:
    image: mysql:5.7
docker-compose up
Pulling mysql (mysql:5.7)...
5.7: Pulling from library/mysql
ERROR: no matching manifest for linux/arm64/v8 in the manifest list entries

ということで、イメージとして対応していないです。

no matching manifest for linux/arm64/v8 in the manifest list entries · Issue #5142 · docker/for-mac · GitHub

が、issueを見るとMariaDBを使うか、特定のIntelのやつを使えばいけるということで、変更します。

services:

  mysql:
    image: mysql:5.7@sha256:b3b2703de646600b008cbb2de36b70b21e51e7e93a7fca450d2b08151658b2dd

Kibana

arm64のイメージがないので、利用できませんでした。

docker-compose up
WARNING: Found orphan containers (tabechoku_elasticsearch, tabechoku_redis, tabechoku_mysql) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up.
Pulling kibana (kibana:7.10.1)...
7.10.1: Pulling from library/kibana
ERROR: no matching manifest for linux/arm64/v8 in the manifest list entries

Docker Hub

ElasticSearch/Redisに関しては問題なかったので、割愛させていただきます。

結論

正直なんとか開発状態までは漕ぎ着けれるのですが、どっちかというと現場で作業を行う場合に最新のバージョンになっていないことが多いので、そこをどうするかというのが、ポイントになるかと思います。

明らかに対応外なら、割り切って最新のを使うようにするしかないです。

Dockerの場合は、利用できない可能性もあるのでそこも割り切るしかないのかなと思います。

この辺りで本番環境と乖離が起こる可能性が高いので、どこまでを許容とするか、あるいは本番環境を合わせるかになるのかなと思います。

また、今回は先人の知恵があり、なんとかなりました。

  • 解決方法を書いてくれている方
  • M1に対応しようとOSSで対応している方

など、実際に対応されている方には、感謝しかありません。

よく言われていることですが、開発環境、ライブラリは常日頃から最新化しておかないと苦労する を痛感します。

こういった問題に解決するためにも今はチームとして頑張っています。

※この記事を書いている間にもRuby 2.6.6にアップデートし、今はRuby 2.7系にアップデートに取り組んでいます。

弊社ではM1 Macbook Proで開発をするエンジニアを募集しております。

www.wantedly.com

Railsバリデーションメッセージのリソース名を特定するパッチの話

こんにちは。食べチョクの開発をお手伝いしているフリーランスエンジニアのもぎゃ です。
ぼくも記事書いていいそうなので、最近食べチョクの開発をやっている時に発見したRailsテクニックの話を書きます。

バリデーションメッセージのつらみ

Railsでバリデーションをつかって入力をチェックしている場合、@model.errors.full_message をエラーメッセージとして出すことができます。

Active Record バリデーション - Railsガイド

ただ、ぼくらが作ったモデルの日本語名までRailsが知っていてくれるわけではないので、"User Messageを入力してください"みたいな分かりづらい文字列になってしまうことがあって、この場合i18nのリソースを適切に書いてあげることでエラーメッセージを改善することが出来ます。

Rails 国際化 (i18n) API - Railsガイド

ということで、エラーメッセージのカスタマイズについては公式ドキュメントに書いていただいているのですが、現場のRailsのモデルは"User"みたいにシンプルなものではなくて、"Users/address"みたいにnamespaceがついていたり、modelではなくformオブジェクトのバリデーションだったり、accepts_nested_attributes_forを使っていたりすると、「え、これどこに名前を書けばいいの?」となることもしばしば。

この場合、エラーメッセージは「リソースが適切な位置にあれば使われる」というヒントしかないので、うまく行かない場合、「これか?」「ここなのか?」「これもちがうの?」という"当て物ゲーム"を繰り返すしかないのが辛いところです。
「もう良いからif文書いて自前のエラーメッセージにしようぜ」みたいなごまかした実装をするたびに、ControllerがFat化していく...

そもそも、「リソースが適切な位置にあれば使われる」というルールである以上、i18nのライブラリは"正解"のリソースを読みに行っているはずです。そのリソース名を教えてくれたら一発じゃない!?

リソース名を教えてくれるモンキーパッチ

細かい実装はさておき、最終的にはi18n.tを呼び出しているはずです。i18n.tのエイリアス元であるtranslateメソッドの実装はこちら。

  def translate(key = nil, *, throw: false, raise: false, locale: nil, **options) 

i18n/i18n.rb at master · ruby-i18n/i18n · GitHub

この最初のkeyが目的のリソース名です。じゃあ最初からこれを教えてよ。ということでパッチを書きました。

ファイル名は何でも良いので、config/initializerの下におきます。

  module I18nMonkeyPatch
    def translate(key = nil, *, throw: false, raise: false, locale: nil, **options)
      Rails.logger.warn "***************************************************"
      Rails.logger.warn "i18nMonkeyPatch key: #{key}"
      Rails.logger.warn "***************************************************"
      super
    end
  end

  I18n.singleton_class.prepend(I18nMonkeyPatch)

そうすると、i18nの変換が行われるたびにログにリソース名が出力されるので、該当箇所を実行してみればこんな感じでリソース名が分かるようになります

 ***************************************************
 i18nMonkeyPatch key: activerecord.attributes.order_inquiry_thread/lines.message
 ***************************************************

ということで、こういうリソースファイルを作ればよいようです

  ja:
    activerecord:
      attributes:
        'order_inquiry_thread/lines':
          message: "メッセージ"

答えを知ってしまえば、"i18n モデル スラッシュ"とかでググってそれらしい記述を見つけ出せるのですが、そもそもなぜ駄目なのかがわからないと検索キーワードすらわからないので、一発で答えを得られるパッチはそれなりに使い所があるのではないかなと思います。

最後に

食べチョクでは、開発者の方を募集しております。
ぼくみたいにフリーランスの立場から開発に参加してissueをガシガシ消化していけるエンジニアも必要だし、急成長する会社の屋台骨として開発を推進できる社員さんはもっともっと必要そうです。

この記事を見て、「おおそんなやり方が」と思った方、ぜひ一緒にやりましょう!
「その程度かよ」と思った人、野菜の新しい流通経路を作るためにぜひその腕前を貸してください!

www.wantedly.com
www.wantedly.com

データベース設計の際に気をつけていること

皆さんこんにちは、エンジニアの西尾です。

新しい機能・サービスを開発する際、私は特にデータベース設計に気をつかいます。

データベースはシステムの土台です。 土台が不安定だと、その上に積み上げていくアプリケーションコードがいびつなものになり、つらい思いをします。 また、一度動き出してしまったシステムのデータベース設計を変えるのは、容易なことではありません。

データベース設計には”これだ!”という正解はないと思っています。 サービスの特徴、システムの性質、toB向け/toC向け、Readが多い・少ない、Writeが多い・少ない。 その他もろもろの背景により、データベース設計の仕方も変わってきます。

このテーブルは正規化していないから駄目だ、この設計はいわゆるポリモーフィック関連だから使ってはいけない、などということはありません。 アンチパターンと呼ばれるものも時と場合によっては正解になります。

今回は、食べチョクのデータベースを設計する際に気をつけていることを共有いたします。 なお、食べチョクではRDBとしてMySQL5.7を利用しています。

1. 制約をつける

データベース設計において重要なのは、いかにして不整合を起こさないようにするかです。

「データを引いてみたら関連先のレコードが無くなっている」、「このレコードはユーザーごとに1つだけ持つはずだけど、2レコードある」など。 不整合は往々にして発生します。

データを挿入・更新・削除してもよいかのチェックはアプリケーションレベルで防ぐだけではなく、可能ならばデータベースレベルで行います。 そのために、以下制約をつける努力をします。

1.1 外部キー制約をつける

外部キー制約は、可能な限りつけるようにしています。 DBが別れている場合、外部キーはもちろん貼れないのですが、そうでない場合はとにかく何も考えず貼っています。

テスト時のテストデータが入れにくいから貼りたくない、とかいってる場合じゃないです。本番環境で不整合が起こる方が怖いですよね。 テストデータ入れるだけなら、 SET FOREIGN_KEY_CHECKS=0; とかでレコードいれればよいだけですし(本番環境ではやらないでください)。

1.2 ユニークキー制約をつける

ユニークキーも可能な限りつけるようにしています。

例えば以下のような注文(orders)テーブルと、支払い(payments)テーブルがあるとして、 注文に対する支払いは1つしか存在しないことがわかっている場合。

CREATE TABLE `orders` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  CONSTRAINT `orders_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB ROW_FORMAT=dynamic DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `payments` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_id` int(11) NOT NULL,
  `transaction_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  CONSTRAINT `payments_order_id_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`)
) ENGINE=InnoDB ROW_FORMAT=dynamic DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

このときは、paymentsテーブルのorder_idにユニークキー制約をつけます。

CREATE UNIQUE INDEX `index_payments_on_order_id` ON `payments`(`order_id`);

そうすれば、注文に対する有効な支払いがなぜか2件できてしまっていた、というバグを未然に防ぐことができます。

別のパターンも見てみます。 例えば、paymentsテーブルに以下のようにactiveというカラムを足します。 有効な支払いは1つ(active=1の場合)だけだが、支払いを変更したなどで履歴情報として過去の支払いをactive=0として持っておきたい場合。

CREATE TABLE `payments` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `active` tinyint(1) NOT NULL DEFAULT '1',
  `order_id` int(11) NOT NULL,
  `transaction_code` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `payments_order_id_fk` (`order_id`),
  CONSTRAINT `payments_order_id_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;

この場合、(active, order_id)にユニークキー制約を貼るわけにはいきません。 支払いを2回変更したら、active=0が2レコードできてしまうのですが、そういうレコードは入れられません。

この場合は以下2つのうち、どちらかで修正します。

そもそもactiveとかいうカラムを持たず、別途payment_historiesなどのテーブルを作り、そちらにレコードを移す

履歴として取っておく必要があるのなら、元のテーブルにレコードを残しておくのではなく、別のテーブルに移動させます。 こちらの設計の方がスマートです。元のユニークキー制約を残したまま、レコードの行数も抑えられるし、paymentsを引くときの余計な条件式も減らせます。

activeをNULL許容にして、過去データは active=NULL としてデータを入れる

NULLは必ずユニーク扱いになります。そのため、activeカラムをnull許可することで、(active, order_id)にユニークキーを貼っていても、 (MySQLでは)以下のようにデータを入れることが可能です。

INSERT INTO payments(active, order_id, transaction_code, created_at) VALUES (1, 1, "AAA1", "2020-06-15 00:00:00");
INSERT INTO payments(active, order_id, transaction_code, created_at) VALUES (NULL, 1, "AAA2", "2020-06-15 00:00:00");
INSERT INTO payments(active, order_id, transaction_code, created_at) VALUES (NULL, 1, "AAA3", "2020-06-15 00:00:00");

この設計は賛否両論あると思いますが、これはNULLの正しい使い方ではないかと思っています。

別テーブルに分けるか、NULL許容して入れるか、実装コストを踏まえて選択しています。

1.3 NOT NULL制約をつける

なるべくNULLが入らないようにテーブルを設計しています。 例えば以下のようなテーブルがあったとします。

CREATE TABLE `something` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `note` varchar(255),
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
) ENGINE=InnoDB ROW_FORMAT=dynamic DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

このテーブルからnoteが空のレコードを抽出する場合、どういうSQLを書くでしょうか。

SELECT * FROM something WHERE note = "";

いやいや、もしかしたらnoteにはNULLが入っているかもしれません。

SELECT * FROM something WHERE note IS NULL OR note = "";

NULLが入っていることで、SQLが複雑化する可能性があります。 また、SQLの書き方を間違えると意図したレコードを抽出できない可能性があります。 そのため、極力NULLを入れなくて良い箇所はNOT NULL制約を入れるようにしています。

ただし、なんでもかんでもNULLを排除すれば良いというわけではありません。 例えば、以下のようにuser_idを持つテーブルがあるとします。

CREATE TABLE `something` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11),
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
) ENGINE=InnoDB ROW_FORMAT=dynamic DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

なんとしてもNULLを入れたくないのか、user_idデータが無い場合はuser_id=0としてレコードを作成する、 といったように無理やりダミーレコードをいれてNULL回避をする方がいます。 データが存在しないのならば、素直にNULLを入れるべきです。

余談ですが、datetime型に0000-00-00 00:00:00を入れるとえらい目にあうので注意が必要です。 ( 参照: https://soudai.hatenablog.com/entry/2018/05/12/191050 )

2. 適切なデータ型を使う

MySQL5.7では、https://dev.mysql.com/doc/refman/5.7/en/data-types.html にあるデータ型を利用できます。

本来数値しか入らないレコードはint型を使うなど、適切な型を設定するよう努力します。 数値カラムに全角数字が入っていた、そんなことはデータ型を正しく選択していれば起こり得ないことです。 なんでもかんでもvarcharで定義しないよう注意します。

以下、型を選ぶ時に気をつけていることを記載します。

  • bool値を入れるカラムには tinyint(1)を利用する
  • 長くない可変文字列を入れる場合(大体1024文字より下くらいかな?)はvarcharを利用します
  • 長い可変文字列を入れる場合はtext型を利用する、またtext型を使う場合はテーブルのROW_FORMATがDynamic(Barracuda)であることを確認する
    • MySQL5.6まではデフォルトのフォーマットはCompact(Antelope)でしたが、これは1レコードあたり8KBまでしかデータを入れることができません。
    • テキスト型を使うと8KB制限を突破してしまうこともあるため、テーブルのフォーマットがDynamic(Barracuda)であることを確認します。
  • 整数値を入れる場合はint型かbigint型を使う
  • float型は使わない
    • 精度のトラブルに巻き込まれたくないためfloatは使いません。多くの場合、doubleかdecimalで問題ありません。
  • 金額情報など、精度を求められる小数値にはdecimalを使う
    • doubleも小数点以下の精度に悩まされることがあります。金額を扱う、精度が必要な計算は必ずDecimalを利用します。
  • 日付を入れる場合はDATE型を使う
    • 商品のお届け日など、日付を入れる場合は DatetimeやTimestamp型ではなくDATE型を使うようにします。DatetimeやTimestampはタイムゾーンの影響を受けるためです。
  • JSON型を使ったら負け

注意点として、数字に見えるデータを入れる際、必ずしもint型が正解ではない可能性もあります。 郵便番号(例: 0100011) とか、電話番号(例: 08000000000) は一見数値に見えますが、これはvarcharで保存すべきです。 なぜなら、数値の場合は先頭の0の値が抜け落ちてしまうためです。

3. 三角関係のリレーションを持つテーブルは(できるだけ)作らない

三角関係という表現が正しいかはさておき、下図ERのようなテーブルは(できるだけ)作らないようにしています。

f:id:vividgarden-tech:20200614205008p:plain

この構造では、注文からグループを引く場合に注文 → ユーザー → グループというたどり方をした場合と、注文 → グループと 直接レコードを引いた際に違うレコードが取れる可能性はあります。 例えば、ユーザーが所属するグループが途中で変わった場合は、整合性が崩れる可能性があります。

三角関係にならないよう、以下のように設計を修正します。

f:id:vividgarden-tech:20200614205031p:plain

性能問題により、注文からグループテーブルへのショートカットを貼る場合もあります。 その場合、ユーザーが所属するグループが変わった場合は、注文レコードが保持しているグループへのリレーションも貼り直す必要があります。 整合性が崩れやすいので、このような設計はできるだけしないようにしています。

4. 一時的なレコードと永続化が必要なレコードを同じテーブルには入れない

一時的に利用するレコードと、永続化すべきレコードは同じテーブルに入れないようにしています。

例えば、以下のような受注(orders)テーブルがあるとします。 これは SpreeというオープンソースのECプラットフォームのテーブル設計です。 (実際はもう少し複雑なのですが、簡略化しています)

f:id:vividgarden-tech:20200614205054p:plain

Spreeにはカート機能があるのですが、ユーザーが一時的にカートに入れた注文も、その後実際に受注したデータも同じordersテーブルに入っています。 カートに入っている未発注の注文は、注文ステータスをcartという状態にして、関連テーブルと共にレコードを挿入してます。

確かにカートの情報をそのままordersに入れれば実装上は楽なのですが、この設計は性能に問題がでる可能性があります。 ユーザーがカートに入れたまま放置したら、ordersテーブルには不要なレコードがたまり続けることになります。

構造が同じでも意味が違うデータは分けて管理すべきだと、私は思います。 その方がレコードの行数も少なく抑えられて、注文テーブルを引くコストが下がります。

5. 定期的に性能を見て設計を検討し直す

データベースは生き物です。

日々データ量が増え続けていき、設計時には思いもよらないようなパフォーマンス問題にぶち当たることがあります。 一度設計したら終わりではなく、日々パフォーマンスを監視し、重いクエリがあればチューニングが必要です。 ときにはテーブル構造自体を見直す必要があるかもしれません。

昔はSlowQueryを吐き出してそれを見る、ということをしていましたが、 最近では性能を監視するためのツールが充実しています。 おすすめのツールはNewRelicとAWSのRDSパフォーマンスインサイトです。

NewRelicでは、下図のようにパフォーマンスを可視化してくれます。 例えば、以下を見るとDeliveryPossibilityテーブルを引くのに時間がかかっていますね。パフォーマンスチューニングが必要そうです。

f:id:vividgarden-tech:20200614205115p:plain

AWSのパフォーマンスインサイトは、下図のように更に細かい単位でパフォーマンスの可視化をしてくれます。

f:id:vividgarden-tech:20200614205126p:plain

f:id:vividgarden-tech:20200614205138p:plain

6. インデックスの性質を知り、適切に貼る

パフォーマンスが出ないクエリは、インデックスを貼れば早くなる!わけではありません。 インデックスは銀の弾丸ではなく、性質を知り適切に貼る必要があります。

6.1 カーディナリティを考慮してインデックスを貼る

MySQLでインデックスを利用する場合、多くはBツリーインデックスを利用するかと思います。 Bツリーだろうがハッシュだろうが、カーディナリティの低いカラムにインデックスを張っても性能はでません。

カーディナリティは、入っている値の種類がどのくらいあるかを表しています。

例えば、レコードが1千万行あるテーブルがあり、activeという1と0の値を取るカラムがあるとします。 このactiveカラムのカーディナリティは2であり、ここにindexを貼っても多くの場合クエリは早くなりません。 逆にカーディナリティが1千万のカラム(すなわち重複の無いデータ)にindexを貼れば、早くデータを引いてこられる可能性があります。

カーディナリティを確認するには、show indexクエリを利用します。 以下のテーブルでは複合indexとして(faxable_type, faxable_id)にインデックスを張ってます。 faxable_typeはカーディナリティ2であり、(ユニークキー制約がある場合は別として)ここに貼る意味はあまりなさそうですね。 faxable_idはカーディナリティが高く、このカラムにインデックスを貼るのは正解です。

f:id:vividgarden-tech:20200614205159p:plain

6.2 インデックスが効いているかをExplainで見る

インデックスが効いているかはExplainを使えばわかります。 Explainの解説をするとそれだけで長文がかけてしまうのでここでは省略します。 クエリにexplainをかけてtypeを見る、possible_keysに意図したインデックスが入っているか、引いてくるレコードの行(rows)が少なくなっているか(フルスキャンしていないか)、Using Indexがでているか、コストが高くないかなどを確認します。

MySQL Workbenchを使うと、グラフィカルにExplain結果が見られるのでおすすめです。

f:id:vividgarden-tech:20200614205211p:plain

6.3 インデックスを貼る順序に気をつける

複合インデックスの場合は、インデックスを貼るカラムの順序も重要です。

(column1, column2, column3) という順に貼ったindexと、(column3, column2, column1) のように貼ったインデックスは別のものとして扱われます。 前者のindexを貼った場合、条件に(column1), (column1, column2), (column1, column2, column3)が入っている場合のみ、インデックスが有効です。 where column3=xxxxx のようにクエリを書いてもindexは使われないことに注意します。

6.4 インデックスショットガンをしない

インデックスを全部のカラムに貼ったら早くなるんじゃないか。 誰しもが思う疑問かもしれませんが、そんなに都合良くは行きません。

まずMySQLのインデックスは1クエリにつき基本的に1つしか利用できません。 (インデックスが1つだけ使われるということで、1つのカラムにだけ効く、という意味ではありません。複合インデックスは有効です。)

index1, index2をいい感じに合体して高速なクエリを作り出してくれる、そんな高度な機能は... インデックスマージという機能はなくはないのですが、基本は1つだけ使うと考えて良いでしょう。

またインデックスを貼るとテーブルの更新処理が遅くなり、ディスク容量も膨れ上がります。 それはそのとおりで、通常のレコードとは別にインデックス用のデータを作るわけですから、遅くなります。

すべてのカラムにインデックスを貼る、インデックスショットガンはしないようにします。インデックスを貼るにもコストがかかるのです。

7. 正規化が必要なところ、不要なところを見極めてテーブル設計をする

MySQLのjoinはとても遅いです。Oracleなら高速で返ってくるであろうクエリも、MySQLでは遅いです。

toC向けのRead負荷が高いページを表示するのに、joinを多用したクエリを実行するべきではありません。 アクセス負荷の高いページではjoinしたら負けです。理想は1テーブルのみのアクセスです。

そういう場合は、各種テーブルのデータをサマった正規化していないテーブルを別途作成し、そのテーブルにのみアクセスするようにします。 いわゆるマテビュー(マテリアライズド・ビュー)のようなものを別途作成することで、アクセスを高速にさばくことができます。

逆にtoB向けのページでは、できるだけ正規化したテーブルにアクセスするようにします。 こちらは整合性の崩れた(キャッシュのような)レコードにアクセスしてデータが間違っていた、という事件が起きるほうがリスクが高いです。

正規化する/しないはアクセスの性質・サイトの性質に合わせて柔軟に検討します。

8. リレーショナルデータベース(RDB)が苦手な表現を理解し設計する

RDBは万能ではありません。RDBでは表現が難しいデータ構造・表現も存在します。 以下はRDBで扱いにくいものの一例です。

  • ツリー構造など階層を持ったデータ構造
  • カラムが動的に変化するデータ構造
  • GIS(地理情報)を扱う場合
  • 全文検索が必要な場合
  • データ量が多すぎる場合(アクセスログデータを入れて計算するなど)

ツリー構造(階層構造)を持ったデータは頻出ですが、RDBでは扱いにくいものの部類にはいります。 そのため、ツリーを表現するための設計パターンが存在します。 隣接リスト・Nested Set・クロージャーテーブルなどなど。どれも一長一短があります。それぞれの設計の利点欠点を理解し、適切なデータ構造を選びます。

カラムが動的に変化する構造も苦手です。GISも苦手、全文検索も苦手です。データ量が多すぎる場合もRDBでは対応できないかもしれません。

RDBに向いていないデータを入れる場合は、素直にRDB以外を選択したほうが良いでしょう。

まとめ

今回はデータベース設計を行う際に気をつけていることをまとめました。

システムの土台となるデータベースの設計は、とても重要です。 DBの寿命はアプリより長い といわれていますが、そのとおりだと思います。

あとでつらい思いをしないためにも、今後も慎重にテーブル設計をしていきたいものです。

www.wantedly.com

www.wantedly.com