dely Tech Blog クラシル・TRILLを運営するdely株式会社の開発ブログです 2024-03-29T15:00:36+09:00 delyjp Hatena::Blog hatenablog://blog/6653812171403292056 デザイナーがローンチ時点からスケールに備えるメリット hatenablog://entry/6801883189092452716 2024-03-29T15:00:36+09:00 2024-03-29T15:00:36+09:00 クラシルリワード プロダクトデザイナーのredです。 クラシルリワードでは、新規事業の立ち上げから担当しており、現在はデザインとプロダクトマネジメントの兼務で開発に携わっています。 クラシルリワードではアプリのローンチ時点でデザインシステムを構築しており、グロースフェーズである現時点でもローンチ時点で設計したものを運用してデザイン・開発を効率的に進めることができています。 そこで今回は、0→1段階でプロダクト・事業のスケールを見越してデザインシステムを構築することのメリット等について書いていきます。 ローンチ時点でデザインシステムを構築するメリット この記事では、プロダクト開発においては「ロー… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/acke_red/20240329/20240329130043.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> クラシルリワード プロダクトデザイナーのredです。 クラシルリワードでは、新規事業の立ち上げから担当しており、現在はデザインとプロダクトマネジメントの兼務で開発に携わっています。</p> <p>クラシルリワードではアプリのローンチ時点でデザインシステムを構築しており、グロースフェーズである現時点でもローンチ時点で設計したものを運用してデザイン・開発を効率的に進めることができています。 そこで今回は、0→1段階でプロダクト・事業のスケールを見越してデザインシステムを構築することのメリット等について書いていきます。</p> <h3 id="ローンチ時点でデザインシステムを構築するメリット">ローンチ時点でデザインシステムを構築するメリット</h3> <p>この記事では、プロダクト開発においては「ローンチ→PMFを目指す→グロース」の3つのフェーズがあるという整理で話を進めます。</p> <p>ローンチ時点でスケールに備えることが必要だと考える理由とそのメリットについては、以下のようなものがあると考えています。</p> <h5 id="1PMF達成へのスピード感のある開発">1.PMF達成へのスピード感のある開発</h5> <p>プロダクトのローンチからPMFを達成するまでの道のりでは、大なり小なりアップデートを重ねて試行錯誤する開発が必要になるかと思います。この過程で開発サイクルのスピードを上げるために、効率的な開発の土台となるデザインシステムがローンチ時点で存在することは重要だと考えています。ローンチ時点から生産性の高い状態にしておくことで、PMF到達に向けた開発サイクルのスピードを上げることにも繋がります。</p> <h5 id="2チーム拡大への対応">2.チーム拡大への対応</h5> <p>PMFを達成すると、多くの場合、プロダクトのさらなる成長を目指してリソースが増えチームが拡大するかと思います。人が増えると、生産性と品質の維持向上のためデザインシステムの重要性は更に増していくため、0のタイミングから組織のスケールに対応しやすい状態にしておくことは有用であると考えています。</p> <h5 id="3グロースへの集中">3.グロースへの集中</h5> <p>グロースフェーズに突入すると、プロダクトの成長スピードを更に上げるために、機能追加やKPI改善に全集中する組織力学が働くケースが多々あるかと思います。そういったケースが発生している場合や、機能追加・改善を重ねて複雑性が増した状態で、いざ負債を返済しましょう、デザインシステムを作りましょうという話になると、ローンチ前に構築するよりもコストがかさみ、重めの意思決定になることが予想されます。「ローンチ後にやろう」「いつかやろう」はなかなか来ないものだと考えて、先んじて基盤を用意しておいた方が生産性向上の恩恵を受ける面が大きいのではないかと考えています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/acke_red/20240329/20240329145533.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="意識したいこと">意識したいこと</h3> <p> 上記の考え方を取り入れる上で意識しておきたいことは以下になります。</p> <ul> <li><p>当然、0→1を成功させるためローンチ→PMF達成が至上命題であり第1優先事項。0→1時点でデザインシステムを構築すること自体はPMF達成の必要条件ではない。1→10以降のデザイン・開発効率をスムーズに進めるための手段であること。</p></li> <li><p>再利用性が低い要素をコンポーネントとして不必要に定義しないなど、オーバーエンジニアリングにならないように注意を払うこと。また、再利用性の低いユニークな要素が生まれることを許容し寛容になること。</p></li> <li><p>0→1デザイン段階で見えているプロダクトのビジョン・青写真を可能な限りインプットして、再利用性が高いもの・高くなりそうなものを予測する。プロダクトをこれから長く運用することを想定して、デザインシステムに一貫性や柔軟性をもたせる。</p></li> <li><p>デザインシステムを最初から完璧な設計にするのは不可能であり、プロダクトと同様にリリース後の改善・運用が必要なものと捉える。</p></li> </ul> <p>これらの点を意識することで、0→1の段階でデザインシステムを構築する際のバランスを見極め、長期的な視点でプロダクトの成長をサポートする土台を築くことを目指すのが理想です。</p> <h3 id="まとめ">まとめ</h3> <p>クラシルリワードはリリースから1~2年ではありますが、頭をかかえる程のデザイン的負債はなく、生産性高くデザインを進めることができてきました。 ローンチ以降、機能追加・改善を順調に積み重ねてきており、0のタイミングで長期を見据えたデザインシステムを構築することによる恩恵を受けることができました。</p> <p>無論、最も重要なのは仮説検証を進めるための速やかなローンチではあるのですが、作る前から未来のスケールに備えることには一定の価値があると考えています。</p> <p>これから0→1をやるプロダクトデザイナーの方にとって何かしらの参考になれば幸いです。</p> acke_red クラシルリワードチーム SLO導入の工夫と現在地 hatenablog://entry/6801883189092735591 2024-03-22T16:43:57+09:00 2024-03-22T16:43:57+09:00 こんにちは、クラシルリワードというサービスでSREときどきサーバーサイドを担当しているjoooee000です。 外の空気がほんのりと暖かくなり、春の訪れを感じさせる今日この頃、心まで軽やかになってきました。冬の長い間、凛とした寒さに耐えていた私たちにとって、この季節の変わり目はまさに待ちに待った瞬間です。 ということで、みなさんのチームではSLOを導入していますか?クラシルリワードのサーバーサイドチームでは、SLOを導入しています。 SLOの設定や浸透、導入ってとても難しいですよね。リワードサーバーサイドチームも、SLO導入までの道のりで最初の成果を実感するまでは何回ももやっと感覚を味わってき… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322161216.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> こんにちは、クラシルリワードというサービスでSREときどきサーバーサイドを担当しているjoooee000です。</p> <p>外の空気がほんのりと暖かくなり、春の訪れを感じさせる今日この頃、心まで軽やかになってきました。冬の長い間、凛とした寒さに耐えていた私たちにとって、この季節の変わり目はまさに待ちに待った瞬間です。</p> <p>ということで、みなさんのチームではSLOを導入していますか?クラシルリワードのサーバーサイドチームでは、SLOを導入しています。</p> <p>SLOの設定や浸透、導入ってとても難しいですよね。リワードサーバーサイドチームも、SLO導入までの道のりで最初の成果を実感するまでは何回ももやっと感覚を味わってきました。 この記事では、SLO導入の軌跡と、導入方法や改善したことなどを書いていきたいと思います!一つのケースとして、参考になれば幸いです。</p> <h1 id="導入当時の課題感">導入当時の課題感</h1> <p>SLOの導入は、もともとサーバーサイドチームで下記のような課題感を持っていたことが発端でした。</p> <ul> <li>アラート設定の閾値がオレオレ <ul> <li>今設定している閾値より、もっと早い段階でユーザーが不便と感じるかもしれない</li> </ul> </li> <li>パフォーマンスが下がっていても気づくことができない (明らかに障害というレベルでしか気付けない)</li> <li>パフォーマンス改善に対する優先順位の意思決定ができない</li> </ul> <p>クラシルリワードはiOS / Android / webでサービス利用できるようになっていますが、まずはサーバー側の計測とスコープを区切り、まずいことが起こったときにアクションが取れるようにしようという意思でSLOの導入をすることにしました。</p> <h1 id="SLOを導入した結果">SLOを導入した結果</h1> <p>まずSLOを導入した結果どうなったかを先にいうと、</p> <ul> <li>大幅なレイテンシ悪化(障害までにはならない)に気づくことができた</li> <li>いくつかの機能でレイテンシを改善し、SLOを満たせるようなパフォーマンス改善ができた🎉</li> <li>継続的にパフォーマンスがウォッチできる様になった</li> <li>やらないこともSLOを基準に決められるようになった <ul> <li>頻繁にtimeoutが発生しているようにみえていた外部サービスも、計測したらSLOの範囲内だった 等</li> </ul> </li> </ul> <p><figure class="figure-image figure-image-fotolife" title="改善前後の1ヶ月間のSLO(before)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322151114.png" width="1200" height="595" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>改善前後の1ヶ月間のSLO(before)</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="改善後の直近1週間のSLO(after)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322151034.png" width="1200" height="607" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>改善後の直近1週間のSLO(after)</figcaption></figure></p> <h1 id="SLO導入の軌跡">SLO導入の軌跡</h1> <p>結果的に改善事例が出来ましたが、そこまでにはいろいろな試行錯誤があった(まだまだ試行錯誤中)なので、導入の軌跡を紹介できればと思います。</p> <h2 id="導入初期--SLO設定編-">導入初期 ~ SLO設定編 ~</h2> <p>まずはじめに、上で書いた課題感からSLIと、SLO基準値を決定することにしました。</p> <p>SLIの決定は、まずはシンプルにAPIのレイテンシと可用性(5xxエラー)にしました。</p> <p>この2つの指標は、以前サーバーサイドチームのメンバーで「クラシルリワードを開発するうえで担保すべきこと」を話し合った結果のうちの2つを指標にしています。</p> <p>下記が、話し合いの結果チームで決定した <strong>「サービスを開発するうえで担保すべきこと」</strong> です。</p> <ul> <li>コインやチケットなど、売上に直結する情報が正しく更新・表示される</li> <li>ユーザーがサービスに来た目的を安全に果たせる <ul> <li><strong>動作が快適 ⇒ レイテンシ</strong></li> <li><strong>エラーが起きない ⇒ 可用性</strong></li> <li>個人情報などセキュリティ面の担保</li> </ul> </li> </ul> <p>次にSLOの設定ですが、下記の2項目を考慮しながら最初は最低限のSLOを引くことにしました。</p> <ul> <li>他社の指標などの参考値</li> <li>「今のレイテンシであれば、許容できる」というEM(兼PDM)のセリフ</li> </ul> <p>しかし、いざごく最低限と思われるラインのSLOを設定してみると、レイテンシのSLOがすでにガッツリ抵触していました。深堀りしていくと、一部の外部サービス連携のある機能のレイテンシに引っ張られているという事がわかりました。その中で下記のような疑問が湧いてきました。</p> <ul> <li>どの機能も同じSLOでいいのか? <ul> <li>外部サービスとの依存が激しい機能で、一部外部サービスの基準に合わせてレイテンシを許容する意思決定をしている箇所が存在する</li> <li>不正防止チェックが重要で、一部レイテンシを許容している箇所が存在する</li> </ul> </li> </ul> <p>レイテンシを許容しなければならない箇所が混ざっている状態で、一律でSLOを設定することに意味はあるのか、SLOが下がったときにどこを改善するかの判断が難しくないか、などを考えました。</p> <h3 id="機能ごとにSLOを分割することに">機能ごとにSLOを分割することに</h3> <p>上記のもやもやがあり、機能ごとにSLOを分割してみることにしました。外部サービスへのリアルタイムのリクエストが発生する機能も一部だったため、機能ごとに分割することでより明快なSLOを設定できると思いました。また、機能ごとに開発メンバーの責任者が決まっていたため、各メンバーやチームがオーナーシップをもって改善しやすいこと、チームによっては、一時的にレイテンシを犠牲にしてスピード開発するPoCを行っているパターンも存在していたため、機能ごとのメリットがあると考えました</p> <p>そこで、エンドポイントのpathごとに意味のある機能にまとめて、SLOを設定しました。</p> <p>機能ごとに分割することで、一律ですべて同じSLOを設定しているときよりは納得感のあるSLOが設定できました。また、どのメンバーが修正するべきかの責任範囲と、どこの機能でレイテンシが悪化しているか明確になりました。</p> <h2 id="導入中期--可視化編-">導入中期 ~ 可視化編 ~</h2> <p>次に、SLOダッシュボードを作成してサービスの健康状態を可視化しました。</p> <p>1ページで各機能のSLOを見れるようにし、SLOがどういう動きをするか観察できるようにしました。</p> <p>ダッシュボード作成やSLOやエラーバジェットのモニタリングはNewRelicのServiceLevel機能を用いて行いました。</p> <p>また、下記terraformで設定することでNewRelicのSLO機能の作成と、サービスごとのSLOをまとめて閲覧できるダッシュボード(機能ごとのSLOをタブ化)とを自動生成できるようにしました。</p> <ul> <li>対象機能</li> <li>機能ごとの対象エンドポイント <ul> <li>例) <code>/api/v1/surveys/*</code></li> </ul> </li> <li>SLO</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="機能ごとのSLOダッシュボード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322151256.png" width="1200" height="336" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>機能ごとのSLOダッシュボード</figcaption></figure> この時点では、サーバーサイドチームにダッシュボードを共有し、SLOの活用方法として5xxアラートがきたときに修正対応すべきかどうかという基準にのみ利用していました。例えば、外部サービスとのつなぎ込みがある機能のtimeoutをどの程度気にして改善するかなどです。SLOに抵触していない限りは、対応不要という意思決定ができるようになりました。</p> <p>しかし、SLOが下がったときにはどういうアクションをとるかということに関しては、仕組み化出来ていませんでした。SLOを普段目にすることがなくそのうちSLOの存在を忘れてしまいそうな状況でした。</p> <h2 id="現在--アラート設定編-">現在 ~ アラート設定編 ~</h2> <p>上で述べた通り、新しいフィーチャーのデプロイなどによりSLOに抵触した時、対応するフックやルールが何も無いため、そのうち見なくなるだろうなという感覚がありました。</p> <p>SLOに抵触した際に気づけるよう、アラートの設定をしました。</p> <p>まずは、SLOのバーンレートアラートを設定し、slackのwarnチャンネルに流せるように、さきほどのダッシュボードの作成時に、terraformによって同時にNewRelicアラートが設定されるようにしました。</p> <p>(リワードのサーバーサイドチームでは、アラートチャンネルがcritical / warn / noticeチャンネルに分類されており、warnチャンネルは、「オンコール担当者(サーバーサイドメンバー含む)が業務時間内に見て対応が必要かどうか判断する必要がある」という基準を設けています。)</p> <p><figure class="figure-image figure-image-fotolife" title="SLO slackアラート"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322151441.png" width="600" height="528" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SLO slackアラート</figcaption></figure> しかし、アラート化するとメンバーには絶対に対応するべきもの、または対応が必要かどうか都度判断するべきものとして今まで以上に認識されるようになります。ここで、SLO設定時の「本当に追うべき指標になっているのか」「メンバーの時間をとって改善すべきと強く言えるか」という問題と再度向き合う必要がありました。</p> <p>そのときのもやもやポイントとして、設定したSLOの値の妥当性がわからないというところにありました。</p> <p>そこで、機能ごとに分割してあるレイテンシのSLOをさらに、GET / POSTごとのリクエストメソッドでも分割しました。このことで、なぜその値に設定しているかわからないSLOよりは、GETで外部サービスとのつなぎこみもなくこの閾値を下回っているのは、なにか実装面で問題がありそう。というように、より指標を一般化できると思いました。例えば、GETとPOST関係なく500ms、99%を目指しましょう。というと、GETだと妥当かな、POSTだとちょっと厳しい、というようにより共通の感覚が持てます。</p> <p>また、サービスの性質上、POSTリクエストに不正リクエストを防ぐための重めの処理が入っている箇所もあり、サービスによってはPOSTのレイテンシに引っ張られてGET系のレイテンシの低下を正しく判断できない箇所もありました。そのため、確認したところで「POSTなので許容します」みたいなコミュニケーションになってしまうのではと思っていたこともあります。</p> <p>実際にやってみると、わりとしっくり来るSLOを設定することができました。基準として、</p> <ul> <li>各サービスごとに一律でGETとPOSTリクエストで基準となるSLOを作成</li> <li>その上で、外部サービスの連携があり一部レイテンシを許容する意思決定をしている機能に関しては、外部サービスが提示している目標レスポンスタイムを基準となるSLOに足す</li> </ul> <p>これで無理なくSLOを守っていける、アラートが上がったらかなりの高確率で実装上の問題があるという感覚のSLOを設定できました。</p> <p>また、対応ルールは最初は修正を強制にせず、各機能チームの意思決定に委ねるという運用から始めました。</p> <h3 id="アラート化することでSLOのチーム内の認識度が上がった">アラート化することで、SLOのチーム内の認識度が上がった</h3> <p>ちょうどそのタイミングで、チームが注力している機能のレイテンシが大幅に下がる事象が発生しました。 (計測してて良かった!)</p> <p>この頃には、当初SLOを計測し始めた頃よりも大きくサービスが成長し、些細な実装がレイテンシに影響するようになってきており、SLOの必要性も当初より上がってきたように思います。</p> <p>そして、SLOに抵触したサービスに関わっていたメンバーが、迅速に対応してくれて、SLOに基づいてパフォーマンス改善をすることが出来ました。</p> <p><figure class="figure-image figure-image-fotolife" title="パフォーマンス改善によってレイテンシをV字回復してくれたメンバー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322162616.png" width="455" height="340" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>パフォーマンス改善によってレイテンシをV字回復してくれたメンバー</figcaption></figure></p> <h1 id="チームでSLOを意識するようになってきた">チームでSLOを意識するようになってきた</h1> <p>最近では、サーバーサイドチームでSLOを意識することが出来ています。</p> <p>アラートにしたことでチームに認識されて改善点の意見もでてきたり</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322145357.png" width="626" height="102" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>開発速度の計測を担当してくれているメンバーが「意識すること」で言及してくれたり</p> <p><figure class="figure-image figure-image-fotolife" title="開発速度計測レポートの一部"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322145057.png" width="1200" height="434" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>開発速度計測レポートの一部</figcaption></figure></p> <p>レイテンシを改善したことによって事業にどういうインパクトがあったかを計測してくれたりしました。 (このときの改善は残念ながらあまりKPIには影響がなかったので、SLOを下げた)</p> <p>また、パフォーマンス改善などによって技術的知見を得る機会も増えたため、サーバーサイドチームのMTGに技術シェアというコーナーができました 🎉</p> <p><figure class="figure-image figure-image-fotolife" title="チームミーティングの議事録の一部"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20240322/20240322144924.png" width="910" height="209" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>チームミーティングの議事録の一部</figcaption></figure></p> <h1 id="まとめや今後の展望">まとめや今後の展望</h1> <p>クラシルリワードのサーバーサイドチームでは、機能ごと、リクエストメソッドごとにSLOを計測しています。</p> <p>また、まだまだSLOの運用を始めたばかりなので、今後は下記にも注力できたらなどと妄想しています。</p> <ul> <li>バックエンド側に閉じないSLOの設定やモニタリング</li> <li>SLOの値の調整 <ul> <li>今も、パフォーマンス改善した際にKPI周りをみて変化がなければSLOを緩めてみたりしています</li> </ul> </li> <li>SLOと離脱率の相関の分析</li> </ul> <p>最後まで目を通していただき、ありがとうございました。</p> joe0000 RemixとConformで動的なフォームを作成する hatenablog://entry/6801883189091131565 2024-03-19T16:44:44+09:00 2024-03-20T19:52:57+09:00 はじめに こんにちは、クラシルリワードのサーバーサイドエンジニアのrakuです! 今回は趣味でRemixを使用した複雑なフォームの実装をする際に便利だった、React向けのtype-safeなフォームライブラリであるConformについてご紹介します。 Conformは、RemixやNext.jsでFormDataの検証をサーバーサイドでも簡単に実装できるため、これらのフレームワークとの相性が抜群です。そのため、Remix Resourcesでも紹介されています。 remix.run またConformの特徴を公式ドキュメントから引用すると↓と書かれています. Progressive enha… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rakutek/20240319/20240319164252.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>こんにちは、クラシルリワードのサーバーサイドエンジニアのrakuです!</p> <p>今回は趣味でRemixを使用した複雑なフォームの実装をする際に便利だった、React向けのtype-safeなフォームライブラリであるConformについてご紹介します。</p> <p>Conformは、RemixやNext.jsでFormDataの検証をサーバーサイドでも簡単に実装できるため、これらのフレームワークとの相性が抜群です。そのため、Remix Resourcesでも紹介されています。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fremix.run%2Fresources%2Fconform" title="CONFORM" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://remix.run/resources/conform">remix.run</a></cite></p> <p>またConformの特徴を公式ドキュメントから引用すると↓と書かれています.</p> <ul> <li>Progressive enhancement first APIs.</li> <li>Type-safe field inference.</li> <li>Fine-grained subscription.</li> <li>Built-in accessibility helpers.</li> <li>Automatic type coercion with Zod.</li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fconform.guide%2F" title="Conform / Overview" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://conform.guide/">conform.guide</a></cite></p> <h2 id="Remixとの連携">Remixとの連携</h2> <p>一般的にフロントエンドとバックエンドのあるシステムでは、入力値のバリデーションをフロントエンドとバックエンドの両方で行うことが望ましいです。</p> <p>ConformはRemixの<code>action</code>関数と<code>useActionData</code>フックを使ってサーバーサイドとクライアントサイドの連携を実現し、zodで定義した型スキーマを使ってデータのバリデーションを行います。 <br/> Conformを使うことでRemixのサーバーサイドとクライアントサイドの処理をシームレスに連携できます。</p> <h2 id="サンプルコード">サンプルコード</h2> <p>以下は、RemixとConformを使った動的なフォームのサンプルコードです。<br/> UIコンポーネントにshadcn/uiを使用しています</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fui.shadcn.com%2F" title="shadcn/ui" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://ui.shadcn.com/">ui.shadcn.com</a></cite></p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> getFormProps<span class="synStatement">,</span> getInputProps<span class="synStatement">,</span> useForm <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@conform-to/react&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> getZodConstraint<span class="synStatement">,</span> parseWithZod <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@conform-to/zod&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> ActionFunctionArgs<span class="synStatement">,</span> json <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@remix-run/node&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> Form<span class="synStatement">,</span> useActionData <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@remix-run/react&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> FC<span class="synStatement">,</span> useEffect <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;react&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> z <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;zod&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> Button <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;~/components/ui/button&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> Input <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;~/components/ui/input&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> Label <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;~/components/ui/label&quot;</span><span class="synStatement">;</span> <span class="synType">const</span> schema <span class="synStatement">=</span> z.<span class="synType">object</span><span class="synStatement">(</span><span class="synIdentifier">{</span> title: z.<span class="synType">string</span><span class="synStatement">(</span><span class="synIdentifier">{</span> required_error: <span class="synConstant">&quot;タイトルは必須です&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">),</span> lists: z .<span class="synType">object</span><span class="synStatement">(</span><span class="synIdentifier">{</span> name: z.<span class="synType">string</span><span class="synStatement">(),</span> location: z.<span class="synType">string</span><span class="synStatement">()</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> .array<span class="synStatement">()</span> .nonempty<span class="synStatement">(</span><span class="synConstant">&quot;アイテムを追加してください&quot;</span><span class="synStatement">),</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synStatement">export</span> <span class="synType">const</span> action <span class="synStatement">=</span> <span class="synStatement">async</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> request <span class="synIdentifier">}</span>: ActionFunctionArgs<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> submission <span class="synStatement">=</span> parseWithZod<span class="synStatement">(await</span> request.formData<span class="synStatement">(),</span> <span class="synIdentifier">{</span> schema <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span>submission.reply<span class="synStatement">());</span> <span class="synStatement">return</span> json<span class="synStatement">(</span><span class="synIdentifier">{</span> message: <span class="synConstant">&quot;エラー&quot;</span><span class="synStatement">,</span> submission: submission.reply<span class="synStatement">(</span><span class="synIdentifier">{</span> formErrors: <span class="synIdentifier">[</span><span class="synConstant">&quot;エラーメッセージ&quot;</span><span class="synIdentifier">]</span> <span class="synIdentifier">}</span><span class="synStatement">),</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synType">const</span> ErrorMessage: FC<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> error?: <span class="synType">string</span><span class="synIdentifier">[]</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synStatement">=</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> error <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">&lt;</span>div className<span class="synStatement">=</span><span class="synConstant">&quot;text-red-500&quot;</span><span class="synStatement">&gt;</span><span class="synIdentifier">{</span>error<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">export</span> <span class="synStatement">default</span> <span class="synStatement">function</span> TestPage<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> actionData <span class="synStatement">=</span> useActionData<span class="synStatement">&lt;typeof</span> action<span class="synStatement">&gt;();</span> <span class="synType">const</span> <span class="synIdentifier">[</span>form<span class="synStatement">,</span> fields<span class="synIdentifier">]</span> <span class="synStatement">=</span> useForm<span class="synStatement">(</span><span class="synIdentifier">{</span> lastResult: actionData?.submission<span class="synStatement">,</span> constraint: getZodConstraint<span class="synStatement">(</span>schema<span class="synStatement">),</span> onValidate: <span class="synStatement">(</span><span class="synIdentifier">{</span> formData <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> parseWithZod<span class="synStatement">(</span>formData<span class="synStatement">,</span> <span class="synIdentifier">{</span> schema <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synType">const</span> lists <span class="synStatement">=</span> fields.lists.getFieldList<span class="synStatement">();</span> useEffect<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>actionData?.submission<span class="synStatement">)</span> <span class="synStatement">return;</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span>actionData<span class="synStatement">);</span> <span class="synStatement">if</span> <span class="synStatement">(</span>actionData.submission.<span class="synStatement">status</span> <span class="synStatement">==</span> <span class="synConstant">&quot;error&quot;</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">alert(</span>actionData.message<span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">[</span>actionData<span class="synIdentifier">]</span><span class="synStatement">);</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>Form method<span class="synStatement">=</span><span class="synConstant">&quot;POST&quot;</span> className<span class="synStatement">=</span><span class="synConstant">&quot;flex flex-col gap-4 p-4&quot;</span> <span class="synIdentifier">{</span>...getFormProps<span class="synStatement">(</span>form<span class="synStatement">)</span><span class="synIdentifier">}</span> <span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>fields.title.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Label htmlFor<span class="synStatement">=</span><span class="synIdentifier">{</span>fields.title.id<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;block text-sm font-medium text-gray-700&quot;</span> <span class="synStatement">&gt;</span> 買い物リスト <span class="synStatement">&lt;</span>/Label<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Input <span class="synIdentifier">{</span>...getInputProps<span class="synStatement">(</span>fields.title<span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">&quot;text&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> placeholder<span class="synStatement">=</span><span class="synConstant">&quot;Title&quot;</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>fields.lists.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span>lists.map<span class="synStatement">((</span>item<span class="synStatement">,</span> index<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> itemFields <span class="synStatement">=</span> item.getFieldset<span class="synStatement">();</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>div key<span class="synStatement">=</span><span class="synIdentifier">{</span>index<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;grid grid-cols-2 gap-4&quot;</span><span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Label htmlFor<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.name.id<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;block text-sm font-medium text-gray-700&quot;</span> <span class="synStatement">&gt;</span> 名前 <span class="synStatement">&lt;</span>/Label<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.name.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Input className<span class="synStatement">=</span><span class="synConstant">&quot;border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500&quot;</span> <span class="synIdentifier">{</span>...getInputProps<span class="synStatement">(</span>itemFields.name<span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">&quot;text&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Label htmlFor<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.location.id<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;block text-sm font-medium text-gray-700&quot;</span> <span class="synStatement">&gt;</span> 場所 <span class="synStatement">&lt;</span>/Label<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.location.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Input className<span class="synStatement">=</span><span class="synConstant">&quot;border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500&quot;</span> <span class="synIdentifier">{</span>...getInputProps<span class="synStatement">(</span>itemFields.location<span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">&quot;text&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> <span class="synStatement">&lt;</span>Button <span class="synStatement">type=</span><span class="synConstant">&quot;button&quot;</span> <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">()</span> <span class="synStatement">=&gt;</span> form.insert<span class="synStatement">(</span><span class="synIdentifier">{</span> name: fields.lists.name<span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">&gt;</span> 追加 <span class="synStatement">&lt;</span>/Button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Button <span class="synStatement">type=</span><span class="synConstant">&quot;submit&quot;</span><span class="synStatement">&gt;</span>登録<span class="synStatement">&lt;</span>/Button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/Form<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span> </pre> <h2 id="ZodとConformの連携">ZodとConformの連携</h2> <p>Conformは、Zodと組み合わせることで、型安全なフォームとバリデーションを実現します。</p> <p>まず、Zodを使ってフォームのスキーマを定義します。ここでは、<code>title</code>と<code>lists</code>の2つのフィールドを定義しています。<code>lists</code>フィールドは、<code>name</code>と<code>location</code>を持つオブジェクトの配列で、<code>nonempty</code>をつけることで空の配列を許容しないようにしています。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> schema <span class="synStatement">=</span> z.<span class="synType">object</span><span class="synStatement">(</span><span class="synIdentifier">{</span> title: z.<span class="synType">string</span><span class="synStatement">(</span><span class="synIdentifier">{</span> required_error: <span class="synConstant">&quot;タイトルは必須です&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">),</span> lists: z .<span class="synType">object</span><span class="synStatement">(</span><span class="synIdentifier">{</span> name: z.<span class="synType">string</span><span class="synStatement">(),</span> location: z.<span class="synType">string</span><span class="synStatement">()</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> .array<span class="synStatement">()</span> .nonempty<span class="synStatement">(</span><span class="synConstant">&quot;アイテムを追加してください&quot;</span><span class="synStatement">),</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre> <p>またformのvalidate結果のエラーメッセージもここで定義します。</p> <p>次に、フォームの送信時に実行されるremixのaction関数を作成します。ここではConformの<code>parseWithZod</code>関数を使って、フォームの値をZodのスキーマに従ってパースします。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synType">const</span> action <span class="synStatement">=</span> <span class="synStatement">async</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> request <span class="synIdentifier">}</span>: ActionFunctionArgs<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> submission <span class="synStatement">=</span> parseWithZod<span class="synStatement">(await</span> request.formData<span class="synStatement">(),</span> <span class="synIdentifier">{</span> schema <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span>submission.reply<span class="synStatement">());</span> <span class="synStatement">if</span> <span class="synStatement">(</span>submission.<span class="synStatement">status</span> <span class="synStatement">!==</span> <span class="synConstant">&quot;success&quot;</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> submission.reply<span class="synStatement">();</span> <span class="synIdentifier">}</span> <span class="synStatement">return</span> submission.reply<span class="synStatement">(</span><span class="synIdentifier">{</span> formErrors: <span class="synIdentifier">[</span><span class="synConstant">&quot;エラーメッセージ&quot;</span><span class="synIdentifier">]</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> </pre> <p>Zodを用いた入力値の検証結果は、parseWithZod関数が返すオブジェクトの中に格納されています。submission.statusプロパティの値が"success"である場合、入力値が定義されたスキーマ通りであることを示しています。一方、検証でエラーが発生した場合は、submission.reply()を呼び出すことで、エラー情報とユーザーが入力したデータをレスポンスとして返すことができます。</p> <p>submission.valueからは、フォームの各フィールドの値を取得できます。ここで得られる値は、Zodのスキーマに基づいて適切な型に変換済みとなっています。</p> <h2 id="useForm">useForm</h2> <p><code>useActionData</code>フックを使うことで、actionからの戻り値を取得することができます。この結果を<code>useForm</code>フックの<code>lastResult</code>に渡すことで、サーバーサイドのバリデーション結果をクライアントサイドで反映できます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> lastResult <span class="synStatement">=</span> useActionData<span class="synStatement">&lt;typeof</span> action<span class="synStatement">&gt;();</span> <span class="synType">const</span> <span class="synIdentifier">[</span>form<span class="synStatement">,</span> fields<span class="synIdentifier">]</span> <span class="synStatement">=</span> useForm<span class="synStatement">(</span><span class="synIdentifier">{</span> lastResult<span class="synStatement">,</span> constraint: getZodConstraint<span class="synStatement">(</span>schema<span class="synStatement">),</span> onValidate: <span class="synStatement">(</span><span class="synIdentifier">{</span> formData <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> parseWithZod<span class="synStatement">(</span>formData<span class="synStatement">,</span> <span class="synIdentifier">{</span> schema <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre> <p>ただし、<code>lastResult</code>は省略可能なので、フォーム側でactionの状態を扱う必要がなければ省略することもできます。</p> <p>また、onValidateにparseWithZod関数を使うことで、サーバーサイドと同じバリデーション処理をクライアントサイドでも1行で記述できます。</p> <h2 id="動的なフォームの実装">動的なフォームの実装</h2> <p>Conformを使うと、動的にフィールドを追加・削除できるフォームを簡単に実装できます。サンプルコードでは、<code>lists</code>フィールドが動的なフィールドになっています。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink> <span class="synType">const</span> <span class="synIdentifier">[</span>form<span class="synStatement">,</span> fields<span class="synIdentifier">]</span> <span class="synStatement">=</span> useForm<span class="synStatement">(</span><span class="synIdentifier">{</span> lastResult<span class="synStatement">,</span> constraint: getZodConstraint<span class="synStatement">(</span>schema<span class="synStatement">),</span> onValidate: <span class="synStatement">(</span><span class="synIdentifier">{</span> formData <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> res <span class="synStatement">=</span> parseWithZod<span class="synStatement">(</span>formData<span class="synStatement">,</span> <span class="synIdentifier">{</span> schema <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synStatement">return</span> res<span class="synStatement">;</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre> <p>次に、<code>useForm</code>フックを使ってフォームの状態を管理します。<code>lastResult</code>には、サーバーサイドから返ってきたバリデーション結果を渡します。<code>constraint</code>には、先ほど定義したスキーマを渡します。<code>onValidate</code>には、フォームのバリデーション処理を記述します。ここでは、<code>parseWithZod</code>関数を使って、フォームのデータをスキーマに基づいてバリデーションしています。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> lists <span class="synStatement">=</span> fields.lists.getFieldList<span class="synStatement">();</span> </pre> <p><code>getFieldList</code>メソッドを使って、<code>lists</code>フィールドの動的なフィールドリストを取得します。これにより、<code>lists</code>フィールドの要素を動的に追加・削除できるようになります。 inputではgetInputPropsヘルパーを利用することによってa11yや冗長な記述を自動で追加することができます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fconform.guide%2Fapi%2Freact%2FgetInputProps" title="Conform / getInputProps" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://conform.guide/api/react/getInputProps">conform.guide</a></cite></p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synIdentifier">{</span>lists.map<span class="synStatement">((</span>item<span class="synStatement">,</span> index<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> itemFields <span class="synStatement">=</span> item.getFieldset<span class="synStatement">();</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>div key<span class="synStatement">=</span><span class="synIdentifier">{</span>index<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;grid grid-cols-2 gap-4&quot;</span><span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Label htmlFor<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.name.id<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;block text-sm font-medium text-gray-700&quot;</span> <span class="synStatement">&gt;</span> 名前 <span class="synStatement">&lt;</span>/Label<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.name.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Input className<span class="synStatement">=</span><span class="synConstant">&quot;border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500&quot;</span> <span class="synIdentifier">{</span>...getInputProps<span class="synStatement">(</span>itemFields.name<span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">&quot;text&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Label htmlFor<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.location.id<span class="synIdentifier">}</span> className<span class="synStatement">=</span><span class="synConstant">&quot;block text-sm font-medium text-gray-700&quot;</span> <span class="synStatement">&gt;</span> 場所 <span class="synStatement">&lt;</span>/Label<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>ErrorMessage error<span class="synStatement">=</span><span class="synIdentifier">{</span>itemFields.location.errors<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Input className<span class="synStatement">=</span><span class="synConstant">&quot;border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500&quot;</span> <span class="synIdentifier">{</span>...getInputProps<span class="synStatement">(</span>itemFields.location<span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">&quot;text&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span><span class="synStatement">)</span><span class="synIdentifier">}</span> </pre> <p><code>lists</code>フィールドの要素をマップして、動的なフィールドを描画します。<code>getFieldset</code>メソッドを使って、各要素のフィールドセットを取得し、<code>name</code>と<code>location</code>のフィールドを描画します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink> <span class="synStatement">&lt;</span>Button <span class="synStatement">type=</span><span class="synConstant">&quot;button&quot;</span> <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">()</span> <span class="synStatement">=&gt;</span> form.insert<span class="synStatement">(</span><span class="synIdentifier">{</span> name: fields.lists.name<span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">&gt;</span> 追加 <span class="synStatement">&lt;</span>/Button<span class="synStatement">&gt;</span> </pre> <p>最後に、<code>form.insert</code>メソッドを使って、<code>lists</code>フィールドに要素を追加するためのボタンを追加します。<code>name</code>属性には、<code>fields.lists.name</code>を指定します。これはConformのIntent Buttonと呼ばれる機能で、これにより簡単に動的なフォームの実装をすることができます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fconform.guide%2Fintent-button" title="Conform / Intent button" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://conform.guide/intent-button">conform.guide</a></cite></p> <h2 id="まとめ">まとめ</h2> <p>RemixとConformを組み合わせることで、型安全で動的なフォームを簡単に実装でき、Intent Buttonの機能を使えば複雑なフォームの実装を大幅に簡略化してくれます。</p> <p>ぜひRemixとConformを使って、効率的にWebアプリケーションを開発してみてください。</p> rakutek potatotips というテック系イベントでHealthKitの権限に苦労した話を発表しました hatenablog://entry/6801883189080105748 2024-03-05T20:29:58+09:00 2024-03-05T20:33:09+09:00 クラシルリワードの新卒2年目iOSエンジニアが勢いで potatotips というイベントに登壇しました。参加経緯とCoreMotion・HealthKitの権限取得についてまとめました。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20240305/20240305202745.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、クラシルリワードのiOSエンジニア <a href="https://twitter.com/psnzbss">uetyo</a> です!</p> <p>先日行われた <a href="https://potatotips.connpass.com/event/307311/">potatotips</a> という、日々の開発の Tips を共有するイベントにて未熟ながら登壇したので、今回は登壇に至った背景なども交えながらレポートします!</p> <p>※ 前回は気合いだけで頑張った話を多く載せすぎてしまったので、今回はスマートにいきます</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2Fios_renewal_2022" title="クラシルiOSアプリのリニューアルと新卒iOSエンジニアの奮闘🔥 - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/ios_renewal_2022">tech.dely.jp</a></cite></p> <h2 id="登壇の経緯">登壇の経緯</h2> <p>ある日、上司と1on1をしているとこのような話になりました</p> <hr /> <p>私:今年の目標はスキルの向上もそうですが、社外のイベントとかコミュニティとの繋がりを増やしていきたいです</p> <p>上司:うえちょさんって社外のiOSのコミュニティとかと関わりありますか?</p> <p>私:iOSに関しては全く無いですね…コロナのタイミングで上京したのでイベントが全然なくて、、</p> <p>上司:ならこのイベントとかどうですか?potatotipsというイベントで、LTで登壇とかもできるみたい。とりあえず今日あるみたいだから見てみたらどうですか?</p> <p>私:お、気になります!見ます!</p> <p>数週間後の1on1にて…</p> <p>上司:うえちょさん、potatotipsの次の開催が決まったみたいだけど、登壇とかしてみるのはどうですか?</p> <p>私:はい、やります 🔥🔥<br/> (最近HealthKitとCoreMotionの開発していて躓いていたのでその話をしようかな)</p> <hr /> <p>という流れであっさり決まってしまったイベント登壇でした。<br/> 私自身iOSDCなど大きめのカンファレンスやイベントは見るようにしていたのですが、小規模な(とはいえ参加者が100人近くいる、東京凄い)イベントは全然知らなかったこともあり、ワクワクしながらイベントを見ました。</p> <h2 id="発表内容---HealthKit-と-CoreMotion-の権限に四苦八苦した話">発表内容 - HealthKit と CoreMotion の権限に四苦八苦した話</h2> <script defer class="speakerdeck-embed" data-id="a49f1c87769b43bd90bfd4cf086abaa5" data-ratio="1.7777777777777777" src="//speakerdeck.com/assets/embed.js"></script> <p>クラシルリワードのiOSアプリでは歩数に応じて歩数ゲージが蓄積され、蓄積された歩数ゲージをチケットに交換することができる機能があります。この機能を実現するために利用しているのが HealthKit と CoreMotion です。</p> <p>この HealthKit と CoreMotion の取得したデータを利用するには、OS側の制限によりユーザーからの許可が必要です。この権限の許可率は、歩数機能をどのくらいのユーザーが利用してくれるかを示す重要な指標であるため、できる限り正確なデータの取得が求められていました。</p> <p>私がクラシルからクラシルリワードへ移動したタイミングは、この許諾率を向上させるための施策が実施されていたため、これまで経験のなかった HealthKit と CoreMotion に初めて取り組むことになりました。</p> <p>まずは大枠を掴もうということで、それぞれの権限状態とミニマムな取得方法ついてまとめました。</p> <h2 id="CoreMotionの権限状態と取得方法">CoreMotionの権限状態と取得方法</h2> <p>CoreMotion は iOS4.0 から利用できる、かなり古くからあるフレームワークです。加速度やジャイロスコープなどアプリが入っている端末のセンサーが取得したデータを利用する際や、ユーザの活動タイプ(歩行、自転車など)を特定したりできます。データを取得するにはユーザの許可が必要です。</p> <p>CoreMotionでは以下の4つの状態が存在します。これらは <code>CMAuthorizationStatus</code> として定義されています。</p> <p><figure class="figure-image figure-image-fotolife" title="CMAuthorizationStatusの4つの許可状態"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20240305/20240305174450.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CMAuthorizationStatusの4つの許可状態</figcaption></figure></p> <ul> <li>notDetermined:ユーザーがアプリにモーションデータへのアクセスを許可・否定どちらもしていない状態。</li> <li>authorized:ユーザーがアプリにモーションデータへのアクセスを許可している状態。</li> <li>denied:ユーザーがアプリにモーションデータへのアクセスを拒否している状態。</li> <li>restricted:ペアレンタルコントロールなど、何らかの制限によりアプリがモーションデータへのアクセスを要求できない状態。</li> </ul> <p>CoreMotion の許可状態は比較的簡単に、素直に取得することができます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// CoreMotionの許可状態の取得方法</span> <span class="synPreProc">import</span> CoreMotion <span class="synPreProc">let</span> <span class="synIdentifier">status</span> <span class="synIdentifier">=</span> CMMotionActivityManager.authorizationStatus() <span class="synStatement">switch</span> status { <span class="synStatement">case</span> .notDetermined<span class="synSpecial">:</span> <span class="synType">print</span>(<span class="synConstant">&quot;🐶&lt; ユーザは許可も拒否もしていないわん&quot;</span>) <span class="synStatement">case</span> .authorized<span class="synSpecial">:</span> <span class="synType">print</span>(<span class="synConstant">&quot;🐶&lt; ユーザはアクセス許可しているわん&quot;</span>) <span class="synStatement">case</span> .restricted<span class="synSpecial">:</span> <span class="synType">print</span>(<span class="synConstant">&quot;🐶&lt; アクセスが制限されているわん&quot;</span>) <span class="synStatement">case</span> .denied<span class="synSpecial">:</span> <span class="synType">print</span>(<span class="synConstant">&quot;🐶&lt; ユーザによってアクセスが拒否されたわん&quot;</span>) } </pre> <h2 id="HealthKit-の権限状態と取得方法">HealthKit の権限状態と取得方法</h2> <p>HealthKit は iOS8.0 から利用できる、それなりに古いフレームワークです。ヘルスケアアプリに保存されるデータ(健康関連データ)を利用することができます。データを取得するにはユーザの許可が必要です。</p> <p>HealthKit の大きな特徴として、iPhoneだけでなく、任意のデバイス(AppleWatch等)が取得したデータも一元管理しているので、アプリは特に意識することなく任意デバイスが収集したデータも利用できます。</p> <p>HealthKit では以下の3つの状態が存在します。これらは <strong><code>HKAuthorizationStatus</code></strong> として定義されています。</p> <p><figure class="figure-image figure-image-fotolife" title="HKAuthorizationStatus の3つの権限状態"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20240305/20240305175218.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>HKAuthorizationStatus の3つの権限状態</figcaption></figure></p> <ul> <li>notDetermined:ユーザがアプリに特定の健康データへのアクセスを許可・拒否どちらもしてない状態(アクセス依頼を受けていない状態)</li> <li>sharingAuthorized:アクセスが許可されている</li> <li>sharingDenied:アクセスが拒否されている</li> </ul> <p>HealthKit の許可状態も同様に取得してみるため以下のようにコードを書きましたが、notDetermined以外の状態が正しく取得できません。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// HealthKit の許可状態の取得方法</span> <span class="synPreProc">import</span> HealthKit <span class="synPreProc">let</span> <span class="synIdentifier">store</span> <span class="synIdentifier">=</span> HKHealthStore() <span class="synPreProc">let</span> <span class="synIdentifier">status</span> <span class="synIdentifier">=</span> store.authorizationStatus(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">HKQuantityType</span>(.stepCount)) <span class="synStatement">switch</span> status { <span class="synStatement">case</span> .notDetermined<span class="synSpecial">:</span>     print(<span class="synConstant">&quot;🐶&lt; ユーザは許可も拒否もしてないわん&quot;</span>) <span class="synStatement">case</span> .sharingDenied<span class="synSpecial">:</span>     print(<span class="synConstant">&quot;🐶&lt; ???&quot;</span>) <span class="synStatement">case</span> .sharingAuthorized<span class="synSpecial">:</span>     print(<span class="synConstant">&quot;🐶&lt; ???&quot;</span>) } </pre> <p>Appleは HealthKit の許可状態に関してもプライバシーとして教えてくれないとのことです。</p> <blockquote><p>To help maintain the privacy of sensitive health data, HealthKit does not tell you when the user denies your app permission to query data.</p></blockquote> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fhealthkit%2Fhkauthorizationstatus" title="HKAuthorizationStatus | Apple Developer Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/documentation/healthkit/hkauthorizationstatus">developer.apple.com</a></cite></p> <p>しかしこれだと施策をするうえで非常に困ってしまうので解決方法を模索することにしました。</p> <h3 id="HealthKit-の許可状態を擬似的に取得する">HealthKit の許可状態を擬似的に取得する</h3> <p>色々と試した結果、実際に数値を取得してみて、取得できる→許可されている、取得できない→許可されていない、と判断することで権限状態を擬似的に取得できることが判明しました。</p> <p>notDetermined の状態は取得できるので、そもそも権限付与依頼のモーダルを出していない場合は出すようにします。notDetermined以外の場合は、実際に数値を取得します。この際、 <code>HKError</code> が発生した場合は拒否されている可能性が高く、それ以外のエラータイプ場合は許可されていると判断できます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// HealthKit の許可状態を擬似的に取得する</span> <span class="synPreProc">import</span> HealthKit <span class="synPreProc">let</span> <span class="synIdentifier">store</span> <span class="synIdentifier">=</span> HKHealthStore() <span class="synPreProc">let</span> <span class="synIdentifier">status</span> <span class="synIdentifier">=</span> store.authorizationStatus(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">HKQuantityType</span>(.stepCount)) <span class="synStatement">if</span> status <span class="synIdentifier">==</span> .notDetermined {     print(<span class="synConstant">&quot;🐶&lt; まずはユーザに許可をもとめるわん&quot;</span>) } <span class="synStatement">else</span> {     <span class="synStatement">do</span> {         _ <span class="synIdentifier">=</span> <span class="synStatement">try</span> await 今日の歩数を取得する関数() <span class="synComment">// 実際に取得しようとする</span>         print(<span class="synConstant">&quot;🐶&lt; 今日の歩数が取得できたのでアクセスは許可されているわん&quot;</span>)     } <span class="synStatement">catch</span> <span class="synPreProc">let</span> <span class="synIdentifier">error</span> <span class="synStatement">as</span> <span class="synType">HKError</span> {         <span class="synStatement">switch</span> error.code {         <span class="synStatement">case</span> .errorAuthorizationDenied, .errorAuthorizationDenied, .errorRequiredAuthorizationDenied<span class="synSpecial">:</span>             print(<span class="synConstant">&quot;🐶&lt; アクセスは拒否されているわん&quot;</span>)         <span class="synStatement">default</span><span class="synSpecial">:</span>             print(<span class="synConstant">&quot;🐶&lt; データは取得できないけど許可されているわん&quot;</span>)         }     } <span class="synStatement">catch</span> {         print(<span class="synConstant">&quot;🐶&lt; データは取得できないけど許可されているわん&quot;</span>)     } } </pre> <p>この際、歩数が0歩の場合もエラーになるので、細かくエラーハンドリングする必要があります。</p> <h2 id="まとめ">まとめ</h2> <p><figure class="figure-image figure-image-fotolife" title="CoreMotionとHealthKitの許可状態の取得まとめ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20240305/20240305191313.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CoreMotionとHealthKitの許可状態の取得まとめ</figcaption></figure></p> <p>CoreMotion は何ら問題なく権限状態が取得できますが、HealthKitでは少しひねって取得する必要がありました。</p> <h3 id="参考">参考</h3> <p>HealthKitの開発時にはこちらの記事が参考になりました: <a href="https://qiita.com/dotrikun/items/f34420cb7f3c0fb2ac09">https://qiita.com/dotrikun/items/f34420cb7f3c0fb2ac09</a></p> psbss 複数チームで働く新卒デザイナーがご機嫌にデザインするための6つのマイルール hatenablog://entry/6801883189087239928 2024-03-01T11:12:52+09:00 2024-03-01T14:41:54+09:00 複数チームで働くクラシルリワードの新卒プロダクトデザイナーがマイペースにデザインをできるようにデザイナーとして働く上で心がけている6つのルールをご紹介します。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229202826.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p> </p> <ul class="table-of-contents"> <li><a href="#はじめに">【はじめに】</a></li> <li><a href="#開発体制とやっていることについて">【開発体制とやっていることについて】</a></li> <li><a href="#6つのマイルール">【6つのマイルール】</a><ul> <li><a href="#-依頼の解像度をその日に上げる">『① 依頼の解像度をその日に上げる』</a></li> <li><a href="#-キリの悪いところで終わりにする">『② キリの悪いところで終わりにする』</a></li> <li><a href="#-20分で終わるなら真っ先に対応">『③ 20分で終わるなら真っ先に対応』</a></li> <li><a href="#-行き詰まったらとりあえず生成">『④ 行き詰まったらとりあえず生成』</a></li> <li><a href="#-自分のことをオープンに">『⑤ 自分のことをオープンに』</a></li> <li><a href="#-穏やかにだけど自分を持つ">『⑥ 穏やかに、だけど自分を持つ』</a></li> <li><a href="#最後に">【最後に】</a></li> </ul> </li> </ul> <h3 id="はじめに"> <br />【はじめに】</h3> <p>はじまして、クラシルリワードの23卒プロダクトデザイナーの<a href="https://twitter.com/ha_ru823" target="_blank">haruto</a>です🐒</p> <p>この記事では<strong>「仮説検証を早く回して、スピード感のある開発組織に」</strong>という開発方針を掲げる、爆速の開発スピードを誇るクラシルリワードのデザイナーとして豆腐メンタルの自分がマイペースにデザインができるように心がけている6つのルールをご紹介しようと思います📮</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229232709.png" width="1200" height="674" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">クラシルリワードの開発方針</span></p> <p>また、今回の記事では【デザインを作る上で】ではなく<strong>【デザイナーとして働く上で】</strong>意識していることについてまとめてみました、お読みいただいた方に何か一つでも発見があると嬉しいです☘️</p> <h3 id="開発体制とやっていることについて"> <br />【開発体制とやっていることについて】</h3> <p>早速、前提として自分が所属する開発チームについてご紹介しようと思います🥕<br />メインでリテンション改善スクラムに所属しスクラムイベント等に参加しており、その他の機能開発チームやクリエイティブチームで必要があればデザインを作成するという複数チームに参加するという働き方でデザインをしています。</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234126.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">クラシルリワードの開発体制</span></p> <p>▼開発体制の詳細については以下の記事をご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2F2024%2F01%2F31%2F142210" title="クラシルリワードの開発体制について - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/2024/01/31/142210">tech.dely.jp</a></cite></p> <p>担当しているデザインの業務としては、仮説検証やキャンペーンのために必要になるUIやグラフィック、LP作成などを作成しています🎨</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234136.png" width="800" height="281" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">こんな感じのものを作っています</span></p> <h3 id="6つのマイルール">【6つのマイルール】</h3> <p>働き方に関する前提をご理解いただけたと思うので、さっそく時間や脳内に余白を生み出しマイペースにデザインを進めるために意識していることをご紹介していこうと思います🏃‍♀️💨</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234153.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">6つのマイルール</span></p> <h4 id="-依頼の解像度をその日に上げる">『① 依頼の解像度をその日に上げる』</h4> <p>私たちのスクラムでは1週間分のプランニングを行いますが、冒頭でご紹介したように他のチームからの急な依頼が日常的にあるため、正確な見積もりが難しく、自ら優先順位や期限を調整する必要があります。このような働き方が始まった当初、各チームに必要なデザインの種類、期限、工数が不明で、頭が真っ白になり、「なにすればいいの…🥺」という状態に陥りかけていました。</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234203.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">ぴえん状態</span></p> <p>その頃から、依頼をいただいた際に以下の点を意識的に行うように心がけています。(全てのタスクでしているわけではありませんが、抽象度が高いものや対応範囲が広いものについては必ずやるようにしています。)</p> <p>「依頼をいただいた当日にやること」・期日を確認する・ちょっとだけ作ってみる・軽く作った上で気になる仕様について質問</p> <p> </p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240301/20240301144128.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p style="text-align: center;"><span style="color: #999999;">依頼当日にやること</span></p> <p>当日に素早く作成して質問することで以下の3つのメリットを感じています💭</p> <ul> <li>事前に認識の齟齬を防ぐことができる</li> <li>依頼の全容を把握し、工数を見積もれることで脳内がクリアになる</li> <li>新鮮なタイミングで質問して文字として残すことで、作業時にスムーズに取り掛かることができる</li> </ul> <p>自分に対するメリット以外にも、早い段階でデザインのイメージを相手にも持ってもらうことで、<strong>事前情報が厚くなりスムーズに施策が回せる</strong>という点で大きなメリットがあるかなと思います。</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234222.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">元気100%</span></p> <p>やるべきことをクリアにすることで、最近は「バッチこい!!🔥🔥」状態でご機嫌にデザインをできています💪</p> <h4 id="-キリの悪いところで終わりにする">『② キリの悪いところで終わりにする』</h4> <p>冒頭で触れたように、クラシルリワードは毎日のリリースを目指すほどの爆速開発を行っており、日々様々なチームから多くの依頼を受けています。ほとんどの依頼は1〜2日以内に完成させる必要があるため、必然的に割ける時間限られてしまいます。このような状況の中でもクオリティを維持するため、以下の2点を特に意識しています。</p> <p><strong>① 新しいデザインは夜に始める </strong><br /><strong>② 最終仕上げを2段階で行う(急ぎでない場合)</strong></p> <p><strong>① 新しいデザインは夜に始める</strong></p> <p>①の内容と似ていますが、翌日に新しいデザインを始められそうな場合は、<strong>終業時間の1時間〜30分前にラフデザイン</strong>を作るようにしています。ラフを作りスコープをクリアにした状態で終業することで、<strong>フリーの時間をアイデアを練るや軽いリサーチに充てることができ、翌朝からすぐにスムーズにデザイン作業ができ</strong>るため意識的にするようにしています。</p> <p><strong>② 最終仕上げを2段階で行う(急ぎでない場合)</strong></p> <p>デザインの最終仕上げを行う際、その瞬間は良さそうと感じても、木を見て森をみず状態に陥ることが見失うことが度々ありました。そのため、急ぎでないものについては<strong>フラットな視点で評価するために最終チェックは翌日の朝に改めてセルフフィードバックを行う</strong>ようにしています。</p> <h4 id="-20分で終わるなら真っ先に対応">『③ 20分で終わるなら真っ先に対応』</h4> <p>自分は記憶力がザルで多くのタスクを抱えると整理が追いつかなくなり対応漏れが発生しエラー状態に陥る傾向があります。</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234236.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">エラー中</span></p> <p>そのため、依頼が入った際には、まず簡単に見積もり、20分以内で完了できるものを小タスクとし、小タスクを最優先で対応するようにしています🚗💨</p> <p>20分以内なら対応ルールを実施してから、脳に余白が生まれることでマイペースに大きなデザインタスクに集中できるようになっただけでなく、それぞれの施策で<strong>デザインに待ちの時間が短くなりスムーズに施策や検証ができるようになった</strong>感覚があるのでおすすめのマイルールです✨</p> <h4 id="-行き詰まったらとりあえず生成">『④ 行き詰まったらとりあえず生成』</h4> <p>クラシルリワードには、「クラシうさぎ」などかわいいキャラクターがいます。キャラクターたちに色々なポーズや着せ替えをする中で、特定のテーマに沿ってデザインすることがよくあります。しかし、テーマは定まっていても、なかなかピッタリとくるアイデアが集まらないことや、特定のポーズをどのように表現すれば良いかが掴みにくい時があります。</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234302.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">わからない状態</span></p> <p>そんな時に、便利なダリさん(DALL-E3)に「2頭身で2足歩行のアニメ風のウサギのキャラクターを生成してください」といった適当なプロンプトから徐々にいい感じにイメージに近いイラストを生成してもらっています。他にもテイストの近いイラストを言語化してもらった上でパターンを出してもらうなどゴニョゴニョして、解像度を上げるようにしています💭</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234310.png" width="800" height="419" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">冬っぽいキャンペーンうさぎを作った時</span></p> <p>まだ全然使いこなせていないですが、息詰まって頭が真っ白になりそうな時にChatGPTや生成AIを使って壁打ちをすることで、デザイン作りきれるかな不安期から脱することができるのでおすすめです💡</p> <p>社内でオススメされていたChatGPTの本でデザイナーも使えるTipsがちらほらあったのでシェアハピです📕</p> <p style="text-align: left;"><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbookclub.kodansha.co.jp%2Fproduct%3Fitem%3D0000384884" title="『面倒なことはChatGPTにやらせよう』(カレーちゃん,からあげ) 製品詳細 講談社BOOK倶楽部" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://bookclub.kodansha.co.jp/product?item=0000384884">bookclub.kodansha.co.jp</a></cite></p> <h4 id="-自分のことをオープンに">『⑤ 自分のことをオープンに』</h4> <p>毎週末、週報を作成し新卒チャンネルや日報チャンネルにシェアするようにしています。(毎週欠かさず書いて今週で#46になりました)始めた当初は、自分がやっていることを公開するという縛りをつけることで気を引き締めるために書き始めました。</p> <p>ただ、続けてみるとマイペースにご機嫌でデザインするという点で以下の2つが良かったなと感じています 💭</p> <p><strong>① 自分がやったことをまとめることで成長を振り返れる</strong></p> <p><strong>② ステイクホルダーに自分のことを知ってもらえる</strong></p> <p> </p> <p><strong>① 自分がやったことをまとめることで成長を振り返れる</strong></p> <p>週報として毎週のやったことを記録することで、抜け漏れなく自分が何をやっていたのか、<strong>何ができるようになったか振り返ることができ、ニンマリご機嫌になれます。</strong>また、評価面談等でちゃんと自分がやっている・やってきたことを伝えるための資料として活用でき、漏れなく評価をしてもらうことができるようになると思うのでおすすめです 💭</p> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234333.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">今年のやったことをまとめたシート</span></p> <p> </p> <p><strong>② ステイクホルダーに自分のことを知ってもらえる</strong></p> <p>おそらく複数チームに所属するデザイナーは、なんかいろいろやってる人だけどイマイチ何ができるかわからないなぁと思われがちだと思います。ただ、<strong>週報を通して自分の状態をオープンにすることで、自分のことを知ってもらえるし、自分は知ってもらえる安心感がある</strong>ので心に余裕ができ、次週もマイペースにデザインに臨むことができています。</p> <p>毎週欠かさず書くために↓のようなことを意識して書いています👀もし、よかったら皆さんもチャレンジしてみてください🔥</p> <p> </p> <p><strong>[ 週報で意識していること ]</strong></p> <ul> <li>振り返り &lt; やっていることをみてもらう</li> <li>時間がないなら画像をペタペタするだけでもいいから継続する</li> <li>1分もしないで見切れるくらいのボリュームにする</li> <li>フリーコーナーで仕事以外のことをシェアする</li> </ul> <p style="text-align: center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/ha_ru328/20240229/20240229234342.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span style="color: #999999;">weekly haruto🐒</span></p> <p> </p> <p>最近は、「あいつは〇〇ができそうだから〇〇にアサインしてみよう」と週報きっかけで新たなバッターボックスに立てる機会を獲得するという密かな野望を抱いています😎</p> <h4 id="-穏やかにだけど自分を持つ">『⑥ 穏やかに、だけど自分を持つ』</h4> <p>チームとして、デザイン・プロダクトを作るためにはフィードバックは必要不可欠なものであると思います。好きな言葉に<strong>「Feedback is Gift 🎁」</strong>というものがあるのですが、自分は良いフィードバックをもらうために以下の2点を意識しています。</p> <p><strong>① フィードバックや意見を伝えてもいい雰囲気を醸し出す</strong></p> <p><strong>② 自分を持った質問をする</strong></p> <p> </p> <p><strong>① フィードバックや意見を伝えてもいい雰囲気を醸し出す</strong></p> <p>まずそもそもフィードバックをもらうためにはあの人には伝えても大丈夫と思ってもらうために感謝の気持ちを常に伝えるよう心がけたり、スタンプを使って積極的に反応し、会話しやすいようにということを心がけています。</p> <p><strong>② 自分を持った質問をする</strong></p> <p>ただ単にフィードバックを求める雰囲気を作るだけでは、自分が求める方向性の良いフィードバックを受け取ることは難しいと思います。そこで<strong>「何をみて欲しいのか」や「何で迷っていて、自分は何がいいと思うのか」</strong> のように具体的に投げかけるようにし、相手にコメントする箇所のスコープを示すようにしています。</p> <p>上記の2点を心がけることで、お互いに嫌な気持ちにならない良いフィードバックをいただくことができ、良いデザイン・プロダクトに向けた建設的なやりとりができるのでおすすめです💡</p> <p>今回は【デザイナーとして働く上で意識していること】がテーマなので実際にやっていることはふわっとしか書かないですが、「みんなではじめるデザイン批評」という本をご紹介します📕(自分はまだnoteにあるサマリに目を通したレベルですが学びがたくさんあったのでおすすめです!💡)</p> <p style="text-align: left;"><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.kinokuniya.co.jp%2Ff%2Fdsg-08-EK-0347152" title="みんなではじめるデザイン批評 - 目的達成のためのコラボレーション&amp;コミュニケーション改善ガイド" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.kinokuniya.co.jp/f/dsg-08-EK-0347152">www.kinokuniya.co.jp</a></cite></p> <h4 id="最後に">【最後に】</h4> <p>ここまでお読みいただきありがとうございました!</p> <p>まだまだまだまだ未熟ですが、なんとかデザイナー1年目をマイペース乗り切ることができそうです 💭2年目も引き続きマイルールを大切に、更新しながらメンバーといいプロダクトを作り、ユーザーの皆様により良い価値提供ができるデザイナーになれるように精進して参りたいと思います💪</p> <p>クラシルリワードでは、一緒にアプリを作ってくださるデザイナーの方を募集しています!<br />ちょっとでも面白そうだな思っていただけた方、ぜひご応募お待ちしています!✨<br /><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fprojects%2F1586581" title="成長率235%の急成長事業クラシルリワードアプリを磨き上げるデザイナー募集 - dely株式会社のUI/UXデザイナーの採用 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/projects/1586581">www.wantedly.com</a></cite></p> <p> </p> <p> </p> <p> </p> ha_ru328 チームと自分を成長させるためにチーム開発で心がけたいこと hatenablog://entry/6801883189083580115 2024-02-16T18:17:18+09:00 2024-02-16T18:17:18+09:00 こんにちは。Androidエンジニアのkenzoです。 今回は普段チームでプロダクトを開発を行う際に、プロダクト・チーム・そして自分自身の成長のために心がけていること、またそうありたいと思っていることを少しだけご紹介します。 これらは内容としては当たり前のことかもしれませんが、改めて意識し実践することで、少しずつそれぞれの成長に繋げていくことができると考えています。 失敗から学ぶ 開発を進める中で、大小様々な事故やミスが発生することがあります。 リリース後のアプリがクラッシュするような大きな事故や、実装時に見つけてハッとして直すような小さなミスなど、失敗の種類は多岐にわたります。 できれば全て… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kenzo_aiue/20240216/20240216164217.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> こんにちは。Androidエンジニアのkenzoです。<br/> 今回は普段チームでプロダクトを開発を行う際に、プロダクト・チーム・そして自分自身の成長のために心がけていること、またそうありたいと思っていることを少しだけご紹介します。<br/> これらは内容としては当たり前のことかもしれませんが、改めて意識し実践することで、少しずつそれぞれの成長に繋げていくことができると考えています。</p> <h2 id="失敗から学ぶ">失敗から学ぶ</h2> <p>開発を進める中で、大小様々な事故やミスが発生することがあります。<br/> リリース後のアプリがクラッシュするような大きな事故や、実装時に見つけてハッとして直すような小さなミスなど、失敗の種類は多岐にわたります。<br/> できれば全て事前に防ぎたいところですが、起きてしまったものは仕方ないので、それらについては事後にきちんと振り返り、そこからきちんと学んで繰り返さないようにします。</p> <p><strong>・失敗は成長の種</strong><br/> 大抵の事故は、複数の要因が組み合わさることで発生します。たとえば、変更に弱い実装だった、コードレビューで見逃された、テストケースにそのパターンが含まれていなかった、仕様を勘違いしていた、特定の条件でしか起きないものだった、他の施策との兼ね合いで本番環境でのテストが難しいタイミングだった、などなど、様々な要因が影響します。どれか1つでも防げていれば回避できたかもしれませんが、実際にはどれもすり抜けた結果、事故は起きてしまいます。<br/> 適切な対応をした後にきちんとチームで振り返れば、いくつもの改善点を発見できると思います。これらに対処することがプロダクト・チーム・自身の成長に繋がるかもしれません。<br/> それぞれをタスクに起こして一つずつ解決していけば(難しいものもあるでしょうが)、全く同じ要因による再発は防げます。<br/> 小さいかもしれませんが、成長ができたと言えるのではないでしょうか。</p> <p><figure class="figure-image figure-image-fotolife" title="チームで振り返りをしたときの議事録"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kenzo_aiue/20240216/20240216175414.png" width="380" height="400" loading="lazy" title="" class="hatena-fotolife" itemprop="image" style="display:block;margin:0 auto;"><figcaption>チームで振り返りをしたときの議事録</figcaption></figure></p> <p><strong>・失敗について話しやすい環境</strong><br/> チームでの失敗には様々な物がありますが、元を辿れば個人の失敗に起因することがあります。それをチームで振り返る際には、誰かのミスを指摘することになりがちですが、する側もされる側も嫌だと思います。 そのため、普段から全員が失敗は共有してみんなで振り返るのが当たり前という認識を持てるような環境をつくることや、失敗を取り上げる際の方法にも配慮することが必要です。</p> <p><strong>・個人の場合</strong><br/> 個人の場合も、事故には至らなくとも、ちょっとしたミスや「あの時こうしていれば」と後悔することはよくあると思います。<br/> こういう日々の伸びしろを逃すことなく、それぞれに積極的に対応することで少しずつ成長していきたいですね。</p> <h2 id="未来の読み手を意識したコード">未来の読み手を意識したコード</h2> <p>今度はコードの話です。<br/> 長く続くプロダクトの仕様や技術が古くなるにつれて、コードの理解が難しくなっていきます。開発が速かったり、変化の激しい環境においてはなおさらで、数ヶ月後にはもはや別のプロダクトのようになっていることもあります。<br/> 現時点でさえ理解しにくいコードは将来さらに読みにくくなります。<br/> 過去のコードに苦しんだ経験のある開発者も少なくないと思います。自分の書くコードを読む未来の開発者にそんな思いをさせないためにも、未来の読み手を意識した親切なコードとその周辺環境を作っていきたいところです。<br/> 背景を知らずドメイン知識のない開発者が一人でそのリポジトリを見たとして、どれくらい理解できるのだろうと考えたりしています。</p> <p><strong>・ベストプラクティスに従う</strong><br/> 基本的には公式のドキュメントで紹介されている書き方や一般的に良いとされている書き方に従うことを推奨したいです。更新頻度の高い新しいフレームワークを使ってる部分でなければAIに教えてもらうのも良さそうです。<br/> 世の多くの開発者に良いと認識されているものがベストプラクティスなので、そのベストプラクティスに則ってコードが書かれていれば、新たにそのコードを触る開発者に「こう書いてね。」と伝えるコストも低く抑えられます。<br/> また、より良いものが新たに生まれた場合には、誰かが紹介してくれるマイグレーションのプロセスにそのまま乗ることもできるかもしれません。<br/> 独自のより良い書き方を生み出して使うのも良いですが、その場合にかかる諸々のコストも考慮し、覚悟の上で導入する必要がありそうです。</p> <p><strong>・書き手は最強</strong><br/> ある処理の実装を書いたとします。書いた直後はその処理についての全てを把握しているので、そのコードの把握も非常に容易です。<br/> そのため、もしそのコードが複雑で読みにくいものとなっていたとしても、それに気付くのは難しいでしょう。<br/> 簡単にはいかないかもしれないですが、他の人や未来の自分が見たらどう思うか、◯◯を見て☓☓だとわかるか、というように一旦意識的に客観的な視点から再度コードを読んでみてもいいかもしれません。<br/> これはちょっと前に自分の書いたコードがクソコードに見える現象の一因にもなっている気がします。</p> <p><strong>・なぜ必要?なぜここに?なぜこう書く?</strong><br/> 自分の書いたコード全てについて、これらの質問に明確に答えられるようにしておきたいです。<br/> 回答が明確でないコードは、他の人が理解するのに時間がかかったり、誤解によるバグを生む可能性もあります。<br/> 逆に明確なコードは、他の人がその意図に沿って開発できたり、意図の共有による技術力の向上にまで貢献するかもしれません。<br/> できることならこの質問を何段階か深堀りすると良さそうです。</p> <p><strong>・手がかりを正しく残す</strong><br/> プロダクトを開発していると、ドメインの仕様に依る箇所や複雑な仕様の処理など、コードからきちんと仕様を把握するのが難しかったり時間がかかるものがあります。<br/> それらを正しく理解するためにコメントやドキュメント、仕様書等で手がかりを残すことになりますが、それを有用なまま維持しておくことが意外と難しいこともあります。<br/> 変更に追従できていないドキュメントや、作ったものが何らかの移動やツールの変更のタイミングで失われるなど、環境によって様々ではありますが、時間の経過でアクセスが難しくなったり害悪になってしまうものもあります。<br/> 残す場合にはそれが将来必要な時が来るまで維持できる仕組みまで考えたいところです。</p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kenzo_aiue/20240216/20240216175836.png" width="110" height="160" loading="lazy" title="" class="hatena-fotolife" itemprop="image" style="display:block;margin:0 auto;" ></p> <h2 id="おわりに">おわりに</h2> <p>今回ご紹介した内容は、多くの開発者が無意識に行っていることかもしれませんが、改めて意識的に実施することで効果を発揮する部分もあると思います。<br/> 私自身としても徹底できていないところもあるので、これらのポイントを意識し、より良いチーム開発を行っていけるよう精進していきたいと思います。</p> kenzo_aiue 学習サイクルを素早く回すために意識していること hatenablog://entry/6801883189079907591 2024-02-02T15:00:46+09:00 2024-02-02T15:00:46+09:00 はじめに こんにちは!クラシルリワードでサーバーサイドエンジニア兼 PM をしている宇野です。 自分は去年の3月からクラシルリワードに JOIN して、おみくじや歩数、お得タブなど新機能の実装を担当してきました。 この記事では、学習サイクルを素早く回すために自分が意識していることを紹介します。 左: おみくじ機能 右: お得タブ dely にきて初めて記事を書くので軽く自己紹介をさせてください。こんな人です dely にきてそろそろ2年が経過します。 ポケモンとご飯が好き。土日は大体ポケモン対戦か美味しいお店を巡っています。 最近食べて美味しかったのは「だしいなり海木 日本橋店」です tabe… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kohei-uno/20240202/20240202105756.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="はじめに">はじめに</h1> <p>こんにちは!クラシルリワードでサーバーサイドエンジニア兼 PM をしている宇野です。</p> <p>自分は去年の3月からクラシルリワードに JOIN して、おみくじや歩数、お得タブなど新機能の実装を担当してきました。</p> <p>この記事では、学習サイクルを素早く回すために自分が意識していることを紹介します。</p> <p><figure class="figure-image figure-image-fotolife" title="左: おみくじ機能 右: お得タブ"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kohei-uno/20240202/20240202084508.png" width="716" height="698" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kohei-uno/20240202/20240202084535.png" width="730" height="796" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>左: おみくじ機能 右: お得タブ</figcaption></figure></p> <p>dely にきて初めて記事を書くので軽く自己紹介をさせてください。こんな人です</p> <ul> <li>dely にきてそろそろ2年が経過します。</li> <li>ポケモンとご飯が好き。土日は大体ポケモン対戦か美味しいお店を巡っています。</li> <li>最近食べて美味しかったのは「だしいなり海木 日本橋店」です <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Ftokyo%2FA1302%2FA130202%2F13239412%2F" title="だしいなり海木 日本橋店 (新日本橋/いなり寿司)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tabelog.com/tokyo/A1302/A130202/13239412/">tabelog.com</a></cite></li> </ul> <p>それでは本題にいきます!!</p> <h1 id="想定読者">想定読者</h1> <p>クラシルリワードは約5人1チームの少人数で開発をしています。そのため、同じぐらいの人数で開発をしているエンジニアを想定しています。</p> <p>詳しい開発体制については開発責任者の funzin の記事を見てもらえると嬉しいです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2F2024%2F01%2F31%2F142210" title="クラシルリワードの開発体制について - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/2024/01/31/142210">tech.dely.jp</a></cite></p> <h1 id="なぜ素早く学習したいのか">なぜ素早く学習したいのか</h1> <p>そもそもなぜ素早く学習サイクルを回したいのかというと、開発した機能をユーザーさんが使ってくれるかは分からないからです。どんなに仕様を綺麗にしてバグがない状態でリリースできても、ユーザーさんが使ってくれないとビジネス価値にはつながりません。特に自分が担当している新機能開発ではユーザーさんが動くか分からないので、リッチな仕様にするのではなく価値検証ができるミニマムの仕様でリリースすることを心がけています。そして、分析・学習をして次に活かすというサイクルが大事になってきます。大きくなる前にリスクを潰せて前に進むことができ、小さいチームでも大きな仕事ができます。</p> <h2 id="目標を可視化してチームで進捗の認識を合わせる">目標を可視化して、チームで進捗の認識を合わせる</h2> <p>自分たちはスクラム開発を採用しており、毎週スプリントプランニングを開催してスプリントゴールを設定しています。ただ、目標を設定するだけでは日々の仕事に追われて意識することは難しく、週の終わりに思い出すという事はよくあると思います。</p> <p>この解決のために開発するときによく利用する場所にスプリントゴールを書くことにしています。Slack チャンネルのトピックやデイリースクラムで毎日見る Notion に貼り自然に目につく仕組みにしています。さらに、スプリント中盤に目標の進捗を確認することで「順調に進んでいるか」「順調ではない場合、ボトルネックは何か」を議論できるようにしています。 こうすることで、チーム全体で目標の認識が揃い最短で目標達成するための行動が考えられるので、開発仕様が洗練されてリリースまでの期間が短くなります。</p> <p><figure class="figure-image figure-image-fotolife" title="左: Slack 右: Notion"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kohei-uno/20240202/20240202105500.png" width="1006" height="236" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kohei-uno/20240202/20240202105511.png" width="567" height="151" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>左: Slack 右: Notion</figcaption></figure></p> <h2 id="専門領域を越境する">専門領域を越境する</h2> <p>(※) ここでいう越境は、自分の専門領域外の仕事をするだけではなく関心を持つ・学ぶことも含めます。</p> <p>これはリリースまでの期間を短くすることに直接は関係ないのですが、専門領域だけではなく他の領域に越境することで学習サイクルが速くなると考えています。エンジニアなら「CS でどういうお問い合わせが来ているのか」「マーケティングチームはどういう施策を考えているのか」などを知ることを指します。これをすることで、相手の話している背景や目的を理解することができるので、「それを実現したいなら、この方法の方が最速で出せる」という話ができたりします。また、優先して改善すべき機能が分かったり、将来的にやりたいことの解像度が上がり設計にも役立ちます。</p> <p>当時、自分は広告周りのことが全く分からないまま PM にアサインされたので事業責任者にお願いして広告勉強会を開いてもらったりしました。これは人数が少ないからそうせざるを得ない部分もありますが、越境をすることで自分が関わっているサービスを開発以外の観点からも見ることができ開発がより自分ごと化します。</p> <p>注意点としては、インプットする情報を少なくして専門領域に集中して成果を出すことが求められることもあります。越境する時・しない時のタイミングは意識して考えるのが良いと思います。</p> <h2 id="初期は手動運用でスタートする">初期は手動運用でスタートする</h2> <p>エンジニアの三大美徳に「怠惰」があり、これは「楽をするために努力を惜しまない」と説明されることが多いです。例えば、繰り返し作業をするのが面倒なので、自動化して効率を上げるために開発をするなどです。これとは対立するのですが初期の学習サイクルを1秒でも早く回すために運用効率化の改善をしないのはありだと考えています。</p> <p>「簡単に実装できるし手動運用めんどくさいから管理画面を作ってからリリースする」と考えたくなりますが、「設計 → 実装 → テスト」というフローを考えると最短でも 1 - 2日はかかると思います。せっかく管理画面を作っても機能を使って貰えなかったら意味がありません。</p> <p>ユーザーさんに機能を触ってもらい価値検証ができるまでは手動運用で頑張り、検証ができてから自動化をします。</p> <p>例えば、以下のようなパターンが考えられます。</p> <ul> <li>管理画面は作らず、CSV とスクリプトでデータベースの更新をする</li> <li>ハードコードをして、変更する場合は都度デプロイする</li> <li>エンドポイントを用意せず、Firebase Remote Config から情報を取得する</li> </ul> <p>「たかが1日、されど1日」ということで、少しでも開発工数を削減してリリースすることを心がけています。もちろん、運用の効率化も大事なことなので機能開発と運用改善はメリハリが大事になります。</p> <h2 id="最後に">最後に</h2> <p>今回は「学習サイクルを素早く回すために意識していること」を紹介してきました。</p> <p>1つ1つはすごく簡単なことですが、凡事徹底してこれからもクラシルリワードを改善していきます。</p> <p>ここまで読んでいただき、ありがとうございました!</p> kohei-uno クラシルリワードの開発体制について hatenablog://entry/6801883189079266196 2024-01-31T14:22:10+09:00 2024-01-31T14:22:41+09:00 こんにちは!クラシルリワードで開発責任者をしているfunzinです。 この記事ではクラシルリワードの開発体制についてお話ししていきます。 カジュアル面談や面接でどのような開発体制かを聞かれることが増えてきたため、こちらに記事としてまとめていきます。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240130/20240130214836.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"><ul class="table-of-contents"> <li><a href="#プロダクトの開発方針">プロダクトの開発方針</a></li> <li><a href="#開発体制">開発体制</a><ul> <li><a href="#1-スモールチーム開発">1. スモールチーム開発</a></li> <li><a href="#2-毎日リリース可能な体制">2. 毎日リリース可能な体制</a></li> <li><a href="#3-CRMツールを活用した施策検証">3. CRMツールを活用した施策検証</a></li> <li><a href="#4-CopilotやChatGPTなどの生成系AIを活用">4. CopilotやChatGPTなどの生成系AIを活用</a></li> <li><a href="#5-M3-Maxの導入">5. M3 Maxの導入</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> <p>こんにちは!クラシルリワードで開発責任者をしている<a href="https://twitter.com/_funzin">funzin</a>です。 この記事ではクラシルリワードの開発体制についてお話ししていきます。 カジュアル面談や面接でどのような開発体制かを聞かれることが増えてきたため、こちらに記事としてまとめていきます。</p> <h2 id="プロダクトの開発方針">プロダクトの開発方針</h2> <p>クラシルリワードのプロダクトの開発方針として、「<strong>仮説検証を早く回して、スピード感のある開発組織に</strong>」を掲げています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240130/20240130201931.png" width="1200" height="674" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>プロダクト開発をする上で、下記のような状況に一度は遭遇したことがある方も多いかと思います。<br> e.g. 数ヶ月かけて開発をした施策が、結局ユーザーに使われなかった <figure class="figure-image figure-image-fotolife" title="DALL-E 3で生成した画像"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240131/20240131120211.jpg" width="768" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>DALL-E 3で生成した画像</figcaption></figure></p> <p>クラシルリワードはまだリリースして1年半ほどのサービスで成長途中なこともあり、まだまだ機能が足りていない箇所がたくさんあります。 そのため施策を早くリリース、検証、改善していく流れがとても重要となります。</p> <p>次の章でどのような開発体制でスピード感のある開発組織を実現しているかを紹介していきます。</p> <h2 id="開発体制">開発体制</h2> <h3 id="1-スモールチーム開発">1. スモールチーム開発</h3> <p>2024/1現在、下記のような開発体制で行っています。 <figure class="figure-image figure-image-fotolife" title="2024/01現在のチーム体制"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240130/20240130205223.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>2024/01現在のチーム体制</figcaption></figure></p> <p>大きく機能開発チームと横断チームに分かれています。<br> 機能開発チームでは職能別で5名ほどのチームを作り、機能開発を行っています。 チームトポロジーでいう、ストリームアラインドチームに近しいです。<br> 原則機能開発チーム内で施策や開発プロセスを全て完結するようにしています。<br> <figure class="figure-image figure-image-fotolife" title="機能開発チーム"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240130/20240130210331.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>機能開発チーム</figcaption></figure></p> <p>このようなチーム体制にしている意図としては下記です。</p> <ul> <li>目標KPIに対して、機能開発チームで責任を持つ</li> <li>少数精鋭でやり切る <ul> <li>チーム内に人数が多くてもタスクのお見合いやコミュニケーションパスの複雑性が発生する</li> <li>各ポジション1名を基本として、足りない領域はチームでカバーする</li> </ul> </li> </ul> <p>どのように機能開発チームが運用されているかは、こちらの記事をご覧ください。<br> <a href="https://tech.dely.jp/entry/2024/01/19/172423">クラシルリワードのプロダクトマネージャーの1週間はどんな感じ?</a></p> <h3 id="2-毎日リリース可能な体制">2. 毎日リリース可能な体制</h3> <p>施策を早く検証する上で、リリース頻度はとても重要になります。 そのため各領域で毎日リリースができる体制を整えています。モバイルとサーバーサイドでのリリース頻度は下記のようになっています。</p> <ol> <li>モバイル(iOS, Android) <ul> <li>各機能開発チームがリリースしたい施策がある場合、いつでも審査提出が可能な状態</li> <li>週によっては<strong>5回リリース</strong>している日もある(平均週2ペース) <figure class="figure-image figure-image-fotolife" title="iOSのリリースログ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240131/20240131120450.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>iOSのリリースログ</figcaption></figure></li> </ul> </li> <li>サーバーサイド <ul> <li>GitHub ActionsとAWS CodeBuildを活用し、mainブランチにマージしたタイミングでリリースフローが実行される</li> </ul> </li> </ol> <p>高頻度なリリースを実現する上で、下記のようなことに取り組んでいます。</p> <ul> <li>PRの粒度を小さくして、早くマージする(レビューの負担を減らす) <ul> <li>チーム全体が共通の認識を持つことでPRサイズを小さく保ち、レビューが早くなる</li> <li>ヘルスチェックとして<a href="https://offers-mgr.com/">OffersMGR</a>を利用して、FourKeysのチェックも行っています <figure class="figure-image figure-image-fotolife" title="とあるチームのFourKeys"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240130/20240130211004.png" width="1052" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>とあるチームのFourKeys</figcaption></figure></li> <li>職能のLDRが隔週でFourKeysのレポートをしています <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20240131/20240131122319.png" width="1200" height="713" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> </li> <li>FeatureFlagを利用したトランクベース開発を活用 <ul> <li>開発環境では常に新機能が確認できる状態(mainブランチに含まれるため)</li> <li>開発中の施策をTestFlight・Firebase App Distributionで配布することで動作確認を行い、すぐにリリースできるようにしている</li> </ul> </li> <li>細かくリリースし続けることでQAスコープの対象も小さくする <ul> <li>そのぶんQAの頻度は上がってきますが、全体機能のデグレを検知できるようにMagicPodを活用<br> <a href="https://tech.dely.jp/entry/rewards_qa">クラシルリワードにおける自動テストツール MagicPodの導入事例</a></li> </ul> </li> </ul> <h3 id="3-CRMツールを活用した施策検証">3. CRMツールを活用した施策検証</h3> <p>施策をする上で第一に機能開発をすることを考えますが、新しい機能を開発する前にCRMツール(<a href="https://karte.io/">KARTE</a>, <a href="https://repro.io/">Repro</a>)を活用できるかを考えます。<br> e.g. RemoteConfig, プッシュ通知、ポップアップなど</p> <p>CRMツールを利用して開発工数を抑えて検証が行えるのであれば、それらを利用するがベストです。 これによって、実際に開発してみたけど使われなかったといった問題も防ぐことができます。</p> <p>実際にどのようなユースケースがあるかを紹介します。<br></p> <p><strong>ユースケース: おすすめ運用枠の検証</strong></p> <ol> <li>運用枠などをRemoteConfigで表示(CTR/CVRの検証)</li> <li>上記で効果が見込めるかつ、運用し続ける場合は、API化や管理画面の入稿できる対応を検討する</li> </ol> <p>運用枠の効果が見込めない場合は、1の検証のみで終了し2の開発工数を抑えることができます。 施策をする上で自分たちで全てを実装する以外にCRMツールを活用することで開発工数を抑えた検証が行えます。</p> <h3 id="4-CopilotやChatGPTなどの生成系AIを活用">4. CopilotやChatGPTなどの生成系AIを活用</h3> <p>生成系AIを活用することで、開発の効率化を図っています。<br> エンジニアチームには<a href="https://docs.github.com/ja/copilot">GitHub Copilot</a>を導入し、コーディングの開発の負担を減らしています。<br> また施策を分析する上で、クエリを書く機会が多いですがこちらも補完が効くため重宝しています。<br> (Copilotはコードだけでなく文章の補完もできるので、このブログの下書きにも利用しています。)</p> <p>今までは社内の利用規則に基づいて個々人がChatGPTを使っていましたが、1月に<a href="https://openai.com/chatgpt/team">ChatGPT Team</a>が出たため、さっそく導入し活用できなかったビジネスデータをChatGPT上で利用し始めています。<br> 下記のような社内専用のGPTsを作って、各チームで利用できる形にしていきたいと考えています。<br> e.g. リリース文言、施策案、クエリ補助</p> <p>まだTeamプランは導入したてなこともあり、実際導入してみてどうだったかは別の機会に紹介できればと思います。</p> <h3 id="5-M3-Maxの導入">5. M3 Maxの導入</h3> <p>最後にPCのスペックについても触れさせてください。<br> 今までエンジニアはM1 Maxを利用していましたが、検証端末でアプリのビルド時間の計測を行い、投資値効果があうと判断したためM3 Maxを2024/4から導入予定です。<br> 検証機としてM3 Maxを2台購入し、iOSアプリでのクリーンビルド時間がM1 Maxに比べて半分(<strong>2min -> 1min</strong>)になったため上記のような意思決定を行いました。</p> <p>M3 Maxは下記2つのサイズで選択できるようにしています。</p> <pre class="code" data-lang="" data-unlink>1. 14インチ - 16コアCPU、40コアGPU、16コアNeural Engine - 64GBユニファイドメモリ - 1TB SSDストレージ 2. 16インチ - 16コアCPU、40コアGPU、16コアNeural Engine - 64GBユニファイドメモリ - 1TB SSDストレージ</pre> <p>純粋にマシンスペックを上げることも、開発生産性において重要な意思決定の一つです。</p> <h2 id="まとめ">まとめ</h2> <p>クラシルリワードの開発体制について紹介してきました。 クラシルリワードでどのようにプロダクト開発を行っているかのイメージがもし伝われば幸いです。</p> funzin クラシルリワードのプロダクトマネージャーの1週間はどんな感じ? hatenablog://entry/6801883189076073479 2024-01-19T17:24:23+09:00 2024-01-19T17:24:23+09:00 はじめに こんにちは!クラシルリワードでプロダクトマネージャーをしているerinaです! 今回のブログでは、クラシルリワードチームでプロダクトマネージャーとしてどんな1週間を過ごしているのを書きたいと思います。 本題に入る前に、簡単に私の背景を紹介したいと思います。dely株式会社では、2022年2月に入社し、最初はTRILLアプリに所属して、2023年にクラシルリワードに異動し、もうすぐ3年目を迎えます。プロダクトマネージャー歴は前職も含めて約5年になります。 1週間はどんな感じ? いろんな人から「プロダクトマネージャーはどんなことしている?」「プロダクトマネージャーってコードを書くの?」よ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118183955.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="はじめに">はじめに</h3> <p>こんにちは!クラシルリワードでプロダクトマネージャーをしているerinaです! 今回のブログでは、クラシルリワードチームでプロダクトマネージャーとしてどんな1週間を過ごしているのを書きたいと思います。</p> <p>本題に入る前に、簡単に私の背景を紹介したいと思います。dely株式会社では、2022年2月に入社し、最初はTRILLアプリに所属して、2023年にクラシルリワードに異動し、もうすぐ3年目を迎えます。プロダクトマネージャー歴は前職も含めて約5年になります。</p> <h3 id="1週間はどんな感じ">1週間はどんな感じ?</h3> <p>いろんな人から「プロダクトマネージャーはどんなことしている?」「プロダクトマネージャーってコードを書くの?」よく聞かれますので、非技術系出身のプロダクトマネージャーはどのような一週間を過ごすかが具体的にイメージできるようになると思います!</p> <p>私が所属しているユーザーリテンション改善スクラムは、部分的にアジャイル開発を導入しているので、基本スクラムイベント(1週間単位)と合わせて調整しています。大きく分けると以下の通りの1週間となります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118172118.png" width="1200" height="774" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="毎日"><毎日></h4> <h5 id="KPIモニタリング">KPIモニタリング</h5> <ul> <li>出勤後にまず前日のKPIを確認する</li> <li>数値の変化があれば調査したり、デイリースクラムでメンバーに共有したりする <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240119/20240119090822.png" width="1200" height="288" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> <h5 id="デイリースクラム">デイリースクラム</h5> <ul> <li>Jiraを使って、チームメンバーと進捗、ブロッカーや相談事項があるかを確認する</li> <li>状況によって急遽対応しないといけないタスクがあれば優先順位を相談する</li> <li>検証中の施策があれば、目標KPIを一緒に確認する</li> </ul> <h4 id="月曜日"><月曜日></h4> <ul> <li>スプリントバックログを整理</li> <li>今のスプリントの達成度を確認する</li> <li>次のスプリントのバックログと優先順位を整理する</li> <li>火曜日プラニング会の資料を更新する → 検証中の施策のインサイトをまとめ、次のスプリント項目の共有など</li> </ul> <h4 id="火曜日"><火曜日></h4> <h5 id="プラニング会-w-PO--他のスクラムPM">プラニング会 w/ PO &amp; 他のスクラムPM</h5> <ul> <li>今進行中と予定のバックログの方向性のズレがないかをすり合わせする</li> <li>他のステークホルダーに共有・相談する</li> </ul> <h4 id="水曜日"><水曜日></h4> <h5 id="レトロスペクティブ">レトロスペクティブ</h5> <ul> <li>KPIと今回のスプリントの達成度の振り返り</li> <li>KPTのフレームワークでチームメンバーと「Keep(成果が出ていて継続すること)」「Problem(解決すべき課題)」を洗い出し、「Try(次に取り組むこと)」を検討する <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118185525.png" width="781" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> <h5 id="スプリントプラニング">スプリントプラニング</h5> <ul> <li>事前にスプリントバックログとチケットをJiraに追加する</li> <li>検討・分析タスクの場合、相談・調査したい要件をまとめる</li> <li>バックログとゴールをメンバーに共有し、リソースや優先順位を調整する <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118185127.png" width="1200" height="518" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> <h4 id="木曜日と金曜日"><木曜日と金曜日></h4> <ul> <li>仕様書の更新とバックログの整理</li> <li>バックログの詳細をエンジニアとデザイナーとすり合わせし、仕様書を更新する</li> <li>ロードマップを調整しながら、次のバックログを整理する <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118185405.png" width="1200" height="701" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> <h4 id="そんな中で業務で心がけていること">そんな中で業務で心がけていること</h4> <p>クラシルリワードチームのプロダクト開発の考え方 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/erinakg/20240118/20240118180143.png" width="1200" height="556" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h5 id="スピード感を大事にする">スピード感を大事にする</h5> <p>特にクラシルリワードチームで大事にしているのは、データ分析を基に具体的な仮説を構築し、スピーディに検証を行い、改善効果を評価して、そのサイクルを繰り返すことです。</p> <p>クラシルリワードチームに異動してからは、チームの迅速なアプローチに驚かされましたが、意識的にアプローチを進めることで、今も多いときは週1回の検証を行っています。これにより、より効果的な意思決定が可能となり、目標達成への方向を見つけることができたと考えています。</p> <h3 id="おわりに">おわりに</h3> <p>クラシルリワードでプロダクトマネジャーの一週間のスケジュールはこんな感じになります。いかがだったでしょうか?</p> <p>これからチームが意識していることを念頭に置いて、今後もユーザーの基礎体験の改善を推進していきます。クラシルリワードを引き続き楽しみにしていただけると嬉しいです🐰🥕</p> erinakg 既存のRails 7アプリケーションにread/writeを分ける仕組みを導入でdatabaseの負荷分散 hatenablog://entry/6801883189068283568 2023-12-22T18:08:00+09:00 2023-12-22T18:08:00+09:00 こんにちは、クラシルリワードのサーバーサイドエンジニアのhaindです。 この記事では、クラシルリワードのdatabase負荷を分散するために、既存のRails 7アプリケーションにdatabaseのread/writeを分ける仕組みを導入した事例についてお話ししたいと思います。 現状と課題 クラシルリワードのサーバーサイドではRails 7を使っており、MySQLをdatabaseとして採用しています。初期段階から、replica(reader)とprimary(writer)のインスタンスが存在していましたが、アプリケーションはprimaryにのみ向けられていました。replicaインスタ… <p><figure class="figure-image figure-image-fotolife" title="image"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/haind111/20231220/20231220190514.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption></figcaption></figure> こんにちは、クラシルリワードのサーバーサイドエンジニアのhaindです。 この記事では、クラシルリワードのdatabase負荷を分散するために、既存のRails 7アプリケーションにdatabaseのread/writeを分ける仕組みを導入した事例についてお話ししたいと思います。</p> <h1 id="現状と課題">現状と課題</h1> <p>クラシルリワードのサーバーサイドではRails 7を使っており、MySQLをdatabaseとして採用しています。初期段階から、replica(reader)とprimary(writer)のインスタンスが存在していましたが、アプリケーションはprimaryにのみ向けられていました。replicaインスタンスは障害発生時のフェールオーバー用に設けられています。</p> <p>クラシルリワードアプリの速い成長に伴い、databaseへのトラフィックも早く増えています。ただ、databaseのprimaryインスタンスだけがクエリを処理するため、負荷が高いです。 primaryインスタンスのCPU使用率が特定の閾値を超えると、インスタンスをスケールアップする必要がありますが、この作業にはダウンタイムが伴うため、深夜にメンテナンスを行うことにしています。</p> <p>それに加えて、databaseのreplicaが存在するにもかかわらず、それをクエリに活用できないのはもったいないです。クエリの増加に対して、replicaに負荷の半分を分散することで、本番のスケールアップをスキップし、コストを削減できます。負荷分散によって処理速度も向上できます。</p> <h1 id="技術選定">技術選定</h1> <p>上記課題を解決するためにRails 7アプリケーションにread/writeを分ける仕組みの導入が検討されました。 調査した方法は以下の2つです。</p> <ol> <li>Gemを利用する</li> <li><a href="https://railsguides.jp/active_record_multiple_databases.html">Rails 7の複数database機能を利用する</a></li> </ol> <p>有名なgemとして<a href="https://github.com/thiagopradi/octopus">octopus</a>と<a href="https://github.com/instacart/makara">makara</a>があります。この2つのgemの詳細な比較についてこの<a href="https://ypoonawala.wordpress.com/2015/11/15/octopus-vs-makara-read-write-adapters-for-activerecord-2/">記事</a>が参考できます。 特にgemのreader/writerの自動切り替え機能に注目しています。</p> <p>要するに、これらのgemは発行されたSQLクエリをもとに、適切なインスタンスにクエリを送信してくれます。</p> <pre class="code" data-lang="" data-unlink>User.last # 裏側で以下のquery文が発行されます # SELECT `users`.* FROM `users` ORDER BY `users`.`id` DESC LIMIT 1</pre> <p><code>select</code> クエリの場合はreplicaに送信され、それ以外(<code>create</code>、<code>update</code>など)はprimaryに送信されます。</p> <p><a href="">makara</a></p> <blockquote><p>What goes where?</p> <p>In general: Any SELECT statements will execute against your replica(s), anything else will go to the primary.</p></blockquote> <p><a href="https://github.com/thiagopradi/octopus?tab=readme-ov-file#replication">octopus</a></p> <blockquote><p>Replication</p> <p>When using replication, all writes queries will be sent to master, and read queries to slaves.</p></blockquote> <p>(replica遅延問題に対して、テーブルに書き込んだ直後に、SELECTクエリを実行する場合は、それをprimaryで行うように指定できます)</p> <p>この機能は便利ですが、残念ながらこれらのgemは直近数年間メンテナンスされていないため、Rails 7での動作がうまくいきませんでした。発行されたqueryを解析できるように、gemはRailsの内部処理に介入しているようです。つまりRailsのソースコードに依存しています。 Railsの新しいバージョンでは、ソースコードの変更によって正しく機能しません。</p> <p>次に、Rails 7の複数database機能の特徴を調べてみましょう。(詳細は <a href="https://railsguides.jp/active_record_multiple_databases.html">こちら</a>) この機能ではreader/writerの自動切り替えと手動切り替えが可能です。</p> <p>自動切り替えの仕組みは、到着したリクエストのHTTPメソッド(GET、POST、PATCH、DELETEなど)を基に、接続を切り替えます。</p> <blockquote><p>アプリケーションがPOST、PUT、DELETE、PATCHのいずれかのリクエストを受け取ると、自動的にwriterデータベースに書き込みます。リクエストがそれ以外のメソッドであっても、直近の書き込みがあった場合にはやはりwriterデータベースが利用されます。それ以外のリクエストではreplicaデータベースを使います。</p></blockquote> <p>手動で切り替えたい場合は特定の処理のブロックを以下で囲みます。</p> <pre class="code" data-lang="" data-unlink>ActiveRecord::Base.connected_to(role: :reading) do # このブロック内のコードはすべてreadingロールで接続される end</pre> <p>選定の要件</p> <ul> <li>メンバーの手が空いている時間に実施できる(数週間にわたる集中的な対応は難しいため)</li> <li>変更箇所を素早くテストし、品質を確保できる</li> </ul> <p>それを踏まえて、Railsの複数database機能の手動切り替えを選択しました(readerに送信したいGET APIを手動でreaderを指定します、それ以外はデフォルトでwriterに向けます)。自動切り替えが望ましいですが、クラシルリワードのGET APIの一部はdatabaseを更新しています。APIが多いため、修正が必要なAPIを見つけ出すには時間がかかります。また、動作を確認する段階も時間がかかる見込みです。そのため、自動切り替えは適していないと判断しました。</p> <h1 id="実際に導入">実際に導入</h1> <p>以下の手順で導入を進めました</p> <h3 id="Railsアプリケーションでreaderwriterの設定">Railsアプリケーションでreader/writerの設定</h3> <p><a href="https://railsguides.jp/active_record_multiple_databases.html#%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97">Rails guide</a>のようにdatabase.ymlを変更しました。クラシルリワードの場合、primary、replicaのendpointが違うため、それぞれのhostも指定しました。 次にmigrationコマンドを実行すると自動的に<code>db/primary_replica_schema.rb</code>が生成されます(内容はschema.rbと同じです)。 この変更をlocalと開発用サーバーで動作確認し、問題がないことを確認した後、本番環境にリリースしました。</p> <h3 id="リクエスト数が多いGET-APIを洗い出す">リクエスト数が多いGET APIを洗い出す</h3> <p>Railsの複数database機能の手動切り替えの場合、クエリはデフォルトでprimaryに送信されます。replicaに送信したいGET APIを洗い出して、それらを優先して対応します。</p> <h3 id="対象APIのクエリをreplicaに送信する対応">対象APIのクエリをreplicaに送信する対応</h3> <p>対象APIを1つずつ実装、動作確認、リリースします。 実装は単にこれを追加するだけでした。</p> <pre class="code" data-lang="" data-unlink>ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do # このブロック内のコードはすべてreadingロールで接続される end</pre> <p><code>prevent_writes: true</code>はreplicaへの書き込みを防ぐことを意味します。ブロック内に書き込みを行った場合、実行時にエラーが発生します。GET APIをreplicaに向けるようにしていますが、後で他のメンバーがAPIを修正する際、更新処理を追加したらエラーが発生して、replicaに書き込まないようにしています。replicaへの書き込みはprimaryと同期されないため、ブロック内で書き込みを行う場合は明示的にprimaryを指定する必要があります。</p> <p>注意:ActiveRecord::Base.connected_toブロックを抜けると、クエリが実行される点に留意してください。同じ処理は同一のブロック内にまとめると良いと思います。(詳細は<a href="https://zenn.dev/yamashun/articles/rails-connected-to">こちら</a>)</p> <h1 id="導入の効果">導入の効果</h1> <p>修正対象のAPIの一部を修正したところ、primaryのCPU使用率が最大20%減少しました。今後はさらにreplicaを効果的に活用して、パフォーマンスとコストを改善していきたいと考えています。</p> <h1 id="まとめ">まとめ</h1> <p>クラシルリワードでRails 7の既存のアプリケーションにread/writeを分ける仕組みを導入する方法を紹介しました。皆さんの参考になれば幸いです!</p> haind111 クラシルリワードにおける自動テストツール MagicPodの導入事例 hatenablog://entry/6801883189066240291 2023-12-13T10:15:07+09:00 2023-12-13T10:18:20+09:00 クラシルリワードに自動テストツールとしてMagicPodを導入したことについて紹介してきます。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231213/20231213101401.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"><ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#導入背景">導入背景</a><ul> <li><a href="#サービス概要">サービス概要</a></li> <li><a href="#リリース頻度">リリース頻度</a></li> <li><a href="#QA事情">QA事情</a></li> </ul> </li> <li><a href="#自動テストツールの要件">自動テストツールの要件</a></li> <li><a href="#トライアル時の検証">トライアル時の検証</a></li> <li><a href="#実際の運用事例">実際の運用事例</a><ul> <li><a href="#実数値">実数値</a><ul> <li><a href="#テストケース">テストケース</a></li> <li><a href="#運用体制">運用体制</a></li> </ul> </li> <li><a href="#実際のテスト運用フロー">実際のテスト運用フロー</a></li> </ul> </li> <li><a href="#MagicPodを運用してみてどうだったか">MagicPodを運用してみてどうだったか</a><ul> <li><a href="#良かったところ">良かったところ</a></li> <li><a href="#運用してみないとわからなかったところ">運用してみないとわからなかったところ</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> <h2 id="はじめに">はじめに</h2> <p>こんにちは!クラシルリワードで開発責任者をしている<a href="https://twitter.com/_funzin">funzin</a>です。 この記事ではクラシルリワードに自動テストツールとして<a href="https://magicpod.com/">MagicPod</a>を導入したことについて紹介してきます。</p> <h2 id="導入背景">導入背景</h2> <h3 id="サービス概要">サービス概要</h3> <p>はじめにクラシルリワードについて紹介します。クラシルリワードは「日常のお買い物体験をお得に変える」アプリです。日常行動をアプリ内で行うことでポイントが貯まり、貯まったポイントを商品券などに交換できるサービスです。 現在、<a href="https://apps.apple.com/jp/app/%E3%82%AF%E3%83%A9%E3%82%B7%E3%83%AB%E3%83%AA%E3%83%AF%E3%83%BC%E3%83%89-%E7%A7%BB%E5%8B%95-%E3%83%81%E3%83%A9%E3%82%B7-%E3%83%AC%E3%82%B7%E3%83%BC%E3%83%88%E3%81%A7%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88%E3%81%8C%E3%81%9F%E3%81%BE%E3%82%8B/id1624606445">iOS</a>・<a href="https://play.google.com/store/apps/details?id=jp.hops">Android</a>・<a href="https://www.rewards.kurashiru.com/">Web</a>の3つのプラットフォームで展開しています。</p> <h3 id="リリース頻度">リリース頻度</h3> <p>クラシルリワードは1年前にリリースしたこともあり現在も機能開発が盛んに行われています。FeatureFlagを利用したトランクベース開発を行い、アプリは平均週2リリースを行うなど高頻度でリリースが行われています。</p> <h3 id="QA事情">QA事情</h3> <p>リリースが高頻度のため開発速度に影響がないように下記のようにQAを行っていました。</p> <ol> <li><p>機能追加・修正時に実装者がQA観点をまとめて、関係者に触ってもらい違和感がないかを確認 <figure class="figure-image figure-image-fotolife" title="SlackでのQA依頼例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231213/20231213085804.png" width="1146" height="518" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SlackでのQA依頼例</figcaption></figure></p></li> <li><p>大きい機能開発の場合、デグレをしていないかを確認するために一括テストを実行 <figure class="figure-image figure-image-fotolife" title="テストケースをスプレッドシートで管理"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212185611.png" width="1200" height="847" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>テストケースをスプレッドシートで管理</figcaption></figure></p></li> </ol> <p>リリース当初から上記のようなQAを行っていましたが、サービス規模も大きくなっていく中でポイントやチラシを提供している小売の情報を扱ってるため、不具合が発生すると大きな影響が出てしまいます。<br> またリリース当初と比較すると機能が増えてきて、手動で既存機能のテストすることの難易度が上がってきました。<br> このような状態の解決をするために、自動テストツールを導入を検討しました。<br> (※各プラットフォームでUnitTestは書いていますが、今回はUIテストの自動化に着目しているため割愛します)</p> <h2 id="自動テストツールの要件">自動テストツールの要件</h2> <p>まずは自動テストツールを導入することで何を実現したいかを整理しました。</p> <ol> <li>人が手動で行っていたテストケースを自動化</li> <li>テストケースのメンテナンスコストを低く保つ</li> <li>引き継ぎを想定して、非エンジニアでもテストケースの対応が可能な状態</li> </ol> <p>上記の要件を実現するために、自動テストツールの候補として下記を洗い出しました。</p> <ol> <li>各プラットフォームでのUITest(iOS: <a href="https://developer.apple.com/documentation/xctest/user_interface_tests">XCTest</a>, Android: <a href="https://developer.android.com/training/testing/espresso?hl=ja">Espresso</a>)</li> <li><a href="https://maestro.mobile.dev/">Maestro</a>を利用したymlベースでのテスト実行</li> <li>MagicPodのようなGUI上での自動テストツール</li> </ol> <p>事前に定義した自動テスト要件と自動テストツールの候補を照らし合わして整理していきました。<br></p> <ul> <li>1のUnitTestに関しては、それぞれのプラットフォームで開発工数がかかる、プラットフォームが別れているためどのようなテストケースを担保するかなどのコミュニケーションなどで工数を使うため見送り</li> <li>2のMaestroに関しては、yml管理できるのは魅力的なものの、非エンジニアの対応コストが高いため見送り</li> <li>3のMagicPodに関しては、GUI上でテストケースを作成できるので非エンジニアでも対応可能。<a href="https://support.magic-pod.com/hc/ja/articles/4408910141977-%E5%85%B1%E6%9C%89%E3%82%B9%E3%83%86%E3%83%83%E3%83%97%E3%81%AE%E6%B4%BB%E7%94%A8">共有ステップ</a>を利用すればメンテナンスコストも低く保てそう</li> </ul> <p>そのため、要件と最もマッチしそうであるMagicPodを第一候補として検討しました。</p> <h2 id="トライアル時の検証">トライアル時の検証</h2> <p>MagicPodを検討したとはいえ実際に触ってみないと判断がつかないため、まずは<a href="https://magicpod.com/pricing/">無料トライアル</a>から始めました。 トライアル期間中に検証したことは下記です。</p> <ol> <li>位置情報計測、ヘルスケアなどのサードパーティ製の仕組みを使った機能を含むためそれらのテストケースの作成が可能か</li> <li>テスト実行の自動化フローが組めるかどうか</li> </ol> <p><br> 1に関しては、事前にMagicPod側に懸念点の質問、実際にMagicPodでテストケースを書いて動作確認をしました。<br> 2に関しては、本来であればCIを構築して検証するべきですがトライアル期間のため撤退する可能性もあります。そのため手元のPCで<code>crontab</code>を利用して擬似的にローカルPCをCIと見立てて検証しました。<br></p> <p>下記がcrontab, script, Makefileの例となります。</p> <pre class="code lang-sh" data-lang="sh" data-unlink>$ crontab <span class="synSpecial">-l</span> <span class="synConstant">0</span> <span class="synConstant">10</span> * * 1-5 <span class="synIdentifier">TZ</span>=Asia/Tokyo make build-and-upload <span class="synComment"># 平日朝10時に実行する</span> </pre> <pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment"># Makefile</span> .PHONY: build-and-upload build-and-upload: sh ./android.sh sh ./ios.sh </pre> <pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment"># android.sh</span> <span class="synIdentifier">current_dir</span>=<span class="synPreProc">$(</span><span class="synStatement">pwd</span><span class="synPreProc">)</span> <span class="synStatement">export</span> <span class="synIdentifier">JAVA_HOME</span>=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home <span class="synStatement">export</span> <span class="synIdentifier">PATH</span>=<span class="synPreProc">$PATH</span>:<span class="synPreProc">$JAVA_HOME</span>/bin <span class="synComment"># Build</span> <span class="synStatement">cd</span> ../android &amp;&amp; git co develop &amp;&amp; git pull &amp;&amp; ./gradlew assembleDebug <span class="synComment"># Renamed</span> <span class="synIdentifier">FILENAME</span>=Rewards-Dev-<span class="synPreProc">$(</span><span class="synSpecial">date +%Y%m%d%H%M%S</span><span class="synPreProc">)</span>.apk <span class="synStatement">cd</span> app/build/outputs/apk/debug &amp;&amp; <span class="synStatement">mv</span> app-debug.apk <span class="synPreProc">$FILENAME</span> <span class="synStatement">export</span> <span class="synIdentifier">MAGICPOD_ORGANIZATION</span>=org <span class="synStatement">export</span> <span class="synIdentifier">MAGICPOD_PROJECT</span>=android <span class="synStatement">cd</span> <span class="synStatement">&quot;</span><span class="synPreProc">$current_dir</span><span class="synStatement">&quot;</span> <span class="synComment"># Upload</span> ./magicpod-api-client upload-app <span class="synSpecial">-a</span> ../android/app/build/outputs/apk/debug/<span class="synPreProc">$FILENAME</span> </pre> <pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment"># ios.sh</span> <span class="synIdentifier">current_dir</span>=<span class="synPreProc">$(</span><span class="synStatement">pwd</span><span class="synPreProc">)</span> <span class="synComment"># Build</span> <span class="synStatement">cd</span> ../ios &amp;&amp; git co master &amp;&amp; git pull &amp;&amp; <span class="synStatement">\</span> xcodebuild <span class="synStatement">\</span> <span class="synSpecial">-workspace</span> App.xcworkspace <span class="synStatement">\</span> <span class="synSpecial">-scheme</span> App-Debug <span class="synStatement">\</span> <span class="synSpecial">-sdk</span> iphonesimulator <span class="synStatement">\</span> <span class="synSpecial">-destination</span> <span class="synStatement">'</span><span class="synConstant">platform=iOS Simulator,name=iPhone 14</span><span class="synStatement">'</span> <span class="synStatement">\</span> <span class="synSpecial">-configuration</span> Debug <span class="synStatement">\</span> <span class="synSpecial">-derivedDataPath</span> DerivedData <span class="synStatement">\</span> clean build <span class="synComment"># Renamed</span> <span class="synIdentifier">FILENAME</span>=Rewards-Dev-<span class="synPreProc">$(</span><span class="synSpecial">date +%Y%m%d%H%M%S</span><span class="synPreProc">)</span>.app <span class="synStatement">cd</span> DerivedData/Build/Products/Debug-iphonesimulator &amp;&amp; <span class="synStatement">mv</span> App-Dev.app <span class="synPreProc">$FILENAME</span> <span class="synStatement">export</span> <span class="synIdentifier">MAGICPOD_ORGANIZATION</span>=org <span class="synStatement">export</span> <span class="synIdentifier">MAGICPOD_PROJECT</span>=ios <span class="synStatement">cd</span> <span class="synStatement">&quot;</span><span class="synPreProc">$current_dir</span><span class="synStatement">&quot;</span> <span class="synComment"># Upload</span> ./magicpod-api-client upload-app <span class="synSpecial">-a</span> ../ios/DerivedData/Build/Products/Debug-iphonesimulator/<span class="synPreProc">$FILENAME</span> </pre> <p> MagicPodの実行は<a href="https://github.com/Magic-Pod/magicpod-api-client">magicpod-api-client</a>を利用しました。<br> crontabで出社時間の毎朝10:00に実行し、MagicPodのテストスケジューラーは10:30に実行するようにしていたため、iOS・Androidの最新ビルドがMagicPodで実行されるようになり自動化フローを検証を進めることができました。<br> <figure class="figure-image figure-image-fotolife" title="crontab経由でslack通知"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212192901.png" width="1200" height="421" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>crontab経由でslack通知</figcaption></figure></p> <p>上記の検証をトライアル期間中に行い、ある程度利用できることが確認できたため、本格導入に踏み切りました。<br> (トライアル期間でも多くの質問に答えていただいたMagicPodのCS担当者の皆様には感謝です。)</p> <h2 id="実際の運用事例">実際の運用事例</h2> <p>実際にMagicPodを導入してみてどうだったかを次のセクションからお話しします。</p> <h3 id="実数値">実数値</h3> <p>2023/12時点での実数値です。</p> <ul> <li>テストケース数 <ul> <li>iOS: 23テストケース</li> <li>Android: 23テストケース</li> </ul> </li> <li>平均テスト実行時間: 30分~1時間</li> <li>MagicPodの運用者: 1名</li> </ul> <p>それぞれ抜粋して補足説明していきます。</p> <h4 id="テストケース">テストケース</h4> <p>基本的にはユーザー体験として優先度が高いものからMagicPodのテストケースとして作成しています。クラシルリワードではオンボーディング突破や、チラシ閲覧からのコイン獲得などがあげられます。 スプレッドシートで管理していたテストケースも考慮しつつ導入初期は一気にテストケースを作成するのではなく、Highのテストケースを一つずつ作成していきました。 <figure class="figure-image figure-image-fotolife" title="テストケースをNotionで一覧化"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212194137.png" width="1200" height="822" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>テストケースをNotionで一覧化</figcaption></figure></p> <p>MagicPodでは開発中のテストケースと一括テスト用のケースが存在するため「Morning Build」, 「Development」という2つのタグを使って管理しています。「Development」タグでは一括テスト対象から除外することで、一括テストに影響がないようにしています。 <figure class="figure-image figure-image-fotolife" title="MagicPodのテストケース一覧"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231213/20231213085324.png" width="777" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>MagicPodのテストケース一覧</figcaption></figure></p> <h4 id="運用体制">運用体制</h4> <p>MagicPodの運用体制としてQAチームを作る or アプリエンジニアに運用を任せるかで悩みましたが、現状は自分1人で運用しています。 理由としては下記です。</p> <ul> <li>両OSの仕様を把握してる人間がどちらもメンテナンスすることで、OS間の差分を検知しやすい</li> <li>QA専任がいない中での複数人運用は、コミュニケーションコストが大きい <ul> <li>最終的にテストケースがメンテナンスされなくなって形骸化する可能性が大きい</li> </ul> </li> <li>一度テストケースを作って慣れたらそこまで時間はかからない</li> </ul> <p>まずは1人での運用を安定的に行い、将来的には引き継ぐことを想定して複数人で運用できる体制にできればと考えています。</p> <h3 id="実際のテスト運用フロー">実際のテスト運用フロー</h3> <p>次に実際のテスト運用フローについて説明します。 以下の図のように2つのタイミングで実行しています。</p> <ol> <li>朝7:00の定期実行</li> <li>リリースタグ付与時に実行</li> </ol> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212194822.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>MagicPodでは主に機能開発によるデグレの検知を行っています。<br> リリース前のブロック用途としても利用可能ですが、テストがまだ不安定にこけることがあり開発者が都度確認するのが現状の開発速度を阻害してしまうためこのような運用にしています。 何回か再実行しても同様の失敗をする場合は開発者に確認するフローをとっています。 <figure class="figure-image figure-image-fotolife" title="開発者への確認例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231213/20231213085742.png" width="1200" height="563" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>開発者への確認例</figcaption></figure></p> <h2 id="MagicPodを運用してみてどうだったか">MagicPodを運用してみてどうだったか</h2> <p>実際に運用してみて、良かったところと運用してみないとわからなかったところがあるので紹介していきます。</p> <h3 id="良かったところ">良かったところ</h3> <ol> <li><p>手動で一括テストをしていた箇所がほぼMagicPodに置き換えができたこと<br> もちろん全てのテストを置き換えることは難しいですが、手動でやっていたテストケースの大半を置き換えることができました。 実際にMagicPodを導入して、肌感は下記のような感覚値になりました。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212200222.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p></li> <li><p>開発者の心理安全性が高まったという声が増えた<br> 毎朝定期実行やリリース前の実行によってデグレ検知ができる認知が開発チームに芽生え、既存機能のデグレを恐れずに安心して開発を行うことができるようになりました。<br></p></li> <li><p>自動修復機能が便利<br> MagicPodの<a href="https://support.magic-pod.com/hc/ja/articles/4408905014041-%E8%87%AA%E5%8B%95%E4%BF%AE%E5%BE%A9%E6%A9%9F%E8%83%BD%E3%81%A8%E3%81%AF">自動修復機能</a>を利用すると、文言系などの細かい修正はMagicPodが自動で直してくれるのは、メンテナンス観点でもとても助かっています。</p></li> </ol> <p> <figure class="figure-image figure-image-fotolife" title="自動修正例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20231212/20231212200441.png" width="1200" height="603" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>自動修正例</figcaption></figure></p> <h3 id="運用してみないとわからなかったところ">運用してみないとわからなかったところ</h3> <ol> <li><p>一度通ったテストが思ったよりもよくこける<br> 個人的には一度テストが通っても、その精度は70%程度と考えています(感覚値)。<br> 複数回実行しないと不安定な箇所は特定ができないため、それを逐一修正していく必要があります。 毎回修正していくことで精度を100%に近づけていくイメージです。<br> 2023/12時点だとテストケースも精度が上がってきてだいぶ落ち着いてきましたが、導入当初は毎朝テストケースを直すところから始めていました。<br></p></li> <li><p>機能開発が活発な画面だとメンテナンスコストが大きすぎる。<br> 冒頭でも述べたように、リリース数が多いため下手にテストケースを追加すると毎日メンテナンスすることになります。<br> その場合、開発者とテストケース作成者のコミュニケーションコストが発生するためにお互いに幸せになりません。 このようなケースでは一定の開発期間は落ち着くまで、テストケースを書かない方が良いと判断しました。</p></li> </ol> <h2 id="まとめ">まとめ</h2> <p>この記事では自動テストツールとしてMagicPodを導入した経緯と実際の運用事例についてまとめました。<br> 初めはリリースサイクルが早いプロダクトにおいて導入できるか懸念がありましたが、現状はワークしており導入して良かったと感じています。<br> MagicPodの導入検討している方はトライアルでお試ししてみて、自社サービスや組織にマッチするかを検討してみるのがおすすめです。</p> funzin Androidアプリで歩数機能をリリースするためにCASA Tier2セキュリティ評価 をおこないました hatenablog://entry/6801883189064269203 2023-12-06T13:00:00+09:00 2023-12-06T13:00:00+09:00 🐰はじめに クラシルリワードのAndroidアプリエンジニアをしているnozakingです、こんにちは! 先日、クラシルリワードのAndroid版でも歩数機能が遂にリリースされました(2023年12月現在はまだ一部のユーザーにのみ提供中です)。機能実現のためにGoogleのFitness APIを利用しているのですが、API利用申請の過程でCASAセキュリティ評価を受ける必要がありました。 今回の記事では、CASAセキュリティ評価を通過し、検証文書(LOV)が発行されるまでの流れを紹介したいと思います。 play.google.com 歩数機能の画面。歩数に応じてゲージが溜まってインセンティブ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nozakichi/20231205/20231205143151.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="はじめに">🐰はじめに</h3> <p>クラシルリワードのAndroidアプリエンジニアをしているnozakingです、こんにちは!<br> 先日、クラシルリワードのAndroid版でも歩数機能が遂にリリースされました(2023年12月現在はまだ一部のユーザーにのみ提供中です)。機能実現のためにGoogleのFitness APIを利用しているのですが、API利用申請の過程で<strong>CASAセキュリティ評価</strong>を受ける必要がありました。 今回の記事では、CASAセキュリティ評価を通過し、検証文書(LOV)が発行されるまでの流れを紹介したいと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Djp.hops" title="クラシルリワード-移動・チラシ・レシートでポイントがたまる - Apps on Google Play" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://play.google.com/store/apps/details?id=jp.hops">play.google.com</a></cite></p> <div style="width:50%; color:#888888; text-align: center;margin: 0 auto;"><figure class="figure-image figure-image-fotolife" title="歩数機能の画面です。歩数に応じてゲージが溜まってインセンティブがもらえます。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nozakichi/20231205/20231205144606.png" width="549" height="676" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>歩数機能の画面。歩数に応じてゲージが溜まってインセンティブがもらえます。</figcaption></figure></div> <h3 id="CASAセキュリティ評価って">🐰CASAセキュリティ評価って?</h3> <p>CASA(Cloud App Security Assessment)はGoogleが提供するアプリのセキュリティ評価プログラムで、アプリの信頼性を徹底的に確保するものです。</p> <p><small><strong>🔗 App Defense Alliance</strong><br> <a href="https://appdefensealliance.dev/casa">https://appdefensealliance.dev/casa</a></small></p> <p>GoogleのFitness APIを利用するためには、<strong>CASAのTier2セキュリティ評価</strong>を完了する必要がありました。</p> <h3 id="CASA-Tier2セキュリティ評価を完了するためにやったこと">🐰CASA Tier2セキュリティ評価を完了するためにやったこと</h3> <p>CASA Tier2セキュリティ評価を通過するために私たちがやったことを紹介します。 基本的に公式で案内されている通りの流れを行いました。</p> <p><small><strong>🔗 CASA Tier 2 Process | App Defense Alliance</strong><br> <a href="https://appdefensealliance.dev/casa/tier-2/tier2-overview">https://appdefensealliance.dev/casa/tier-2/tier2-overview</a></small></p> <div style="font-size: 1.8rem;font-weight:bold;">1. 通知</div> <p>API利用申請のメールの中でCASA Tier2セキュリティ評価を実施するように指示を受けます。いくつかの選択肢が提示されましたが、私たちは<strong>オープンソースツールを活用したTier2セルフスキャン</strong>を選択しました。</p> <div style="font-size: 1.8rem;font-weight:bold;">2. アプリのスキャン</div> <p>ガイダンスに従い、アプリケーションのスキャンを実施します。スキャンをおこなうと結果としてCWEリストが出力されます。CWEは、ソフトウェアおよびハードウェアの弱点タイプのリストで、コミュニティによって開発されたものです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcwe.mitre.org%2F" title="CWE - Common Weakness Enumeration" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://cwe.mitre.org/">cwe.mitre.org</a></cite></p> <p>この工程はプロセスに記載されているものの、結果のCWEリストを提出する機会はありませんでした。 とはいえ、何かCWE結果が何かしらあれば、後々対応することになるはずなのでここで対応しておいた方が後でスムーズだと思います。</p> <div style="font-size: 1.8rem;font-weight:bold;">3. 結果の送信</div> <div style="font-size: 1.6rem;font-weight:bold;">初回提出と修正対応</div> <p>CASAポータルにアカウントを作成し、アプリに関する情報とアプリのソースコードを提出します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Frc.products.pwc.com%2Fcasa" title="PwC Digital" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://rc.products.pwc.com/casa">rc.products.pwc.com</a></cite></p> <p>アプリのソースコード全体を提出可能な状態(zip)にまとめるのですが、詳しい手順は申請フォームの中に記載されているのでそれに従います。提出後、静的解析の結果が通知され、指摘事項があれば修正対応を行います。(たしか30〜1時間くらいで結果が来ました)<br></p> <p>クラシルリワードのケースでは軽微な修正を数個対応するだけで済みました。 例えばセキュリティプロバイダにパッチを適用できるように対応するなどをおこないました。</p> <p><small><strong>🔗 Update your security provider to protect against SSL exploits</strong><br> <a href="https://developer.android.com/training/articles/security-gms-provider">https://developer.android.com/training/articles/security-gms-provider</a></small></p> <div style="font-size: 1.6rem;font-weight:bold;">アンケート回答</div> <p>静的解析を通過する連絡を受け取ったら、次はアンケートに回答を記入して提出します。こちらは十数ページに渡るほど項目数が多く、内容が難しく、すべて英語で記載する必要があったので時間が掛かりました。<br> アンケートの回答を提出すると、CASAの担当者から必要に応じて追加の質問が来ます。やりとりはCASAポータル内にあるメッセージ画面で行います。<br><br></p> <p>質問に対して該当しない場合は <code>N/A</code> を記入し、その根拠を説明する必要があるのですが、ここが一番時間がかかったポイントです。<br></p> <p>例えば、クラシルリワードにおけるアカウント管理はFirebase Authenticationを用いたGoogle認証だけなので、認証に関する質問内容について<code>N/A</code>と回答していました。これについて、Google認証APIの仕様(セキュリティ水準を満たすものかどうか)について詳細に説明を求められ、何度もやり取りを行いました。</p> <div style="font-size: 1.8rem;font-weight:bold;">4. ファイナライズ</div> <p>アンケート回答後の問答を通過したら検証文書(LOV)が発行されます。このLOVをGoogleからのセキュリティ評価要求メールに返信し、Fitness APIの利用が承認されました。</p> <h3 id="スムーズな完了のために">🐰スムーズな完了のために</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nozakichi/20231205/20231205160203.png" width="111" height="120" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>実は歩数機能の実装よりもGoogleからの承認を得るためのやり取りの方が多くの時間が掛かりました。スムーズに終えるために気をつけたいポイント、工夫を紹介したいと思います。</p> <div style="font-size: 1.8rem;font-weight:bold;">納得させるために必要なことは全て記載する</div> <p>CASA担当者とのやり取りで、「これくらい説明すれば分かるだろう」と思っていてもなかなか伝わらなかった印象でした。何度もメッセージを送って返信を待つを繰り返すくらいなら、最初から具体的に詳細すぎるくらいに説明した方がスムーズに進んだなと思いました。</p> <div style="font-size: 1.8rem;font-weight:bold;">メッセージの作成はAIの助けを借りる</div> <p>GoogleやCASA担当者からの返信は待つしかないですが、こちらボールになったらなるべく早く返すことが大事だと思います。今回のメッセージのやり取りは英語で行っていたのですが、私は英語が得意ではなかったため余計に時間を掛けないようにChatGPTを活用しました。</p> <ul> <li>伝えたい内容を箇条書きにする</li> <li>ChatGPTで、このメールに箇条書きした内容を伝える返信メッセージを作成してほしいと依頼する</li> <li>ChatGPTがいい感じの英語の文章を作成してくれる</li> <li>適切な形に修正して返信メッセージを送る</li> </ul> <p><small>※質問の回答内容すべてを丸投げするのはNGです。伝えたい内容は自分でちゃんと考えましょう。</small></p> <p>という感じで、返信する内容を考える以外は素早く行えました。ありがたい技術ですね。</p> <h3 id="おわりに">🐰おわりに</h3> <p>時間はかかりましたが、CASAセキュリティ評価を受けることでセキュリティについて一定のレベルをクリアし、大きな達成感を得ました。</p> <p>Android版のクラシルリワードでは歩数機能を一部のユーザーに公開中ですが、ユーザー体験や収益性をブラッシュアップさせ、全Androidユーザーにも使ってもらえるように改善を重ねているところです。歩数機能に対するユーザーからの期待も高まっているため、全Androidユーザーに最高の形で提供できるよう、これからも頑張ります。どうぞご期待ください!</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nozakichi/20231206/20231206094136.png" width="92" height="120" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> nozakichi キャラクター運用する上で工夫していること hatenablog://entry/6801883189060215131 2023-11-22T12:03:35+09:00 2023-11-22T13:32:31+09:00 こんにちは! クラシルリワードでグラフィックデザイナーをしているmakosunです🐍 クラシルリワードには2023年7月に「クラシうさぎ」というキャラクターが新しく登場しました🐰🥕 私はキャラクターに関連するデザイン・グラフィック面を主に担当しています。 キャラクターを使ったクリエイティブのデザイン、ポーズや表情の追加、シーズンに合わせたイラストの制作、アニメーションの制作などを行なっています。 今回のブログでは、「キャラクター運用する上で工夫していること」を書いていきます。 一番左のうさぎ キャラクターのポーズは立体感を意識する🕺 イラストを新しく描き起こすときは、基本的に斜めから見たポーズ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231122/20231122131744.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは! クラシルリワードでグラフィックデザイナーをしているmakosunです🐍</p> <p>クラシルリワードには2023年7月に「クラシうさぎ」というキャラクターが新しく登場しました🐰🥕</p> <p>私はキャラクターに関連するデザイン・グラフィック面を主に担当しています。 <br> キャラクターを使ったクリエイティブのデザイン、ポーズや表情の追加、シーズンに合わせたイラストの制作、アニメーションの制作などを行なっています。</p> <p>今回のブログでは、「<strong>キャラクター運用する上で工夫していること</strong>」を書いていきます。 <figure class="figure-image figure-image-fotolife" title="一番左のうさぎ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231120/20231120111208.png" width="990" height="539" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>一番左のうさぎ</figcaption></figure></p> <h4 id="キャラクターのポーズは立体感を意識する">キャラクターのポーズは立体感を意識する🕺</h4> <p>イラストを新しく描き起こすときは、基本的に<strong>斜めから見たポーズ</strong>にしています。 正面からのイラストは立体感がなく、のっぺりとした印象になってしまうので、 斜めのポーズや、正面でも奥行きのあるイラストにしています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231121/20231121162540.png" width="1200" height="345" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="共感性の高いポーズで親近感を沸かす">共感性の高いポーズで親近感を沸かす🤝</h4> <p>ポーズのイラストを新しく追加するときは共感性の高いものにしています。 スマホを見る、晩御飯を考える、寝る、本を読む、など人が普段生活しているのと同じようにクラシうさぎも過ごしていることで、親近感が湧くようにしています。一緒に生活しているような気分になって、愛着を持ってほしいという期待も込めています🐰</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231121/20231121171928.png" width="1200" height="374" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="キャラクターの表情">キャラクターの表情🐰</h4> <p>バナーやPOPUPなどのクリエイティブに使用する際は、キャラクターのビジュアルに飽きが来ないようにするために、同じイラストを使いすぎないことを気をつけています。 「喜ぶ」という感情だけでも違う表現がいくつかあるので、それを使い分けています。同じポーズでも目を笑わせたり、口を開かせたりなど変化があるようにしています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231121/20231121172921.png" width="1200" height="424" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="アプリアイコン">アプリアイコン📱</h4> <p>アプリのアイコンはノーマルポーズのクラシうさぎを使用していますが、季節のイベントに合わせて、うさぎにコスチュームを着せたり、背景を変更したりしています。</p> <p>その時は通常用のアイコンからの変化を少なくするために、<strong>クラシうさぎの位置・大きさは変えない、顔の見える範囲を広くする</strong>ことを意識しています。</p> <p>変化がありすぎると、クラシルリワードのアプリだと気づかず開くことができない、または間違えてアプリを削除してしまう可能性があるので、ユーザーの皆さんが気づく範囲で変えるということに気をつけています。<strong>特に顔の横についているもふもふは特徴的な形</strong>をしているので、モチーフで隠れないようにしています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231120/20231120133216.png" width="1200" height="647" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h5 id="終わりに">終わりに</h5> <p>今回は「キャラクター運用で工夫していること」をご紹介しました! キャラクター運用をやりたい!という方の参考になれば幸いです。 またクラシうさぎもどんどん活躍の場を広げていくので、楽しみにしていただけると嬉しいです🐰🥕</p> <p><strong>おまけ</strong><br> クラシうさぎのぬいぐるみとCGです。 私はぬいぐるみ作りが趣味なので、個人的に作ってみました。 CGはBlenderの勉強をするために作ってみました。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/makosun1020/20231120/20231120125528.png" width="1200" height="784" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> makosun1020 小さいチームで実践する!開発速度・信頼性向上のためにやってよかったシステム改善3選 hatenablog://entry/6801883189057054376 2023-11-09T10:02:13+09:00 2023-11-09T10:02:13+09:00 こんにちは、クラシルリワードのSRE担当のjoooee0000です。 私はクラシルリワードのサービスローンチの約3ヶ月後にサーバー兼インフラエンジニアとしてjoinし、サービスの成長と共に、開発速度とシステムの信頼性の向上を目指してシステムの改善を行ってきました。 その中で、特に開発速度と信頼性向上に寄与したと思う3つの改善を紹介したいと思います。 改善を行う際に「コスト(金額、導入共に)と効果のバランス」「運用しやすいシンプルな設計」に特に気をつけたので、参考なると幸いです。 (話すこと: 取り組んだ施策の概要紹介、 話さないこと: 細かいhow to) やってよかったこと3選 ブランチ戦略… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108155137.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、クラシルリワードのSRE担当のjoooee0000です。</p> <p>私はクラシルリワードのサービスローンチの約3ヶ月後にサーバー兼インフラエンジニアとしてjoinし、サービスの成長と共に、開発速度とシステムの信頼性の向上を目指してシステムの改善を行ってきました。</p> <p>その中で、特に開発速度と信頼性向上に寄与したと思う3つの改善を紹介したいと思います。 改善を行う際に「コスト(金額、導入共に)と効果のバランス」「運用しやすいシンプルな設計」に特に気をつけたので、参考なると幸いです。</p> <p>(話すこと: 取り組んだ施策の概要紹介、 話さないこと: 細かいhow to)</p> <h1 id="やってよかったこと3選">やってよかったこと3選</h1> <ol> <li>ブランチ戦略の見直しとシンプルな検証環境の維持</li> <li>ログの構造化とNewRelicログUIの導入</li> <li>インシデント管理ツールの導入</li> </ol> <p>それぞれ、改善前にどのような課題があったか、改善後のメリットなどを紹介していきます。</p> <h1 id="1-ブランチ戦略の見直しとシンプルな検証環境の維持">1. ブランチ戦略の見直しとシンプルな検証環境の維持</h1> <p>ここ1年間、チームの成長に合わせてブランチ戦略や検証環境が今の開発スタイルに合っているかを都度検討しながら、速度を落とさずに開発を進めてきました。そこで、初期から現在までのブランチ戦略の改善や検証環境をどのように運用しているかを紹介します。</p> <p>チームが始まった当初のサーバーサイドエンジニアが1 ~ 3人くらいだった頃の話になりますが、下記の図のように、main / develop / stagingとfeatureブランチの4種類のブランチで運用されていました。</p> <p><figure class="figure-image figure-image-fotolife" title="当時のブランチ戦略"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108135903.png" width="809" height="253" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>当時のブランチ戦略</figcaption></figure></p> <p>また、main / develop / stagingそれぞれのブランチをベースにしたPullRequestをマージすると各環境にデプロイされる、というデプロイ戦略でした。</p> <p>当時のブランチ戦略は、GitFlowなどのブランチ戦略と違い、developブランチを経由してmainブランチにリリースされるといった開発フローではなく、開発フロー中にdevelopブランチとmainブランチが合流するポイントがありませんでした。</p> <p>それゆえ、mainブランチからdevelopブランチを一回切ったあとはdevelopブランチとmainブランチが徐々に乖離していくという課題があり、featureブランチをマージする際に複雑なコンフリクトやforce pushが必要になることが頻繁に発生し、開発速度が低下していました。stagingに関しては、利用頻度が活発ではなかったためデプロイの内容が徐々に古くなっていくという状況でした。</p> <p>そこでブランチ戦略をmainとfeatureブランチだけのGithubFlowのブランチ戦略に変更し、developブランチの廃止に伴い、検証環境には個々のfeatureブランチをGitHub Actionsのworkflow dispatchをhookにブランチを選んでデプロイできるようにしました。ステージング環境にはfeatureブランチからmainブランチへのマージをhookに本番と一緒にデプロイされるようにしました。</p> <p><figure class="figure-image figure-image-fotolife" title="デプロイフロー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108143156.png" width="1200" height="549" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>デプロイフロー</figcaption></figure></p> <p>また、そのタイミングで検証環境を開発者個々人に用意するかどうかが議論に上がりました。しかし、個々人用に検証環境を作ることで検証環境独自の運用が発生することを回避するために、本番環境と全く同じ構成の1台で運用することで検証環境の運用コストを下げることを選択しました。そして、検証環境は最終的な確認のみに使い、local環境での開発をメインにするようにしました。ブランチ戦略とデプロイを改善したことで、検証環境が1台でも問題なく開発ができるようになりました。また、少なくともここ1年間はほとんど検証環境の運用作業が発生しておらず、シンプルな構成の恩恵を受けています。</p> <p><figure class="figure-image figure-image-fotolife" title="開発者のフィードバック"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108144950.png" width="751" height="52" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>開発者のフィードバック</figcaption></figure></p> <p>現在、サーバーサイドエンジニアが6名+web開発の1名になり、ブランチ戦略を整理した当時の倍以上になったことで複数人が同時に検証環境を利用したいシーンが増えてきました。個人のfeatureブランチをデプロイする方法のみだと、検証に待ちが発生する状態になりました。</p> <p>そこで、検証環境を複数台に増やす検討の前に、まずはブランチ戦略やデプロイの改善をしました。</p> <p>デイリーでmainからその日限りのdevelopブランチを自動作成し、そのブランチに複数人がGitHub Actionsを通して自動マージ&amp;デプロイをして検証を行う設計に変更しました。別の開発者がマージしたコードとコンフリクトした場合は自動マージできないので開発者が解消する必要がありますが、デイリーでmainからdevelopブランチを作成しているため、mainとの乖離による無駄なコンフリクトは発生することはありません。また、developブランチへのマージやブランチ作成をGitHub Actionsで自動化することで開発者の負担を下げています。</p> <p><figure class="figure-image figure-image-fotolife" title="最新ブランチ戦略"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108143809.png" width="911" height="254" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>最新ブランチ戦略</figcaption></figure></p> <p>このように、チームの規模や開発スタイルに合わせたブランチ戦略の見直しと、検証環境をシンプルに保つことで、開発速度の向上や運用工数の削減をすることができています。</p> <h2 id="2-ログの構造化とNewRelicログUIの導入">2. ログの構造化とNewRelicログUIの導入</h2> <p>サービスをリリースしてから数カ月間は、CloudWatch Logsを利用しており、かつ構造化していないログで検索性がとても低い状況でした。そのため、ほとんどアプリケーションログが活用されておらず、不具合調査などの業務効率が大幅に下がっていました。</p> <p>そこで、NewRelicの導入とアプリケーションログをjson形式に構造化をすることにしました。</p> <p>まず、NewRelicを選定した理由として、下記があります。</p> <ul> <li>NewRelicのデータ転送コストがCloudWatch Logsの1/3程度で済むこと</li> <li>ログ基盤のElasticsearchの運用はSaaSのNewRelicに任せることで運用コストが削減できること</li> <li>ログ管理UIの利便性</li> </ul> <p>ログ管理をしようと思ったときに一番料金がかさむポイントとしてログ基盤へのデータ転送の料金があります。実際に、CloudWatch Logsでもデータ転送料がコストの大半を締めていました。</p> <p>NewRelicのデータ転送コストはSaaSの中でもトップクラスで安く、CloudWatch Logsの1/3程度で済んだためコスト削減にも繋がりました。(実際にはつなぎ込みのためのKinesis Data FirehoseとBackup用のS3の料金があり1/3コスト減とまでは行きませんが、それでもCloudWatch Logs単体利用より安価)NewRelicはAPMのSaaSとしてよく知られていますが、2022年からはフルオブザーバビリティプラットフォームとして様々な機能が提供されています。より高度な機能の利用を検討する場合はユーザーアカウントごとの課金もありますが、ログ管理UIの利用はBasicUserという無料ユーザーでも利用可能です。</p> <p>また、自社基盤でログ用のElasticsearchを運用することも考えましたが、Elasticsearchの運用はNewRelicに任せることで運用コストを削減しました。</p> <p>NewRelicのログ管理UIはポチポチするだけである程度の絞り込みが行えたり、下記のようにグラフも自動で出してくれるのでCloudWatch Logsを利用していたときと比べて圧倒的に利便性が上がりました。</p> <p><figure class="figure-image figure-image-fotolife" title="実際のNewRelicログUI"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108150436.png" width="1200" height="530" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>実際のNewRelicログUI</figcaption></figure></p> <p>また、無料で使えるダッシュボード機能を使い、よく検索するログ(5xxエラーが出ている上位10エンドポイントの検索、など)をダッシュボード化して可視化することでトラブルシュートの速度が上がりました。</p> <p><figure class="figure-image figure-image-fotolife" title="NewRelicDashboard"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108150740.png" width="1200" height="482" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>NewRelicDashboard</figcaption></figure></p> <p>NewRelicは前に述べたように、他の機能も充実しているため拡張性もあります。実際に、SLI / SLOの可視化や外形監視など、他でも利用できるところが多々あり、サービスが大きくなってきた現在も便利に使えています。</p> <p>また、アプリケーションログをjson形式に構造化することでログ検索の利便性が上がりました。</p> <p>アプリケーションのサーバーサイドはRailsを利用していますが、Railsのログは、エンドポイント、リクエストパラメータ、レスポンスタイム、ヘッダーなどの情報が複数行に分かれているため、各エンドポイントにかかった時間やパラメータなどを一行で検索することができませんでした。また、例えば、CloudWatch Logsのクエリでリクエストごとのレスポンスタイムを検索しようとすると下記のような複雑なクエリを毎回書く必要があり不便でした。</p> <pre class="code" data-lang="" data-unlink>fields @timestamp, @message | filter @message like /Completed 200/ | parse @message &#34;Completed 200 OK in *ms (Views: *| ActiveRecord: *ms&#34; as response_time_rails, response_time_view, response_time_rds | filter response_time_rails &gt; 500 | sort @timestamp desc | limit 20</pre> <p>そこで、 <a href="https://github.com/roidrage/lograge">GitHub - roidrage/lograge: An attempt to tame Rails&#39; default policy to log everything.</a> を利用してエンドポイントやレスポンスタイムの情報、ヘッダーの情報の一部を一行で出力できるようにし、コード上で指定している ::Rails.logger で出力されるRailsのアプリケーションログも同様にjson形式に構造化しました。</p> <p>また、::Rails.loggerのタグ機能でrequest_idを1行ごとに付与し、1リクエストのログを紐付けられるようにすることでリクエストごとのログが追いやすくなりました。</p> <p><figure class="figure-image figure-image-fotolife" title="request_idによるログ検索"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108151333.png" width="1200" height="236" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>request_idによるリクエストごとのログ検索</figcaption></figure></p> <p>Railsのログの構造化に関しては、意外と参考記事が見つからなかったので後日追ってブログにしようと思います。</p> <p>NewRelicを導入したことで、開発速度・信頼性共に恩恵を受けることが多々ありましたし、わたしたちのサービスのトラフィックやログ量だと、CloudWatch LogsからNewRelicに移行することでコスト削減にも繋がりました。また、ログの構造化をすることで不具合調査や障害対応時にスムーズに原因を特定でき、役立っています。</p> <h1 id="3-インシデント管理ツールの導入">3. インシデント管理ツールの導入</h1> <p>3つめは、インシデント管理ツールを導入し、MTTR(平均復旧時間)の短縮をした話です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108151643.png" width="200" height="195" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>私達のサービスは、サービス利用ピーク時間帯が朝方です。サービス利用のピーク時間は、他の時間帯と比較して障害発生確率が高くなる傾向にあります。しかし、ピーク時間帯が朝方のため、開発メンバーが起きておらず、障害に気が付かないことでMTTR(平均復旧時間)が伸びてしまったことがありました。</p> <p>アラートはSlackに流すようにしていましたが、それだけだと 「常にSlackを見ていないと障害に気づけない」、「アラート通知が他のSlack通知と一緒の音で紛れがち」という課題がありました。</p> <p>そこで、まずは朝方や休日のインシデントに確実に気づけるよう、インシデント管理ツールのOpsgenieというインシデント管理ツールを導入しました。インシデント管理ツールの機能の中でも、アラート通知機能によってMTTRを短くする対策することにしました。</p> <p>CloudwatchAlarmでアラートが上がったらSNS経由でOpsgenieにアラートを送信するようにします。また、自分のスマートフォンにOpsgenieのアプリをインストールすることで、アラートをスマホで受け取れるようになります。</p> <p>そうすることで、障害が起きるとスマホから目覚ましのアラームのような音が鳴り(心臓に悪いという点では不評)、障害に迅速に気づくことができます。Opsgenieの導入以降は障害に気づくまでの時間が短縮され、MTTRの短縮に寄与しました。</p> <p>加えて、サービスに関する重要な意思決定ができるメンバー(現EM)も一緒にアラートを受け取れるようにし、障害時の対応に迷った際、迅速に意思決定ができるようにしています。</p> <p>Opsgenieは、アラート通知機能を使いたいだけであれば、利用人数5人まではフリープランで足ります。</p> <p>最近チームの人数が増えたこと、過去のインシデントとアラートの紐づけのニーズが上がってきたことにより有料プランへの切り替えを予定していますが、この1年間はフリープランで十分なメリットを享受できていました。その他にも、フリープランで</p> <ul> <li>オンコールローテーションの自動スケジューリング (メンバーとローテーション期間を指定すれば自動でスケジュールを組んでくれる)</li> <li>エスカレーション機能 (スマホで受け取ったアラートのボタンを押せば自動でエスカレーションできる)</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="オンコールスケジュール画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/joe0000/20231108/20231108152120.png" width="1200" height="468" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>オンコールスケジュール画面</figcaption></figure></p> <p>などが利用でき、複数人の開発者のオンコールローテーションを簡単に仕組み化することができます。</p> <p>インシデント管理ツールとしてはPagerDutyが有名ですが、Opsgenieは同じような機能をPagerDutyの半額で利用できるのでコスト面も優れています。また、<strong><a href="https://www.atlassian.com/ja">Atlassian</a></strong> の製品なので品質も特に問題なくUIも使いやすいです。</p> <p>Opsgenieには様々な機能がありますが、まずはシンプルにアラート通知の機能を使うだけでもメリットがあるので、おすすめです。</p> <p>また、OpsgenieはTerraformでproviderが提供されており、設定内容をTerraformで管理することができます。Terraformで管理することで、属人化や設定のブラックボックス化を防いでいます。</p> <h1 id="まとめ">まとめ</h1> <p>開発速度・信頼性向上のための取り組みを3つ紹介しました。</p> <p>運用面やコスト面を考慮して改善を行っているので、小さいチームにも参考になれば幸いです!</p> joe0000 おい、誰も騒いでないから騒ぐけどExternal Network AccessっていうSnowflakeから外部へアクセスできる機能、データサイロ完全にぶっ壊せるぞ。 hatenablog://entry/6801883189053120864 2023-10-25T16:16:43+09:00 2023-10-25T17:41:12+09:00 SnowflakeからExternal Network Accessを利用してGoogle BigQueryのデータを取得する話 <p><figure class="figure-image figure-image-fotolife" title="NHK関連の話ではないです"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20231025/20231025141825.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>NHK関連の話ではないです</figcaption></figure> こんにちは harry(<a href="https://twitter.com/gappy50">@gappy50</a>)です〜。</p> <p>これまでクラシルでデータエンジニアをしておりましたが、最近クラシルリワードという別プロダクトでデータエンジニアをしております。</p> <p>クラシルリワードのデータ基盤は以下に詳細がありますので、ご興味あればどうぞ!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2Frewards_data_infrastructure" title="クラシルリワードのデータ基盤 - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/rewards_data_infrastructure">tech.dely.jp</a></cite></p> <p>本記事のタイトルは私がTwitter改めXにポストした投稿から抜粋しました(恥</p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">おい、誰も騒いでないから騒ぐけどExternal Network AccessっていうSnowflakeから外部へアクセスできる機能、データサイロ完全にぶっ壊せるぞ。<br>Federated Queryとして他のPFのDWHだけじゃなく独自のデータもAPIとかから取ってSnowflakeでクエリぶん回せるんだぜ。<a href="https://t.co/qj18hEkMAh">https://t.co/qj18hEkMAh</a></p>&mdash; harry (@gappy50) <a href="https://twitter.com/gappy50/status/1715329057301451074?ref_src=twsrc%5Etfw">2023年10月20日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <p>今日は、クラシルリワードとクラシルのデータをSnowflakeのExternal Network Accessを使って越境させて分析できるようにしたよ〜というお話をします。</p> <p>まずはその前に少し小話を。 どうやるかだけ気になる人は後半をご覧ください。</p> <h2 id="クラシルとクラシルリワードのデータ基盤とデータ基盤よもやま話">クラシルとクラシルリワードのデータ基盤とデータ基盤よもやま話</h2> <p>クラシルリワードではGoogle Analytics for Firebaseの行動ログを主に分析するためデータ基盤はBigQueryをベースに据えて日々の分析業務を行っています。 一方、これまでわたしがいたクラシルではAWS上に構築されたSnowflakeに行動ログや各種データを格納して分析をしております。</p> <p>各プロダクトがアジリティをもって意思決定をしていくには、余計なことはせずに大事なデータがあるところ、重心があるところに軸(DWH)を置いていくのがまずは正攻法ですよね。</p> <p>クラシルの場合は、AWS環境上にのみデータがあるのでSnowflakeを使うのは自然な流れですし、クラシルリワードの場合はGCPにデータの重心があるのでそこを軸に考えるのが自然です。</p> <p>ただし、それぞれのデータの利活用がどんどん進んでいくとこんな声も聞こえてきます。</p> <p>(´-`).。oO(クラシルの○○のデータとクラシルリワードの△△のデータを串刺しでみることで◎◎の結果を知りたい)</p> <p>(´-`).。oO(クラシルとリワードのデータをうまく突合できないかね?)</p> <p>そういった場合、データエンジニアがこれまで考えるべきことは</p> <p>😤 &lt; (必要なデータunloadしてどっちかに寄せておくか〜)とか</p> <p>😤😤 &lt; (Embulkを使って全部データかき集めるか〜)とか</p> <p>😤😤😤 &lt; (データサイロをぶっ壊すための天下一武道会を開催してデータ基盤を統一するぞ!)とか。</p> <p>データエンジニアの腕がなるところですね。後半になるにつれて鼻息が聞こえてきます😤</p> <p>全社のデータ基盤をAにしました!とかBに移行しました!とかいろんな事例も世の中にはわんさかあるし、 長期目線で考えたらデータサイロはないほうがよいに決まってます。</p> <p>ちなみにですが、データサイロは以下のようなものを指します。</p> <blockquote><p>データサイロとは、互いに分離され、社内の他部門、他部署からアクセスできないデータストレージ/管理システムのことです。これは、互いに通信したり情報を共有したりすることができない個別のシステムやデータベースにデータが保存されている場合に発生します。データサイロは、非効率、ミス、遅延の原因となるだけでなく、企業がデータを活用して有益な情報を取得し、より適切な意思決定をするうえで妨げとなります。その結果、社内の複数の部署や事業部門でデータが重複したり、一貫性なく使用されることが多くなります。</p> <p><a href="https://www.hpe.com/jp/ja/what-is/data-silos.html#:~:text=%E3%83%87%E3%83%BC%E3%82%BF%E3%82%B5%E3%82%A4%E3%83%AD%E3%81%A8%E3%81%AF%E3%80%81%E4%BA%92%E3%81%84%E3%81%AB,%E5%A0%B4%E5%90%88%E3%81%AB%E7%99%BA%E7%94%9F%E3%81%97%E3%81%BE%E3%81%99%E3%80%82">&#x30C7;&#x30FC;&#x30BF;&#x30B5;&#x30A4;&#x30ED;&#x3068;&#x306F; | &#x7528;&#x8A9E;&#x96C6; | HPE &#x65E5;&#x672C;</a></p></blockquote> <p>まさに今クラシルとクラシルリワードはデータサイロが起きそうな(起きてる)状況ですね。 我々の場合は、それぞれのデータの重心がAWSとGCPに分かれてしまっていることがより難しくしている原因ですね。</p> <p>ただし、鼻息荒くするだけでこのデータサイロをぶっ壊すことはできません。 これらを解消するための価値やコストを説明できる状況にならなければそんなものは夢物語になってしまいます。 そもそも本当に組織のアジリティを落としてまでやるべきことなのかみたいなことまで考え出すとそんなに気軽に意思決定なんてできません。</p> <p>特にAWSやGCPなどのプラットフォームを横断せざるを得ない場合、そのデータ量が多いものを重心がないほうへ寄せるのはストレージコストだけでなく、転送コスト、それらを実現するためのコンピューティングリソースのコストなどの運用コストを寄せ始めてからずっと払い続けることを意味しています。 そしてそれらを乗り越えてデータサイロをぶっ壊した!となっても、事業インパクトもなく、むしろ利便性を下げてしまったら本末転倒ですよね。</p> <p>なので、各事業の状況や会社としての状況、それらに紐づく制約や条件によっては、データサイロをぶっ壊すとことがいいかといえばそうでないことも大いにありますし、逆にいえば最初から長期目線でデータサイロを起こさない意思決定をすることで利活用が進むことでかかってしまう莫大な移行コストを抑えるようなことも考えられると思います。 後者ができれば一番幸せではありますが、前者も後者もやり直しがほぼできない一発勝負の世界だということも肝に銘じないといけません。</p> <p>そうやって、データエンジニアは鼻息ではなく溜息をつきながら都度都度必要なデータの出し入れするコストを払い続けるのです。</p> <p>このような状況において、データサイロはぶっ壊すことは不可能なのでしょうか?</p> <p>…もしかしたら、SnowflakeのExternal Network Accessがあればぶっ壊すことができるかもしれません。</p> <p>External Network Accessのサンプルは以下に詳細があります。 この例ではSnowflakeのSQLからGoogle Translateを呼び出すサンプルを例示しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.snowflake.com%2Fen%2Fdeveloper-guide%2Fexternal-network-access%2Fexternal-network-access-examples" title="External network access examples | Snowflake Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.snowflake.com/en/developer-guide/external-network-access/external-network-access-examples">docs.snowflake.com</a></cite></p> <p>話長くなりました。本編です。</p> <h2 id="External-Network-AccessでBigQueryのデータをSnowflakeから呼び出す">External Network AccessでBigQueryのデータをSnowflakeから呼び出す</h2> <p>元ネタはこれです。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedium.com%2Fsnowflake%2Fexecuting-federated-queries-in-snowflake-using-google-big-query-4ccaa20eac31" title="Executing Federated Queries in Snowflake using Google Big Query" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://medium.com/snowflake/executing-federated-queries-in-snowflake-using-google-big-query-4ccaa20eac31">medium.com</a></cite></p> <p>上記簡単に説明すると</p> <ul> <li>SnowflakeでExternal Network AccessというPuPrの機能を使うことで外部APIへセキュアにアクセスできる</li> <li>BigQueryやGoogle スプレッドシート等にあるデータセットもAPIから呼べるのでデータサイロ打破できる</li> <li>ただし、大量なデータを頻度高く取るとBigQueryからの転送コストやBigQueryとSnowflakeのどちらにもコンピューティングリソースのコストを払わないといけないから注意してね</li> </ul> <p>みたいな記事です。</p> <p>ただし上記記事のままだと、大量のデータを取得できてもSnowflake側で展開する際のコストがかかりすぎたり、そもそもデータをSnowflake上に展開できないことがあります。</p> <p>そのため、UDTFsに変更をした上でSnowflakeからBigQueryのデータを取得してみたいと思います。 また、OAuthではなくGoogle Cloudのサービスアカウントを利用してアクセスしてみます。</p> <p>今回は <code>ACCOUNTADMIN</code> で実装をしておりますが、External Network Accessは外部へのデータ出し入れが可能になる機能であるため、Snowflake上での適切な権限設定が必要であることは理解していただければと思います。 また、現時点(2023-10-25時点)ではPuPrの機能であることを念頭においておく必要があります。</p> <p>まずBigQueryへのアクセスのためのルールをスキーマ内に作成します。</p> <pre class="code lang-sql" data-lang="sql" data-unlink>use hoge_dev.external; <span class="synStatement">create</span> <span class="synStatement">or</span> <span class="synIdentifier">replace</span> network rule bq_rule <span class="synSpecial">mode</span> = egress <span class="synSpecial">type</span> = host_port value_list = (<span class="synSpecial">'</span><span class="synConstant">oauth2.googleapis.com</span><span class="synSpecial">'</span>,<span class="synSpecial">'</span><span class="synConstant">bigquery.googleapis.com</span><span class="synSpecial">'</span>); </pre> <p>次にサービスアカウントのアカウントキーを <code>GENERIC_STRING</code> としてシークレットを作成します。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">create</span> <span class="synStatement">or</span> <span class="synIdentifier">replace</span> secret bq_service_account <span class="synSpecial">type</span> = GENERIC_STRING SECRET_STRING = <span class="synSpecial">'</span><span class="synConstant">{ここに払い出されたアカウントキーの情報を入れる。必要に応じてエスケープしたりする。}</span><span class="synSpecial">'</span>; </pre> <p>そして、上記のネットワークルールとシークレットを使ってexternal access integrationを作成します</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">create</span> <span class="synStatement">or</span> <span class="synIdentifier">replace</span> external <span class="synSpecial">access</span> integration bq_access allowed_network_rules = (bq_rule) allowed_authentication_secrets = (bq_service_account) enabled = <span class="synSpecial">true</span>; </pre> <p>最後に上記のexternal access integrationを使ってUDTFsを作成します。</p> <pre class="code lang-python" data-lang="python" data-unlink>CREATE OR REPLACE FUNCTION run_bq_query_table(projid string, sql string) RETURNS table(value variant) LANGUAGE PYTHON RUNTIME_VERSION = <span class="synConstant">3.8</span> HANDLER = <span class="synConstant">'BigQueryDataFetcher'</span> EXTERNAL_ACCESS_INTEGRATIONS = (bq_access) PACKAGES = (<span class="synConstant">'snowflake-snowpark-python'</span>,<span class="synConstant">'requests'</span>, <span class="synConstant">'google-auth'</span>) SECRETS = (<span class="synConstant">'cred'</span> = bq_service_account) AS $$ <span class="synPreProc">import</span> _snowflake <span class="synPreProc">import</span> requests <span class="synPreProc">import</span> json <span class="synPreProc">from</span> google.oauth2 <span class="synPreProc">import</span> service_account <span class="synPreProc">from</span> google.auth.transport.requests <span class="synPreProc">import</span> Request <span class="synStatement">class</span> <span class="synIdentifier">BigQueryDataFetcher</span>: <span class="synStatement">def</span> <span class="synIdentifier">__init__</span>(self): self.headers = self._get_headers() <span class="synPreProc">@</span><span class="synIdentifier">staticmethod</span> <span class="synStatement">def</span> <span class="synIdentifier">_get_headers</span>(): <span class="synComment"># Snowflakeで作成したsecretから認証情報を取得</span> credentials_info = _snowflake.get_generic_secret_string(<span class="synConstant">&quot;cred&quot;</span>) credentials_dict = json.loads(credentials_info) credentials = service_account.Credentials.from_service_account_info( credentials_dict, scopes=[<span class="synConstant">&quot;https://www.googleapis.com/auth/bigquery&quot;</span>, <span class="synConstant">&quot;https://www.googleapis.com/auth/cloud-platform&quot;</span>] ) <span class="synComment"># 認証情報をもとにGCPのサービスアカウントのトークンを取得</span> credentials.refresh(Request()) <span class="synComment"># ヘッダー情報を返却</span> <span class="synStatement">return</span> { <span class="synConstant">&quot;Authorization&quot;</span>: f<span class="synConstant">&quot;Bearer {credentials.token}&quot;</span>, <span class="synConstant">&quot;Content-Type&quot;</span>: <span class="synConstant">&quot;application/json&quot;</span> } <span class="synStatement">def</span> <span class="synIdentifier">process</span>(self, project_id, sql_query): <span class="synComment"># BigQueryにクエリを投げるエンドポイント</span> query_url = f<span class="synConstant">&quot;https://bigquery.googleapis.com/bigquery/v2/projects/{project_id}/queries&quot;</span> <span class="synComment"># クエリを実行して初回のレスポンスデータを取得</span> response_data = self._execute_query(query_url, sql_query) job_id = response_data[<span class="synConstant">&quot;jobReference&quot;</span>][<span class="synConstant">&quot;jobId&quot;</span>] location = response_data[<span class="synConstant">&quot;jobReference&quot;</span>][<span class="synConstant">&quot;location&quot;</span>] schema = response_data[<span class="synConstant">'schema'</span>][<span class="synConstant">'fields'</span>] columns = [field[<span class="synConstant">'name'</span>] <span class="synStatement">for</span> field <span class="synStatement">in</span> schema] <span class="synComment"># 取得した行データを{&quot;カラム名&quot;: &quot;値&quot;}のjson formatに変換しtupleを返却</span> <span class="synComment"># UDTFではreturnよりもyieldにするほうが遅延評価の恩恵を受けて効率がよい</span> <span class="synComment"># https://docs.snowflake.com/ja/developer-guide/udf/python/udf-python-tabular-functions#implementing-a-handler</span> <span class="synStatement">for</span> row <span class="synStatement">in</span> response_data.get(<span class="synConstant">'rows'</span>, []): <span class="synStatement">yield</span> ({col: item[<span class="synConstant">'v'</span>] <span class="synStatement">for</span> col, item <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(columns, row[<span class="synConstant">'f'</span>])}, ) <span class="synComment"># ページトークンが存在する場合は次ページのデータを取得する</span> page_token = response_data.get(<span class="synConstant">&quot;pageToken&quot;</span>) result_url = f<span class="synConstant">&quot;{query_url}/{job_id}&quot;</span> <span class="synStatement">while</span> page_token: payload = { <span class="synConstant">&quot;timeoutMs&quot;</span>: <span class="synConstant">180000</span>, <span class="synConstant">&quot;location&quot;</span>: location, <span class="synConstant">&quot;pageToken&quot;</span>: page_token } response_data = self._get_query_results(result_url, payload) page_token = response_data.get(<span class="synConstant">&quot;pageToken&quot;</span>) <span class="synStatement">for</span> row <span class="synStatement">in</span> response_data.get(<span class="synConstant">'rows'</span>, []): <span class="synStatement">yield</span> ({col: item[<span class="synConstant">'v'</span>] <span class="synStatement">for</span> col, item <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(columns, row[<span class="synConstant">'f'</span>])}, ) <span class="synStatement">def</span> <span class="synIdentifier">_execute_query</span>(self, url, sql_query): <span class="synComment"># BigQueryにクエリをpostする</span> payload = { <span class="synConstant">&quot;query&quot;</span>: sql_query, <span class="synConstant">&quot;useLegacySql&quot;</span>: <span class="synIdentifier">False</span>, <span class="synConstant">&quot;timeoutMs&quot;</span>: <span class="synConstant">180000</span> } response = requests.post(url, headers=self.headers, data=json.dumps(payload)) response.raise_for_status() <span class="synStatement">return</span> response.json() <span class="synStatement">def</span> <span class="synIdentifier">_get_query_results</span>(self, url, payload): <span class="synComment"># クエリの結果をgetする</span> response = requests.get(url, headers=self.headers, params=payload) response.raise_for_status() <span class="synStatement">return</span> response.json() $$; </pre> <p>あとは、SnowflakeからBigQueryに投げるクエリとSnowflake側での変換処理を書いておしまいです。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">select</span> value:event_date::string <span class="synSpecial">as</span> event_date, value:event_name::string <span class="synSpecial">as</span> event_name, value:record_count::<span class="synType">number</span> <span class="synSpecial">as</span> record_count, value <span class="synSpecial">from</span> <span class="synSpecial">table</span>(run_bq_query_table( <span class="synSpecial">'</span><span class="synConstant">&lt;GCPのProject ID&gt;</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">select event_date, event_name, count(*) record_count from `events_*`</span> <span class="synConstant"> where _TABLE_SUFFIX = </span><span class="synSpecial">''</span><span class="synConstant">20231001</span><span class="synSpecial">''</span> <span class="synConstant"> group by 1, 2</span><span class="synSpecial">'</span> )); </pre> <p>どやぁ</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20231025/20231025122503.png" width="1200" height="543" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ある程度Snowflakeで扱いやすいようにBigQueryからのレスポンスを整形してvalueというvariantの1列にデータを格納するようにしています。 また、レスポンスが大きいとAPI側でページングがされるので、そこらへんもよしなに取れるようにしてます。 手元では100万行超、2列のデータをBigQueryから取得して表示されるまでXSで動かして1分程度で返ってきました。</p> <p>ただし、VARIANT列にすべてのレコード情報をkey-valueの形で格納しているので、1行あたりのデータ量が最大長の16 MBを超えると何かしらの問題があるかもしれません。 また、BigQueryのSTRUCT型のレスポンスが結構カオスなので事前にBigQuery側でunnestする等の処理はしておいたほうが幸せになれそうです。 他にも、こうしたほうがもっとよいよ〜みたいなのあればぜひ教えてください💪</p> <h2 id="さいごに">さいごに</h2> <p>いかがでしたでしょうか?</p> <p>煩雑なデータエンジニアリングを日々行ったり、データサイロの解消のための天下一武道会を開催をせずに、SnowflakeからExternal Network Accessを使えば最速でデータサイロを解消することもできそうですね。</p> <p>今回はBigQueryからのデータ取得をサンプルとして例示しましたが、流行りのOpenAIをSnowflakeにちょちょっと記述するだけでSQLから呼び出すこともできそうですし、他にも既存の外部データの連携やデータパイプラインに対しても色々な選択肢を取れるようになるので、色々と夢が広がりますね。</p> <p>そういうわけで、External Network Accessはデータサイロをぶっ壊せる可能性がありますよというお話でした。</p> gappy50 Relay: Streamlining UI Development from Figma to Compose hatenablog://entry/820878482965495714 2023-09-11T14:53:10+09:00 2023-09-11T14:53:10+09:00 Hello. My name is K, and I am currently working as an android engineer in Kurashiru. 🚀 Preface Brief Intro to Relay Creating UI Packages with Relay Using the component in Android Studio Map to Compose Theme Map to Existing Components Review of Relay: thoughts on it’s practical usage Preface At Kuras… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/ktmoe/20230907/20230907191945.png" width="1168" height="606" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Hello. My name is K, and I am currently working as an android engineer in Kurashiru. 🚀</p> <ul class="table-of-contents"> <li><a href="#Preface">Preface</a></li> <li><a href="#Brief-Intro-to-Relay">Brief Intro to Relay</a></li> <li><a href="#Creating-UI-Packages-with-Relay">Creating UI Packages with Relay</a></li> <li><a href="#Using-the-component-in-Android-Studio">Using the component in Android Studio</a></li> <li><a href="#Map-to-Compose-Theme">Map to Compose Theme</a></li> <li><a href="#Map-to-Existing-Components">Map to Existing Components</a></li> <li><a href="#Review-of-Relay-thoughts-on-its-practical-usage">Review of Relay: thoughts on it’s practical usage</a></li> </ul> <h4 id="Preface">Preface</h4> <p>At Kurashiru, we're on a journey of transitioning to Jetpack Compose. This has led me to experiment with Relay plugin in Kurashiru-next. As you may be aware, <a href="https://developer.android.com/jetpack/compose/tooling/relay">Relay</a> is currently in alpha and is a design-to-code transformation tool. Relay makes it possible for designers to create UI components in Figma and export/import them into Android Studio to generate pixel-perfect Compose code.</p> <p>The ability to seamlessly generate pixel-perfect Compose code from Figma UI components, makes me wonder a bunch of questions, “Does this tool suitable for practical use?”, “How will it look like with Kurashiru?” and “Who is this for?”. So, I did a little experiment to transform a few Kurashiru’s components in Figma to Compose code using Relay and I would like to share that experience in this article.</p> <h4 id="Brief-Intro-to-Relay">Brief Intro to Relay</h4> <blockquote><p>Relay provides instant handoff of Android UI components between designers and developers.</p> <p>Designers use the Relay for Figma plugin to annotate and package UI components for developer use, including information about layout, styling, dynamic content and interaction behavior. These UI Packages provide a shared model for UI components, and can be exchanged and updated in a collaboration between designers and developers.</p></blockquote> <p>Relay consists of three plugins: the Relay for Figma plugin, the Relay for Android Studio plugin, and the Relay Gradle plugin for developers. Once setup, the process of converting the design into code can be done in a few steps:</p> <ol> <li>Package up a component in Figma by specifying parameters (information about the layout, styling, arbitrary content, and interaction behavior of the design).</li> <li>Import UI packages into Android Studio.</li> <li>Build the project and the codes will be generated.</li> </ol> <p>I will skip the setup parts and jump straight to using it. Here is documentation on <a href="https://developer.android.com/jetpack/compose/tooling/relay/install-relay">how to set up</a>.</p> <h4 id="Creating-UI-Packages-with-Relay">Creating UI Packages with Relay</h4> <p>Let’s work on simple Button Component as an example. <figure class="figure-image figure-image-fotolife" title="Button Component in Design system"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/ktmoe/20230907/20230907181954.png" width="1200" height="220" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Button Component in Design system</figcaption></figure> These are different variants of button used in Kurashiru app. I reckon a button is a good exercise to study basic capabilities of Relay.</p> <p>Continuing to the packaging of component, define the parameters that the developers need to be able to control. In this scenario, design (size, primary, alternative, filled, outlined and so on), onTap and Text are added to be able to pass dynamically. <figure class="figure-image figure-image-fotolife" title="Packaging the Component"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/ktmoe/20230907/20230907182132.png" width="1200" height="753" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Packaging the Component</figcaption></figure></p> <h4 id="Using-the-component-in-Android-Studio">Using the component in Android Studio</h4> <p>Importing to android studio is pretty simple to sort out by following the steps from <a href="https://developer.android.com/jetpack/compose/tooling/relay/convert-designs-android-studio#import-design">here</a>. After building the project, the codes are auto-generated. Details of what kind of codes are generated can be read <a href="https://developer.android.com/jetpack/compose/tooling/relay/convert-designs-android-studio#build-&amp;">here</a>.</p> <p>To see the results, I dropped the generated Button into a @Preview composable like this.</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Preview</span> <span class="synIdentifier">@Composable</span> <span class="synType">fun</span> ThemeButtonPreview() { Button( text = <span class="synConstant">&quot;HELLO&quot;</span>, type = Type.Filled, size = Size.Large, state = State.Theme, onTap = { <span class="synComment">/* do nothing*/</span> } ) } <span class="synIdentifier">@Preview</span> <span class="synIdentifier">@Composable</span> <span class="synType">fun</span> PrimaryButtonPreview() { Button( text = <span class="synConstant">&quot;HELLO&quot;</span>, type = Type.Filled, size = Size.Large, state = State.Primary, onTap = { <span class="synComment">/* do nothing*/</span> } ) } </pre> <p><figure class="figure-image figure-image-fotolife" title="Preview Composable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/ktmoe/20230907/20230907182522.png" width="600" height="522" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Preview Composable</figcaption></figure></p> <h4 id="Map-to-Compose-Theme">Map to Compose Theme</h4> <p>For colors and typography, Relay generates literal values by default. This ensures translation accuracy, but prevents components from using the Compose theming system. To resolve the theme issues, Relay offers an experimental feature,  <a href="https://developer.android.com/jetpack/compose/tooling/relay/mapping-styles-to-compose-theme">Mapping Styles to a Compose Theme</a>. This feature allows to map named styles in Figma, to tokens, and then from tokens to Compose theme properties which allows us to point Figma styles directly to their Compose equivalents. Mappings are defined in <code>json</code> but I will not go further into those and continue with the next point which is about Mapping components to existing code.</p> <h4 id="Map-to-Existing-Components">Map to Existing Components</h4> <blockquote><p>Developers can customize the code generation process by providing a mapping between a UI Package and an existing code component instead of the generated code. This is beneficial when the existing implementation has features that cannot be achieved by the generated code such as animation or complex behavior (such as a drop down menu).</p></blockquote> <p>Another experimental feature, allows us to map our own created components to the generated components.</p> <p>In addition to the button, I also conducted an experiment with the SearchBar. The generated search bar is not user interactive, which means cannot type inputs or detect input changes, and other complex logics are needed to be handled as well.</p> <p>To solve these problems, Map Components to existing code feature can be used. This feature basically allows to use the developer coded composable which can do the functionalities that are hard to describe in Figma.</p> <p>The mapping file name must match with that of the UI Package folder for the component it replaces, &lt;&lt;component_package.json>>, mappings/search_bar.json in this case. This file will map ui-packages/search_bar to the hand-rolled composable.</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">target</span>&quot;: &quot;<span class="synConstant">KurashiruSearchBar</span>&quot;, <span class="synError">// name of existing composable</span> &quot;<span class="synStatement">package</span>&quot;: &quot;<span class="synConstant">com.kurashiru.ui.textfield</span>&quot;, <span class="synError">// package directory of existing composable</span> &quot;<span class="synStatement">generateImplementation</span>&quot;: <span class="synConstant">true</span>, &quot;<span class="synStatement">generatePreviews</span>&quot;: <span class="synConstant">true</span> <span class="synSpecial">}</span> </pre> <p>This way, the generated code can be customized and composables that operate the required functions can be created. 🎉</p> <h4 id="Review-of-Relay-thoughts-on-its-practical-usage">Review of Relay: thoughts on it’s practical usage</h4> <p>Still in alpha, Relay is a tool that is stable and functional. It does as it promises, seamlessly produce Compose code from Figma designs.</p> <ul> <li><p>Using the versioned design system components in Figma as the source of truth is a valuable approach from a design system standpoint, necessitating a rigorous process for designers who collaborate with the system.</p></li> <li><p>It requires more communication between designers and developers to settle on things like naming of parameters and to be able to deliberate with interaction &amp; accessibility handling, which may require modification of the existing design system. Kurashiru has a stable design system which is being used for both Android and iOS. Since Relay doesn't support iOS, adapting the design system exclusively for Android would be an extra effort.</p></li> <li><p>The generated component is tightly coupled with Figma design system and allowing for effortless one-click updates to the UI packages. However, this tight coupling may pose challenges when conducting AB tests.</p></li> <li><p>It definitely is faster than building of simple components by hand but as there are limitations to it, coding the complex components by hand and mapping to those generated components will become a necessity.</p></li> </ul> <p>To wrap up my thoughts on Relay, it is a pretty dandy tool. However, its limitations, mostly stemming from its early stages, mean that its benefits depend on the size and composition of the mobile team that inhabits it. For now, Relay feels like more effort than it’s worth to Kurashiru. But hey! Since it is introduced as part of Modern Android, I would like to wait to see its growth and expecting Relay to grow into a tool that bridge design-to-code without the need of extra works.</p> <p>Of course these are just my thoughts and words on trying it out, so, do not take these words and try it out!</p> <p>See you in the next articles 👋🏼</p> <p>References 📚</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Fjetpack%2Fcompose%2Ftooling%2Frelay" title="Overview  |  Jetpack Compose  |  Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/jetpack/compose/tooling/relay">developer.android.com</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcodelabs.developers.google.com%2Frelay-complete-app%230" title="Build a complete app with Relay and Jetpack Compose  |  Google Codelabs" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://codelabs.developers.google.com/relay-complete-app#0">codelabs.developers.google.com</a></cite></p> ktmoe iOSDC 2023に登壇しました & 社内で聴講した内容の共有会を行いました hatenablog://entry/820878482964599580 2023-09-07T17:18:22+09:00 2023-09-07T17:18:22+09:00 iOSDCについて iOSDCは公式ページによると、 iOSDC Japan 2023はiOS関連技術をコアのテーマとしたソフトウェア技術者のためのカンファレンスです。 と紹介されており、日本最大級のiOS関連のカンファレンスと知られています。 iOSDC 2023では1,409枚のチケットが発行され、非常に多くの方々が参加されました。 このカンファレンスは9/1から9/3の3日間、オフラインとオンラインのハイブリッド形式で開催されました。 発表した内容 当日は私もLT枠で"ShazamKitの魔法を解き明かす: 音楽認識技術「オーディオフィンガープリント」の探検!"というタイトルで発表を行い… <h1 id="iOSDCについて">iOSDCについて</h1> <p>iOSDCは<a href="https://iosdc.jp/2023/">公式ページ</a>によると、</p> <blockquote><p>iOSDC Japan 2023はiOS関連技術をコアのテーマとしたソフトウェア技術者のためのカンファレンスです。</p></blockquote> <p>と紹介されており、日本最大級のiOS関連のカンファレンスと知られています。</p> <p>iOSDC 2023では1,409枚のチケットが発行され、非常に多くの方々が参加されました。 このカンファレンスは9/1から9/3の3日間、オフラインとオンラインのハイブリッド形式で開催されました。</p> <h1 id="発表した内容">発表した内容</h1> <p>当日は私もLT枠で"<a href="https://fortee.jp/iosdc-japan-2023/proposal/e3369fcc-d6a1-4f9a-8921-9d3bcce62f29">ShazamKitの魔法を解き明かす: 音楽認識技術「オーディオフィンガープリント」の探検!</a>"というタイトルで発表を行いました。 当日の資料は以下からご覧いただけます。</p> <p><iframe id="talk_frame_1071946" class="speakerdeck-iframe" src="//speakerdeck.com/player/08abb28e51fc4829ae43362ed0eacf9e" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/1plus4/shazamkitnomo-fa-wojie-kiming-kasu-yin-le-ren-shi-ji-shu-odeiohuingapurinto-notan-jian">speakerdeck.com</a></cite></p> <p>このテーマは業務とは直接関係ありませんが、社内勉強会用にまとめた内容をもとに応募しました。 iOSの技術とは直接関連しない音楽認識技術に関する話だったため、反応が気になりましたが、Twitterやニコ生のコメントを見ると多くの方に楽しんでいただけたようです。</p> <p>iOSDCでは、他にも音楽関連の発表があり、自動作曲や空間オーディオなどの興味深いトピックについて学ぶことができました。</p> <h1 id="社内での共有会">社内での共有会</h1> <p>カンファレンス終了後、dely社のiOSエンジニアたちが集まり、印象的だった発表についての共有会を開催しました。</p> <p>聴講した発表をもとに自社サービスに使えそうなもの・単純に面白かった内容について話し合いました。 特に盛り上がりましたスライドを以下に紹介します。</p> <p><iframe id="talk_frame_1072062" class="speakerdeck-iframe" src="//speakerdeck.com/player/e927bfb543a24cda8538a1da53c18bd8" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/kamimi/understanding-the-fundamentals-privacy-changes-to-address-by-next-spring">speakerdeck.com</a></cite></p> <p><iframe id="talk_frame_1070354" class="speakerdeck-iframe" src="//speakerdeck.com/player/3566c9a8af7447d88697c4e5ec55bac7" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/recruitengineers/iosdc-japan-2023">speakerdeck.com</a></cite></p> <p><iframe id="talk_frame_1072205" class="speakerdeck-iframe" src="//speakerdeck.com/player/f8405979fe754acebf7edb830c09fe78" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/motokiee/mercari-10years-ios-development">speakerdeck.com</a></cite></p> <p><iframe id="talk_frame_1071948" class="speakerdeck-iframe" src="//speakerdeck.com/player/e62aaa0859e2467798782fdb24409b6f" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/akihiro_kokubo/swiftkodonopahuomansue-hua-6tunokesudenoshi-yan-jie-guo-wobao-gao-iosdc-japan-2023">speakerdeck.com</a></cite></p> <p>他にも興味深い発表が多く、上に挙げた以外の発表も含め社内のiOSエンジニアで盛り上がりました。</p> <h1 id="終わりに">終わりに</h1> <p>iOSDCでの登壇と、社内での共有会について紹介しました。</p> <p>年に一度の大きなカンファレンスなので、社内でも多くの興味と議論が湧き上がりました。 WWDCの後にも同様の共有会を設けましたが、今後もこのような機会を増やしていきたいと思います。</p> <p>最後に、カンファレンスの運営を支えてくださった皆さま、スポンサーの皆さまに心からの感謝を申し上げます。 次回もこのような機会があれば、積極的に参加したいと思っております。</p> ishida-dely Sharding vs. Partitioning Demystified: Scaling Your Database hatenablog://entry/820878482965398734 2023-09-07T12:00:25+09:00 2023-09-07T12:00:25+09:00 Hello, I'm Allan, a Server-Side Engineer at Kurashiru 🚀 While Kurashiru predominantly relies on MySQL, it's intriguing to explore the broader landscape of database management. Enter PostgreSQL, a robust contender, known for its powerful techniques of Sharding and Partitioning. Even if you're steeped… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/A/AllanJone/20230906/20230906081529.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>Hello, I'm Allan, a Server-Side Engineer at Kurashiru 🚀</strong></p> <p>While Kurashiru predominantly relies on MySQL, it's intriguing to explore the broader landscape of database management. Enter PostgreSQL, a robust contender, known for its powerful techniques of Sharding and Partitioning.</p> <p>Even if you're steeped in a different database system, understanding these strategies can be invaluable. This guide delves into the world of PostgreSQL, elucidating how to implement and monitor Sharding and Partitioning within a Dockerized Rails framework. It's a journey of ensuring agility and scalability, irrespective of your primary database preference. Let's dive in!</p> <h2 id="-Table-of-Contents">📘 Table of Contents</h2> <ul> <li><a href="#understanding-the-strategies">Understanding the Strategies</a> <ul> <li><a href="#1-sharding">Sharding</a> <ul> <li><a href="#why-use-sharding">Why Use Sharding?</a></li> <li><a href="#challenges">Challenges</a></li> <li><a href="#when-to-use-sharding">When to use Sharding</a></li> </ul> </li> <li><a href="#2-partitioning">Partitioning</a> <ul> <li><a href="#types-of-partitioning-in-postgresql">Types of Partitioning in PostgreSQL</a></li> <li><a href="#advantages">Advantages</a></li> </ul> </li> <li><a href="#summary">Summary of key concepts</a></li> </ul> </li> <li><a href="#practical-implementation-with-docker--rails">Practical Implementation with Docker &amp; Rails</a> <ul> <li><a href="#1-sharding-1">Sharding</a></li> <li><a href="#2-partitioning-1">Partitioning</a></li> </ul> </li> <li><a href="#monitoring--performance-insights">Monitoring &amp; Performance Insights</a> <ul> <li><a href="#1-the-power-of-pg_stat_statements">The Power of pg_stat_statements</a></li> <li><a href="#2-disk-usage-metrics">Disk Usage Metrics</a></li> <li><a href="#3-monitoring-connections">Monitoring Connections</a></li> <li><a href="#4-third-party-monitoring-tools">Third-Party Monitoring Tools</a></li> <li><a href="#5-always-test-in-staging">Always Test in Staging</a></li> </ul> </li> <li><a href="#parting-thoughts">Parting Thoughts</a></li> </ul> <p><a name="understanding-the-strategies"></a></p> <h2 id="-Understanding-the-Strategies"><strong>🌐 Understanding the Strategies</strong></h2> <p><a name="1-sharding"></a></p> <h2 id="1-Sharding"><strong>1. Sharding</strong>:</h2> <p>Sharding is essentially the practice of splitting your database into several smaller, more manageable pieces, and distributing them across a range of storage resources.</p> <p><a name="why-use-sharding"></a></p> <h3 id="Why-Use-Sharding"><strong>Why Use Sharding?</strong></h3> <ul> <li><p><strong>Distributed Data</strong>: With data distributed across servers, you can leverage the power of multiple machines. This ensures that as your data grows, you can keep adding servers to handle the load.</p></li> <li><p><strong>Isolation</strong>: By isolating data, you ensure that issues affecting one shard (like an overload or crash) won't cripple your entire system.</p></li> </ul> <p><a name="challenges"></a></p> <h3 id="Challenges"><strong>Challenges</strong>:</h3> <ul> <li><p><strong>Operational Complexity</strong>: Maintaining multiple shards, ensuring data consistency, and handling backups can be challenging.</p></li> <li><p><strong>Cross-Shard Queries</strong>: Aggregating data or joining tables across shards can be complex and slower.</p></li> </ul> <p><a name="when-to-use-sharding"></a></p> <h3 id="When-to-use-Sharding"><strong>When to use Sharding</strong>:</h3> <ul> <li>When data is too large to fit efficiently on a single server.</li> <li>When you have high transaction rates that require distributed processing.</li> <li>Geographic distribution requirements.</li> </ul> <p><a name="2-partitioning"></a></p> <h2 id="2-Partitioning"><strong>2. Partitioning</strong>:</h2> <p>Partitioning divides a table into smaller, more manageable pieces, yet the partitioned table itself is still treated as a single entity.</p> <p><a name="types-of-partitioning-in-postgresql"></a></p> <h3 id="Types-of-Partitioning-in-PostgreSQL"><strong>Types of Partitioning in PostgreSQL</strong>:</h3> <ul> <li><p><strong>Range Partitioning</strong>: Rows are partitioned based on a range of values. For instance, orders from different months can be stored in separate partitions.</p></li> <li><p><strong>List Partitioning</strong>: Rows are partitioned in specific predefined categories. E.g., sales data can be partitioned by country.</p></li> <li><p><strong>Hash Partitioning</strong>: Rows are partitioned using a hash function on the partition key, ensuring even data distribution.</p></li> </ul> <p><a name="advantages"></a></p> <h3 id="Advantages"><strong>Advantages</strong>:</h3> <ul> <li><p><strong>Improved Query Performance</strong>: Data retrieval can be faster since scans are limited to specific partitions.</p></li> <li><p><strong>Maintenance</strong>: Older data can be purged without affecting the rest of the table by simply dropping a partition.</p></li> </ul> <p><a name="summary"></a></p> <h2 id="3-Summary-of-key-concepts"><strong>3. Summary of key concepts</strong>:</h2> <p>The table below summarizes the significant differences between sharding and partitioning for your reference. We will explain these terms in detail further on.</p> <table> <thead> <tr> <th> Dimension </th> <th> Sharding </th> <th> Partitioning </th> </tr> </thead> <tbody> <tr> <td> Data Storage </td> <td> On separate machines </td> <td> On the same machine </td> </tr> <tr> <td> Scalability </td> <td> High </td> <td> Limited </td> </tr> <tr> <td> Availability </td> <td> High </td> <td> Similar to unpartitioned database </td> </tr> <tr> <td> Number of Parallel Queries </td> <td> Depends on the number of machines </td> <td> Depends on the number of cores in the single machine </td> </tr> <tr> <td> Query Time </td> <td> Low </td> <td> Medium to Low </td> </tr> </tbody> </table> <p><figure class="figure-image figure-image-fotolife" title="temp-thumbnail"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/A/AllanJone/20230904/20230904231347.png" width="1200" height="1103" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Vertical partitioning Vs Horizontal partitioning (sharding)</figcaption></figure></p> <p><a name="practical-implementation-with-docker--rails"></a></p> <h2 id="-Practical-Implementation-with-Docker--Rails"><strong>🛠 Practical Implementation with Docker &amp; Rails</strong></h2> <p><a name="1-sharding-1"></a></p> <h3 id="1-Sharding-1"><strong>1. Sharding</strong>:</h3> <p><strong>Setup</strong>: Create multiple PostgreSQL instances using Docker Compose, each representing a shard.</p> <p><strong><code>docker-compose.yml</code></strong>:</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">version</span><span class="synSpecial">:</span> <span class="synConstant">'3'</span> <span class="synIdentifier">services</span><span class="synSpecial">:</span> <span class="synIdentifier">primary_db</span><span class="synSpecial">:</span> <span class="synIdentifier">image</span><span class="synSpecial">:</span> postgres <span class="synIdentifier">environment</span><span class="synSpecial">:</span> <span class="synIdentifier">POSTGRES_USER</span><span class="synSpecial">:</span> user <span class="synIdentifier">POSTGRES_PASSWORD</span><span class="synSpecial">:</span> password <span class="synIdentifier">POSTGRES_DB</span><span class="synSpecial">:</span> app_development <span class="synIdentifier">shard_one_db</span><span class="synSpecial">:</span> <span class="synComment"> # ... similar to above ...</span> <span class="synIdentifier">shard_two_db</span><span class="synSpecial">:</span> <span class="synComment"> # ... and so on ...</span> </pre> <p><strong><code>database.yml</code></strong>:</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">development</span><span class="synSpecial">:</span> <span class="synIdentifier">primary</span><span class="synSpecial">:</span> <span class="synIdentifier">adapter</span><span class="synSpecial">:</span> postgresql <span class="synIdentifier">database</span><span class="synSpecial">:</span> app_development <span class="synComment"> # other settings...</span> <span class="synIdentifier">shard_one</span><span class="synSpecial">:</span> <span class="synIdentifier">adapter</span><span class="synSpecial">:</span> postgresql <span class="synIdentifier">database</span><span class="synSpecial">:</span> app_shard_one <span class="synComment"> # other settings...</span> <span class="synComment"> # ... and so on ...</span> </pre> <p><strong>Model Connection</strong>: By default, models connect to the primary. For specific models:</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">SomeModel</span> &lt; <span class="synType">ApplicationRecord</span> connects_to <span class="synConstant">database</span>: { <span class="synConstant">writing</span>: <span class="synConstant">:shard_one</span>, <span class="synConstant">reading</span>: <span class="synConstant">:shard_one</span> } <span class="synPreProc">end</span> </pre> <p><a name="2-partitioning-1"></a></p> <h3 id="2-Partitioning-1"><strong>2. Partitioning</strong>:</h3> <p><strong>Setup</strong>: Partition tables based on chosen criteria.</p> <p><strong>Migration Example</strong> (Partitioning by Date):</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">CreatePosts</span> &lt; <span class="synType">ActiveRecord</span>::<span class="synType">Migration</span>[<span class="synConstant">6.1</span>] <span class="synPreProc">def</span> <span class="synIdentifier">up</span> create_table <span class="synConstant">:posts</span>, <span class="synConstant">partition_key</span>: <span class="synConstant">:created_at</span> <span class="synStatement">do</span> |t| t.string <span class="synConstant">:title</span> t.text <span class="synConstant">:content</span> t.timestamps <span class="synStatement">end</span> <span class="synComment"># Define a partition for 2023</span> execute &lt;&lt;-<span class="synSpecial">SQL</span> <span class="synConstant"> CREATE TABLE posts_2023 PARTITION OF posts</span> <span class="synConstant"> FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');</span> <span class="synConstant"> </span><span class="synSpecial">SQL</span> <span class="synPreProc">end</span> <span class="synPreProc">def</span> <span class="synIdentifier">down</span> drop_table <span class="synConstant">:posts</span> execute <span class="synSpecial">&quot;</span><span class="synConstant">DROP TABLE posts_2023;</span><span class="synSpecial">&quot;</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> </pre> <p><a name="monitoring--performance-insights"></a></p> <h2 id="-Monitoring--Performance-Insights"><strong>📊 Monitoring &amp; Performance Insights</strong></h2> <p>The real worth of any database scaling strategy is determined by how it performs in the wild. To ensure your sharding or partitioning strategy is delivering its promise, you need robust monitoring and performance insights.</p> <p><a name="1-the-power-of-pg_stat_statements"></a></p> <h3 id="1-The-Power-of-pg_stat_statements"><strong>1. The Power of <code>pg_stat_statements</code></strong>:</h3> <p>This module provides a means to track execution statistics of all SQL statements executed by a server.</p> <p><strong>Activation</strong>: Uncomment or add the following line in <code>postgresql.conf</code>:</p> <pre class="code lang-sql" data-lang="sql" data-unlink>shared_preload_libraries = <span class="synSpecial">'</span><span class="synConstant">pg_stat_statements</span><span class="synSpecial">'</span> </pre> <p><strong>Usage</strong>: This view will contain rows of normalized query strings and associated statistics:</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink>results = <span class="synType">ActiveRecord</span>::<span class="synType">Base</span>.connection.execute(<span class="synSpecial">&quot;</span><span class="synConstant">SELECT query, calls, total_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;</span><span class="synSpecial">&quot;</span>) results.each <span class="synStatement">do</span> |row| puts <span class="synSpecial">&quot;</span><span class="synConstant">Query: </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">query</span><span class="synSpecial">'</span>]<span class="synSpecial">}</span><span class="synConstant">, Calls: </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">calls</span><span class="synSpecial">'</span>]<span class="synSpecial">}</span><span class="synConstant">, Total Time: </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">total_time</span><span class="synSpecial">'</span>]<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> </pre> <p>This code fetches the top 10 queries with the highest total execution time, which helps in identifying slow-performing queries.</p> <p><a name="2-disk-usage-metrics"></a></p> <h3 id="2-Disk-Usage-Metrics"><strong>2. Disk Usage Metrics</strong>:</h3> <p>As data grows, monitoring the disk space becomes crucial, especially when using partitioning, as each partition can grow at different rates.</p> <p><strong>Checking Table and Partition Sizes</strong>:</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink>sizes = <span class="synType">ActiveRecord</span>::<span class="synType">Base</span>.connection.execute(&lt;&lt;~<span class="synSpecial">SQL</span> <span class="synConstant"> SELECT nspname || '.' || relname AS &quot;relation&quot;, pg_size_pretty(pg_total_relation_size(C.oid)) AS &quot;size&quot;</span> <span class="synConstant"> FROM pg_class C</span> <span class="synConstant"> LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)</span> <span class="synConstant"> WHERE nspname NOT IN ('pg_catalog', 'information_schema')</span> <span class="synConstant"> ORDER BY pg_total_relation_size(C.oid) DESC</span> <span class="synConstant"> LIMIT 10;</span> <span class="synSpecial">SQL</span> ) sizes.each { |row| puts <span class="synSpecial">&quot;#{</span>row[<span class="synSpecial">'</span><span class="synConstant">relation</span><span class="synSpecial">'</span>]<span class="synSpecial">}</span><span class="synConstant"> - </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">size</span><span class="synSpecial">'</span>]<span class="synSpecial">}&quot;</span> } </pre> <p>This snippet fetches the top 10 relations (tables/partitions) based on their size.</p> <p><a name="3-monitoring-connections"></a></p> <h3 id="3-Monitoring-Connections"><strong>3. Monitoring Connections</strong>:</h3> <p>With sharding, connections can exponentially grow as you connect to multiple databases. Use the following to monitor active connections:</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink>active_connections = <span class="synType">ActiveRecord</span>::<span class="synType">Base</span>.connection.execute(<span class="synSpecial">&quot;</span><span class="synConstant">SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;</span><span class="synSpecial">&quot;</span>) active_connections.each <span class="synStatement">do</span> |row| puts <span class="synSpecial">&quot;</span><span class="synConstant">Database: </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">datname</span><span class="synSpecial">'</span>]<span class="synSpecial">}</span><span class="synConstant">, Active Connections: </span><span class="synSpecial">#{</span>row[<span class="synSpecial">'</span><span class="synConstant">count</span><span class="synSpecial">'</span>]<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> </pre> <p><a name="4-third-party-monitoring-tools"></a></p> <h3 id="4-Third-Party-Monitoring-Tools"><strong>4. Third-Party Monitoring Tools</strong>:</h3> <p>There are a myriad of third-party tools that offer extensive PostgreSQL monitoring capabilities:</p> <ul> <li><strong><a href="https://github.com/darold/pgbadger">pgBadger</a></strong>: An advanced PostgreSQL log analyzer.</li> <li><strong><a href="https://www.datadoghq.com/">Datadog</a></strong>: Offers PostgreSQL integration to monitor performance and health.</li> <li><strong><a href="https://newrelic.com/">New Relic</a></strong>: Their PostgreSQL monitoring integration provides insights into slow queries, busy times, and more.</li> </ul> <p><a name="5-always-test-in-staging"></a></p> <h3 id="5-Always-Test-in-Staging"><strong>5. Always Test in Staging</strong>:</h3> <p>Before pushing any changes, especially related to database scaling, always test in a staging environment. This environment should mirror your production setup. Tools like <strong>pgbench</strong> can help simulate load on your database, allowing you to gather performance insights.</p> <p><a name="parting-thoughts"></a></p> <h2 id="-Parting-Thoughts"><strong>🌟 Parting Thoughts</strong></h2> <p>Whether sharding or partitioning, the choice hinges on your application’s needs. If you anticipate vast datasets with geographical dispersion, sharding can be a boon. However, if your dataset is massive but localized, partitioning might suffice.</p> <p>In either case, a well-architected Rails app, coupled with PostgreSQL's robustness and Docker's encapsulation, can stand tall against scaling challenges and hope this article find it helpful for those who are considering sharding or partitioning.</p> <h2 id="-References"><strong>📚 References</strong></h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fplanetscale.com%2Flearn%2Farticles%2Fsharding-vs-partitioning-whats-the-difference" title="Sharding vs Partitioning: What’s the Difference? | PlanetScale" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://planetscale.com/learn/articles/sharding-vs-partitioning-whats-the-difference">planetscale.com</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fedgeguides.rubyonrails.org%2Factive_record_multiple_databases.html" title="Multiple Databases with Active Record — Ruby on Rails Guides" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://edgeguides.rubyonrails.org/active_record_multiple_databases.html">edgeguides.rubyonrails.org</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fblog.bytebytego.com%2Fp%2Fvertical-partitioning-vs-horizontal" title="Vertical partitioning vs horizontal partitioning" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://blog.bytebytego.com/p/vertical-partitioning-vs-horizontal">blog.bytebytego.com</a></cite> Sharding vs. Partitioning Demystified: Scaling Your Database</p> AllanJone クラシルリワードのデータ基盤 hatenablog://entry/820878482958837113 2023-08-22T13:10:22+09:00 2023-08-22T13:10:39+09:00 この記事ではクラシルリワードのデータ基盤の構成について紹介していきます。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230816/20230816075954.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"><ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#クラシルリワードについて">クラシルリワードについて</a></li> <li><a href="#データ基盤の全体構成">データ基盤の全体構成</a></li> <li><a href="#BigQueryの選定理由">BigQueryの選定理由</a></li> <li><a href="#データ基盤における重要な役割">データ基盤における重要な役割</a><ul> <li><a href="#アプリのイベントログのscan量削減">アプリのイベントログのscan量削減</a></li> <li><a href="#DynamoDBやRDSをBigQueryにSync">DynamoDBやRDSをBigQueryにSync</a></li> <li><a href="#Snowflakeの活用">Snowflakeの活用</a></li> <li><a href="#BIツールRedash-Looker">BIツール(Redash, Looker)</a><ul> <li><a href="#Redash">Redash</a><ul> <li><a href="#Redash-repositoryのフォルダファイル構成">Redash repositoryのフォルダ・ファイル構成</a></li> <li><a href="#git管理下のSQLクエリをRedashに反映">git管理下のSQLクエリをRedashに反映</a></li> </ul> </li> <li><a href="#Lookerについて">Lookerについて</a></li> </ul> </li> </ul> </li> <li><a href="#さいごに">さいごに</a></li> </ul> <h2 id="はじめに">はじめに</h2> <p>こんにちは!クラシルリワードで開発責任者をしているfunzinです。 <br> この記事ではクラシルリワードのデータ基盤の構成について紹介していきます。</p> <h2 id="クラシルリワードについて">クラシルリワードについて</h2> <p>はじめにクラシルリワードのサービス概要について紹介させてください。 クラシルリワードは「日常のお買い物体験をお得に変える」アプリです。 買い物のためにお店に行く(移動する)、チラシを見る、商品を買う、レシートを受け取る......。これら日常の行動がポイントに変わり、そのポイントを使って様々な特典と交換することができます。 気になる方は<a href="https://about.rewards.kurashiru.com">サービスサイト</a>をぜひご確認ください。</p> <p>それではさっそく本題のデータ基盤の構成について説明していきます。</p> <h2 id="データ基盤の全体構成">データ基盤の全体構成</h2> <p><figure class="figure-image figure-image-fotolife" title="データ基盤の全体構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821181054.png" width="1200" height="975" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>データ基盤の全体構成</figcaption></figure></p> <p>クラシルリワードのデータ基盤の技術スタックは下記になります。</p> <ul> <li>DWH: BigQuery(メイン), Snowflake(サブ)</li> <li>ETL: Embulk, Digdag</li> <li>BI Tool: Redash, Looker</li> </ul> <p>全体像のみではそれぞれの役割が把握しにくいため、次の章より詳細を説明していきます。</p> <h2 id="BigQueryの選定理由">BigQueryの選定理由</h2> <p>クラシルリワードではメインのデータ基盤としてBigQueryを利用しています。 BigQueryを選定した理由としては下記となります。</p> <ol> <li>データエンジニアが不在のため、フルマネージドなサービスを利用したい</li> </ol> <p>サービスの立ち上げ当初、新規事業ということもあり最小人数で開発をしていたためデータエンジニアが不在でした。 しかし、施策結果を振り返るためにイベントログ実装は必須であり、データ基盤を構築する必要がありました。<br> また自社データ基盤の場合、イベントログのスキーマ設計、エラー時の対応、データパイプラインの修正対応などに時間がかかることが懸念としてありました。<br> そのためフルマネージドなBigQueryを採用することで、データ基盤の構築にかかる時間を短縮しサービス開発にリソースを割くことができると判断しました。<br><br> 2. Google Analytics for Firebaseとの相性が良い</p> <p>クラシルリワードは 現在<a href="https://apps.apple.com/jp/app/%E3%82%AF%E3%83%A9%E3%82%B7%E3%83%AB%E3%83%AA%E3%83%AF%E3%83%BC%E3%83%89-%E7%A7%BB%E5%8B%95-%E3%83%81%E3%83%A9%E3%82%B7-%E3%83%AC%E3%82%B7%E3%83%BC%E3%83%88%E3%81%A7%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88%E3%81%8C%E3%81%9F%E3%81%BE%E3%82%8B/id1624606445">iOS</a> / <a href="https://play.google.com/store/apps/details?id=jp.hops&amp;hl=ja&amp;gl=US">Android</a>アプリでサービスを提供しています。<br> モバイルアプリでは<a href="https://firebase.google.com/?hl=ja">Firebase</a>を利用することが一般的であり、クラシルリワードでもFirebaseを積極的に利用しています。<br> <a href="https://firebase.google.com/docs/analytics?hl=ja">Google Analytics for Firebase</a>を利用することで下記のメリットがあります。</p> <ul> <li><a href="https://support.google.com/firebase/answer/6318765?hl=ja#about&amp;zippy=%2C%E3%81%93%E3%81%AE%E8%A8%98%E4%BA%8B%E3%81%AE%E5%86%85%E5%AE%B9">BigQuery Export</a>を利用することで、GAに送信されたイベントログが1日一回定期実行でイベントログのテーブルとしてBigQueryに自動でエクスポートされるため自前でイベントログのデータパイプラインを作成する必要がない</li> <li><a href="https://support.google.com/analytics/answer/3437719?hl=ja&amp;ref_topic=3416089&amp;sjid=751107677003313808-AP">ログのスキーマ</a>が事前に決まっているため、開発者は<code>event_name</code>, <code>event_params</code>など最低限のものだけを意識すれば良い</li> <li>アプリ側ではFirebase Analyticsのライブラリが提供されているため、下記のように送信処理を書くのみで完結する</li> </ul> <p>iOSでのイベント送信例</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// event_name: test_event</span> <span class="synComment">// event_params: [&quot;index&quot;: 0]</span> Analytics.logEvent(<span class="synConstant">&quot;test_event&quot;</span>, parameters<span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">&quot;index&quot;: 0</span><span class="synSpecial">]</span>) </pre> <p>インフラはAWSで構築しているためAWS Athena、クラシルで利用しているSnowflakeなども検討しましたが、Google Analytics for Firebaseを利用するメリットが大きいためBigQueryを採用しました。</p> <h2 id="データ基盤における重要な役割">データ基盤における重要な役割</h2> <p>この章ではクラシルリワードのデータ基盤において、重要な役割を抜粋して説明していきます。</p> <h3 id="アプリのイベントログのscan量削減">アプリのイベントログのscan量削減</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821174622.png" width="1012" height="304" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>BigQueryの選定理由でも説明しましたが、 アプリからイベントログを送信するためにGoogle Analytics for Firebaseを利用しています。<br> 日付別テーブルがBigQueryにエクスポートされるため、これだけでもイベントログ分析は可能になります。<br> しかし、そのまま日付別テーブルを利用する場合、DAUが増えるにつれて、BigQueryのscan量が増加しコストに影響してきます。<br></p> <p>例えば起動イベントのみを抽出する場合、日付別テーブルを利用するとwhere句で条件を指定してもその範囲の日付全てのイベントが対象となり、パーティション分割テーブルと比較してscan量が多くなってしまい、結果的にコストに影響します。</p> <p>日々分析をする上でのscan量の課題を解決するために、下記のような工夫をしています。</p> <ol> <li>パーティション分割テーブルを利用</li> </ol> <p>BigQueryにエクスポートされた日付別テーブルを直接利用するのではなく、パーティション分割テーブルとして変換して分析ではこちらを利用するようにしています。 下記がパーティション分割テーブルを作成するサンプルクエリです。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">CREATE</span> <span class="synStatement">OR</span> <span class="synIdentifier">REPLACE</span> <span class="synSpecial">TABLE</span> `product_firebase_analytics.events` PARTITION <span class="synSpecial">BY</span> <span class="synType">DATE</span>(event_timestamp_micros) <span class="synSpecial">CLUSTER</span> <span class="synSpecial">BY</span> event_name, new_event_name, platform <span class="synSpecial">AS</span> <span class="synStatement">SELECT</span> event_date, TIMESTAMP_MICROS(event_timestamp) <span class="synSpecial">AS</span> event_timestamp_micros, ARRAY_TO_STRING( [event_name, ( <span class="synStatement">SELECT</span> value.string_value <span class="synSpecial">FROM</span> UNNEST(event_params) <span class="synSpecial">WHERE</span> KEY = <span class="synSpecial">'</span><span class="synConstant">id</span><span class="synSpecial">'</span> )], <span class="synSpecial">&quot;</span><span class="synConstant">_</span><span class="synSpecial">&quot;</span> ) <span class="synSpecial">AS</span> new_event_name, platform, ... <span class="synComment">-- カラムが続く</span> <span class="synSpecial">FROM</span> `project.events_*` </pre> <p>パーティション: Date<br> クラスタ: event_name, new_event_name(後述), platform</p> <p>パーティション分割テーブル作成後は<a href="https://cloud.google.com/bigquery/docs/scheduling-queries?hl=ja">クエリスケジューリング</a>を利用し、毎日定期実行で指定範囲の日数を上書きするような形でイベントログをアップデートしています。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synComment">-- 直近3日以内のイベントログを上書きする</span> DECLARE start_date_interval INT64 <span class="synSpecial">DEFAULT</span> <span class="synConstant">3</span>; DECLARE end_date_interval INT64 <span class="synSpecial">DEFAULT</span> <span class="synConstant">0</span>; <span class="synStatement">DELETE</span> <span class="synSpecial">FROM</span> `product_firebase_analytics.events` <span class="synSpecial">WHERE</span> <span class="synType">DATE</span>(event_time, <span class="synSpecial">'</span><span class="synConstant">Asia/Tokyo</span><span class="synSpecial">'</span>) <span class="synStatement">BETWEEN</span> DATE_SUB( <span class="synIdentifier">CURRENT_DATE</span>(<span class="synSpecial">'</span><span class="synConstant">Asia/Tokyo</span><span class="synSpecial">'</span>), INTERVAL start_date_interval DAY ) <span class="synStatement">AND</span> DATE_SUB( <span class="synIdentifier">CURRENT_DATE</span>(<span class="synSpecial">'</span><span class="synConstant">Asia/Tokyo</span><span class="synSpecial">'</span>), INTERVAL end_date_interval DAY ); <span class="synStatement">INSERT</span> <span class="synSpecial">INTO</span> `product_firebase_analytics.events` <span class="synStatement">SELECT</span> event_date, TIMESTAMP_MICROS(event_timestamp) <span class="synSpecial">AS</span> event_timestamp_micros, ARRAY_TO_STRING( [event_name, ( <span class="synStatement">SELECT</span> value.string_value <span class="synSpecial">FROM</span> UNNEST(event_params) <span class="synSpecial">WHERE</span> KEY = <span class="synSpecial">'</span><span class="synConstant">id</span><span class="synSpecial">'</span> )], <span class="synSpecial">&quot;</span><span class="synConstant">_</span><span class="synSpecial">&quot;</span> ) <span class="synSpecial">AS</span> new_event_name, platform, ...<span class="synComment">-- カラムが続く</span> <span class="synSpecial">FROM</span> `project.events_*` <span class="synSpecial">WHERE</span> REGEXP_EXTRACT(_TABLE_SUFFIX, r <span class="synSpecial">'</span><span class="synConstant">[0-9]+</span><span class="synSpecial">'</span>) <span class="synStatement">BETWEEN</span> FORMAT_DATE( <span class="synSpecial">&quot;</span><span class="synConstant">%Y%m%d</span><span class="synSpecial">&quot;</span>, DATE_SUB( <span class="synIdentifier">CURRENT_DATE</span>(<span class="synSpecial">'</span><span class="synConstant">Asia/Tokyo</span><span class="synSpecial">'</span>), INTERVAL start_date_interval DAY ) ) <span class="synStatement">AND</span> FORMAT_DATE( <span class="synSpecial">&quot;</span><span class="synConstant">%Y%m%d</span><span class="synSpecial">&quot;</span>, DATE_SUB( <span class="synIdentifier">CURRENT_DATE</span>(<span class="synSpecial">'</span><span class="synConstant">Asia/Tokyo</span><span class="synSpecial">'</span>), INTERVAL end_date_interval DAY ) ) </pre> <p>2. new_event_nameの導入</p> <p><a href="https://firebase.google.com/docs/analytics/events?hl=ja&amp;platform=ios">Google Analytics for Firebase</a>の仕様上、イベント名は最大500までしか定義できません。 カジュアルに新規イベントを定義しすぎると上限の500に到達してしまい、新規イベントの計測ができなくなります。<br> イベント数の上限が500を超えないために、イベント名を一意にするのではなく、<code>event_params</code>にイベントを識別できる<code>id(STRING)</code>を含めて、パーティション分割テーブル作成時に<code>new_event_name</code>として定義しています。</p> <p>例えば下記のようなイベント名とIDの場合、<code>new_event_name</code>はtest_hogeになります</p> <ul> <li>event_name: test</li> <li>event_params.id: hoge</li> <li>new_event_name: test_hoge</li> </ul> <p><code>new_event_name</code>はパーティション分割テーブル作成時に<code>event_name</code>と<code>id</code>を結合することで、新しいイベント名として定義することで実現しています。</p> <pre class="code lang-sql" data-lang="sql" data-unlink> ARRAY_TO_STRING( [event_name, ( <span class="synStatement">SELECT</span> value.string_value <span class="synSpecial">FROM</span> UNNEST(event_params) <span class="synSpecial">WHERE</span> KEY = <span class="synSpecial">'</span><span class="synConstant">id</span><span class="synSpecial">'</span> )], <span class="synSpecial">&quot;</span><span class="synConstant">_</span><span class="synSpecial">&quot;</span> ) <span class="synSpecial">AS</span> new_event_name </pre> <p><code>new_event_name</code>によって、下記のようなメリットが得られます。</p> <ul> <li><code>event_name</code>はtest, imp, tapなど汎用的なイベントのみを定義して使い回すため、イベント名の定義数で500を超えることがなくなる</li> <li>クラスタに<code>new_event_name</code>を指定しているため、分析する側は<code>new_event_name</code>を条件に指定することでscan量を削減することが可能</li> </ul> <h3 id="DynamoDBやRDSをBigQueryにSync">DynamoDBやRDSをBigQueryにSync</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821181424.png" width="748" height="566" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>クラシルリワードではインフラ環境をAWSで構築しているため、ユーザーのデータはRDSやDynamoDBに保存されています。<br> これらのデータをアプリから送信したイベントログと結合してBigQueryで分析をしたいニーズがでてきます。<br> 現在は<a href="https://www.digdag.io/">Digdag</a>と<a href="https://www.embulk.org/">Embulk</a>を利用して、1日1回定期実行でBigQueryにSyncをしています。 これらはAWS ECSの<a href="https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/scheduling_tasks.html">タスクスケジューリング</a>で実現しています。<br></p> <p>多くのテーブルは全体更新をしていますが、一部テーブルはレコード数が多くなっているため差分更新をしています。 差分更新の対応については説明が長くなってしまうため、また別のテックブログで話せればと思います。</p> <p>運用面に関しては、現状大きな課題はないもののバッチ処理が失敗した時の調査やリトライなどの運用工数もかかり始めてはきているので、自前のETLフローではなく<a href="https://trocco.io/lp/index.html">trocco</a>や<a href="https://www.fivetran.com/">Fivetran</a>などのSaaS ETLツールも検討しています。</p> <h3 id="Snowflakeの活用">Snowflakeの活用</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821175226.png" width="930" height="242" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>メインのデータ基盤はBigQueryですが、一部で<a href="https://www.snowflake.com/en/">Snowflake</a>を利用しています。 Snowflakeを利用することになった背景は下記2つです。</p> <ol> <li>クラシルのデータをクラシルリワード開発部で分析可能にするため</li> </ol> <p>現状dely社では各事業部ごとに技術選定をしています。 クラシルではAWS Athena, Snowflakeを利用してデータ分析をしています。クラシルリワードでは今まで説明してきましたがBigQueryをメインで利用しています。<br> しかし、事業部が分かれているため、クラシル側のデータを分析するときに何か問題が発生するとクラシルのデータエンジニアに都度依頼する運用コストが発生していました。<br> この課題を解消するために、クラシルリワード側でSnowflakeをアカウントを作成し、Snowflakeの<a href="https://docs.snowflake.com/ja/user-guide/data-sharing-intro">データシェアリング</a>を利用することで、クラシルリワード開発部でクラシルのデータを自由に分析することができるようにしています。</p> <p>2. 一部のクラシルのテーブルをBigQueryにエクスポートしたいため</p> <p>クラシルリワードでは<a href="https://chirashi.kurashiru.com/">クラシルチラシ</a>にまつわる情報もユーザーに提供していますが、チラシ情報のテーブルがクラシル側にあるため、BigQueryで分析を完結することができません。<br></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821181544.png" width="1200" height="271" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>例えば、クラシルリワード側でどの店舗のチラシを見られたかを分析したい場合、どうしてもBigQueryに店舗のマスターデータが必要になってきます。</p> <p>そのためクラシルチラシのデータ(e.g. 店舗情報)をクラシルリワード側のBigQueryに格納するために下記のようなフローを構築することで実現しています。<br> Snowflake(クラシル) -> Snowflake(クラシルリワード) -> Google Storage(クラシルリワード) -> BQ(クラシルリワード)<br> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/funzin/20230821/20230821180941.png" width="1200" height="202" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>クラシルでのデータをBigQueryにエクスポートすることで、クラシルリワード側ではBigQueryのみで完結してチラシ情報を分析することが可能となっています。</p> <h3 id="BIツールRedash-Looker">BIツール(Redash, Looker)</h3> <p>最後にBIツールについて説明します。<br></p> <h4 id="Redash">Redash</h4> <p>現状はセルフホスティングで<a href="https://redash.io/">Redash</a>用のEC2インスタンスを立てて、SQLクエリを書いて分析をすることが多いです。<br> しかしSQLクエリは人によって書き方が異なる、数値出しの判断が間違える可能性があるため、SQLクエリをGitHubのRepositoryで管理してレビューをする体制をとっています。 こちらはバイセルさんの<a href="https://tech.buysell-technologies.com/entry/2019/04/01/130059">Redashのクエリ管理方法</a>を参考にしています。</p> <h5 id="Redash-repositoryのフォルダファイル構成">Redash repositoryのフォルダ・ファイル構成</h5> <p>フォルダ構造</p> <pre class="code" data-lang="" data-unlink>├── README.md ├── csv // クエリ名などをcsv管理 ├── queries // クエリ管理 ├── requirements.txt └── scripts // Redashに反映用のスクリプト</pre> <p>csv</p> <pre class="code csv" data-lang="csv" data-unlink>name,data_source_id,query_id query_test_name,1,29</pre> <p>CSVではクエリ名、データソースID, クエリIDのみを最低限管理しています。</p> <h5 id="git管理下のSQLクエリをRedashに反映">git管理下のSQLクエリをRedashに反映</h5> <p>PRがマージされたタイミングでGitHub Actionsを実行し差分があるクエリのみをRedashに反映させるようにしています。<br></p> <p>GitHub Actions ステップ実行順</p> <ol> <li>変更があったクエリの差分抽出(<code>write_diff_query_only.sh</code>)</li> <li>Redashに反映(<code>main.py</code>)</li> </ol> <p><code>write_diff_query_only.sh</code></p> <pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment">#!/bin/bash</span> <span class="synComment"># write_diff_query_only.sh</span> <span class="synComment"># PR差分のみをcsv_query_list.csvに反映するスクリプト</span> <span class="synIdentifier">header</span>=<span class="synSpecial">`head -n </span><span class="synConstant">1</span><span class="synSpecial"> csv/query_list.csv`</span> <span class="synIdentifier">current_commit</span>=<span class="synPreProc">$GITHUB_SHA</span> <span class="synIdentifier">previous_commit</span>=<span class="synPreProc">$(</span><span class="synSpecial">git rev-parse </span><span class="synStatement">&quot;</span><span class="synPreProc">${current_commit}</span><span class="synStatement">&quot;</span><span class="synSpecial">\^</span><span class="synConstant">1</span><span class="synPreProc">)</span> <span class="synIdentifier">query_ids</span>=<span class="synSpecial">`git diff --name-only </span><span class="synStatement">&quot;</span><span class="synPreProc">$current_commit</span><span class="synStatement">&quot;</span><span class="synSpecial"> </span><span class="synStatement">&quot;</span><span class="synPreProc">$previous_commit</span><span class="synStatement">&quot;</span><span class="synSpecial"> </span><span class="synStatement">|</span><span class="synSpecial"> </span><span class="synStatement">grep</span><span class="synSpecial"> queries </span><span class="synStatement">|</span><span class="synSpecial"> </span><span class="synStatement">grep</span><span class="synSpecial"> -o </span><span class="synStatement">'</span><span class="synConstant">[0-9]\+</span><span class="synStatement">'</span><span class="synSpecial"> </span><span class="synStatement">|</span><span class="synSpecial"> tr </span><span class="synStatement">'</span><span class="synConstant">\n</span><span class="synStatement">'</span><span class="synSpecial"> </span><span class="synStatement">'</span><span class="synConstant">|</span><span class="synStatement">'</span><span class="synSpecial">`</span> <span class="synIdentifier">query_ids</span>=<span class="synPreProc">${query_ids</span><span class="synStatement">%</span>|<span class="synPreProc">}</span> <span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">&quot;</span><span class="synPreProc">$header</span><span class="synStatement">&quot;</span><span class="synConstant"> </span><span class="synStatement">&gt;</span> csv/diff_query_list.csv <span class="synStatement">if [</span> <span class="synStatement">!</span> <span class="synStatement">-z</span> <span class="synStatement">&quot;</span><span class="synPreProc">$query_ids</span><span class="synStatement">&quot;</span> <span class="synStatement">];</span> <span class="synStatement">then</span> <span class="synStatement">grep</span> <span class="synStatement">-E</span> <span class="synStatement">&quot;</span><span class="synConstant">(</span><span class="synPreProc">$query_ids</span><span class="synConstant">)$</span><span class="synStatement">&quot;</span> csv/query_list.csv <span class="synStatement">&gt;&gt;</span> csv/diff_query_list.csv <span class="synStatement">fi</span> <span class="synComment"># main.pyで差分のみを上書きして実行するため</span> cat csv/diff_query_list.csv <span class="synStatement">&gt;</span> csv/query_list.csv </pre> <p><code>main.py</code></p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># main.py</span> <span class="synPreProc">import</span> os.path <span class="synPreProc">from</span> redash_client.client <span class="synPreProc">import</span> RedashClient <span class="synPreProc">import</span> csv <span class="synPreProc">import</span> os <span class="synStatement">class</span> <span class="synIdentifier">Query</span>: <span class="synStatement">def</span> <span class="synIdentifier">__init__</span>(self, base_path, name, data_source_id, query_id, schedule=<span class="synIdentifier">None</span>, description=<span class="synConstant">'generated by retail-redash-query'</span>): self.query_id = query_id self.name = name self.data_source_id = data_source_id self.schedule = schedule self.description = description path = <span class="synConstant">'queries/{0}.sql'</span>.format(query_id) self.load_query(base_path, path) <span class="synStatement">def</span> <span class="synIdentifier">load_query</span>(self, base_path, path): <span class="synStatement">with</span> <span class="synIdentifier">open</span>(<span class="synConstant">'{0}/{1}'</span>.format(base_path, path), <span class="synConstant">'r'</span>) <span class="synStatement">as</span> f: self.query = f.read() <span class="synComment"># Read query list</span> <span class="synStatement">def</span> <span class="synIdentifier">get_queries</span>(): reader = csv.reader(<span class="synIdentifier">open</span>(<span class="synConstant">'csv/query_list.csv'</span>, <span class="synConstant">'r'</span>)) queries = [] <span class="synComment"># Skip header</span> header = <span class="synIdentifier">next</span>(reader) <span class="synStatement">for</span> row <span class="synStatement">in</span> reader: queries.append(Query(<span class="synConstant">&quot;./&quot;</span>, row[<span class="synConstant">0</span>], row[<span class="synConstant">1</span>], row[<span class="synConstant">2</span>])) <span class="synStatement">return</span> queries RedashClient.BASE_URL = os.environ[<span class="synConstant">'REDASH_HOST'</span>] RedashClient.API_BASE_URL = os.environ[<span class="synConstant">'REDASH_HOST'</span>] + <span class="synConstant">&quot;/api/&quot;</span> api_key = os.environ[<span class="synConstant">'REDASH_API_KEY'</span>] redash_client = RedashClient(api_key) <span class="synComment"># Update queries</span> <span class="synStatement">for</span> query <span class="synStatement">in</span> get_queries(): <span class="synIdentifier">print</span>(<span class="synConstant">'QueryID: '</span> + query.query_id) <span class="synStatement">try</span>: redash_client.update_query( query.query_id, query.name, query.query, query.data_source_id, query.description ) <span class="synStatement">except</span> RedashClient.RedashClientException: <span class="synIdentifier">print</span>(<span class="synConstant">'Error: '</span> + query.query_id) </pre> <p>現状は差分があるクエリのみをRedashに反映するように影響範囲をとどめています。</p> <h4 id="Lookerについて">Lookerについて</h4> <p>RedashではSQLクエリを書くというハードルが残り続けるため、SQLクエリをかけない非エンジニアでも分析が可能な体制にするため、現在は並行して<a href="https://cloud.google.com/looker?hl=ja">Looker</a>にも移行中です。</p> <h2 id="さいごに">さいごに</h2> <p>クラシルリワードのデータ基盤について紹介しました。 データエンジニアが不在の中でも、分析基盤を構築しサービス改善につなげることができました。 引き続きデータを分析することでクラシルリワードを改善していきたいと思いますので、よろしくお願いします。</p> funzin Utilising UX Mapping Method for Task Identification and Prioritisation by Kurashiru Search Team hatenablog://entry/820878482956534477 2023-08-18T17:18:00+09:00 2023-09-14T16:58:51+09:00 Hi, I am Akane, a UI/UX designer at Kurashiru’s search team. This time I would like to talk about how our search team uses the UX mapping methods to organise, identify and prioritise our tasks from the product’s perspective by using “User story mapping”. 1. To identify the users’ goals. At this step… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/delyjp/20230818/20230818105337.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Hi, I am Akane, a UI/UX designer at Kurashiru’s search team.</p> <p>This time I would like to talk about how our search team uses the UX mapping methods to organise, identify and prioritise our tasks from the product’s perspective by using “User story mapping”.</p> <p><strong>1. To identify the users’ goals. </strong> At this step, we will decide what’s our user’s goals.</p> <p><strong>2. Map users’ activities.</strong> <br/> List out key Activities, Steps and Details that are involved in the search function. By viewing the mapping, our team members could have a clear vision of each key activity, and follow up with steps users take during each activity and details.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808150337.png" width="1200" height="749" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>3. Organise tasks for future release</strong> We have summarised all tasks that are possible to get improved for feature releases from left to right.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808150406.png" width="1200" height="444" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Before discussing with PdM, I listed each potential task along with current screenshots as well as some detailed notes that could help us to visualise tasks.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808150438.png" width="1200" height="1146" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>After discussing this mapping with PdM, we “finalised” tasks by each phase and priority. Because we are using Figjam’s sticker function, it is very easy for us to adjust the priority of each task, remove un-needed ones and add new ones as well.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808150504.png" width="1093" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <hr /> <p>Here is an example of how we bring an “easy-to-use” search experience to our users. Before discussing with PdM, I will write down which area I would like to improve (WHAT), the reason (WHY), and how I plan to do it (HOW.) Here is the summary of why we decided to add the search bar to the home screen.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808152656.png" width="1200" height="975" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h5 id="Kurashiru-currently-has-two-types-of-users"><span style="color: #dd830c"><strong>Kurashiru currently has two types of users:</strong></span></h5> <p><span style="color: #1464b3"><strong>Type 1: </strong></span>users who already know what they are looking for (search for ingredients that they already have in the fridge or at home, shopping before)</br> <span style="color: #1464b3"><strong>User action: </strong></span>open app → input query from the home search bar → view result Most of Kurashiru's loyalty users are in this category.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808151829.png" width="1200" height="938" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span style="color: #dd830c"><strong>Type 2: </strong></span>users who don’t know what they are looking for, just want to browse some ideas (based on the ideas, they will go buy these essential ingredients, shopping after)</br> <span style="color: #dd830c"><strong>User action: </strong></span>open app → Access search top feed → browse recommended keywords or content list → input query or click recommended keywords → view result</p> <p>These 2 types of users' needs and requirements are different, so we want to create the best user experience that would cover both our user's needs (create 2 different entry points for different user groups). </br> For those Kurashiru’s loyal users, they need to take 2 steps in order to reach the search bar. After the UX adjustment, they could access the search bar once they open our app, imagining you try to find a recipe after a busy day of work or taking care of kids at home every day, this small UX improvement could save our user half of the time every day.</p> <p>By adding the “search bar” to the home feed, after the release, the data proves that most of our users are choosing to use the “search bar” on the home feed. After a certain period, the number of users who use the “home search bar” and “search tab” are showing a stable trend. <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akane_y/20230808/20230808150548.png" width="1200" height="720" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>So, here is how our team prioritise and manages our tasks by using the UX mapping method. In the end, these methods are just theories, we need to adjust how we will use them to match our team's needs.</p> <p>Thank you for reading, see you next time. :)</p> akane_y dbt's Slim CI: An Introduction to Efficient Data Modeling CI Workflow hatenablog://entry/820878482954801335 2023-08-03T10:17:24+09:00 2023-08-03T10:17:24+09:00 Hello, my name is Niko, and I am currently working in Kurashiru's data enabling team as a newly joined data engineer. While I'm enthusiastic about learning Japanese, my proficiency with Japanese particles is still a work in progress (笑). For this reason, I have decided to write this blog in English.… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802185303.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><em>Hello, my name is Niko, and I am currently working in Kurashiru's data enabling team as a newly joined data engineer. While I'm enthusiastic about learning Japanese, my proficiency with Japanese particles is still a work in progress (笑). For this reason, I have decided to write this blog in English.</em></p> <hr /> <h4 id="Preface">Preface</h4> <p>At Kurashiru, we use dbt (data build tools) as our platform to handle data transformations from the data lake to our data warehouse. dbt is a helpful set of tools and frameworks that let us create transformation queries in line with software engineering principles. Gone are the days of dealing with complicated queries on our data platform. With dbt, we can manage and "engineer" our data just like regular software development.</p> <p>Since we started using dbt, our productivity in data modeling has significantly improved, which we find very beneficial. dbt also provides us with useful tools like "slim CI" for Continuous Integration. In this blog, I'll explain what SLIM CI is and why it's essential for our workflow. If you want to know more about dbt, you can explore the official documentation here:</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.getdbt.com%2F" title="dbt Developer Hub" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.getdbt.com/">docs.getdbt.com</a></cite></p> <h5 id="dbt-CI-Job"><strong>dbt CI Job</strong></h5> <p>In Continuous Integration (CI), the process involves automatically building and testing new code whenever a developer tries to merge it into the main repository. This principle applies to dbt too. When a developer makes a Pull Request (PR) in a dbt repository, it's essential to follow CI practices. Doing so helps us avoid broken queries in our Production database and maintain good code quality. Now, let's see the flow of CI job that dbt performs using an illustration:</p> <p><figure class="figure-image figure-image-fotolife" title="dbt&#x27;s CI Job"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802115903.png" width="931" height="361" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt&#x27;s CI Job</figcaption></figure></p> <p>Let's imagine the Master branch codes are linked to the Production environment. When a Pull Request (PR) is made, the CI JOB will duplicate the existing data model from Production to a temporary environment. Then, it will build and test the copied model along with the changes from the PR.</p> <p>So far, this process works fine for application code because it usually requires only a small amount of data for building and testing new codes. However, the problem arises when dealing with data models in data modeling works. It demands a significant amount of data and incurs high data processing costs for just one PR. Therefore, we need to find a way to improve this process and reduce the overall expenses.</p> <h5 id="Slim-CI"><strong>Slim CI</strong></h5> <p>Luckily, dbt offers us SLIM CI, which plays a crucial role in this situation. When we switch from the default CI Job to SLIM CI, our CI workflow transforms into the following:</p> <p><figure class="figure-image figure-image-fotolife" title="dbt&#x27;s Slim CI Job"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802135005.png" width="811" height="361" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt&#x27;s Slim CI Job</figcaption></figure></p> <p>In the new workflow, the CI Job doesn't have to copy all existing models from the Production environment for building and testing. Instead, it only creates the data model that has changed in the PR and refers to other required dependencies (like source tables or views for JOIN, UNION) already present in the production environment. This way, the process becomes much faster and more cost-effective since we only create what is necessary.</p> <p>dbt's SLIM CI achieves this by using a built-in powerful feature called "defer." With "defer," a single model can be built without the need to rebuild all its dependencies in the CI temporary environment. This efficient approach saves time and resources, making the whole data modeling process smoother and more efficient.</p> <h5 id="To-Defer-or-Not-Defer"><strong>To Defer or Not Defer</strong></h5> <p>So, what is "defer"? To grasp the concept of "defer," let's first understand how dbt functions. To build our data models using dbt, we typically run this command</p> <pre class="code" data-lang="" data-unlink># first run dbt build</pre> <p>Under the hood, dbt will do something like this:</p> <p><figure class="figure-image figure-image-fotolife" title="dbt command under the hood"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802144127.png" width="761" height="61" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt command under the hood</figcaption></figure></p> <p>When we examine the final step of the dbt process, we notice that dbt always produces artifacts. These artifacts represent the most recent state of the current data models in the target database or data warehouse. Now, how does "defer" come into play? Well, "defer" uses these artifacts to check if there are any differences between the dbt runs and the existing state of the target database or data warehouse, kind of like a DIFF logic.</p> <p>To enable "defer" in dbt, we can include some flags to the previously run <code>first run</code> command, which looks something like this:</p> <pre class="code" data-lang="" data-unlink># second run # --select state:modified # means only select changed states compared to the previous state dbt build --select state:modified --defer --state path_to_first_run_state</pre> <p>With this approach, dbt constantly checks the previous state and only builds models that have been changed from the successful previous run of dbt. <strong>When we include this command in a Job that triggers when a Pull Request (PR) is requested, dbt refers to it as "SLIM CI". </strong></p> <h5 id="Example"><strong>Example</strong></h5> <p>Imagine we work at a beef bowl restaurant, and our responsibility is managing the restaurant's data. In the data warehouse's production environment (DBT_PRD), we have two models named <code>meat</code> and <code>rice</code>:</p> <p><figure class="figure-image figure-image-fotolife" title="Model in our production datawarehouse"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802160310.png" width="324" height="214" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Model in our production datawarehouse</figcaption></figure></p> <p>We want to create a new model called "meat_rice_menu" by combining the data from the two existing models, <code>meat</code> and <code>rice</code>. This new model will be represented in a chart like this:</p> <p><figure class="figure-image figure-image-fotolife" title="meat_rice_menu model"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802160815.png" width="1200" height="365" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>meat_rice_menu model</figcaption></figure></p> <p><strong>default CI Job</strong></p> <p>Let's proceed with the PR and use this model plan. First, we'll run the regular dbt CI Job without defer/SLIM CI. During this process, dbt will create a new temporary schema for each PR that is being tested. Fortunately, dbt will always log the activities of the run. Here's the completion message from dbt's CI logs:</p> <p><figure class="figure-image figure-image-fotolife" title="Completion status dbt CI job"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802191732.png" width="1200" height="161" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Completion status dbt CI job</figcaption></figure></p> <p>There, we can see that dbt made 2 table models and 1 view model. To make sure, let's look at our current temporary PR schema. When we check the temporary PR schema, we'll see that it did create all three models, both the parent and child models:</p> <p><figure class="figure-image figure-image-fotolife" title="CI without defer"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802161844.png" width="454" height="322" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CI without defer</figcaption></figure></p> <p>When we take a look at the definition, we can see that the newly created model was referencing the temporary schema:</p> <p><figure class="figure-image figure-image-fotolife" title="Newly created model referencing temporary models. "><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802162323.png" width="1090" height="650" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Newly created model referencing temporary models. </figcaption></figure></p> <p><strong>SLIM CI Job</strong></p> <p>Now, let's try the PR again, but this time, we'll use defer/SLIM CI. We already have artifacts that show the current state from the production's dbt Job runs. When we run the PR job with defer/SLIM CI, let's look at the same finished log message that dbt always creates for every run:</p> <p><figure class="figure-image figure-image-fotolife" title="Completion status dbt SLIM CI job"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802192714.png" width="1200" height="154" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Completion status dbt SLIM CI job</figcaption></figure></p> <p>We notice that it only created one view model. When we check the PR's temporary schema, we see that only the new model is created, which is <code>meat_rice_menu</code> and it didn't duplicate the existing tables:</p> <p><figure class="figure-image figure-image-fotolife" title="CI Job with Defer (Slim CI)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802163300.png" width="418" height="166" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CI Job with Defer (Slim CI)</figcaption></figure></p> <p>When we see the definition again, we can see that now it referencing the production environment (DBT_PRD):</p> <p><figure class="figure-image figure-image-fotolife" title="Newly created model referencing production's models. "><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/niko_dely/20230802/20230802163714.png" width="1122" height="632" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Newly created model referencing production's models.</figcaption></figure></p> <p>By referring directly to the production models instead of copying them, we can greatly reduce the costs of our PR. This approach avoids unnecessary duplication, and we only focus on building and testing the changes needed for the new model. As a result, the process becomes more efficient and cost-effective.</p> <h5 id="Summary"><strong>Summary</strong></h5> <p>Slim CI is a powerful dbt feature that can save time and reduce costs during the build and testing of models in the CI workflow. By using defer, Slim CI can identify differences between the current data warehouse state and the changes in PR requests. This boosts our productivity since we don't have to wait long for PRs to be built and tested before merging them to the master branch.</p> <p>Today, I introduced one of the powerful features of dbt in this blog. However, we don't want to stop there. In line with our commitment to continuous improvement (KAIZEN) and one of our value which is <strong>good to great</strong>, we aim to enhance our data infrastructure further. Thank you for reading this blog, and stay tuned for more data engineering adventures ahead!</p> <h4 id="References"><strong>References</strong></h4> <p><a href="https://docs.getdbt.com/docs/deploy/continuous-integration">Continuous integration in dbt Cloud | dbt Developer Hub</a></p> <p><a href="https://docs.getdbt.com/reference/node-selection/defer">Defer | dbt Developer Hub</a></p> <p><a href="https://infinitelambda.com/dbt-deferral-simplify-development/">Using dbt Deferral to Simplify Development | Infinite Lambda</a></p> <p><iframe src="https://hatenablog-parts.com/embed?url=http%3A%2F%2Fcareers.dely.jp" title="dely株式会社 - クラシル エンジニア採用情報" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="http://careers.dely.jp">careers.dely.jp</a></cite></p> niko_dely エンジニアが中途採用業務を担当するにあたって改善したこと hatenablog://entry/820878482954290144 2023-08-02T18:00:00+09:00 2023-08-02T18:00:05+09:00 もうすぐ8月で猛暑も超えて酷暑の季節になりましたね🥵 毎日エアコンで涼みながら最近はEMとして仕事している「みうら」です。お久しぶりです。 前回私の書いたブログ記事では、採用活動に関わっていたと記載していました。転職活動や選考フローへの参加は自身でも行ったことはあるのですが、実は採用活動を業務の中心として活動した経験はなく、初めての経験でした。そんな採用ビギナーな私でしたが、前期では私が関わった採用活動で数名エンジニアがありがたいことにジョインしてくれました🙌ということでその中途採用活動をする際に行ったことを一部紹介しようと思います。 エンジニア採用の方法 これを見ている方はエンジニアだと思う… <p>もうすぐ8月で猛暑も超えて酷暑の季節になりましたね🥵 <br />毎日エアコンで涼みながら最近はEMとして仕事している「みうら」です。お久しぶりです。</p> <p>前回私の書いたブログ記事では、採用活動に関わっていたと記載していました。<br />転職活動や選考フローへの参加は自身でも行ったことはあるのですが、実は採用活動を業務の中心として活動した経験はなく、初めての経験でした。<br />そんな採用ビギナーな私でしたが、前期では私が関わった採用活動で数名エンジニアがありがたいことにジョインしてくれました🙌<br />ということでその中途採用活動をする際に行ったことを一部紹介しようと思います。</p> <h3 id="エンジニア採用の方法">エンジニア採用の方法</h3> <p>これを見ている方はエンジニアだと思うので大体イメージついている方は多いと思いますが、少しエンジニア採用の方法を紹介します。<br />弊社では主に大きく分けると3つの方法で行っています。</p> <ul> <li>ダイレクトリクルーティング <ul> <li>Findy/Wantedly/Green/forkwell…といった転職マッチングサービス。</li> <li>サービス上で企業が候補者を探してメールのやり取りをするサービス。</li> <li>会社と個人を直接繋いでやり取りできるのが特徴。</li> </ul> </li> <li>転職エージェント <ul> <li>レバテック/Geekly/リクルート…といった転職エージェントサービス/企業。</li> <li>会社と個人の間に仲介で入ってくれることで、会社と個人間のマッチングコストを担ってくれるのが特徴。</li> </ul> </li> <li>自社サイト採用/リファラル採用 <ul> <li>自社のHPからの応募や、社員紹介から会社と個人を直接繋いでやり取りする、他社を挟まない形の採用。</li> <li>コストが安かったり、リファラルだと入社後の期待値が合いやすいなどが特徴。</li> </ul> </li> </ul> <h3 id="エンジニア採用に参加した時に感じた課題">エンジニア採用に参加した時に感じた課題</h3> <p>採用に参加した時は右も左も分からず、まずは内定を取るために候補者の方々をリストアップし、いわゆる母集団形成から始めたのですが、進めていくうちに弊社の採用業務には以下のような課題があることに気づき始めました。</p> <ul> <li>採用担当の残業が多い</li> <li>求人票が整備されておらず記載内容がバラバラ</li> <li>二次選考突破率が低い</li> <li>採用の進め方が定型化されていない</li> </ul> <p>リクルーターからすると残業が多かったり業務フローが整っていないことはあるあるなのかもしれませんが、採用業務全体として工数観点での改善点があったり、フォーマットが揃っていないことで余計に工数が掛かっていたり、工数や業務フローを改善しなければより上の採用業務は行えない感触をその時は感じていました。</p> <h3 id="採用戦略の策定と改善">採用戦略の策定と改善</h3> <p>課題は多く見つかったので、あとはどう解決していくかです。<br />上記課題を踏まえて、以下を戦略として進めていました。</p> <ul> <li>工数と時間削減</li> <li>募集(母集団)を増やす</li> <li>選考体験の改善</li> </ul> <p>特に、エンジニア採用は工数がかなり掛かることがわかったので、<br />まずは工数と時間削減から改善することにしました。<br />業務としては内定を取らないといけないのですが、まずは既存の業務を改善しないと内定を得ることが難しいと強く感じていました。<br />上記の戦略を元に、前期の採用活動では多くの改善を行いましたが、紹介しきれないので、今回は戦略の内、「工数と時間削減」で行ったことについて紹介します。</p> <h3 id="工数と時間削減">工数と時間削減</h3> <p>採用には候補者1名に対してもかなり人と工数が掛かっています。<br />弊社では最終選考までに大体以下の工数が候補者1名辺りに掛かっています。</p> <ul> <li>スカウター: 15分</li> <li>リクルーター: 4時間</li> <li>エンジニア3名: 4時間</li> <li>マネージャー: 1時間</li> <li>CTO: 1時間</li> </ul> <p>約10時間!今回改めてまとめてみましたが、かなりの工数が掛かっていますね…!😰<br />内定までとなるともう3〜4時間ほどは工数が掛かることでしょう。<br />これを減らしたり、スムーズにするといった改善をすることは、とても重要で大きい話になってくるわけです。</p> <h4 id="候補者とのスケジュール調整簡略化">候補者とのスケジュール調整簡略化</h4> <p>今までのフローだとメールのやり取りの途中で、eeasyという日程調整ツールをスカウターから採用チームに発行してもらわないといけなかったのですが、実はこれに問題がありました。</p> <ul> <li>スカウト送信時に日程調整URLを送れず、候補者から面談参加希望をもらった後に発行するため、メールやり取りに時間が掛かる</li> <li>日程調整URLを発行するためだけで採用チームに依頼が必要となり、時間が掛かる</li> </ul> <p>日程調整に採用チームを挟むことで無駄な工数と時間がかかっていたということです。ここはスカウト送信と同時に日程調整URLを送ることで改善できないかと考えていました。</p> <p>改善のためにeeasyのプランを変えたり他のツールも検討したのですが、Googleワークスペースを契約していたこともあり、追加費用無しで使えることもあって、Googleカレンダーの予約スケジュールで対応しました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsupport.google.com%2Fcalendar%2Fanswer%2F10729749%3Fhl%3Dja" title="予約スケジュールを作成する - Google カレンダー ヘルプ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://support.google.com/calendar/answer/10729749?hl=ja">support.google.com</a></cite></p> <p>細かい設定部分で融通が効かず、使いづらい部分もありますが、今の所問題なく使えており、スカウターが採用チームをまたずに素早く日程調整ができるようになっています🙌</p> <h3 id="エンジニアが送っていたスカウトをやめスカウターからスカウトを実施">エンジニアが送っていたスカウトをやめ、スカウターからスカウトを実施</h3> <p>ダイレクトスカウト媒体では、エンジニアが直接スカウトを送るほうがよいだろう、という判断の元、エンジニアがスカウトを送信するルールが有りました。しかし、ここにも問題がありました。</p> <p>というのも、エンジニアは本来メールを書くのに慣れていないんですね。一筆メールを書くのにも時間が掛かりますし、スカウトが本来の業務でもない…スカウトメールを書くより開発したい思いがありスカウトメール作成も上達しない…ということで、廃止しました。<br />代わりにエンジニアは候補者のチェックに専念し、専門のスカウターがスカウトメッセージを送るように変更しました。</p> <p>このおかげでスカウト通数を増やすことができ、エンジニア側の負担も減らせてwin-winで良かったと思います🙌</p> <h3 id="採用チームにコーディネーター職を定義">採用チームにコーディネーター職を定義</h3> <p>社内には採用担当としてリクルーター職が存在するのですが、そのリクルーター職がコーディネーター業務として、候補者との調整業務も行っていました。これも課題のポイントでした。<br />これが原因でリクルーター職の残業時間がとても多い状況となっていたり、本来行いたい改善業務に集中出来ない問題がありました。<br />これを改善するために、採用のアシスタントの方にコーディネーター業務の教育を実施し、リクルーターの調整業務をコーディネーター職に移管しました。</p> <p>朝会15分の認識合わせをするだけで、リクルーター職の負担を減らし、リクルーター職が行うよりも候補者との調整業務をよりスピーディにするように変えました🙌</p> <p>現在は体制が少し変わってしまったため、当時のコーディネーターの運用はそのままとはいかないのですが、現在もアシスタントの方はコーディネーター業務を活かしてもらっています。</p> <h3 id="まとめ">まとめ</h3> <p>今回はエンジニアがエンジニア中途採用業務を担当するにあたって改善したことの一部を紹介しました。</p> <p>エンジニア採用は難しく厳しい時代と言われていますが、逆に言うと社内も社外も正解がわかっておらず、そのわからない状況を正解に持っていく面白さがあると思います😄</p> <p>私も採用活動は業務として行うのは初めてでしたが、数名エンジニア採用に繋がったなど、無事成果が出せてよかったと思います。</p> <p>採用活動は関わる人数が多く、工数削減や業務フロー改善が効きやすい業務だと思います。これを読んだ方も参考に改善してみたり、ノウハウをシェアいただけると嬉しいです。</p> <h3 id="最後に">最後に</h3> <p>最後に、弊社ではSRE職を積極的に募集しています。<br />クラウドインフラ経験者や、AWSを経験したいサーバサイドエンジニアの方、良ければお話伺ってみませんか?高トラフィックサービスである、クラシルのインフラに興味あれば良ければお話しましょう!以下リンクから応募いただけると嬉しいです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fherp.careers%2Fv1%2Fdely%2F4Q-61zGiM_ju" title="【クラシル/フルリモート】国内最大級のレシピ動画サービス「クラシル」のSite Reliability Engineerを募集! - dely株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://herp.careers/v1/dely/4Q-61zGiM_ju">herp.careers</a></cite></p> <p>採用情報はこちらです</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcareers.dely.jp%2F" title="dely株式会社 - クラシル エンジニア採用情報" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://careers.dely.jp/">careers.dely.jp</a></cite></p> miura_dely dbtとSnowflakeを使ってるなら迷わずにdbt-snowflake-monitoringを入れたほうがよさそう🉐 hatenablog://entry/820878482951463479 2023-07-21T13:37:53+09:00 2023-07-21T13:37:53+09:00 SELECT社のOSSであるdbt-snowflake-monitoringの紹介をします <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721123636.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>こんにちは〜。 クラシルでデータエンジニアをしておりますharry(<a href="https://twitter.com/gappy50">@gappy50</a>)です。</p> <p>これまで、クラシルではデータ基盤からのデータをアプリケーションや推薦システムへ活用するためにDWHにSnowflakeを導入し、様々な活用をしてきました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2F2023%2F03%2F24%2F131917" title="推薦システムにおけるSnowparkの活用 - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/2023/03/24/131917">tech.dely.jp</a></cite></p> <p>その一方で、データ「分析」基盤としての利用を推し進めていくために現在はdbtでのデータモデリングを中心として、一貫性のあるデータ分析が行えるような土台作りに注力しています。</p> <p>今回は、Snowflakeのコスト監視やdbtのモデリングのコストの監視・可視化ができるdbt-snowflake-monitoringについて簡単にご紹介したいと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fselect.dev%2Fdocs%2Fdbt-snowflake-monitoring%23dbt-snowflake-monitoring" title="dbt-snowflake-monitoring" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://select.dev/docs/dbt-snowflake-monitoring#dbt-snowflake-monitoring">select.dev</a></cite></p> <h2 id="現在のクラシルの分析における課題">現在のクラシルの分析における課題</h2> <p>クラシルでは高速で開発・検証をするためにSQLをみんなで書ける文化が根付いている反面、BIツールであるRedashに様々なKPIや指標が散在するためデータカオスが生まれておりました。 そのため2021年からdbtを導入し、必要なデータマートやマスタ関連のデータ整備をしていました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2F2021%2F12%2F09%2F093000" title="クラシルでのSnowflakeデータパイプラインのお話&活用Tips - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/2021/12/09/093000">tech.dely.jp</a></cite></p> <p>それから2年後、高速で開発・検証を進めた結果、dbtでもデータカオスが生まれはじめそうな気配が漂ってきました!(なんてこった</p> <p>分析で使われているのかわからないテーブル、定期実行しているクエリの実行コストがかさんできた、dbtのbuildに時間がかかりはじめているけど消していいのか、など…</p> <p>データエンジニアでよくある課題だと思うのですが、テーブルやパイプラインの棚卸はコスト死を防ぐためには死活問題です。 日々の運用クエリはここ1〜2年間でどうにか最低限のお掃除はできるものはあるものの、不要になったリソースを特定し利用者と合意を取るためにはそれなりの時間と根性と思い切りが必要です。</p> <p>また、そのような監視は分析ワークロードだけでなくdbtから実行されるELTワークロードに対しての監視までを行う必要があるため、 監視対象はdbtのモデルが増えれば増えるほどELTと分析の両側面からの棚卸が必要になります。</p> <h2 id="dbt-snowflake-monitoringの導入">dbt-snowflake-monitoringの導入</h2> <p>そのような複雑なワークロードの監視を簡単にしてくれる最高のツール、あります。 Snowflakeのコスト監視SaaSを提供しているSELECT社のOSSであるdbt-snowflake-monitoringです。</p> <p>SaaS版のSELECTはWeb上からLooker統合やSlackへの通知機能等もあるのでかなり魅力的ですね。</p> <p>OSS版のdbt-snowflake-monitoringはdbtとSnowflakeに特化しているものの、我々のワークロードのほぼほぼを監視できるので導入をするだけで即時メリットがあります。</p> <p>導入は以下のQuick Startの手順に従うだけなのでそれほど難しくないです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fselect.dev%2Fdocs%2Fdbt-snowflake-monitoring%2Fquickstart" title="Quickstart" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://select.dev/docs/dbt-snowflake-monitoring/quickstart">select.dev</a></cite></p> <p>導入が完了したら <code>dbt build --select dbt_snowflake_monitoring</code> を実行するとdbt-snowflake-monitoringのモデルがデプロイされます。dbt Cloudの場合はdbt jobに定義をしておいて定期的に更新できるようにしておくのもよいでしょう。</p> <p><figure class="figure-image figure-image-fotolife" title="dbt-snowflake-monitoringのプロジェクトはSnowflakeのaccount_usageなどの情報からコスト監視用のモデルを作成してくれる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721115348.png" width="1200" height="626" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt-snowflake-monitoringのプロジェクトはSnowflakeのaccount_usageなどの情報からコスト監視用のモデルを作成してくれる</figcaption></figure></p> <p>デプロイされたモデルは以下のような使用方法ができます!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fselect.dev%2Fdocs%2Fdbt-snowflake-monitoring%2Fexample-usage" title="Sample Queries" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://select.dev/docs/dbt-snowflake-monitoring/example-usage">select.dev</a></cite></p> <p>毎月の請求額をサービスごとに算出したり</p> <p><figure class="figure-image figure-image-fotolife" title="請求額の推移を可視化できる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721120220.png" width="1200" height="609" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>請求額の推移を可視化できる</figcaption></figure></p> <p>毎月のウェアハウスごとのコストを可視化できるのはもちろんのこと</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721120715.png" width="1200" height="613" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>直近クエリが実行されていないテーブルをテーブルサイズ順に確認できるので不要になってるテーブルの棚卸もコストパフォーマンスがよいものから棚卸できますし</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721120959.png" width="1200" height="603" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>直近コストがかかっているクエリの特定もすぐにできます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721121649.png" width="1200" height="435" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>さらには、これが強いのですがdbtのモデルごとのコストの監視もできます。 <code>query_base_table_access</code> のクエリ実行回数とあわせて監視すると不要なデータモデリングのお掃除も楽になりそうですね!</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gappy50/20230721/20230721122137.png" width="1200" height="778" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="さいごに">さいごに</h2> <p>いかがでしたか? dbtとSnowflakeを利用しているのであればまず入れてみるのはいかがでしょうか?</p> <p>我々クラシルのデータチームは、今年度からは分析の利便性を向上させるためにパワーが出せる状態になっているので、これまでの負債を解消しながら現在はこのようなコスト監視や、立ち向かえていなかったデータモデリングやデータカタログの充足化、Lookerの利活用など新たな分析基盤のための土台作りに注力をしてます。 全員がSSOTのデータやロジックで意思決定をしはじめて、正しい意味で高速で開発・検証ができる状態を目指して日々奮闘していきたいと思います。yatteiki 🦵 :takeshi_arigato:</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcareers.dely.jp%2F" title="dely株式会社 - クラシル エンジニア採用情報" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://careers.dely.jp/">careers.dely.jp</a></cite></p> gappy50 dbtプロジェクトにSQLFluffを導入する hatenablog://entry/820878482949208850 2023-07-14T11:42:05+09:00 2023-07-14T11:42:05+09:00 はじめに SQLFluffとは? 導入の背景 SQLFluffの導入 SQLFluffをインストールする SQLFluffを試してみる .sqlfluffを作成する dbt templaterをインストールする SQLFluffの使用 CLIでの使用 dbt Cloud IDEでの使用 さいごに はじめに こんにちは、データエンジニアの加藤です。 クラシルのデータ基盤ではdbt(data build tool)を使ってデータを変換しデータウエアハウス・データマートを構築しています。 今回はdbtプロジェクトにSQLFuffを導入したので紹介します。 SQLFluffとは? 様々な種類のSQLに… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taakuuyaa/20230713/20230713003041.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#SQLFluffとは">SQLFluffとは?</a></li> <li><a href="#導入の背景">導入の背景</a></li> <li><a href="#SQLFluffの導入">SQLFluffの導入</a><ul> <li><a href="#SQLFluffをインストールする">SQLFluffをインストールする</a></li> <li><a href="#SQLFluffを試してみる">SQLFluffを試してみる</a></li> <li><a href="#sqlfluffを作成する">.sqlfluffを作成する</a></li> <li><a href="#dbt-templaterをインストールする">dbt templaterをインストールする</a></li> </ul> </li> <li><a href="#SQLFluffの使用">SQLFluffの使用</a><ul> <li><a href="#CLIでの使用">CLIでの使用</a></li> <li><a href="#dbt-Cloud-IDEでの使用">dbt Cloud IDEでの使用</a></li> </ul> </li> <li><a href="#さいごに">さいごに</a></li> </ul> <h2 id="はじめに">はじめに</h2> <p>こんにちは、データエンジニアの加藤です。<br/> クラシルのデータ基盤ではdbt(data build tool)を使ってデータを変換しデータウエアハウス・データマートを構築しています。 今回はdbtプロジェクトにSQLFuffを導入したので紹介します。</p> <h2 id="SQLFluffとは">SQLFluffとは?</h2> <p>様々な種類のSQLに対応するリンターです。<br/> Jinjaやdbtにも対応しておりコーディング規約に違反した記述を自動で修正してくれます。</p> <blockquote><p>SQLFluff is a dialect-flexible and configurable SQL linter. Designed with ELT applications in mind, SQLFluff also works with Jinja templating and dbt. SQLFluff will auto-fix most linting errors, allowing you to focus your time on what matters.</p></blockquote> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fsqlfluff%2Fsqlfluff" title="GitHub - sqlfluff/sqlfluff: A modular SQL linter and auto-formatter with support for multiple dialects and templated code." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/sqlfluff/sqlfluff">github.com</a></cite></p> <h2 id="導入の背景">導入の背景</h2> <p>これまでdbtプロジェクト内で作成されたSQLにはコーディング規約がなくSQLを書く各人の経験や暗黙的な規約で書かれていました。 そこに新メンバーがジョインしたことで暗黙的に守られていた規約に対するコードレビューに時間がかかったり、修正する手間が発生しました。</p> <p>そこで以下の価値を期待してSQLFluffを導入することを決めました。</p> <ul> <li>暗黙的なコーディング規約の明確化</li> <li>規約違反コードのレビューコストの軽減・削減</li> <li>統一されたコーディングによる認知・キャッチアップコストの軽減</li> </ul> <h2 id="SQLFluffの導入">SQLFluffの導入</h2> <h3 id="SQLFluffをインストールする">SQLFluffをインストールする</h3> <p>SQLFluffでは<a href="https://docs.sqlfluff.com/en/stable/gettingstarted.html#installing-python">Python 3が必要</a>です。</p> <pre class="code" data-lang="" data-unlink>$ pip install sqlfluff</pre> <p>以下のコマンドでインストールが成功していることを確認しましょう。 バージョンが表示されればOKです。</p> <pre class="code" data-lang="" data-unlink>$ sqlfluff version 2.1.2</pre> <h3 id="SQLFluffを試してみる">SQLFluffを試してみる</h3> <p>テスト用に<code>test.sql</code>を用意</p> <pre class="code" data-lang="" data-unlink>$ cat test.sql sELECt a, b , c + d, e FRoM hoge;</pre> <p>lintコマンドでコーディング規約に違反している箇所を確認できます。<br/> --dialectには<a href="https://github.com/sqlfluff/sqlfluff#dialects-supported">使用したいSQL</a>を指定します。(<code>$ sqlfluff dialects</code>でも確認できます)</p> <pre class="code" data-lang="" data-unlink>$ sqlfluff lint test.sql --dialect snowflake</pre> <p>以下のように結果が表示されます。<br/> Lは違反のある行数をPは何文字目であるかを表しています。<br/> CP01などはSQLFluffのルールのコードを表します。 以降は違反の説明とルール名が表示されます。</p> <pre class="code" data-lang="" data-unlink>L: 1 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] L: 1 | P: 1 | LT09 | Select targets should be on a new line unless there is | only one select target. | [layout.select_targets] L: 1 | P: 1 | ST06 | Select wildcards then simple targets before calculations | and aggregates. [structure.column_order] L: 1 | P: 7 | LT02 | Expected line break and indent of 4 spaces before &#39;a&#39;. | [layout.indent] L: 2 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 2 | P: 8 | LT04 | Found leading comma &#39;,&#39;. Expected only trailing near | line breaks. [layout.commas] L: 2 | P: 10 | AL03 | Column expression without alias. Use explicit `AS` | clause. [aliasing.expression] L: 3 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 4 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords]</pre> <p>fixコマンドでコーディング規約に合わせて自動修正できます。</p> <pre class="code" data-lang="" data-unlink>$ sqlfluff fix test.sql --dialect snowflake</pre> <p>違反箇所の修正を試みるか聞かれるのでyを入力します。</p> <pre class="code" data-lang="" data-unlink>==== finding fixable violations ==== == [test.sql] FAIL L: 1 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] L: 1 | P: 1 | LT09 | Select targets should be on a new line unless there is | only one select target. | [layout.select_targets] L: 1 | P: 1 | ST06 | Select wildcards then simple targets before calculations | and aggregates. [structure.column_order] L: 1 | P: 7 | LT02 | Expected line break and indent of 4 spaces before &#39;a&#39;. | [layout.indent] L: 2 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 2 | P: 8 | LT04 | Found leading comma &#39;,&#39;. Expected only trailing near | line breaks. [layout.commas] L: 3 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 4 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] ==== fixing violations ==== 8 fixable linting violations found Are you sure you wish to attempt to fix these? [Y/n] ... Attempting fixes... Persisting Changes... == [test.sql] FIXED Done. Please check your files to confirm. All Finished 📜 🎉! [1 unfixable linting violations found]</pre> <p>成功すると<code>test.sql</code>が修正されます。</p> <pre class="code" data-lang="" data-unlink>$ cat test.sql SELECT a, b, e, c + d FROM hoge;</pre> <h3 id="sqlfluffを作成する"><code>.sqlfluff</code>を作成する</h3> <p>独自のコーディング規約を定義するためにプロジェクトのルートディレクトリに<code>.sqlfluff</code>を作成しましょう。<br/> <code>.sqlfluff</code>を作成しなくても<a href="https://docs.sqlfluff.com/en/stable/configuration.html#default-configuration">デフォルトの設定</a>で使用することが可能です。</p> <p>今回は<a href="https://github.com/dbt-labs/corp/blob/main/dbt_style_guide.md">dbt Style Guide</a>を参考にコーディング規約を整備していくことにしました。<br/> 基本的にはデフォルトと異なる設定のみを定義するようにしています。</p> <pre class="code" data-lang="" data-unlink>[sqlfluff] dialect = snowflake # templaterにdbtを指定する場合はプラグインのインストールが必要です(後述) templater = dbt # SQLFluffで定義されているcoreルールのみを使用する # https://docs.sqlfluff.com/en/stable/rules.html#core-rules rules = core # dbt templaterの設定 # https://docs.sqlfluff.com/en/stable/configuration.html#installation-configuration [sqlfluff:templater:dbt] # 環境に合った設定をする project_dir = ./ profiles_dir = ~/.dbt/ profile = default target = dev # 各種ruleについては以下を参照 # https://docs.sqlfluff.com/en/stable/rules.html:title [sqlfluff:indentation] allow_implicit_indents = true [sqlfluff:rules:aliasing.table] aliasing = explicit [sqlfluff:rules:aliasing.column] aliasing = explicit [sqlfluff:rules:capitalisation.keywords] capitalisation_policy = lower [sqlfluff:rules:capitalisation.identifiers] capitalisation_policy = lower [sqlfluff:rules:capitalisation.functions] extended_capitalisation_policy = lower [sqlfluff:rules:capitalisation.literals] capitalisation_policy = lower [sqlfluff:rules:capitalisation.types] extended_capitalisation_policy = lower [sqlfluff:rules:ambiguous.column_references] group_by_and_order_by_style = implicit</pre> <h3 id="dbt-templaterをインストールする">dbt templaterをインストールする</h3> <p>dbt templaterはSQLFluffのデフォルトテンプレートでないためプラグインのインストールが必要です。<br/> dialectに対応する<a href="https://docs.getdbt.com/docs/available-adapters">dbt adapter</a>と<a href="https://pypi.org/project/sqlfluff-templater-dbt/">sqlfluff-templater-dbt</a>をインストールします。</p> <pre class="code" data-lang="" data-unlink>$ pip install dbt-snowflake sqlfluff-templater-dbt</pre> <p>今回はdbt templaterを使用しますが用途によってはjinja templaterを使用する方が有効な場合もあるため<a href="https://docs.sqlfluff.com/en/stable/configuration.html#dbt-templater">こちら</a>を確認して状況に応じて適切なtemplaterを使用しましょう。</p> <h2 id="SQLFluffの使用">SQLFluffの使用</h2> <h3 id="CLIでの使用">CLIでの使用</h3> <p><a href="#SQLFluff%E3%82%92%E8%A9%A6%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B">こちら</a>と同じように実行します。<br/> <code>.sqlfluff</code>があるディレクトリでコマンドを実行することで自動でコーディング規約を読み込んでくれます。</p> <pre class="code" data-lang="" data-unlink>$ sqlfluff lint models/test.sql</pre> <pre class="code" data-lang="" data-unlink>$ sqlfluff fix models/test.sql</pre> <h3 id="dbt-Cloud-IDEでの使用">dbt Cloud IDEでの使用</h3> <p>dbt Cloud IDEではデフォルトで<a href="http://sqlfmt.com/">sqlfmt</a>を使ったフォーマットができます。<br/> プロジェクトのルートディレクトリに<code>.sqlfluff</code>を作成することでSQLFluffでLintとFixを行うことができます。(注意: mainブランチや読み取り専用ブランチでは使用できません) <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.getdbt.com%2Fdocs%2Fcloud%2Fdbt-cloud-ide%2Flint-format%23format-sql" title="Lint and format your code | dbt Developer Hub" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.getdbt.com/docs/cloud/dbt-cloud-ide/lint-format#format-sql">docs.getdbt.com</a></cite></p> <h2 id="さいごに">さいごに</h2> <p>dbtプロジェクトへSQLFuffの導入を紹介しました。 現在はSQLFluffを手動で実行する必要があるのでpre-commitやGitHub Actionsを使って必ずSQLFluffが実行されるようにCI/CDパイプラインを改善しコーディング規約の徹底を目指していきたいと考えています。加えて、今回はSQLFluffのcoreルールのみを適用しているのでチームで議論しながらより効果的なコーディング規約を育てていきたいと考えています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcareers.dely.jp%2F" title="dely株式会社 - クラシル エンジニア採用情報" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://careers.dely.jp/">careers.dely.jp</a></cite></p> taakuuyaa クラシルにおけるElasticsearch v7へのアップグレードおよびElastic Cloudへの移行 hatenablog://entry/820878482948491066 2023-07-10T19:49:24+09:00 2023-07-10T19:49:24+09:00 はじめに 移行が必要となった背景 Elastic Cloudへの移行およびv7へのバージョンアップ 旧構成について 構成図 なぜElastic Cloudか なぜ移行と同時にアップグレードを行ったか なぜ最新のv8ではなくv7か サーバサイドの修正内容 新構成について 構成図 Traffic Filter経由での接続 監視 Datadog Elastic Status ログ deprecation slowlog audit 権限管理 S3バックアップ Kibana Spaceのロゴ調整 辞書・同義語の運用 補足(unassigned shardの調査) 移行後に起きた問題 CPUクレジット枯… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710174504.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#移行が必要となった背景">移行が必要となった背景</a></li> <li><a href="#Elastic-Cloudへの移行およびv7へのバージョンアップ">Elastic Cloudへの移行およびv7へのバージョンアップ</a><ul> <li><a href="#旧構成について">旧構成について</a><ul> <li><a href="#構成図">構成図</a></li> </ul> </li> <li><a href="#なぜElastic-Cloudか">なぜElastic Cloudか</a></li> <li><a href="#なぜ移行と同時にアップグレードを行ったか">なぜ移行と同時にアップグレードを行ったか</a></li> <li><a href="#なぜ最新のv8ではなくv7か">なぜ最新のv8ではなくv7か</a></li> <li><a href="#サーバサイドの修正内容">サーバサイドの修正内容</a></li> <li><a href="#新構成について">新構成について</a><ul> <li><a href="#構成図-1">構成図</a></li> <li><a href="#Traffic-Filter経由での接続">Traffic Filter経由での接続</a></li> <li><a href="#監視">監視</a><ul> <li><a href="#Datadog">Datadog</a></li> <li><a href="#Elastic-Status">Elastic Status</a></li> </ul> </li> <li><a href="#ログ">ログ</a><ul> <li><a href="#deprecation">deprecation</a></li> <li><a href="#slowlog">slowlog</a></li> <li><a href="#audit">audit</a></li> </ul> </li> <li><a href="#権限管理">権限管理</a></li> <li><a href="#S3バックアップ">S3バックアップ</a></li> <li><a href="#Kibana-Spaceのロゴ調整">Kibana Spaceのロゴ調整</a></li> <li><a href="#辞書同義語の運用">辞書・同義語の運用</a><ul> <li><a href="#補足unassigned-shardの調査">補足(unassigned shardの調査)</a></li> </ul> </li> </ul> </li> </ul> </li> <li><a href="#移行後に起きた問題">移行後に起きた問題</a><ul> <li><a href="#CPUクレジット枯渇">CPUクレジット枯渇</a><ul> <li><a href="#原因">原因</a></li> <li><a href="#対応">対応</a></li> </ul> </li> </ul> </li> <li><a href="#今後の展望">今後の展望</a></li> <li><a href="#さいごに">さいごに</a></li> </ul> <h3 id="はじめに">はじめに</h3> <p>クラシルSREのkashと申します。</p> <p>クラシルでは検索エンジンとしてElasticsearchを様々な用途で使用しています。 ElasticsearchのクラスタはAWSのEC2上で構築・運用されていましたが、多くの課題が溜まっていたことから、バージョンアップおよびElastic Cloudへの移行を行いました。</p> <p>本記事では新構成や移行後に起きた問題についてご紹介します。</p> <h3 id="移行が必要となった背景">移行が必要となった背景</h3> <p>AWSのEC2上で構築されていたクラスタは久しくバージョンアップが行われておらず、インスタンスタイプも旧世代が使われていました。また、インデックスごとにS3へのバックアップ用にLambdaが存在していたり、そのLambdaがEOLになっているなど、運用維持が困難な状態でした。</p> <p>このような状況があったため、インフラ面をSREが担当しつつ、Ruby側の修正はサーバサイドチームの協力ももらいながら、1ヶ月程度かけて新しい構成への移行を行いました。</p> <h3 id="Elastic-Cloudへの移行およびv7へのバージョンアップ">Elastic Cloudへの移行およびv7へのバージョンアップ</h3> <h4 id="旧構成について">旧構成について</h4> <h5 id="構成図">構成図</h5> <p><figure class="figure-image figure-image-fotolife" title="旧構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710093727.png" width="902" height="558" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>旧構成</figcaption></figure></p> <p>大幅に簡略化していますが、アプリケーションはいくつかのサービスとして分割されており、ECS上で動いています。 またElasticsearchのクラスタはマスタ3台と、数台のデータノードで構成されていました。</p> <h4 id="なぜElastic-Cloudか">なぜElastic Cloudか</h4> <p>AWSのOpenSearchも候補の一つでしたが、最終的にElastic Cloudを選定しました。 他にも<a href="https://www.elastic.co/jp/elastic-cloud-kubernetes">Elastic Cloud on Kubernetes</a>などが候補としてありますが、クラシルのアプリケーションはECSで稼動しているため対象外としました。</p> <ul> <li><a href="https://github.com/elastic/elasticsearch-ruby">elasticsearch-ruby</a>および<a href="https://github.com/elastic/elasticsearch-rails">elasticsearch-rails</a> gemへの依存があるため <ul> <li>AWSにはOpenSearchがありますが、クラシルが依存しているこれらのgemがv7そしてv8以降で使えなくなる可能性を考慮した結果です<a href="#f-d4ac6825" name="fn-d4ac6825" title="[https://zenn.dev/hajimeni/articles/682e81fa68c7af:title]">*1</a></li> </ul> </li> <li>クラシル以外の自社サービス(TRILL)で既にElastic Cloudの採用実績があるため</li> <li>運用負荷を軽減するため <ul> <li>Elastic Cloudであればハードウェアプロファイルの変更やサイズを容易に変えることができます</li> <li>クラスタのバックアップも自動で行われており、任意のタイミングの状態を復元することができます</li> <li>また、Kibanaの構築やプロキシ設定を自前で行う必要はないのも大きいです</li> </ul> </li> <li>最新機能を活用するため <ul> <li> Elastic Cloudユーザであれば、Elastic社が独自に開発した機械学習モデルである<a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html">ELSER</a>のような新しい機能を使えるメリットがあります</li> </ul> </li> </ul> <h4 id="なぜ移行と同時にアップグレードを行ったか">なぜ移行と同時にアップグレードを行ったか</h4> <p>本来であれば、Elastic Cloudへの移行という大きい変更と、バージョンアップは同時に行いたくありません。</p> <p>同時に行ったのはElastic Cloudで起動できる最低バージョンがv7だったことが理由の一つです。 移行を何段階かのフェーズに分けて、Opensearch上で今と同じバージョンを起動したりEC2上でアップグレード作業をしてからElastic Cloudへ移行を行う段階的な方法も検討しましたが、大幅に時間がかかることになります。</p> <p>新環境に加えて旧環境へのインデックスもしばらく動かし続け、いつでも切り戻せる状態にしておくことで、このリスクを許容することにしました。</p> <h4 id="なぜ最新のv8ではなくv7か">なぜ最新のv8ではなくv7か</h4> <p>移行時点で<a href="https://github.com/elastic/elasticsearch-rails/pull/1056">elasticsearch-railsのv8対応</a>が進行中という状況でした。 積極的にメンテナンスされているわけではなさそうなため、今後の新しいバージョンでも同様の問題が起きる可能性があり、<code>elasticsearch-rails</code>への依存を剥がすことの検討が必要になるかもしれません。</p> <h4 id="サーバサイドの修正内容">サーバサイドの修正内容</h4> <p>サーバサイドチームに協力いただき、<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/breaking-changes.html">Breaking Changes</a>と照らし合わせながら、アプリケーションの改修を行ってもらいました。以下は修正が必要だった変更内容の一部です。</p> <ul> <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/removal-of-types.html">typeの廃止</a></li> <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/breaking-changes-7.0.html#_weights_in_function_score_must_be_positive">function_scoreクエリのweightに負の値が使えなくなった</a></li> <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/breaking-changes-7.0.html#hits-total-now-object-search-response">hits.totalの型が変更</a></li> </ul> <p>ElasticsearchのClient初期化方法も変わりました。基本的には下記のような<code>cloud_id</code>を指定する方法で問題ないのですが、クラシルでは<a href="https://www.elastic.co/guide/en/cloud/current/ec-traffic-filtering-deployment-configuration.html">Traffic Filter</a>(後述)を使用しているため、下の<code>elastic-cloud.com</code>をホストとして指定した初期化方法にしています。Traffic Filterを使用していない場合はデフォルトの<code>found.io</code>ドメインになります。このあたりは最初戸惑いがありました。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink>client = <span class="synType">Elasticsearch</span>::<span class="synType">Client</span>.new( <span class="synConstant">cloud_id</span>: <span class="synSpecial">'</span><span class="synConstant">&lt;deployment name&gt;:&lt;cloud id&gt;</span><span class="synSpecial">'</span>, <span class="synConstant">api_key</span>: <span class="synSpecial">'</span><span class="synConstant">******</span><span class="synSpecial">'</span> ) client = <span class="synType">Elasticsearch</span>::<span class="synType">Client</span>.new( <span class="synConstant">host</span>: <span class="synSpecial">&quot;</span><span class="synConstant">https://*****.es.vpce.ap-northeast-1.aws.elastic-cloud.com</span><span class="synSpecial">&quot;</span>, <span class="synComment"># host: https://*****.es.ap-northeast-1.aws.found.io</span> <span class="synConstant">api_key</span>: <span class="synSpecial">'</span><span class="synConstant">*****</span><span class="synSpecial">'</span> ) </pre> <p>辞書や同義語はElastic Cloudの<a href="https://www.elastic.co/guide/en/cloud/current/ec-custom-bundles.html">Extension</a>としてアップロードします。 インデックスのマッピング(kuromoji_tokenizerのuser_dictionary等)もパスを<code>/app/config</code>に変更する必要がありました。ZIPで圧縮する際は<code>dictionaries</code>ディレクトリを作る必要がある点もご注意ください。</p> <blockquote><p>The entire content of a bundle is made available to the node by extracting to the Elasticsearch container’s /app/config directory. This is useful to make custom dictionaries available. Dictionaries should be placed in a /dictionaries folder in the root path of your ZIP file.</p></blockquote> <pre class="code" data-lang="" data-unlink>GET /&lt;index name&gt;/_settings?filter_path=**.kuromoji_tokenizer { &#34;&lt;index name&gt;&#34; : { &#34;settings&#34; : { &#34;index&#34; : { &#34;analysis&#34; : { &#34;tokenizer&#34; : { &#34;kuromoji_tokenizer&#34; : { &#34;type&#34; : &#34;kuromoji_tokenizer&#34;, &#34;user_dictionary&#34; : &#34;/app/config/&lt;dic&gt;.csv&#34; } } } } } } }</pre> <h4 id="新構成について">新構成について</h4> <h5 id="構成図-1">構成図</h5> <p><figure class="figure-image figure-image-fotolife" title="新構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710093622.png" width="906" height="487" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>新構成</figcaption></figure></p> <p>こちらも大幅に簡略化しており、実際にはElastic Cloud上に環境ごとのクラスタを構築しています。</p> <h5 id="Traffic-Filter経由での接続">Traffic Filter経由での接続</h5> <p><a href="https://www.elastic.co/guide/en/cloud/current/ec-traffic-filtering-vpc.html">Traffic Filter for AWS</a>によってクラシルのAWSアカウントとElastic Cloudをプライベート接続しています。インターネット経由での接続に比べて、パフォーマンスが安定しました。AWSの場合、実態としてはPrivateLinkになっています。</p> <p>パフォーマンス測定は<a href="https://github.com/elastic/rally">esrally</a>で行いました。</p> <pre class="code" data-lang="" data-unlink>esrally --pipeline=benchmark-only --target-hosts=&#34;https://*****.es.ap-northeast-1.aws.found.io&#34; --client-options=&#34;basic_auth_user:&#39;elastic&#39;,basic_auth_password:&#39;*****&#39;&#34; --track=&lt;track&gt; esrally --pipeline=benchmark-only --target-hosts=&#34;https://*****.es.vpce.ap-northeast-1.aws.elastic-cloud.com&#34; --client-options=&#34;basic_auth_user:&#39;elastic&#39;,basic_auth_password:&#39;*****&#39;&#34; --track=&lt;track&gt; esrally compare --baseline=&lt;before&gt; --contender=&lt;after&gt;</pre> <p>以下の記事ではTraffic Filterの仕組みが図解されており、わかりやすいのでおすすめです。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.creationline.com%2Flab%2F35952" title="Elastic Cloudへプライベート接続を試してみた #elastic #AWS - クリエーションライン株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.creationline.com/lab/35952">www.creationline.com</a></cite></p> <h5 id="監視">監視</h5> <h6 id="Datadog">Datadog</h6> <p>Elasticsearchクラスタの各種メトリクスは<a href="https://docs.datadoghq.com/ja/integrations/elastic_cloud/">DatadogのElastic Cloud連携</a>を使用し、必要に応じて監視を入れています。</p> <p>ただ、注意点があります。Traffic Filterを有効化すると許可したネットワーク以外は接続できなくなり、Datadogのような外部から監視を行う場合や、更にはエンジニアがKibanaにアクセスするときにも影響します。</p> <p>タイプがIPアドレスのTraffic Filterを追加して<a href="https://ip-ranges.datadoghq.com/">DatadogのIP</a>やエンジニアのIPを適宜追加するか、VPNなど何らかの方法でIPを固定する必要があります。</p> <p>クラシルでは現時点ではオフィスのIPとVPNからのアクセスを許可し、必要に応じてエンジニアのIPを追加しています。 CloudflareやTailscale等を活用して<code>elastic-cloud.com</code>へのアクセスをPrivateLink経由にする方法もできそうですが、まだ検証できておらず現時点では手動で管理しています。</p> <p>このような仕様のため、本番稼動中にTraffic Filterを有効化するのは意図せずアクセスを遮断してしまう可能性があるため注意が必要です。これからElastic Cloudへの移行を考えている場合、可能であれば初期からTraffic Filterを使用するかを決めたほうがよいかもしれません。</p> <h6 id="Elastic-Status">Elastic Status</h6> <p><a href="https://status.elastic.co/">Elastic Cloud Status</a>が公開されているので、メール購読およびDatadogへのRSS登録を行なっています</p> <h5 id="ログ">ログ</h5> <p>メインとなるクラスタのログとメトリクスは他のクラスタに転送しています。以下の画像は例です。 <figure class="figure-image figure-image-fotolife" title="メトリクスおよびログの転送"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710095556.png" width="1200" height="422" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>メトリクスおよびログの転送</figcaption></figure></p> <p>転送先のクラスタはv7にする必要はないため、最新バージョンであるv8にしており、これによって<a href="https://www.elastic.co/jp/what-is/elasticsearch-monitoring">Stack Monitoring</a>が使えるようになりました。インデックスごとの検索レートがリアルタイムで分かるため非常に便利です。</p> <p>ゆくゆくは<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/actions-slack.html">WatcherのSlack通知</a>を活用したいと思っています。</p> <h6 id="deprecation">deprecation</h6> <p>バージョンを上げたことで非推奨の設定もあります。ログは<code>elastic-cloud-logs-*</code>インデックスに格納されており、deprecationに関するログは<code>event.dataset: "elasticsearch.deprecation"</code>という条件でKibanaのDiscoverで可視化するか、以下のようなクエリで抽出できます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.elastic.co%2Fguide%2Fen%2Fcloud%2Fcurrent%2Fec-enable-logging-and-monitoring.html%23ec-access-kibana-monitoring" title="Enable logging and monitoring | Elasticsearch Service Documentation | Elastic" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.elastic.co/guide/en/cloud/current/ec-enable-logging-and-monitoring.html#ec-access-kibana-monitoring">www.elastic.co</a></cite></p> <pre class="code" data-lang="" data-unlink>GET elastic-cloud-logs/_search?filter_path=**.message { &#34;query&#34;: { &#34;term&#34;: { &#34;event.dataset&#34;: { &#34;value&#34;: &#34;elasticsearch.deprecation&#34; } } }, &#34;sort&#34;: [ { &#34;@timestamp&#34;: { &#34;order&#34;: &#34;desc&#34; } } ] }</pre> <p>クラシルでは以下のようなログが発生しており、次のバージョンにアップグレードする前に対応する必要があります。</p> <blockquote><p>The [edgeNGram] token filter name is deprecated and will be removed in a future version. Please change the filter name to [edge_ngram] instead</p></blockquote> <h6 id="slowlog">slowlog</h6> <p>インデックスごとに<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/index-modules-slowlog.html">slowlog</a>の設定をしています。</p> <p>以下の例では直接インデックスの設定を更新していますが、実際には<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/index-templates.html">インデックステンプレート</a>でインデックスパターンに対して指定しています。閾値を<code>0s</code>にすると全てのクエリをログに出すことができるため、開発環境のクラスタでどのようなクエリが実行されているかを把握するために活用していたりします。</p> <pre class="code" data-lang="" data-unlink>PUT /&lt;index name&gt;/_settings { &#34;index.search.slowlog.threshold.query.trace&#34;: &#34;&lt;threshold&gt;&#34; &#34;index.search.slowlog.threshold.query.debug&#34;: &#34;&lt;threshold&gt;&#34; &#34;index.search.slowlog.threshold.query.info&#34;: &#34;&lt;threshold&gt;&#34; &#34;index.search.slowlog.threshold.query.warn&#34;: &#34;&lt;threshold&gt;&#34; } </pre> <p>ログはdeprecationと同様で、<code>event.dataset: "elasticsearch.slowlog"</code>で抽出できます。 このログにはクエリの全文が格納されており、KibanaのProfilerにコピペすることでクエリが遅い原因を調査することが可能です。</p> <h6 id="audit">audit</h6> <p><code>xpack.security.audit.enabled</code>を指定にすることで、<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/enable-audit-logging.html">監査ログ</a>を有効化でき、ログは<code>event.dataset: "elasticsearch.audit"</code>として出力されます。記録するイベントの種類やインデックスを限定することもできます。</p> <pre class="code" data-lang="" data-unlink>xpack.security.audit.enabled: true xpack.security.audit.logfile.events.include: access_denied, authentication_failed xpack.security.audit.logfile.events.emit_request_body: true</pre> <p>以下のようにUser Settingsから<code>elasticsearch.yml</code>を指定しますが、ハマりどころとしては、<code>kibana.yml</code>にも同様の設定が必要だったということでした。</p> <p><figure class="figure-image figure-image-fotolife" title="ElasticsearchのUser Settings"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710152633.png" width="1178" height="212" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ElasticsearchのUser Settings</figcaption></figure></p> <h5 id="権限管理">権限管理</h5> <p>各クラスタの権限管理はElastic Cloudに最近導入された<a href="https://www.elastic.co/guide/en/cloud/current/ec-release-notes-2023-04-18.html">ロール機能</a>を使っています。</p> <p>カスタムロールなどは現状使えませんが、特定クラスタのadmin、editor、viewer権限を特定の人に対して割り当てることができます。 権限を割り当てることにより、Kibanaで<code>Login with Elastic Cloud</code>を選択することでログインできます。</p> <p>実はこの機能がリリースされる前は<a href="https://www.elastic.co/guide/en/cloud/current/ec-securing-clusters-oidc-op.html">Oktaログイン</a>を使用することを想定し、検証していました。手順に従って有効化すると、以下のようにログイン画面に<code>Login with Okta</code>が表示されます。</p> <p><figure class="figure-image figure-image-fotolife" title="Kibanaへのログイン"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710144343.png" width="988" height="982" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Kibanaへのログイン</figcaption></figure></p> <p>このやり方であれば権限を細かく調整できるメリットがあるのですが、クラスタごとにロールを定義する必要があります。現時点ではTerraformで管理しておらず、クラスタが増えると大変になるため、どうしたものかと考えていました。そのような状況でロール機能がリリースされたため、そちらを使うことにしました。</p> <p>今後は二つを組み合わせることも想定しており、基本的にはElastic Cloudのロール機能でviewer権限を付与し、本番クラスタのみOktaで細かく制御するハイブリッド方式も良さそうと思っています。</p> <h5 id="S3バックアップ">S3バックアップ</h5> <p>Elastic Cloudはデフォルトで定期的にバックアップをとってくれています。そのバックアップを使ってクラスタ全体や特定のインデックスのみを復元できるのが非常に便利です。</p> <p>ただ、クラスタを消すとそのバックアップも当然消えてしまいます。なにかしらの問題があったときのことを考えて、以下の手順に従ってクラシルのAWSアカウント側のS3にもバックアップすることにしました。</p> <p><a href="https://www.elastic.co/guide/en/cloud/current/ec-aws-custom-repository.html">Configure a snapshot repository using AWS S3 | Elasticsearch Service Documentation | Elastic</a></p> <h5 id="Kibana-Spaceのロゴ調整">Kibana Spaceのロゴ調整</h5> <p>本番用のクラスタ以外にも開発用のクラスタを起動しています。Kibanaを使っているときに、どの環境のクラスタに接続しているかはURLでしかわかりません。 そのため、開発環境だと勘違いして本番に変更を加えてしまう恐れがあります。</p> <p>ミスが起きることを完全になくすことは難しいですが、可能性を減らすためにKibanaのデフォルトスペースのロゴを変えて環境が本番(<strong>Pr</strong>oduction)であることに気づきやすくしました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230709/20230709232940.png" width="452" height="296" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>なお、8.8系では<code>Custom Branding</code>という機能が導入され、FaviconやKibanaのロゴも変えられるようです。うまいこと活用できれば本番環境であることを強調できるかもしれません。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.elastic.co%2Fguide%2Fen%2Fkibana%2F8.8%2Fwhats-new.html%23_custom_branding" title="What’s new in 8.8 | Kibana Guide [8.8] | Elastic" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.elastic.co/guide/en/kibana/8.8/whats-new.html#_custom_branding">www.elastic.co</a></cite></p> <h5 id="辞書同義語の運用">辞書・同義語の運用</h5> <p>Elastic Cloudでは辞書や同義語ファイルをzipで圧縮して前述したExtensionの仕組みを使って、クラスタに紐づけます。</p> <p>既存のインデックスで既に辞書を使っている状態で、シンタックスやディレクトリ構造が間違った辞書をアップロードするとローリングアップデートが行われますが、その結果、unassigned shard状態になってしまうため注意が必要です。</p> <p>慌てて前の辞書を使うようにクラスタの状態を戻しても解消しないことがありました。その場合は<code>Restart Elasticsearch</code>で明示的に再起動することで元に戻りました。 本番環境で直面すると非常に焦る事象のため、辞書の更新は慎重にやる必要があり、開発クラスタで事前に辞書が問題ないことを試すことに加えて、あえて不正なファイルをアップロードしてエラー対応をする流れを検証することをお勧めします。</p> <h6 id="補足unassigned-shardの調査">補足(unassigned shardの調査)</h6> <p>Elasticsearchの運用をしていると、unassigned shardという状態になることは避けて通れません。 Datadogでは<code>elastic_cloud.unassigned_shards</code>でメトリクスを確認できますが、Dev Toolsなどからは以下のクエリで確認できます。</p> <pre class="code" data-lang="" data-unlink>GET /_cat/shards?v=true&amp;h=index,shard,prirep,state,node,unassigned.reason&amp;s=state index shard prirep state node unassigned.reason &lt;index name&gt; 0 p UNASSIGNED ALLOCATION_FAILED</pre> <p>原因調査には<code>/_cluster/allocation/explain</code>のAPIが使えます。</p> <pre class="code" data-lang="" data-unlink>GET /_cluster/allocation/explain { &#34;index&#34;: &#34;&lt;index name&gt;&#34;, &#34;shard&#34;: 0, &#34;primary&#34;: true } failed shard on node [xSxiF3YWS1yeShDqWQohbg]: failed to create index, failure IllegalArgumentException[Failed to resolve file: system_core.dic\nTried roots: [Filesystem{base=/app/config/sudachi}, Classpath{prefix=}]]</pre> <h3 id="移行後に起きた問題">移行後に起きた問題</h3> <p>移行直後は大きな問題は起きず、めでたしめでたし、で終わるかと思いきや5月前半にアクセス数が突発的に跳ねたタイミングで検索リクエストも急増し、それによって障害を起こしてしまいました。</p> <h4 id="CPUクレジット枯渇">CPUクレジット枯渇</h4> <p>Sentryから以下のような<code>429: Too Many Requests</code>エラーの通知が来ました。</p> <blockquote><p>rejected execution of <strong>** on QueueResizingEsThreadPoolExecutor[name = instance-00000000</strong>/search, queue capacity = 1000, ...</p></blockquote> <p>queue_sizeはデフォルトの1000のままになっており、何らかの理由で捌ききれずに検索リクエストがキューから溢れてしまったようです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.elastic.co%2Fguide%2Fen%2Felasticsearch%2Freference%2Fcurrent%2Fmodules-threadpool.html" title="Thread pools | Elasticsearch Guide [8.8] | Elastic" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html">www.elastic.co</a></cite></p> <h5 id="原因">原因</h5> <p>突発的なクラシルへのアクセス増によって検索リクエストも増え、その結果クラスタにCPU負荷がかかり、データノードのCPUクレジットが枯渇しました。それによって、パフォーマンスの悪化が発生しました。</p> <p>事象としてはこちらと同じです。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.elastic.co%2Fguide%2Fen%2Fcloud%2Fcurrent%2Fec-scenario_why_is_performance_degrading_over_time.html" title="Why is performance degrading over time? | Elasticsearch Service Documentation | Elastic" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.elastic.co/guide/en/cloud/current/ec-scenario_why_is_performance_degrading_over_time.html">www.elastic.co</a></cite></p> <p>残念ながら、現時点ではCPUクレジットの情報をメトリクスとして取得することはできないとサポートの方から聞きました。<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/cluster-nodes-stats.html">Nodes Stats API</a>では<code>cfs_quota_micros</code>が取得できますが、これはCPUクレジットが「枯渇した後」に変化が起きるため、CPUクレジットが「枯渇し始めた」という兆候を検知することはできないようです。</p> <p>兆候を検知する方法はいまだに未解決で、CPUクレジットの減少が発生しないような余裕のあるハードウェアプロファイルとサイズで構築するしかないという認識です。</p> <h5 id="対応">対応</h5> <p>どの<a href="https://www.elastic.co/guide/en/cloud/current/ec_selecting_the_right_configuration_for_you.html">ハードウェアプロファイル</a>を使用するか、どのサイズ(ストレージ、メモリ、vCPU)を使用するかをワークロードに応じて決定する必要があります。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710015305.png" width="948" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>結果的に、ハードウェアプロファイルを<code>Storage Optimized</code>から、<code>CPU optimized (ARM)</code>に変えることにしました。指定できるサイズは倍々になっていくため、<code>Storage Optimized</code>のまま次のサイズにすると予算を大幅に超えてしまうため、多少のコスト増でおさまり、CPUにも余裕の出る<code>CPU optimized (ARM)</code>にしました。</p> <p>基本的にElastic Cloudに変更を加える時はローリングアップデートになり、1台ずつ順にアップデート処理されます。 ただ、落とし穴として、ハードウェアプロファイルの変更はローリングアップデートにはならず、全ノードが一斉にダウンするようです(2023/07時点)。</p> <p>以下は例ですが、全データノードが<code>Not Routing Requests</code>になっています。 <figure class="figure-image figure-image-fotolife" title="ハードウェアプロファイルの更新例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710131517.png" width="1200" height="214" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ハードウェアプロファイルの更新例</figcaption></figure></p> <p>クラシルのあらゆるところでElasticsearchが使われているため、このままではサービスが全体的に止まってしまいます。 どうしたものか、と悩みましたが、ちょうど直近でメンテナンスを行う予定があったため、そのときにハードウェアプロファイルの変更も行うことにしました。 ちなみに、これはAurora MySQL v2からv3にアップグレードするメンテナンスでした。こちらも別の機会で紹介したいと思います。</p> <p>問題はメンテナンスまでの数日間をどう凌ぐかです。</p> <p>Elastic Cloudはデータノードを任意の数にすることはできません。EC2で自前で構築していたときのように、今3台だとして気軽に5台にすることはできません。起動しようとするAZ(availability zone)数でノード数が決まり、例えば3AZを指定すると3ノードになります。</p> <p>ではデータノード数の上限は3なのか?と疑問に思って試したところ、一定サイズ以上(最低でもメモリが116GB以上)のクラスタを起動しようとするとノード数が増える仕組みのようです。例えば、116GBのメモリを搭載したサイズを指定した場合は58GBメモリのノードが2台、174 GBの場合は3台になる仕組みのようでした。</p> <p><figure class="figure-image figure-image-fotolife" title="ノード数の増減"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/d/dely_kashi/20230710/20230710162338.png" width="1200" height="572" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ノード数の増減</figcaption></figure></p> <p>幸い本番クラスタでは<a href="https://www.elastic.co/guide/en/cloud-enterprise/2.8/ece-getting-started-profiles-hot-warm.html">Warmノード</a>を2台起動していため、苦肉の策としてこれらを活用することにしました。</p> <p>Elasticsearchには<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/data-tiers.html">data tier</a>という概念があり、デフォルトでは<code>data_content</code>が割り当てられています。以下のようなクエリで確認できます。</p> <pre class="code" data-lang="" data-unlink>GET /&lt;index name&gt;/_settings?filter_path=**._tier_preference { &#34;&lt;index name&gt;&#34; : { &#34;settings&#34; : { &#34;index&#34; : { &#34;routing&#34; : { &#34;allocation&#34; : { &#34;include&#34; : { &#34;_tier_preference&#34; : &#34;data_content&#34; } } } } } } }</pre> <p>通常はライフサイクルポリシーでインデックス作成後、指定日数経過したら<code>data_warm</code>、<code>data_cold</code>などに遷移させるようにできますが、手動で変えることができます。メンテナンス日までは負荷のピーク時にCPUクレジットを確認するようにし、0になるまえにCPU負荷の高いインデックスの<code>_tier_preference</code>を<code>data_warm</code>にすることで、強制的にノードを変える対応をとりました。ピークを過ぎたら元の値に戻します。</p> <p>本来のWarmノードの用途ではないので、おすすめはできません。例えば、Warmノードが存在してない状態で指定するとunassinged状態になるため、手動で変えるとリスクがあります。あくまで緊急対応という形です。</p> <p>結果的に、<code>_tier_preference</code>を変える必要があったのは1日のみでしたが、仮に長期間の対応となる場合は厳しいため、その際は一時的なコスト増を許容してハードウェアプロファイルを維持したまま深夜にサイズを上げる対応をとったと思います。</p> <p>ただ、ローリングアップデートになるか否かについての条件がドキュメントで見当たらなかったため、この仕様が永続的とは限りません。事前に別クラスタ等で検証は必要になりそうです。</p> <p>結果的に<code>CPU optimized (ARM)</code>にしたあとは、CPUクレジットが問題となることはなくなりました。</p> <h3 id="今後の展望">今後の展望</h3> <p>v8へのアップグレードはできるかぎり早く行いたいと思っています。 また、バージョンが上がったことで、今までは不可能だったことが可能になりました。以下については実現がいつになるかは分かりませんが、導入できたらいいなと思っています。</p> <ul> <li>大きな変更前後の差異を定量的に評価できる仕組みづくり <ul> <li>v7になったことで、<a href="https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-rank-eval.html">Ranking evaluation API</a>が使えるようになりました</li> <li>v8へのアップグレード等が控えていることもあり、同一クエリにおける検索結果の順位変動を定量的に測る仕組みを整えたいです</li> </ul> </li> <li>新バージョンの機能を活用 <ul> <li>Elasticsearchの進化は凄まじく、全てを追えているわけではないですが、魅力的な機能が多く導入されています</li> <li>特に、同義語運用で楽をできる可能性のある<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-reload-analyzers.html">検索アナライザーのリロード</a>、Lookup Runtime Field<a href="#f-b149aa34" name="fn-b149aa34" title="[https://qiita.com/takeo-furukubo/items/8f09081e0bb625f3ff73:title]">*2</a>、サジェスト機能の改善が見込めるkuromojiのsuggester<a href="#f-47c1e8d6" name="fn-47c1e8d6" title="[https://blog.johtani.info/blog/2022/08/09/japanese-auto-completion/:title]">*3</a>などは活用できそうでした</li> <li>また、少し試した程度で分からないことは多いですがELSERを活用することで、よりよい検索体験にできそうです</li> </ul> </li> <li>sudachiの検討 <ul> <li>クラシルは辞書や同義語の数が膨れ上がっており、管理に課題がある状況です</li> <li>メンテナンスが行われている<a href="https://github.com/WorksApplications/Sudachi">sudachi</a>を活用させてもらうことで辞書や同義語の管理を簡素化できないかと思っています</li> </ul> </li> </ul> <h3 id="さいごに">さいごに</h3> <p>クラシルにおけるElasticsearch v7へのアップグレードおよびElastic Cloudへ移行した結果を振り返りました。 Elatic Cloud移行を検討している方にとって何か参考になることがあれば幸いです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcareers.dely.jp%2F" title="dely株式会社 - クラシル エンジニア採用情報" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://careers.dely.jp/">careers.dely.jp</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-d4ac6825" name="f-d4ac6825" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://zenn.dev/hajimeni/articles/682e81fa68c7af">ElasticSearchClient&#x3092;&#x5229;&#x7528;&#x3059;&#x308B;&#x969B;&#x306E;&#x6CE8;&#x610F;&#x70B9;</a></span></p> <p class="footnote"><a href="#fn-b149aa34" name="f-b149aa34" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://qiita.com/takeo-furukubo/items/8f09081e0bb625f3ff73">Lookup Runtime Field &#x301C;Elasticsearch 8.2 &#x65B0;&#x6A5F;&#x80FD;&#x301C; - Qiita</a></span></p> <p class="footnote"><a href="#fn-47c1e8d6" name="f-47c1e8d6" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://blog.johtani.info/blog/2022/08/09/japanese-auto-completion/">&#x65E5;&#x672C;&#x8A9E;&#x7528;&#x30AA;&#x30FC;&#x30C8;&#x30B3;&#x30F3;&#x30D7;&#x30EA;&#x30FC;&#x30C8;&#x306E;&#x305F;&#x3081;&#x306E;Analyzer | @johtani&#x306E;&#x65E5;&#x8A18; 3rd | @johtani&#39;s blog 3rd edition</a></span></p> </div> dely_kashi クラシルiOSアプリのリニューアルと新卒iOSエンジニアの奮闘🔥 hatenablog://entry/820878482945848957 2023-06-30T16:30:00+09:00 2023-06-30T16:30:00+09:00 2022年12月にアプリリニューアルしたクラシルiOSアプリ。その開発裏側では新卒iOSエンジニアが奮闘していました。カラー・タイポグラフィの変更、独自アイコンへの差し替へについてプラクティスを紹介しています。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630021627.png" alt="Top OGP" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、クラシルiOSエンジニアの <a href="https://twitter.com/psnzbss" target="_blank">uetyo</a> です!</p> <p><a href="https://www.kurashiru.com/" target="_blank">クラシル</a> では、2022年12月に アプリリニューアル を含む、クラシル史上最大規模のブランドリニューアルを実施しました。iOSアプリでは、「ダークモード対応」、「タイポグラフィの再定義・統一」、「アイコン変更」、「カラー定義の全変更」など、大幅なリニューアルを行いました!</p> <p>この記事は、2022年4月に新卒でdelyに入社し、iOS未経験から数ヶ月の研修を受けた後、アプリリニューアルのためにクラシルのほぼ全ての画面を改修していった裏側についてのお話です 🛠️</p> <p>この記事の読者対象:</p> <ul> <li>アプリリニューアルを控えているデザイナーやエンジニア</li> <li>ダークモード対応時のポイントやハウツーを知りたい方</li> <li>アプリ内のタイポグラフィを再定義して既存画面に適用したい方</li> <li>アプリ内で利用するアイコンを一括変更したい方</li> </ul> <ul class="table-of-contents"> <li><a href="#カラーの再定義適用とダークモード対応-">カラーの再定義・適用とダークモード対応 🎨🌙</a><ul> <li><a href="#カラー対応のポイント">カラー対応のポイント</a></li> <li><a href="#カラーの定義">カラーの定義</a></li> <li><a href="#カラーの命名">カラーの命名</a></li> <li><a href="#カラーの管理方法">カラーの管理方法</a></li> <li><a href="#カラーの適用">カラーの適用</a></li> <li><a href="#CGColor-で指定されたコンポーネントのダークモード対応">CGColor で指定されたコンポーネントのダークモード対応</a></li> <li><a href="#UIButtonにBackgroundImageで指定した際のダークモード対応">UIButtonにBackgroundImageで指定した際のダークモード対応</a></li> </ul> </li> <li><a href="#新タイポグラフィの統一とサイズ変更-">新タイポグラフィの統一とサイズ変更 🔠</a><ul> <li><a href="#タイポグラフィ変更のポイント">タイポグラフィ変更のポイント</a></li> <li><a href="#タイポグラフィの定義と命名">タイポグラフィの定義と命名</a></li> <li><a href="#新タイポグラフィの格納">新タイポグラフィの格納</a></li> <li><a href="#タイポグラフィの変更">タイポグラフィの変更</a></li> </ul> </li> <li><a href="#アイコン変更-️">アイコン変更 🏷️</a><ul> <li><a href="#アイコン変更のポイント">アイコン変更のポイント</a></li> <li><a href="#画像系リソースの格納方法を設計する">画像系リソースの格納方法を設計する</a></li> <li><a href="#AssetChangerを用意する">AssetChangerを用意する</a></li> </ul> </li> <li><a href="#アプリリニューアルを振り返って-">アプリリニューアルを振り返って 💭</a></li> <li><a href="#関連リンク">関連リンク</a></li> </ul> <h2 id="カラーの再定義適用とダークモード対応-">カラーの再定義・適用とダークモード対応 🎨🌙</h2> <p>対応期間:4ヶ月</p> <h3 id="カラー対応のポイント">カラー対応のポイント</h3> <ul> <li>気合で乗り切る 🔥</li> <li>カラーの管理は Asset Catalog で行う</li> <li>カラーの命名は最重要</li> <li>CGColorを利用する場合は TraitCollection を監視する</li> <li>ダークモード対応のデバッグも TraitCollection で実現できる</li> </ul> <h3 id="カラーの定義">カラーの定義</h3> <p>アプリのリニューアル以前は、カラーを多用した華やかなデザインでしたが、それらがあらゆるボタンやテキストに対して使用されていたため、ユーザに認知・行動して欲しいものにまとまりがありませんでした。その結果、ユーザに行って欲しいアクションの学習(メンタルモデルの構築)を促すことができませんでした。</p> <p>この問題を解決するために、リニューアル時にデザイナーチームがゼロからカラーの定義を再考し、定義外の使用を原則として禁止することになりました。こららの定義は、よりモダンで今後クラシルが目指す食のプラットフォームとしての基盤となるUIを目指して設計されています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630002959.png" alt="&#x30E9;&#x30A4;&#x30C8;&#x30E2;&#x30FC;&#x30C9;&#x3067;&#x5229;&#x7528;&#x3059;&#x308B;&#x30AF;&#x30E9;&#x30B7;&#x30EB;&#x30C7;&#x30B6;&#x30A4;&#x30F3;&#x30B7;&#x30B9;&#x30C6;&#x30E0;&#x30AB;&#x30E9;&#x30FC;" width="1113" height="612" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630003019.png" alt="&#x30C0;&#x30FC;&#x30AF;&#x30E2;&#x30FC;&#x30C9;&#x3067;&#x5229;&#x7528;&#x3059;&#x308B;&#x30AF;&#x30E9;&#x30B7;&#x30EB;&#x30C7;&#x30B6;&#x30A4;&#x30F3;&#x30B7;&#x30B9;&#x30C6;&#x30E0;&#x30AB;&#x30E9;&#x30FC;" width="1113" height="612" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>実装を始めたところ、思っていたように機能しないカラー定義が発見されました。しかし、後述する<a href="https://tech.dely.jp/entry/K_y5fC4iCzkDNiKCTkPvh0_Lf0g#%E3%82%AB%E3%83%A9%E3%83%BC%E3%81%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%B3%95">カラーの管理方法</a>を採用していたため、途中で何度か定義変更を行っても、最小限のコード変更で対応することができました。</p> <h3 id="カラーの命名">カラーの命名</h3> <p>クラシルの新しいアプリデザインではメインカラーとして13種類を利用します。それぞれの命名はエンジニア・デザイナー双方にフレンドリーな命名になっています。</p> <pre class="code planeText" data-lang="planeText" data-unlink>Content - 最も利用率の高いカラー郡です。文字やアイコンなどの要素に対して利用するため Content という命名にしています - 優先度が高いほうから Primary, Secondary, Tertiary, Quaternary と定義しています - PrimaryColor のボタン内に表示するテキストなどは PrimaryInverseColor を利用します Theme - ユーザに最もアクションして欲しい場合に利用するブランドカラーです Background / Elevated - 背景色です。BaseViewの上にホバーするようなコンポーネントを表示する場合は Elevated を利用します。これはすべて Backgound に統一してしまうと著しく視認性が悪いことがあるためです Fixed - クラシル内に投稿されたコンテンツ上や常に同じ色を表示する場合に利用します Overlay - コンテンツにマスク的なものをつける際に利用します</pre> <p>以前は Color.base, Color.state のような何を基準としてベースなのか、ステータスなのか不明な命名となっていたため、エンジニア↔デザイナー間で認識がずれることがありました。しかし、再定義によりで抽象度を高く保ちつつ、より明確な指定ができ、ダークモード・ライトモードのステータスに関係しない命名で設定できるようになりました。</p> <h3 id="カラーの管理方法">カラーの管理方法</h3> <p>これまでは、UIColorを拡張して独自のブランドカラーを定義していましたが、Xcode 9 (iOS 11)以降では、カラーの管理もAsset Catalog(Color)で行えるようになりました。さらに、Xcode 11 (iOS 13)以降では、ダークモード対応もXcode側で自動的に行ってくれるため、Asset Catalogで管理することにしました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630010233.png" alt="Asset Catalog contentPrimary" width="1036" height="164" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ブランドカラー及びアプリカラーの管理はすべてAsset Catalogで行っています。Asset CatalogはHexColorStyleだけでなく、RGBやOpacityなど、色に関係するプロパティであれば基本的に設定可能です <a href="#f-612852e2" name="fn-612852e2" title="https://developer.apple.com/documentation/uikit/appearance_customization/supporting_dark_mode_in_your_interface">*1</a>。</p> <p>クラシルでは、<a href="https://github.com/SwiftGen/SwiftGen" target="_blank">SwiftGen</a> を使用して、Asset Catalogに定義されたカラーを静的に参照できるようにしています。また、Asset Catalog側でColorの名前空間を設定することで、カラーを指定する際に他のリソース(例えばアイコン)のサジェストが表示されないようにしました。</p> <p>例:ライトモード( <code>#FFFFFF</code> ), ダークモード( <code>#000000</code> ) のカラーを <code>Colors.Primary</code> として定義</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630011622.png" alt="&#x540D;&#x524D;&#x7A7A;&#x9593;&#x306E;&#x6709;&#x52B9;&#x5316;&#xFF08;Provides Namespace &#x3092;&#x6709;&#x52B9;&#x5316;&#x3059;&#x308B;&#xFF09;" width="1113" height="197" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630011659.png" alt="Asset Catalog primary color settings" width="1113" height="477" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>SwiftGenで静的プロパティを生成後、実際のコードで利用する際</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// UIKit</span> Asset.Colors.PrimaryColor.color <span class="synComment">// SwiftUI</span> Asset.Colors.PrimaryColor.swiftUIColor </pre> <p>名前空間はネストさせることも可能なので、普段は利用しないブランドカラー等を <code>Colors.Brand.blueRegular</code> として定義することも可能です</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630012157.png" alt="Asset Catalog Sub directory with Provides Namespace" width="1113" height="197" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// UIKit</span> Asset.Colors.Brand.BaseColor.color <span class="synComment">// SwiftUI</span> Asset.Colors.Brand.BaseColor.swiftUIColor </pre> <h3 id="カラーの適用">カラーの適用</h3> <p>※ 気合です</p> <p>Git Branch 戦略<br/> クラシルのiOSアプリはサービス開始から約7年経過しており、現在では100枚以上の画面があります。アプリをリニューアルする際には、すべての画面が最新でメンテナンスが行き届いていることが望ましいですが、難しい状況でした。また、日々大量の変更が発生するため、数ヶ月間リニューアル用のブランチを運用することは不可能だと判断し、<strong>画面ごとに分割してリリース</strong>することになりました。しかし、上記で紹介したようにアプリリニューアルに合わせてダークモード対応も行っているため、開発者のみ切り替えれるようにしておく必要がありました。</p> <p>そこで クラシル iOS ではリリース版はライトモード固定、デバッグ版では設定からライトモードとダークモードを切り替えれるようにすることで開発やQAの効率を向上しました。</p> <p>アプリのディストリビューション毎に画面モードを固定するには AppDelegate にて <code>UIWindow.overrideUserInterfaceStyle</code> に対して状態を上書きすることでダークモードを無効にすることが可能です。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// For debug version</span> <span class="synPreProc">#if</span> DEBUG <span class="synStatement">if</span> isEnabledDebugDarkmode { <span class="synComment">// 端末外観設定に準拠</span> UIWindow.overrideUserInterfaceStyle <span class="synIdentifier">=</span> UIUserInterfaceStyle.unspecified } <span class="synStatement">else</span> { <span class="synComment">// ライトモード固定</span> UIWindow.overrideUserInterfaceStyle <span class="synIdentifier">=</span> UIUserInterfaceStyle.light } #endIf <span class="synComment">// For public app version</span> UIWindow.overrideUserInterfaceStyle <span class="synIdentifier">=</span> UIUserInterfaceStyle.light <span class="synComment">// UIUserInterfaceStyle.unspecified = 端末設定に準拠</span> <span class="synComment">// UIUserInterfaceStyle.light = ライトモード固定</span> <span class="synComment">// UIUserInterfaceStyle.dark = ダークモード固定</span> </pre> <p>カラーの適用は後述する<a href="https://tech.dely.jp/entry/K_y5fC4iCzkDNiKCTkPvh0_Lf0g#%E6%96%B0%E3%82%BF%E3%82%A4%E3%83%9D%E3%82%B0%E3%83%A9%E3%83%95%E3%82%A3%E3%81%AE%E7%B5%B1%E4%B8%80%E3%81%A8%E3%82%B5%E3%82%A4%E3%82%BA%E5%A4%89%E6%9B%B4-">タイポグラフィの変更</a>と同時進行で進めました。以前のデザインシステムカラーが多種多用な設定方法(RGB/Hex直書き、Colorの拡張を指定、独自ColorEnumを指定)だったことと、タイポグラフィの変更した画面かどうかをダークモードに対応しているかどうかで判別するためです。</p> <p>基本的にマッピング等は用いず、一つ一つの画面を調査して色を変更してはビルド→シミュレータで確認を繰り返しました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630122021.png" alt="&#x30A2;&#x30D7;&#x30EA;&#x30AB;&#x30E9;&#x30FC;&#x30EA;&#x30CB;&#x30E5;&#x30FC;&#x30A2;&#x30EB;&#x524D;&#x3068;&#x30EA;&#x30CB;&#x30E5;&#x30FC;&#x30A2;&#x30EB;&#x5F8C;&#x306E;&#x6BD4;&#x8F03;&#x753B;&#x50CF;" width="1113" height="634" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>変更を始めた当初は、iOSの実務経験が全くなかったこともあり、意図しないコンポーネントに影響を与えたり、他の画面からカラーの上書きをしていることに気が付かず文字が背景色と混同して読めなくなるなどの問題が発生しました。カラー変更が半分ほど終わると、ドメイン知識や実装経験が身についたため、リファクタリングにも積極的に取り組みました。かなり古い画面になると、カラーの適用だけでも影響範囲が膨大で、どこで状態が変更されているのか不透明で、リーディング力も向上しました。</p> <p>結果的にこの戦略は膨大な時間を利用することになりましたが、iOSエンジニアとして技術力が幼かった私にとって膨大な画面の実装を読む良い経験になりました。</p> <h3 id="CGColor-で指定されたコンポーネントのダークモード対応">CGColor で指定されたコンポーネントのダークモード対応</h3> <p>iOS と Xcode Asset Catalog の機能を利用したダークモード対応ではUIColorで指定されているものであれば良しなにOS側が変更してくれますが <code>CGColor</code> を利用しているコンポーネントでは端末の外観設定を切り替えてもアプリを再起動するまで適用されない問題に遭遇しました。調査した結果、ライト・ダークモードが切り替わったことを判定して再適用することで解決することができました!</p> <p>CGColor を指定している場合は <code>traitCollectionDidChange</code> をオーバーライドすることで外観設定が切り替わったことを判定することができます。このタイミングでCGClorで設定するプロパティを再指定すれば意図した表示にできます。特に <code>layer</code> 系の場合は関係するプロパティも一緒に再指定する必要があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ダーク&lt;-&gt;ライトモード切り替え時に適用されないため再設定する</span> <span class="synStatement">override</span> <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">traitCollectionDidChange</span>(_ previousTraitCollection<span class="synSpecial">:</span> <span class="synType">UITraitCollection?</span>) { <span class="synIdentifier">super</span>.traitCollectionDidChange(previousTraitCollection) layer.borderColor <span class="synIdentifier">=</span> Const.borderColor.cgColor <span class="synComment">// もし borderWidth も変更する必要がある場合は再指定する</span> layer.borderWidth <span class="synIdentifier">=</span> Const.borderWidth } </pre> <h3 id="UIButtonにBackgroundImageで指定した際のダークモード対応">UIButtonにBackgroundImageで指定した際のダークモード対応</h3> <p>AppDelegateでライトモードに固定しているにもかかわらず、端末側の外観設定をダークモードに切り替えると UIButton に image を設定しているコンポーネントのカラーが切り替わる、という問題に遭遇しました。こちらも調査した結果、<code>Color.resolvedColor(traitCollection)</code> は何も指定していなければ <code>UITraitCollection.current</code> を参照してしまい、起動時(AppDelegate)に <code>overrideUserInterfaceStyle</code> を上書きして、アプリ外観設定を固定しても端末の設定に基づいた色を適用してしまうようです。以下のようにbackgroundImageを現在のアプリ外観設定で上書きすることで解決することができました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">containerColor</span><span class="synSpecial">:</span> <span class="synType">UIColor</span> <span class="synIdentifier">=</span> .clear { <span class="synStatement">didSet</span> { setBackgroundImage( containerColor.resolvedColor(with<span class="synSpecial">:</span> <span class="synType">traitCollection</span>).image(), <span class="synStatement">for</span><span class="synSpecial">:</span> .normal ) } } </pre> <h2 id="新タイポグラフィの統一とサイズ変更-">新タイポグラフィの統一とサイズ変更 🔠</h2> <p>対応期間:4ヶ月</p> <h3 id="タイポグラフィ変更のポイント">タイポグラフィ変更のポイント</h3> <ul> <li>気合で乗り切る 🔥</li> <li>タイポグラフィの定義と命名がとても大事</li> </ul> <h3 id="タイポグラフィの定義と命名">タイポグラフィの定義と命名</h3> <p>カラーと同様にアプリリニューアルに合わせてタイポグラフィも変更することになったため、こちらも変更します。以前はよくある段階式サイズ指定(title1, subtitle, body, button, caption)と独自指定でした。そのため利用したい場所がタイトルなのに <code>button</code> サイズが利用したい…ニーズに対応できず個別で独自指定することがありました。</p> <p>新しいタイポグラフィの定義では、この問題を解決するためデザイナーチームがサイズに基づく設計してくれました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630012745.png" alt="&#x30AF;&#x30E9;&#x30B7;&#x30EB;&#x30C7;&#x30B6;&#x30A4;&#x30F3;&#x30B7;&#x30B9;&#x30C6;&#x30E0; &#x30BF;&#x30A4;&#x30DD;&#x30B0;&#x30E9;&#x30D5;&#x30A3;" width="975" height="612" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この設計により、利用範囲が制限されない指定をすることが可能になりました。また命名がAndroidと共通になっているため、例えばAndroidで先行している機能を確認する際にiOS版が作成されていなくても、ほぼ実装できるようになりました。</p> <p>iOS アプリでは、タイポグラフィの定義を直に指定しています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">NewTypography</span> { <span class="synStatement">public</span> <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">size36w6</span> <span class="synIdentifier">=</span> NewTypography(.w6, fontSize<span class="synSpecial">:</span> <span class="synConstant">36</span>) <span class="synStatement">public</span> <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">size36w3</span> <span class="synIdentifier">=</span> NewTypography(.w3, fontSize<span class="synSpecial">:</span> <span class="synConstant">36</span>) <span class="synStatement">public</span> <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">size32w6</span> <span class="synIdentifier">=</span> NewTypography(.w6, fontSize<span class="synSpecial">:</span> <span class="synConstant">32</span>) <span class="synStatement">public</span> <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">size32w3</span> <span class="synIdentifier">=</span> NewTypography(.w3, fontSize<span class="synSpecial">:</span> <span class="synConstant">32</span>) <span class="synComment">//...</span> } <span class="synComment">// 利用時</span> <span class="synPreProc">let</span> <span class="synIdentifier">textStyle</span><span class="synSpecial">:</span> <span class="synType">TextStyle</span> <span class="synIdentifier">=</span> NewTypography.size36w6.style </pre> <h3 id="新タイポグラフィの格納">新タイポグラフィの格納</h3> <p>クラシルはアプリリニューアルに付随してアプリ内のタイポグラフィを統一する方針となりました。日本語向けは <code>Hiragino-sans(ヒラギノ角ゴシック)</code> 数字向けは外部フォントを利用することになりました。</p> <p><code>Hiragino-sans</code> はiOS標準フォントになるので、別途外部からインポートする必要もないですが、数字向けはiOSには存在しない外部フォントとなるためResourceの一部としてアプリ内に配置、 SwiftGen を用いて静的に参照できるようにしています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">NumberTypography</span> { <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">style</span><span class="synSpecial">:</span> <span class="synType">TextStyle</span> { <span class="synPreProc">let</span> <span class="synIdentifier">size</span> <span class="synIdentifier">=</span> Typography.addFontSizeIfNeeded(size<span class="synSpecial">:</span> <span class="synType">fontSize.value</span>) <span class="synStatement">return</span> TextStyle( weight<span class="synSpecial">:</span> <span class="synType">fontType.weight</span>, size<span class="synSpecial">:</span> <span class="synType">size</span>, lineHeightValue<span class="synSpecial">:</span> <span class="synType">fontSize.lineHeight</span>, kerning<span class="synSpecial">:</span> <span class="synType">fontSize.kerning</span>, font<span class="synSpecial">:</span> <span class="synType">fontType.fontFamily.font</span>(size<span class="synSpecial">:</span> <span class="synType">size</span>) ) } <span class="synComment">// ...</span> <span class="synComment">// 利用時</span> <span class="synPreProc">let</span> <span class="synIdentifier">textStyle</span><span class="synSpecial">:</span> <span class="synType">TextStyle</span> <span class="synIdentifier">=</span> NumberTypography.size32w6.style </pre> <h3 id="タイポグラフィの変更">タイポグラフィの変更</h3> <p>※ 気合です</p> <p>こちらもカラー変更と同様に画面毎に変更してはリリースする戦略で進めました。幸い、日本語向けタイポグラフィは以前同様にApple標準 Hiragino-sans だったため、特にフラグなどは用いることなく、差し替えとサイズ調整のみ行いながら進めました。</p> <p>基本的に定義したTextStyleに置き換えるだけだったので非常にスムーズに進めることができましたが、 <code>UITextView</code> と <code>UISearchBar</code> のダークモード対応+タイポグラフィ変更に関しては、悩む日々を過ごしました… もしタイポグラフィを変更される際は最新のサポートバージョンに合わせて新規でコンポーネントを作成することを強くおすすめします。</p> <h2 id="アイコン変更-️">アイコン変更 🏷️</h2> <p>対応期間:1ヶ月</p> <h3 id="アイコン変更のポイント">アイコン変更のポイント</h3> <ul> <li>アイコンを含む画像系リソースの格納方法を設計する</li> <li>AssetChangerを用意する</li> <li>本番リリースしながら確認できるようにする</li> <li>QAチームと特に協力する</li> <li>やっぱり気合 📛</li> </ul> <h3 id="画像系リソースの格納方法を設計する">画像系リソースの格納方法を設計する</h3> <p>クラシルでは約300個ほどのアイコンを含む画像系リソースを利用していました。これらのリソースはアプリリリース時から特に格納方法など設計されずに積み上げられていたため似通ったアイコンが重複登録されていることや、ベクター画像(svg)とラスター画像(jpeg, png)がごちゃごちゃに登録されている状態でした。</p> <p>アプリリニューアルに合わせてアイコンはすべてクラシル独自のものに差し替えることになったため、このタイミングで格納方法についてもゼロから再設計しました。設計の際に気をつけたポイントは以下です。</p> <ol> <li>利用する画像にView側で着色する必要があるのか分かりやすくする</li> <li>RenderingModeを Asset Catalog 格納時に指定する</li> <li>View側で着色する画像に関してはダーク・ライトモードで確認が抜け落ちないようにする</li> </ol> <p>(1)利用する画像にView側で着色する必要があるのか分かりやすくする<br/> こちらは名前空間を用いて画像を指定する際に理解できるようにしました。Colorのとき同様に、AssetCatalog にて名前空間を有効化して登録→SwiftGenを用いて静的に参照できるようにしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630014002.png" alt="Asset Catalog &#x753B;&#x50CF;&#x306E;&#x5834;&#x5408;&#x3082;&#x30C7;&#x30A3;&#x30EC;&#x30AF;&#x30C8;&#x30EA;&#x306B;&#x540D;&#x524D;&#x7A7A;&#x9593;&#x3092;&#x5229;&#x7528;&#x3059;&#x308B;&#xFF08;Provides NameSpace&#x306E;&#x6709;&#x52B9;&#x5316;&#xFF09;" width="1113" height="197" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>また、着色不要であってもベクター画像の場合は <code>Scales=Single Scale</code> , <code>Resizing=True</code> , <code>SVG</code> 形式で格納することにしました。これは PDF 形式に比べて約50%ファイルサイズを削減することができるためです。さらに、極稀に存在する5MBを超える画像を格納する必要がある場合は <code>PNG</code> 形式で格納します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630014535.png" alt="Scales, Resizing, SVG &#x3092;&#x8A2D;&#x5B9A;&#x3057;&#x3066;&#x767B;&#x9332;&#x3057;&#x305F;&#x56F3;" width="1113" height="546" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>以上のように画像を格納することで、どんな画面サイズでも高解像度かつ低容量に扱うことができました。</p> <p>(2)RenderingModeを Asset Catalog 格納時に指定する<br/> アイコンを格納する際に Image set プロパティから <code>Render As = Template Image</code> を明示的に指定するようにしました。明示的に指定することでView側で色をつける際に <code>.withRenderingMode(.alwaysTemplate)</code> を呼ぶ必要がなくなります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// in UIKit</span> <span class="synComment">// 🙅🏻 Wrong</span> Asset.Image.hogehoge.image.withRenderingMode(.alwaysTemplate) <span class="synComment">// 🙆🏻 Correct</span> Asset.Image.fugafuga.image <span class="synComment">// in SwiftUI</span> <span class="synComment">// 🙅🏻 Wrong</span> Asset.Image.hogehoge.swiftUIImage.renderingMode(.template) <span class="synComment">// 🙆🏻 Correct</span> Asset.Image.fugafuga.swiftUIImage </pre> <p>(3)View側で着色する画像に関してはダーク・ライトモードで確認が抜け落ちないようにする<br/> 少しSwift / Xcodeから離れてデザイナーとのやり取りが必要です。</p> <p>アプリリニューアルに付随してクラシルではダークモード対応を行いました。これまではアイコンを格納する際は黒色で格納していましたが、作業を進める中で以下の問題が発覚しました。</p> <ol> <li>Xcode ダークモードだと Asset catalog に登録したアイコンの識別が難しい</li> <li>View側で着色を忘れてしまい、QA時にダークモード化した際に着色されていないことが発覚する</li> </ol> <p>Apple純正アイコンのSFSymbolではカラーを指定しない限り 青色( <code>#007AFF</code> ) が適用されます。独自で作成したアイコンもデフォルトカラーとして、この青色を利用することで上記の問題を解決しました</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/psbss/20230630/20230630015435.png" alt="&#x30A2;&#x30A4;&#x30B3;&#x30F3;&#x306B;&#x306F;&#x30C7;&#x30D5;&#x30A9;&#x30EB;&#x30C8;&#x30AB;&#x30E9;&#x30FC;&#x3068;&#x3057;&#x3066; #007AFF &#x3092;&#x9069;&#x7528;&#x3059;&#x308B;" width="1113" height="197" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="AssetChangerを用意する">AssetChangerを用意する</h3> <p>※ 造語です</p> <p>本番リリースしながらアイコンを変更するために、AssetChangerというアイコン切り替えシステムを導入しました。これは、本番バージョンでは常に古いアイコンを表示しますが、開発者の場合は新旧アイコンを切り替えることができるものです。システムといっても複雑ではなく、300個の画像系リソースを<strong>手動でマッピング</strong>すると本番に影響させることなく新アイコンを利用できる、という代物です。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// in Resources module</span> <span class="synPreProc">import</span> SwiftUI <span class="synPreProc">import</span> UIKit <span class="synStatement">public</span> <span class="synPreProc">enum</span> <span class="synIdentifier">AssetChanger</span> { <span class="synStatement">case</span> homeIcon <span class="synStatement">case</span> playIcon <span class="synStatement">case</span> stopIcon <span class="synComment">// ...</span> <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">image</span><span class="synSpecial">:</span> <span class="synType">UIImage</span> { <span class="synStatement">if</span> UserDefaults.standard.bool(forKey<span class="synSpecial">:</span> <span class="synConstant">&quot;debug_asset_change&quot;</span>) { <span class="synStatement">return</span> newAssetImage.image } <span class="synStatement">else</span> { <span class="synStatement">return</span> oldAssetImage.image } } <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">swiftUIImage</span><span class="synSpecial">:</span> <span class="synType">SwiftUI.Image</span> { <span class="synStatement">if</span> UserDefaults.standard.bool(forKey<span class="synSpecial">:</span> <span class="synConstant">&quot;debug_asset_change&quot;</span>) { <span class="synStatement">return</span> newAssetImage.swiftUIImage } <span class="synStatement">else</span> { <span class="synStatement">return</span> oldAssetImage.swiftUIImage } } <span class="synComment">/// リニューアルまで利用するアイコン</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">oldAssetImage</span><span class="synSpecial">:</span> <span class="synType">ImageAsset</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synStatement">case</span> .homeIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.iconHome <span class="synStatement">case</span> .playIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.iconPlay <span class="synStatement">case</span> .stopIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.iconStop24 <span class="synComment">// ...</span> } } <span class="synComment">/// リニューアル後に利用するアイコン</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">newAssetImage</span><span class="synSpecial">:</span> <span class="synType">ImageAsset</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synStatement">case</span> .homeIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.Icon.iconHome <span class="synStatement">case</span> .playIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.Icon.iconStart <span class="synStatement">case</span> .stopIcon<span class="synSpecial">:</span> <span class="synType">return</span> Asset.Icon.iconPause <span class="synComment">// ...</span> } } } </pre> <p>画面数と画像数がとても多いため、それなりに時間がかかりましたが、マッピングと新アイコン適用した際のアイコンサイズ調整以外は基本的に一発置換することで変更できました。SwiftGenで画像を管理していたおかげでかなりスムーズに行うことができました!</p> <h2 id="アプリリニューアルを振り返って-">アプリリニューアルを振り返って 💭</h2> <p>研修後すぐにアサインされたリニューアルプロジェクトですが、無事に期日に間に合わせて完了することができました!</p> <p>総プロジェクト期間:5ヶ月<br/> GitHubPR数:250+<br/> 扱った画面数:100+(クラシル内のほぼすべてのVC、コンポーネント)</p> <p>とても作業量の多いプロジェクトでしたが、今後のクラシルの基盤となるUIへアップデートできたこと、これまで乱立していたデザインシステムを最新のものへ統一できたこと、ほぼすべての画面を入社&amp;実務1年以内で経験できたことはとても大きな学びとなりました!</p> <p>iOS未経験だった私がこのプロジェクトをやり切れたのは、アイコン・クリエイティブ制作、カラー定義などでとてもお世話になったデザイナーの方やレビュー・技術的サポートしてくれたiOSチームメンバー、毎週山のようなチェック項目を確認してくれたQAチームのおかげです!</p> <p>このプロジェクトが完了したあと約半年経過した現在では、クラシルが目指す新しい方針に向けて新規の機能開発をガシガシと進めつつ、スクラムマスターとしてチームビルディングを行っています。実務2年目も楽しみながらプロダクト・事業に貢献していきます 🔥</p> <h2 id="関連リンク">関連リンク</h2> <ul> <li>クラシルブランドリニューアルサイト:<a href="https://www.kurashiru.com/rebrand">https://www.kurashiru.com/rebrand</a></li> <li>クラシルブランドガイドライン:<a href="https://speakerdeck.com/delyinc/kurashiru-brand-guideline">https://speakerdeck.com/delyinc/kurashiru-brand-guideline</a></li> </ul> <div class="footnote"> <p class="footnote"><a href="#fn-612852e2" name="f-612852e2" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/documentation/uikit/appearance_customization/supporting_dark_mode_in_your_interface">https://developer.apple.com/documentation/uikit/appearance_customization/supporting_dark_mode_in_your_interface</a></span></p> </div> psbss Aurora MySQL 5.7とRailsで実現する全文検索機能 hatenablog://entry/820878482943618262 2023-06-23T17:00:00+09:00 2023-06-24T10:02:03+09:00 こんにちは。 クラシル開発部、バックエンドエンジニアの松嶋です。 delyに入社してから約3年間、私はSREチームに所属していましたが、昨年10月にバックエンドに転向しました。バックエンドに転向してからは、主にクラシルアプリの公式レシピおよびCGMコンテンツの検索機能に関する開発・改善に取り組んでいます。 クラシルは、2016年2月にサービスを開始してから、管理栄養士監修の「誰でも安全に・おいしい料理を作ることができるレシピ動画」を5万件以上提供してきました。 昨年12月には、クラシルのブランドリニューアルを行い、今後はシェフや料理研究家を中心としたクリエイターとともに多様化したユーザーの食の… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/akngo22/20230622/20230622144420.png" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> こんにちは。</p> <p>クラシル開発部、バックエンドエンジニアの松嶋です。</p> <p>delyに入社してから約3年間、私はSREチームに所属していましたが、昨年10月にバックエンドに転向しました。バックエンドに転向してからは、主にクラシルアプリの公式レシピおよびCGMコンテンツの検索機能に関する開発・改善に取り組んでいます。</p> <p>クラシルは、2016年2月にサービスを開始してから、管理栄養士監修の「誰でも安全に・おいしい料理を作ることができるレシピ動画」を5万件以上提供してきました。</p> <p>昨年12月には、クラシルのブランドリニューアルを行い、今後はシェフや料理研究家を中心としたクリエイターとともに多様化したユーザーの食の好みや課題解決に応えられるよう、幅広い食のコンテンツを提供するプラットフォームを目指しています。</p> <p>ブランドリニューアルの詳細に関しては、こちらを御覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.kurashiru.com%2Frebrand" title="Kurashiruで、「おいしい」を想う | Kurashiru Brand" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.kurashiru.com/rebrand">www.kurashiru.com</a></cite></p> <p>このような背景から、私たちはクリエイターのコンテンツもクラシルアプリで検索でき、お気に入りのレシピをストックできるように、UGC検索エンジンのMVP開発に着手することになりました。 自分たちが運営するサービスに検索機能を導入する場合、DBのLIKE検索で簡易的な検索機能を実現する、またはElasticsearchのような全文検索エンジンを導入する方法を思いつく方が多いと思います。</p> <p>しかし、今回のMVP開発において私たちが選択したのはMySQLの全文検索機能です。 MySQL InnoDBのFULLTEXTインデックスは、MySQL 5.6の段階では様々な制約があり、日本語環境では使うことが難しい言われていましたが、MySQL 5.7ではそのような事情が大幅に改善され、日本語環境でも適用できるようにパーサーが変更できるようになりました。<a href="#f-0cc25036" name="fn-0cc25036" title="[https://www.shoeisha.co.jp/book/detail/9784798147406:title] p.168">*1</a></p> <p>昨年、クラシルが使用しているAurora MySQLのバージョンを5.6から5.7にアップグレードしたことがきっかけで、「今回のUGC検索のMVPを実施する上で使えないか?」という話が出たため、開発環境での検証を経て導入することに決定しました。(2023年6月現在、クラシルが使用しているAurora MySQLのバージョンは、既に8.0までアップグレードされています。)</p> <p>この記事では、MySQL全文検索を導入・運用した経験から得られたTipsやメリット及びデメリットについて紹介したいと思います。</p> <h2 id="MySQLの全文検索とは">MySQLの全文検索とは</h2> <p>MySQLの全文検索は、検索対象のカラムにFULLTEXTタイプのインデックスを貼るだけで簡単に実現することが可能です。また、パーサーは、デフォルトの<a href="https://dev.mysql.com/doc/refman/5.7/en/fulltext-search-ngram.html">ngram</a>とインストール可能な<a href="https://dev.mysql.com/doc/refman/5.7/en/fulltext-search-mecab.html">MeCab</a>のどちらかを選択できます。しかし、AWS環境でAurora MySQLやRDS MySQLを使用している場合は、ngramパーサーしか選択できないことに注意してください。</p> <p>以下にngramパーサーを用いてフルテキストインデックスを貼る例を示します。</p> <pre class="code lang-sql" data-lang="sql" data-unlink># 単一カラムにインデックス貼る場合 <span class="synStatement">alter</span> <span class="synSpecial">table</span> videos <span class="synSpecial">add</span> FULLTEXT <span class="synSpecial">index</span> ngram_idx (title) <span class="synSpecial">with</span> parser ngram; # 複数カラムにインデックス貼る場合 <span class="synStatement">alter</span> <span class="synSpecial">table</span> videos <span class="synSpecial">add</span> FULLTEXT <span class="synSpecial">index</span> ngram_idx (title, introduction) <span class="synSpecial">with</span> parser ngram; </pre> <p>全文検索は以下の3種類があり、これらの中から1つをmodifierとして指定できます。</p> <ul> <li>自然言語検索:検索文字列を人間の自然な単語のフレーズとして扱う(デフォルト)</li> <li>boolean検索:完全一致検索(大文字、小文字、ひらがな、カタカナ完全区別する)、特殊演算子(+,-,*)を使ってAND検索やOR検索が可能。</li> <li>クエリ拡張検索:自然言語検索の拡張版。自然言語検索が最初に実行され、その結果、最も関連のあるレコードの単語が検索文字列に追加され、再検索を行う。</li> </ul> <p>全文検索を実行する際には、以下のように<code>MATCH() AGAINST()</code>シンタックスを使用します。</p> <pre class="code lang-sql" data-lang="sql" data-unlink># title, introductionで複合インデックスを貼り、自然言語検索を実行する場合 <span class="synStatement">select</span> title <span class="synSpecial">from</span> recipes <span class="synSpecial">where</span> match (title, introduction) against (<span class="synSpecial">&quot;</span><span class="synConstant">フレンチトースト</span><span class="synSpecial">&quot;</span> <span class="synStatement">in</span> natural language <span class="synSpecial">mode</span>); </pre> <h2 id="全文検索の種類の選択">全文検索の種類の選択</h2> <p>上述の通り、MySQLの全文検索には3種類のモードが存在します。完全一致させたいのであれば、boolean検索一択になるかと思いますが、自然言語検索も検索文字列をダブルクォートで囲むことで、検索対象カラムにその文字列が含まれているレコードをマッチさせることが可能と言われています。そのため、自然言語検索もboolean検索と同等の検索結果が得られるのはないかと考え、検索結果を比較してみました。</p> <p><code>MATCH()</code> 関数は、返り値として適合度(relevance value)を数値として返します。この適合度は、行(ドキュメント)内の単語数、行内のユニーク単語数、コレクション内の単語の総数、及び特定の単語を含む行数に基づいて計算されます。すなわち、適合度が高いほど検索文字列と類似性の高いレコードであることが分かります。</p> <p>この適合度をscoreとして扱い、まずは自然言語検索で関連性が高いと判断されたレシピTOP10を見ていきましょう。</p> <p>「ロールキャベツ」、「ピーマン 肉詰め」を検索してみましたが、検索意図に合ったレシピが上位10件に含まれており、検索結果として良さそうです。</p> <pre class="code bash" data-lang="bash" data-unlink># 「ロールキャベツ」を検索 mysql&gt; select title, match (title, introduction) against (&#34;ロールキャベツ&#34; in natural language mode) as score from recipes where match (title, introduction) against (&#34;ロールキャベツ&#34; in natural language mode) order by score desc limit 10; +------------------------------------------------------------------------------+-------------------+ | title | score | +------------------------------------------------------------------------------+-------------------+ | とっても簡単!逆ロールキャベツ | 76.14885711669922 | | ロールキャベツの巻き方 | 70.85739135742188 | | 旨味ぎっしり リゾット風ロールキャベツ | 56.6859130859375 | | のせるだけ ロールキャベツ | 47.8058967590332 | | 巻かない コーンクリームのミルフィーユロールキャベツ | 47.8058967590332 | | 基本のロールキャベツ | 47.8058967590332 | | ウインナーとチーズの変わり種ロールキャベツ | 47.8058967590332 | | コンソメ味のシンプルロールキャベツ | 47.8058967590332 | | ロールキャベツ | 47.8058967590332 | | トマトクリームの巻かないミルフィーユロールキャベツ | 47.8058967590332 | +------------------------------------------------------------------------------+-------------------+ # 「ピーマン 肉詰め」を検索 mysql&gt; select title, match (title, introduction) against (&#34;ピーマン 肉詰め&#34; in natural language mode) as score from videos where match (title, introduction) against (&#34;ピーマン 肉詰め&#34; in natural language mode) order by score desc limit 10; +-----------------------------------------------------------------------------------------------------------------+--------------------+ | title | score | +-----------------------------------------------------------------------------------------------------------------+--------------------+ | 【後藤シェフ】ピーマンの肉詰め&かぼちゃのポタージュ&ペペロンチーノライス | 66.87279510498047 | | 【後藤シェフ】ピーマンの肉詰め | 66.87279510498047 | | ピーマンの肉詰め | 56.90451431274414 | | 種ごとピーマンの肉詰め | 56.90451431274414 | | ひとくちピーマンの肉詰め | 53.58710479736328 | | 五目春雨のピーマンカップ詰め | 50.7970085144043 | | とろーりチーズがたまらない!まるごとピーマンの肉詰め | 50.368736267089844 | | 大豆ミートでピーマンの肉詰め | 50.154598236083984 | | ピーマンの肉詰め カレー風味 | 50.154598236083984 | | ピーマンのご飯入り肉詰め | 50.154598236083984 | +-----------------------------------------------------------------------------------------------------------------+--------------------+</pre> <p>続いて、私の好きな料理でもある「キッシュ」で検索したところ、約半数はキッシュのレシピが表示されましたが、下位4つはキッシュと関係のないレシピがヒットしてしまいました。おそらく、マッシュルームやラディッシュがキッシュと近しい言葉であると判断されているのでしょう。</p> <pre class="code bash" data-lang="bash" data-unlink>mysql&gt; select title, match (title, introduction) against (&#34;キッシュ&#34; in natural language mode) as score from videos where match (title, introduction) against (&#34;キッシュ&#34; in natural language mode) order by score desc limit 10; +--------------------------------------------------------------------------------------------------------+--------------------+ | title | score | +--------------------------------------------------------------------------------------------------------+--------------------+ | 豆乳で和風キッシュ | 28.9677734375 | | かぼちゃとカリフラワーのパンキッシュ | 28.9677734375 | | 北海道アスパラガスとベーコンの簡単キッシュ | 28.9677734375 | | 彩り夏野菜の パンキッシュ | 28.9677734375 | | アスパラベーコンのバゲットキッシュ | 28.9677734375 | | イングリッシュマフィンで ズッキーニのフラワーパンキッシュ | 27.980960845947266 | | 【マッシュルームトーキョー】マッシュルームとチキンのハニーマスタード | 21.8929500579834 | | 【マッシュルームトーキョー】マッシュルームあんかけ和風ハンバーグ | 21.8929500579834 | | 【マッシュルームトーキョー】マッシュルームの炊き込みご飯と味噌汁 | 21.8929500579834 | | ラディッシュとマッシュルームのバター醤油炒め | 21.8929500579834 | +--------------------------------------------------------------------------------------------------------+--------------------+</pre> <p>boolean検索で同じく「キッシュ」を検索したところ、TOP10は全てキッシュのレシピであること確認できました。</p> <pre class="code bash" data-lang="bash" data-unlink>mysql&gt; select title, match (title, introduction) against (&#34;キッシュ&#34; in boolean mode) as score from videos where match (title, introduction) against (&#34;キッシュ&#34; in boolean mode) order by score desc limit 10; +---------------------------------------------------------------------------------------+--------------------+ | title | score | +---------------------------------------------------------------------------------------+--------------------+ | 冷凍パスタの簡単おかず ハムカップdeパスタキッシュ | 36.209716796875 | | 豆乳で和風キッシュ | 28.9677734375 | | かぼちゃとカリフラワーのパンキッシュ | 28.9677734375 | | 北海道アスパラガスとベーコンの簡単キッシュ | 28.9677734375 | | 彩り夏野菜の パンキッシュ | 28.9677734375 | | アスパラベーコンのバゲットキッシュ | 28.9677734375 | | チーズたっぷり じゃがいもとベーコンのキッシュ | 28.9677734375 | | イングリッシュマフィンで ズッキーニのフラワーパンキッシュ | 27.980960845947266 | | キャベツの食パンキッシュ | 21.725830078125 | | たっぷりきのこのキッシュ | 21.725830078125 | +---------------------------------------------------------------------------------------+--------------------+</pre> <p>自然言語検索は、完全一致しなくとも検索文字列に近しいレコードを返してくれるため、boolean検索のデメリットを補うことができると感じました。しかし、検索文字列によっては検索意図から外れる結果になることがあるため、今回は意図通りの検索結果が得られやすいboolean検索を選択しました。 検索ヒット率を考慮する場合、最初にboolean検索を実行し、ヒットしなかった場合には自然言語検索で再検索を実行すると良いかもしれません。ただし、2回検索を実行する点でパフォーマンスが悪化してしまう懸念があります。</p> <p>クエリ拡張検索でも、同じく「キッシュ」で検索してみましたが、自然言語検索及びboolean検索は0.01-0.02秒程度で検索結果が返ってくるのに対して、クエリ拡張検索では2-3分程度かかってしまい、加えて検索意図と全く異なるレコードが返ってきたため、却下しました。</p> <pre class="code bash" data-lang="bash" data-unlink>mysql&gt; select title, match (title, introduction) against ( &#34;キッシュ&#34; in natural language mode with query expansion ) as score from videos where match (title, introduction) against ( &#34;キッシュ&#34; in natural language mode with query expansion ) order by score desc limit 10; +--------------------------------------------------------------------------------------------+-------------------+ | title | score | +--------------------------------------------------------------------------------------------+-------------------+ | 豆乳で作ったヨーグルトで和風アボカド冷製パスタ | 4640.31787109375 | | 紅茶が香る りんごがのったふわふわマフィン | 4060.578369140625 | | フライパン1つで完成!こってりたっぷり豚の角煮風 | 3647.45849609375 | | とろうま ハーブシュリンプとナスのとろたまチリソース | 3316.906494140625 | | さわやかな酸味 北海道の秋鮭とポテトのレモンクリーム煮 | 3312.131103515625 | | 食べ応え抜群!ハーブシュリンプとニラのカリカリもっちりチヂミ | 3130.677490234375 | | 【名古屋】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3061.77734375 | | 【仙台】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3059.89990234375 | | 【北海道】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3059.4404296875 | | 【長野】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3058.764892578125 | +--------------------------------------------------------------------------------------------+-------------------+ 10 rows in set (2 min 35.11 sec)</pre> <h2 id="FULLTEXTインデックス設計と照合順序の選択">FULLTEXTインデックス設計と<strong>照合順序の選択</strong></h2> <p>今回のMVP開発の要件は、コンテンツのタイトル、キャプション、およびクリエイター名を検索対象のカラムとし、ひらがな、カタカナ、大文字、小文字などの表記ゆれに対応することでした。</p> <p>使用するboolean検索は、大文字、小文字、ひらがな、カタカナを完全区別するため、検索ヒット率が低下する可能性があります。そのため、デフォルトの照合順序である<code>utf8mb4_general_ci</code>から変更する必要がありました。既存のカラムの照合順序を変更するよりも、全文検索用のカラムを新設し、そのカラムの照合順序を変更する方がリスクが少ないと考えました。全文検索用カラムの照合順序には<code>utf8mb4_unicode_ci</code>を選択しました。<code>utf8mb4_unicode_ci</code>は、濁音・破裂音の区別などもなくなりますが、許容範囲としました。</p> <p>全文検索用のカラムは、タイトル、キャプション、及びクリエイター名のそれぞれに別々のカラムを作成して、それらをマルチインデックスにする予定でした。しかし、検索対象のカラムは増減する可能性があること、また、FULLTEXTのマルチインデックスを貼るよりも、1つのカラムにインデックスを貼った方がレスポンスが早いことが検証により判明しました。従って、新設する全文検索用のカラムは1つにし、既存のタイトル、キャプション、及びクリエイター名の各カラムの中身を<code>CONCAT</code>したものをコピーすることにしました。</p> <table> <thead> <tr> <th> 回数 </th> <th> 2カラムに対してインデックスを貼った場合 (s) </th> <th> 1カラムに対してインデックスを貼った場合(s) </th> </tr> </thead> <tbody> <tr> <td> 1 </td> <td> 1.30 </td> <td> 0.35 </td> </tr> <tr> <td> 2 </td> <td> 0.55 </td> <td> 0.27 </td> </tr> <tr> <td> 3 </td> <td> 0.55 </td> <td> 0.27 </td> </tr> <tr> <td> 4 </td> <td> 0.39 </td> <td> 0.28 </td> </tr> <tr> <td> 5 </td> <td> 0.39 </td> <td> 0.25 </td> </tr> </tbody> </table> <p>Railsで照合順序の指定、FULLTEXTインデックスを貼る場合は以下のようにマイグレーションファイルを記載します。<code>add_index</code>を使用してFULLTEXTタイプのインデックスを作成することもできますが、パーサーの指定ができません。そのため、直接SQL文をマイグレーションファイルに書く必要があります。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">AddColumnsToVideos</span> &lt; <span class="synType">ActiveRecord</span>::<span class="synType">Migration</span>[<span class="synConstant">6.1</span>] <span class="synPreProc">def</span> <span class="synIdentifier">change</span> add_column <span class="synConstant">:videos</span>, <span class="synConstant">:full_text_search</span>, <span class="synConstant">:text</span>, <span class="synConstant">collation</span>: <span class="synSpecial">'</span><span class="synConstant">utf8mb4_unicode_ci</span><span class="synSpecial">'</span> execute <span class="synSpecial">'</span><span class="synConstant">alter table videos add FULLTEXT index index_recipes_on_full_text_search (full_text_search) with parser ngram</span><span class="synSpecial">'</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> </pre> <h2 id="Railsで全文検索を実現するための実装例">Railsで全文検索を実現するための実装例</h2> <p>ここからは、Railsで全文検索機能を実装するために考慮した点について説明していきます。</p> <p>まず、全文検索に使用するモデルのインスタンスメソッドは、以下のように定義しました。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">def</span> <span class="synConstant">self</span>.<span class="synIdentifier">full_text_search</span>(keyword) ngram_words = generate_ngram_words(keyword) boolean_mode = <span class="synSpecial">'</span><span class="synConstant">match (search_full_text) against (? in boolean mode)</span><span class="synSpecial">'</span> search_text = ngram_words.map { |key| <span class="synSpecial">&quot;</span><span class="synConstant">+</span><span class="synSpecial">#{</span>key<span class="synSpecial">}&quot;</span> }.join(<span class="synSpecial">'</span><span class="synConstant"> </span><span class="synSpecial">'</span>) sanitize_sql = sanitize_sql_array([<span class="synSpecial">&quot;</span><span class="synConstant">*, </span><span class="synSpecial">#{</span>boolean_mode<span class="synSpecial">}</span><span class="synConstant"> as score</span><span class="synSpecial">&quot;</span>, search_text]) where(<span class="synSpecial">&quot;#{</span>boolean_mode<span class="synSpecial">}</span><span class="synConstant"> and </span><span class="synSpecial">#{</span>boolean_mode<span class="synSpecial">}</span><span class="synConstant"> &gt; 10</span><span class="synSpecial">&quot;</span>, search_text, search_text).select(sanitize_sql) <span class="synPreProc">end</span> <span class="synPreProc">def</span> <span class="synConstant">self</span>.<span class="synIdentifier">generate_ngram_words</span>(keyword) keywords = keyword.split ngram_words = [] keywords.each <span class="synStatement">do</span> |item| words = item.chars words.each.with_index(<span class="synConstant">1</span>) <span class="synStatement">do</span> |word, i| ngram_words &lt;&lt; (word + words[i]) <span class="synStatement">unless</span> words.size == i <span class="synStatement">end</span> <span class="synStatement">end</span> ngram_words <span class="synPreProc">end</span> </pre> <p>少し複雑になっていますが、検索文字列を<code>ngram_token_size</code>に合わせた文字数に分割し、AND 検索をするために <code>+</code>演算子を使用しています。今回は、<code>ngram_token_size=2</code>であるため2文字ごとに検索文字列を区切っています。 実際のMySQLのクエリに置き換えると以下のようになります。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">SELECT</span> *, MATCH (search_full_text) AGAINST (<span class="synSpecial">'</span><span class="synConstant">+作り +り置 +置き +お弁 +弁当</span><span class="synSpecial">'</span> <span class="synStatement">IN</span> <span class="synType">BOOLEAN</span> <span class="synSpecial">MODE</span>) <span class="synSpecial">AS</span> score <span class="synSpecial">FROM</span> cards <span class="synSpecial">WHERE</span> publish_status = <span class="synSpecial">'</span><span class="synConstant">published</span><span class="synSpecial">'</span> <span class="synStatement">AND</span> MATCH (search_full_text) AGAINST (<span class="synSpecial">'</span><span class="synConstant">+作り +り置 +置き +お弁 +弁当</span><span class="synSpecial">'</span> <span class="synStatement">IN</span> <span class="synType">BOOLEAN</span> <span class="synSpecial">MODE</span>) <span class="synStatement">AND</span> MATCH (search_full_text) AGAINST (<span class="synSpecial">'</span><span class="synConstant">+作り +り置 +置き +お弁 +弁当</span><span class="synSpecial">'</span> <span class="synStatement">IN</span> <span class="synType">BOOLEAN</span> <span class="synSpecial">MODE</span>) &gt; <span class="synConstant">10</span> </pre> <p>このように検索文字列を分割して渡す理由は、MySQLのInnoDBにおけるFULLTEXTインデックスが転置インデックスの設計に基づいているためです。<code>ngram_token_size</code>で指定した文字数以外の検索文字列が渡ってきた場合、パフォーマンスが低下することが検証中に判明したため、<code>ngram_token_size</code>にあわせて分割するようにしました。その結果、2秒かかっていたクエリが0.6s程度に短縮されました。</p> <h2 id="Rspecで全文検索のテストを行うときの注意点">Rspecで全文検索のテストを行うときの注意点</h2> <p>通常通り、Rspecでテストを書き実行すると、全文検索に関連するテストは軒並み失敗します。これは、Railsがngramに対応しておらず、マイグレーションを実行しても、schemaファイルにパーサーの記述が反映されないためです。私たちのRspecは、schemaファイルを元にしてテストDBが作成されているため、パーサーの指定が反映されず、全文検索を実行しても何のコンテンツも返ってきませんでした。</p> <p>テストDBを作成する際に、schemaファイルではなく、マイグレーションファイルを元にすることもできますが、全体のSpecにも影響があるため、この方法は見送りました。</p> <p>代わりに、<a href="https://github.com/DatabaseCleaner/database_cleaner">DatabaseCleaner</a>を使用して、全文検索のテスト前に<code>FULLTEXT</code>インデックスを再構築するようにしました。通常は、テスト実行前にインデックスを再構築すると、トランザクションの外側でDBデータが作成され、残ってしまうことがありますが、DatabaseCleanerを使用すると、残ってしまったデータを綺麗に削除してくれます。</p> <p>実装例は以下の通りです。<code>clean_database=true</code>の場合のみ<code>truncation</code>が実行されます。</p> <pre class="code lang-sql" data-lang="sql" data-unlink># rails_helperの設定 config.before(:each, clean_database: <span class="synSpecial">true</span>) do DatabaseCleaner.strategy = :truncation DatabaseCleaner.<span class="synSpecial">start</span> <span class="synSpecial">end</span> config.after(:each, clean_database: <span class="synSpecial">true</span>) do DatabaseCleaner.clean <span class="synSpecial">end</span> # 実際のspec context <span class="synSpecial">'</span><span class="synConstant">公式レシピが存在しないとき</span><span class="synSpecial">'</span>, clean_database: <span class="synSpecial">true</span> do before do recreate_indexes_with_ngram_parser <span class="synSpecial">end</span> let(:params) { { query: <span class="synSpecial">'</span><span class="synConstant">麻薬卵</span><span class="synSpecial">'</span>, page_size: <span class="synConstant">3</span>, next_page_key: nil } } it <span class="synSpecial">'</span><span class="synConstant">UGCコンテンツが返ること</span><span class="synSpecial">'</span> do subject expect(response).<span class="synSpecial">to</span> have_http_status(<span class="synConstant">200</span>) expect(response_data.map { |obj| obj[<span class="synSpecial">'</span><span class="synConstant">type</span><span class="synSpecial">'</span>] }.uniq.sort).<span class="synSpecial">to</span> eq([<span class="synSpecial">&quot;</span><span class="synConstant">videos</span><span class="synSpecial">&quot;</span>, <span class="synSpecial">&quot;</span><span class="synConstant">cards</span><span class="synSpecial">&quot;</span>]) expect(response_json[<span class="synSpecial">'</span><span class="synConstant">meta</span><span class="synSpecial">'</span>][<span class="synSpecial">'</span><span class="synConstant">total-count</span><span class="synSpecial">'</span>]).<span class="synSpecial">to</span> be &gt;= <span class="synConstant">3</span> <span class="synSpecial">end</span> def recreate_indexes_with_ngram_parser ActiveRecord::Base.connection.<span class="synStatement">execute</span>(<span class="synSpecial">'</span><span class="synConstant">drop index index_cards_on_full_text_search on cards</span><span class="synSpecial">'</span>) ActiveRecord::Base.connection.<span class="synStatement">execute</span>(<span class="synSpecial">'</span><span class="synConstant">drop index index_videos_on_full_text_search on videos</span><span class="synSpecial">'</span>) ActiveRecord::Base.connection.<span class="synStatement">execute</span>(<span class="synSpecial">&quot;</span><span class="synConstant">alter table cards add FULLTEXT index index_cards_on_full_text_search (full_text_search) with parser ngram</span><span class="synSpecial">&quot;</span>) ActiveRecord::Base.connection.<span class="synStatement">execute</span>(<span class="synSpecial">&quot;</span><span class="synConstant">alter table videos add FULLTEXT index index_videos_on_full_text_search (full_text_search) with parser ngram</span><span class="synSpecial">&quot;</span>) <span class="synSpecial">end</span> <span class="synSpecial">end</span> </pre> <h2 id="運用上で発覚した課題">運用上で発覚した課題</h2> <h3 id="Auroraはinnodb_ft_result_cache_limitを変更できない">Auroraはinnodb_ft_result_cache_limitを変更できない</h3> <p>MySQL innoDBは、各全文検索クエリ、またはスレッドごとに検索結果のキャッシュ上限値 (<code>innodb_ft_result_cache_limit</code>) を設定しています。つまり、テーブルのレコード数が増えると、全文検索のクエリ結果も比例して大きくなり、必要なキャッシュサイズが増えていくため、メモリを過剰消費しないように制限されています。しかし、<a href="https://bugs.mysql.com/bug.php?id=86036">Bug#86036</a>に記載されているように、このパラメータは上限の最大値が4GBしかないため、大規模なテーブルの場合、この上限値を超える可能性があります。<a href="#f-95f889fe" name="fn-95f889fe" title="MySQL8.0でもinnodb_ft_result_cache_limitの上限の最大値は4GBから変更されていません。">*2</a></p> <p>Auroraの場合、このパラメータのデフォルト値は2GBであり<a href="#f-b9b27596" name="fn-b9b27596" title="[https://aws.amazon.com/jp/blogs/news/best-practices-for-amazon-aurora-mysql-database-configuration/:title], パラメータの分類">*3</a>、変更可能なパラメータのように見えますが、Aurora MySQL 5.7のパラメータグループには存在していません。(*)AWSサポートに問い合わせたところ、Auroraでは<code>innodb_ft_result_cache_limit</code>を変更できないとのことでした。ただし、AuroraではなくRDS MySQLを使用している場合は、このパラメータを変更することができます。</p> <p>調査段階で4GBまでの上限があることは認識していましたが、Auroraでこの値を変更できないことは把握しておらず、2GBのままだと想定より早くキャッシュエラーが返ってくるようになりました。テーブルレコード数やデータ量にも依存しますが、私たちの場合、約74万レコードに達したタイミングでこのキャッシュエラーが発生するようになりました。</p> <p>ただし、エラーが発生する頻度は稀であったため、まずは暫定的な対応を実施しました。Rails側では、キャッシュエラーが発生した場合には例外処理で空の配列を返すよう修正し、エラー発生頻度は検知できる状態を維持しました。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">def</span> <span class="synIdentifier">fetch_contents</span>(model, keyword) <span class="synStatement">begin</span> model.display_on_public.full_text_search(keyword).map <span class="synStatement">do</span> |record| { <span class="synConstant">score</span>: record.score.to_i, <span class="synConstant">record</span>: record } <span class="synStatement">end</span> <span class="synStatement">rescue</span> <span class="synType">ActiveRecord</span>::<span class="synType">StatementInvalid</span> =&gt; e <span class="synType">Sentry</span>.capture_exception(e) [] <span class="synStatement">end</span> <span class="synPreProc">end</span> </pre> <p>また、長い検索クエリが渡ってきた場合には、キャッシュサイズが大きくなり、エラーが発生しやすいことが検証によって判明したため、検索クエリの長さにも制限を追加しました。</p> <p>今振り返ってみると、このキャッシュエラーをできる限り防ぐ方法として、FULLTEXTインデックスに不要な文字列をパターンマッチさせて除外する仕組みも検討すればよかったと感じています。</p> <p>MySQLのFULLTEXTインデックスの実装では、true word(文字、数字、アンダースコア)のみを文字として扱うため、記号等はFULLTEXTインデックスに追加されません。<a href="#f-d722fc62" name="fn-d722fc62" title="[https://dev.mysql.com/doc/refman/5.7/en/fulltext-natural-language.html:title]">*4</a></p> <p>しかし、UGCコンテンツには、クリエイターが何かしらのURLを記載している場合など全文検索に不要な文字列もFULLTEXTインデックスに含まれてしまいます。そのため、レコードが追加・更新されるタイミングでパターンマッチで不要な文字列を取り除けば、FULLTEXTインデックスの肥大化の速度を落とすことが可能だったのではと思います。</p> <h3 id="ALTER-TABLEするときにINPLACE方式が使えない">ALTER TABLEするときにINPLACE方式が使えない</h3> <p>MySQLの<code>ALTER TABLE</code>によるカラム追加や削除などのDDL操作は、通常INPLACE方式で変更できます。ただし、<code>FULLTEXT</code>インデックスを持つテーブルに関しては、INPLACE方式による変更はサポートされておらず、COPY方式しか利用できないため、テーブルの更新時にロックがかかってしまいます。<a href="#f-9f95ad78" name="fn-9f95ad78" title="[https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html:title]">*5</a></p> <pre class="code bash" data-lang="bash" data-unlink># fulltextインデックスを持つテーブルにてINPLACE方式でカラム追加しようとした場合 mysql&gt; alter table videos add test varchar(255), LOCK=NONE, ALGORITHM=INPLACE; ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: InnoDB presently supports one FULLTEXT index creation at a time. Try ALGORITHM=COPY.</pre> <p>MySQLの本番稼働中にロックされると、DBの負荷が高まり、障害につながる可能性があります。クラシル規模のサービスでは、サービスダウンが大きな損失につながるため、FULLTEXTインデックスを持つテーブルにカラムを追加・削除する必要が生じたタイミングで、MySQLからElasticsearchの全文検索に移行しました。Elasticsearchの導入実績が既にあったため、移行自体はスムーズに行えたと思います。</p> <h2 id="まとめ">まとめ</h2> <p>テーブルのレコード数が想定よりも早く増加したこと、及びMySQL側の制約によって、MySQLの全文検索を使用する期間は想定よりも短くなりました。しかし、MySQLの全文検索を運用する上で必要な知識を得ることができたため、私自身とても勉強になりました。</p> <p>MySQLの全文検索は、簡単に導入でき、数十万程度までのレコード数が多くないテーブルに対しては、LIKE検索よりもレスポンスが早く返ってくるので、初期フェーズの検索機能を実装する場合には有用だと思いました。ただし、現状はMySQLの全文検索を長期間運用するには向いていないと思います。そのため、Elasticsearchなどへの移行を前提に、MySQLの全文検索を導入することがベストだと感じました。</p> <p>この記事が誰かの役に立てれば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-0cc25036" name="f-0cc25036" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://www.shoeisha.co.jp/book/detail/9784798147406">&#x8A73;&#x89E3;MySQL 5.7 &#x6B62;&#x307E;&#x3089;&#x306C;&#x9032;&#x5316;&#x306B;&#x4E57;&#x308A;&#x9045;&#x308C;&#x306A;&#x3044;&#x305F;&#x3081;&#x306E;&#x30C6;&#x30AF;&#x30CB;&#x30AB;&#x30EB;&#x30AC;&#x30A4;&#x30C9;&#xFF08;&#x5965;&#x91CE; &#x5E79;&#x4E5F;&#xFF09;&#xFF5C;&#x7FD4;&#x6CF3;&#x793E;&#x306E;&#x672C;</a> p.168</span></p> <p class="footnote"><a href="#fn-95f889fe" name="f-95f889fe" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">MySQL8.0でもinnodb_ft_result_cache_limitの上限の最大値は<a href="https://dev.mysql.com/doc/refman/8.0/ja/innodb-parameters.html#sysvar_innodb_ft_result_cache_limit">4GB</a>から変更されていません。</span></p> <p class="footnote"><a href="#fn-b9b27596" name="f-b9b27596" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://aws.amazon.com/jp/blogs/news/best-practices-for-amazon-aurora-mysql-database-configuration/">Amazon Aurora MySQL &#x30C7;&#x30FC;&#x30BF;&#x30D9;&#x30FC;&#x30B9;&#x8A2D;&#x5B9A;&#x306E;&#x30D9;&#x30B9;&#x30C8;&#x30D7;&#x30E9;&#x30AF;&#x30C6;&#x30A3;&#x30B9; | Amazon Web Services &#x30D6;&#x30ED;&#x30B0;</a>, パラメータの分類</span></p> <p class="footnote"><a href="#fn-d722fc62" name="f-d722fc62" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://dev.mysql.com/doc/refman/5.7/en/fulltext-natural-language.html">MySQL :: MySQL 5.7 Reference Manual :: 12.9.1 Natural Language Full-Text Searches</a></span></p> <p class="footnote"><a href="#fn-9f95ad78" name="f-9f95ad78" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html">MySQL :: MySQL 5.7 Reference Manual :: 14.13.1 Online DDL Operations</a></span></p> </div> akngo22 プロダクト開発におけるMVPは、どのようにMinimizeされるべきなのか hatenablog://entry/820878482940981345 2023-06-16T17:35:55+09:00 2023-06-19T09:06:16+09:00 こんにちは、クラシルPOの小川です。 この界隈でプロダクト開発をしていると、 「MVPで開発しよう」という発言をしたり聞いたりすると思います。 このMVPに対する認識が、おそらく会社や組織、個人間で様々だと感じています。 現在はクラシルアプリの開発を管掌していますが、以前は新規事業の開発を管掌していました。 新規事業と既存事業の開発をする中で、MVPのニュアンスが自分の中で違うなと感じることがありました。 MVPってなんだろうと考えていたのですが、そんな時にテックブログという機会があったので吐き出していこうと思います。 あたらめて、MVPとは? 一般的な答えをgoogleさんで検索すると、下記… <p>こんにちは、クラシルPOの小川です。</p> <p>この界隈でプロダクト開発をしていると、 「MVPで開発しよう」という発言をしたり聞いたりすると思います。</p> <p>このMVPに対する認識が、おそらく会社や組織、個人間で様々だと感じています。</p> <p>現在はクラシルアプリの開発を管掌していますが、以前は新規事業の開発を管掌していました。 新規事業と既存事業の開発をする中で、MVPのニュアンスが自分の中で違うなと感じることがありました。</p> <p>MVPってなんだろうと考えていたのですが、そんな時にテックブログという機会があったので吐き出していこうと思います。</p> <h3 id="あたらめてMVPとは">あたらめて、MVPとは?</h3> <p>一般的な答えをgoogleさんで検索すると、下記のような結果を得られました。</p> <blockquote><p>MVP(Minimum Viable Product)とは、顧客に価値を提供できる最小限のプロダクトのことを指します。 完璧な製品・サービスを目指すのではなく、顧客が抱える課題を解決できる最小限の状態で提供します。 提供後は、顧客からのフィードバックなどを参考にし、新機能の追加や改善点の見直しを図ります。</p></blockquote> <p>顧客に価値を提供できる最小限のプロダクトのことらしいです。 プロダクトは機能にも置き換えられそうですね。</p> <p>では、何を持って「最小限の状態」と言えるのか。</p> <p>「どのようにMinimizeされるべきなのか」が、この問いの解にあたります。</p> <p>僕個人の経験として、理想的なプロダクトの状態・機能があるが、</p> <ul> <li>実装が難しそうだから簡易的なものを作ってみよう</li> <li>全部やると工数がかかりそうだから、工数少なくできる方法で開発してみよう</li> <li>競合の提供している機能のコア部分だけ最小限で開発しよう</li> </ul> <p>などいろいろなMVPを開発してきました。</p> <p>これらは難易度や工数、機能そのものなどのMinimizeをしていますね。</p> <p>これらは、<strong>結果的に正しいMinimize</strong>になることはありますが、 もう少し踏み込んで考えることで、より良い最小限の状態を実現できるのではないかと考えています。</p> <h3 id="どのようにMinimizeされるべきなのか">どのようにMinimizeされるべきなのか</h3> <p>本題です。</p> <p>結論として、MVPは</p> <p><strong>「問題に対しての課題設定と、その課題に対する解決策が適切だったかを検証できて、その後学習できる」</strong></p> <p>ようになるまでMinimizeされるべきだと思っています。</p> <p>長いですね。もう少し端的に言えたらよかったですが、 次の項目で、良いと考えるMinimizeについて説明していきます。</p> <p>※ここで定義されている「課題」や「解決策」は、ユーザーの持つ課題や、ユーザーに提供する解決策ではありません。下記に定義を記載しておきます。</p> <blockquote><p>自分は仮説を整理するときに、「問題・課題・解決策」に分けて考えます。よくあるフレームワークなので、気になる方は検索してみてください。</p> <p>簡単に説明すると以下になります。</p> <p><strong>問題</strong> ・・・ 理想的な状態と現状のギャップ</p> <p><strong>課題</strong> ・・・ 問題を取り除くために達成すべきもの</p> <p><strong>解決策</strong> ・・・ 課題を解決するために行う策</p></blockquote> <h3 id="惜しいMinimizeの例">惜しいMinimizeの例</h3> <p>プロダクト開発には不確実性がつきものです。 そんな不確実性の中で様々な仮説を持って開発を行っていきます。</p> <p>例えば、「ユーザーに適したコンテンツを届けたいから、機械学習アプローチで推薦システムを作ろう」となった時、どのように開発を開始しますか。</p> <p>いきなり推薦システムを作り込む意思決定はできないと思います。作り込むには膨大なコストがかかり、たとえ作り込んだとしてもウケるかわからないからです。</p> <p>そこで、「市場に早く出して反応みよう」「不確実性が高いものを作り込まず試すそう」と言って、推薦システムをMVPで作ります。</p> <ul> <li><p>とりあえず初手で協調フィルタリングで推薦アルゴリズム試してみる</p></li> <li><p>ユーザーが閲覧したコンテンツのカテゴリと同じコンテンツを推薦する</p></li> </ul> <p>パッと最初に考えつくのはこのあたりでしょうか。</p> <p>ここから深く考えていくこともあると思いますが、例のために上記を要件としてMVPを作成するとします。</p> <p>それって本当に良いMVPと言えるでしょうか。</p> <p>「ユーザーに適したコンテンツ届けられていない」という問題に対して、課題が曖昧になっており、解決策ファーストでMVPが作られてしまっています。 課題が精査されないままリリースを行うと、結果「うまくいかないことが分かった」という意味のない結果が起こりえます。検証・学習のサイクルが回りません。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/te_o/20230616/20230616093728.png" width="1200" height="330" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="もう少し踏み込んだMinimize">もう少し踏み込んだMinimize</h3> <p>不確実性が高い問題解決のための適切なアプローチを探すために、我々は「開発・検証・学習」のサイクルを回せる状態にする必要があります。</p> <p>このサイクルを回すために、<strong>課題設定が必要不可欠</strong>です。</p> <p>下記に例を示しました。 課題設定をするのとしないのでは、解決策でやることの解像度も違うことがわかります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/te_o/20230616/20230616173147.png" width="1048" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>「よし!じゃあ、課題設定もできたし趣向をクラスタリングして推薦する事にとりかかろう!」と言いたいところですが、この解決策は「趣向に合わせて配信できるようにする」という課題設定が正しい場合に有効な解決策です。</p> <p>それなりに大きな実装なので、課題設定が正しいことを担保してから取り掛かりたいです。</p> <p><span style="color: #ff5252"><strong>この課題設定が正しいことを検証するために、MVPを用いましょう</strong></span>。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/te_o/20230616/20230616155919.png" width="1200" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この解決策を実装し反応を見ることで、課題設定の確らしさを検証します。 たとえ反応が悪かったとしても、得られた結果から次の課題設定の糧にします。</p> <p>違う課題が設定できるかもしれないし、既存の課題の解像度が上がるかもしれないです。もしかしたら、課題に対する解決策の方がアップデートできるかもしれません。</p> <p>いずれにしろ次につながるので、「開発・検証・学習」のサイクルが回り、「うまくいかないことが分かった」などという曖昧な形で終わることはありません。</p> <h4 id="余談">余談</h4> <p>機械学習という専門的なテーマを挙げましたが、実際はより高度な検証をおこなうかと思います。</p> <p>この時、機械学習知見があるかつPdMのようなロールもできると、自身の頭の中で高度なやりとりができるので、かなり効果的で早いサイクルを回せるようになります。</p> <p>この領域はスキルの掛け合わせのレバレッジがかけやすいですね。</p> <h3 id="最後に">最後に</h3> <p>再度結論になりますが、</p> <p>MVPは「問題に対しての課題設定と、その課題に対する解決策が適切だったかを検証できて、その後学習できる」ようになるまでMinimizeすると良いです。</p> <p>決して、工数や機能そのもののMinimizeではありません。結果としてそうなっているだけです。</p> <p>MVPについて話すつもりが、課題設定の話になってしまいました。 ですが、ある種本質のテーマだと思っています。</p> <p>巷では、PdMなどに求められるスキルとして、課題設定能力と言われることが多いかと思います。(細かくいうと問題設定の確らしさもありますが割愛)</p> <p>課題設定を行うには、自社・他社のサービス触ってユーザーを想像して考え抜く。 レビューなどを見るのも手です。様々な手段でプロダクトへの所感を集めて、それらで帰納的に所感を抽象化し課題設定をしていきます。</p> <p>数値を見たり、ユーザーインタビューしたりするような業務全ては、確度の高い課題設定するためと割り切ってもいいかもしれません。</p> <p>課題設定能力はかなり応用がきく能力だと思います。</p> <p>課題設定をプロダクト方面に向けるのがPdMやPOですが、これを事業や会社方面に向けられると、さらに視野や視座が上がった状態と言えるでしょう。</p> <p>この辺りも泥臭く頑張っていこうと思います。</p> te_o 他人軸ではなく自分軸で行うQA業務 hatenablog://entry/820878482940015436 2023-06-12T17:18:29+09:00 2023-06-12T17:18:29+09:00 はじめに こんにちは!クラシルでQAを担当しているumepiです! 今回のブログでは、「他人軸ではなく自分軸で行うQA業務」についてを書いていきます。 個人的な社会人2年目の自身の課題として、内観を私生活で進めています。そんな中で、仕事においてももっと自分軸で進められるのでは?と考えたことが今回のテーマのきっかけです。 自分軸とは 他人がどう思うかに左右されず、「自分はどうしたいか」を基準に行動することです。 自分がこの挑戦をすると相手がネガティブに受け取るかもしれないから、その挑戦はやめておこうというような経験はありませんか? 本来は「他人から嫌われないようにしよう」という考えが他人軸で、「… <h5 id="はじめに">はじめに</h5> <p>こんにちは!クラシルでQAを担当しているumepiです!</p> <p>今回のブログでは、「他人軸ではなく自分軸で行うQA業務」についてを書いていきます。</p> <p>個人的な社会人2年目の自身の課題として、内観を私生活で進めています。そんな中で、仕事においてももっと自分軸で進められるのでは?と考えたことが今回のテーマのきっかけです。</p> <h5 id="自分軸とは">自分軸とは</h5> <p><span style="font-size: 115%">他人がどう思うかに左右されず、「自分はどうしたいか」を基準に行動する</span>ことです。</p> <p>自分がこの挑戦をすると相手がネガティブに受け取るかもしれないから、その挑戦はやめておこうというような経験はありませんか?</p> <p>本来は「他人から嫌われないようにしよう」という考えが他人軸で、「自分はどうしたいか」が自分軸だと思うのですが、QAの業務においても近い部分はあるのかなと思っています。 品質保証という、リリース予定の機能をリリースされる前に品質に問題がないか検証するというQAの仕事を行っていると、締め切りや開発の事情などがあるが故に追われていると感じやすく、「他人の行動に影響されて自身の行動が変わってくる」という他人軸があるように私は感じました。</p> <p>そのような中でも、私たちQAチームはなるべく自分軸で仕事ができるような工夫をいくつか行っているのでご紹介します。</p> <h5 id="先に情報を取りに行く姿勢">先に情報を取りに行く姿勢</h5> <p>機能の追加や変更に対するQA依頼を受けて、テスト計画からテストの完了までを進めるという流れが、ざっくりとした従来のテストの流れです。 私たちの従来の方法では依頼が多かったり確認に時間がかかってしまうと、フレックスであるにも関わらず、プライベートやライフスタイルに影響が出てしまう状態でした。 「想定外の急な依頼によりタスクの進め方を左右される」という他人軸で、急な依頼があるかもしれないという不安もあり、ヘルシーな心ではありませんでした。</p> <p>事前に情報を得て設計、レビューを行い、QA依頼を受けたタイミングで実行から進めるという流れが現在の新しい方法です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/u/umepidesu/20230612/20230612092202.png" width="1024" height="660" loading="lazy" title="" class="hatena-fotolife" style="width:650px" itemprop="image"></span></p> <p>従来の方法との最大の違いは、QA依頼を受ける前に新規機能開発を行う各スクラムの開発状況の情報をJIRAで確認しているという点です。(JIRAの機能の活用についてはshiominさんが以前の記事で紹介しています。)</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.dely.jp%2Fentry%2F2023%2F05%2F19%2F160803" title="2人目のQAメンバーとして入社してから取り組んだこと - dely Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.dely.jp/entry/2023/05/19/160803">tech.dely.jp</a></cite></p> <p>この新しい方法はQAチームリーダーのshiominさんによる提案なのですが、事前に変更内容や意図、進捗を知ることができます。そのため、どういう変更の依頼に対しどのような実行が必要で、いつ頃依頼を受けるかがわかるというメリットがあります。</p> <p>仕様確認、設計、レビューを自分たちのタイミングで行うことができ、テストタスクの全量のイメージができるようになりました。それにより、「他人の行動に影響されて自身の行動が変わってくる」「想定外の急な依頼によりタスクの進め方を左右される」という他人軸の部分が減ったように思います。</p> <h5 id="素直に伝える">素直に伝える</h5> <p>私たちQAチームでは素直に自身の状況を伝えるようにしています。挑戦したいこと、取り組みたい課題、プライベートなどをお互い伝え合うようにしています。</p> <p>自分軸のデメリットとして、周囲の人が離れてしまったり、対立関係になってしまうことがあります。よって「他人から嫌われないようにしよう」という他人軸の考えを持ってしまい、自分の本来望んでいる行動ではない行動をとってしまうかもしれません。</p> <p>ですが、相手の感情が自分の想像する相手の感情と必ずしも同じとは限りません。もしかすると、相手は気にしているだろうと自分が思っていても、相手は全く気にしていないかもしれません。自分の思っていることを伝え、どう進めていくことが業務を達成する上でベストなのかを一緒に決めていくことが、なるべく自分軸で業務を行おうとする上で重要なのではないかと考えています。</p> <p>しかし、素直に伝えることは心理的安全性が必要な場合もあるかもしれません。そのために私たちは毎朝の朝会でGood&amp;Newに取り組みお互いを知ることで、心理的安全性を得ています。 また、できないことはできないと言う気持ちも必要かと思います。急ぎでなければ来週でも構わないかどうかを確認し、タスクに対し自分軸で取り組めるよう心がけています。</p> <h5 id="さいごに">さいごに</h5> <p>自分軸で業務に取り組むことで心もヘルシーに、キャリアや自身の取り組みたいことに対してつながっていくのではないかと考えています。</p> <p>この内容が少しでも見ていただいた方の参考になればと思います!</p> umepidesu Zapierを活用したデザインチームの業務改善ナレッジ hatenablog://entry/820878482939262599 2023-06-06T17:13:21+09:00 2023-06-06T17:20:37+09:00 はじめに こんにちは!クラシルでプロダクトデザイナーをしているkashikoです! 今回のブログでは、「Zapierを活用したデザインチームの業務改善ナレッジ」を書いていきます。 Zapierとは操作の自動化を非エンジニアでも簡単に行えるツールで、私たちの場合はSlackで特定のスタンプを使うとNotionにリスト形式で自動でストックするのに使用しています。クラシルのデザインチームでこの機能をどのように活用し、チームとしてのレベルアップを図っているのかご紹介します。 ユースケース クラシルデザインチームがZapierを利用しているのは以下の3つのシーン。 ①実機を触っていて見つけたデザイン改善… <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230606/20230606171818.png" width="1200" height="628" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <h3 id="はじめに">はじめに</h3> <p>こんにちは!クラシルでプロダクトデザイナーをしているkashikoです!</p> <p>今回のブログでは、「Zapierを活用したデザインチームの業務改善ナレッジ」を書いていきます。</p> <p>Zapierとは操作の自動化を非エンジニアでも簡単に行えるツールで、私たちの場合は<strong>Slackで特定のスタンプを使うとNotionにリスト形式で自動でストックする</strong>のに使用しています。クラシルのデザインチームでこの機能をどのように活用し、チームとしてのレベルアップを図っているのかご紹介します。</p> <h3 id="ユースケース">ユースケース</h3> <p>クラシルデザインチームがZapierを利用しているのは以下の3つのシーン。</p> <blockquote> <p>①実機を触っていて見つけたデザイン改善タスクのストック</p> <p>②デザインの過程でリサーチしたナレッジのストック</p> <p>③相互に行うデザインレビューのストック</p> </blockquote> <p>です。</p> <p>それぞれ具体的にどのような運用になっているのか、詳しく見ていきます!</p> <h4 id="デザイン改善タスク">①デザイン改善タスク</h4> <figure class="figure-image figure-image-fotolife mceNonEditable" title="デザイン改善ストック"> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605174222.png" width="1200" height="721" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <figcaption class="mceEditable">デザイン改善ストック</figcaption> </figure> <p>「デザイン改善」では、<strong>特定のチームに紐づきにくいUI/UX上の改善点</strong>や<strong>デザインシステムを運用していて出た課題</strong>、更には閲覧頻度の低い箇所に残っていた旧ロゴや旧カラーの掲出箇所をストックしています。</p> <p>誰もアサインされなかったものはNo Assignとして一番左に、担当者が決まったものは担当者ごとの列に表示されるようにし、担当が明確でないことで長期間放置されてしまう、ということを防ぐために仕組み化しています!</p> <h4 id="デザインの過程でリサーチしたナレッジの蓄積">②デザインの過程でリサーチしたナレッジの蓄積</h4> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605180051.png" width="1176" height="858" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p><br />クラシル開発部には現在6名のデザイナーが在籍しており、それぞれ別のsquadに配属しています。<strong>自分が現在取り組んでいることが、別の時期に他のデザイナーの参考になることがある</strong>ため、デザインリサーチDBではデザインワークの過程で調べた他社の参考事例などを簡単にストックすることで、<strong>似たリサーチを何度も0からしなくてもいいようになりました。</strong></p> <p>また、ジャンルをタグづけすることで、膨大な情報量の中からも必要な時に必要な参考例を取り出せるように工夫しています。</p> <h4 id="レビューストック">③レビューストック</h4> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605182700.png" width="1200" height="940" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p>クラシルでは、デザインが実装される前に、<strong>デザインの品質を高めることを目的としてsquadを跨いでデザイナー間でレビュー</strong>を行っています。</p> <p>この「レビューストック」は、Slack上でやりとりされたレビューそのものをストックしていくことで、<strong>「レビューそのもの」を振り返り、どんな形式でレビューしたらより品質を高めることができるのか、改善していく</strong>ことを目的に運用しています。</p> <p>レビューをする側・される側のちょっとした工夫がレビュー及び最終的な成果物の品質に影響していくので、このストックをもとに、より良くなるようフォーマット化していく予定です!</p> <h3 id="安定的な運用のためにおすすめしたいこと">安定的な運用のためにおすすめしたいこと</h3> <h4 id="スタンプは直感的なものにする">●スタンプは直感的なものにする</h4> <figure class="figure-image figure-image-fotolife mceNonEditable" title="スタンプをわかりやすく変更"> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605171746.png" width="1200" height="530" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <figcaption class="mceEditable">スタンプをわかりやすく変更</figcaption> </figure> <p><strong>どのスタンプで何がストックされるのか、直感的に分かるもの</strong>を設定することをおすすめします!</p> <p>デザインチームでも当初、「デザスト」というスタンプでデザインレビューをストックして行っていたので、ナレッジシェアと間違えてしまう事態が頻発していました。。。</p> <p>正しいページに正しく情報をストックするために、地味ですが大事なポイントです◎</p> <p> </p> <h4 id="見返す仕組みも同時に作る">●見返す仕組みも同時に作る</h4> <p>また、<strong>手軽にストックするだけではなく、それを定期的に取り出せる仕組みも同時に作る</strong>ことを強くおすすめします。</p> <p>ただ溜め続ける運用では、肝心な時に見返し忘れたり、一人だけ情報ストックにコミットし続ける・・といった状況になりかねません。</p> <p>そこでクラシルデザインチームでは、月曜日を改善タスク確認デーとし、朝会前にbotを設定することで週に一回必ず進捗や状況を確認できるようにしています。</p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605181129.png" width="1200" height="206" loading="lazy" title="" class="hatena-fotolife" itemprop="image" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;" />また、デザイナー・PdMのNotionワークスペ<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">ースのトップにもリンクを貼ることで「あれ、どこ行ったっけ・・?」を防いでいます◎</span></p> <p> </p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shikashi_ma/20230605/20230605185108.png" width="1200" height="472" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p> </p> <p>そもそも、<strong>ナレッジシェアや改善タスクは全員がコミットしなければ、仕組みとして成立しづらい</strong>ですし、貯め続けた情報は<strong>適切なタイミングで取り出して活用しなければ貯めている意味を見出しづらく</strong>なります。</p> <p>クラシルでは、情報を貯める・取り出すを同時に仕組み化することでこの問題を解決し、チームとしてデザイン改善に取り組める体制を少しずつ構築しています。</p> <h3 id="終わりに">終わりに</h3> <p>今回は、「Zapierを活用したデザインチームの業務改善ナレッジ」を紹介しました!</p> <p>チームとしてデザイン品質を上げていきたい!という方の参考になれば幸いです◎</p> shikashi_ma