株式会社スタンバイのブログ - TECH PLAY

TECH PLAY

株式会社スタンバイ

株式会社スタンバイ の技術ブログ

71

プロダクト部の高原です。 今年度の上半期に、私たちプロダクト部門のマネージャー全員で「マネジメントポリシー」なるものを作成して部門内に宣言するという活動をしました。 この活動の、背景、ねらい、プロセス、作成後のこれまで、これから、などについてお話ししたいと思います。 マネジメントポリシーとは Management Policy というと「経営方針」「会社と社員の約束事」「組織の行動指針」などなど、そこそこ振れ幅がある言葉になりますが・・・ 私たちが作った「マネジメントポリシー」の位置づけは、「マネージャーたちがメンバーに対して守りたい約束事」という表現が適切かなと思います。 他社の先行事例を参考にして作成したもので、例えば amazon 社の Leadership Principles などを参考にしました。 プロダクト部門のマネージャーが、自分が管掌しているグループだけでなく部門全体の組織運営に取り組むスタンスを示すものとして、次の7項目を掲げています。 1人1人がスタンバイの未来を語れる組織へ 強みを引き出し可能性を拡げる環境づくり 役割遂行のサポート 誰もが自分らしく意見を出せる仕組みづくり 意思決定の透明性向上 全社を横断するマネジメント意識を持つ 共に事業をつくる仲間を増やす 7つの約束事それぞれに具体的なアクションを示すサブテキストを添えていて、主語は全て「マネージャーは」に揃えています。 以降では、私たちがなぜこのようなマネジメントポリシーを作ったのか?、また、このマネジメントポリシーをどう使おうとしているのか?などをお伝えできればと思います。 前提状況 まず、お話の舞台となるプロダクト部門の組織構造について共有しておきたいと思います。 現在の開発組織体制 当社のプロダクト部門は、扱う技術ドメインまたはビジネスドメインによる線引きによって開発グループを分割しています。 チームトポロジーの考え方に近い、認知負荷を考慮した組織体制を採用しています。 以前の記事 (プロダクト開発体制のこれまでとこれから - Stanby Tech Blog) で書いていた「技術ドメインの構成イメージ」と同じ方針で継続しています 図の機能群よりも開発グループのほうが細分化されています さらに今年度から、これらの開発グループのマネージャー全員が兼務所属する「組織運営グループ」という組織を設けて、グループや部門のマネジメントを協力して行う体制をとっています。 ※メンバー全員が兼務で成り立っている組織のため、部門内で「バーチャル組織」と呼んでいます この組織運営グループのミッションには次のようなものを掲げています。 事業フェーズの変化に対応して成果を最大化できる組織の仕組みづくり プロダクトメンバーの満足度向上 なお、今期(2024年度)の注力ポイントは次のようになっています。 ここでお話しする内容は、プロダクト部門のマネージャー全員の活動の記録であり、それはすなわち組織運営グループの活動の記録ということになります。 マネジメントポリシーを作った経緯 なぜ作ったのか? 上記のように今期から組織運営グループというバーチャル組織で動き出しましたが、当初はグループ間で情報共有を促してもほとんど何も出てきませんでした。また、グループ間を横断する共通課題について話していても、自分ごと化(自分たちごと化?)できている割合が低そうに感じていました。 いろいろな理由を考えました。 自グループの組織とプロダクト開発のマネジメントで手一杯だから 自グループや他グループのことを話すことでメリットがあると思えないから マネージャーまで登ってきた自負があるので他人にとやかく言われたくないから 各マネージャーの育ってきた環境が違うから 特に「各マネージャーの育ってきた環境が違う」ことについては、ちょっと考えたら分かることなのに、これまであまり意識できていなかったことに気付いてハッとしました。 私も含め、マネージャーのほとんどが2020年のジョイント・ベンチャー発足後の入社で、それまでは各々がそれぞれのキャリアを経るなかでリーディングやマネジメントの経験を積み重ねてきています。ゆえに、それぞれのマネージャーが持っている成功/失敗の定義とか理想像の認識とかにけっこう差異があるかもしれない・・ということに思い至りました。 中途入社者がミドルマネジメントを担っているのはスタートアップならどこも似た状況でしょうし、べつにマネージャーだけに特化した話でもないと思いますが、なかでも「マネジメント」文脈で文化や認識の差異を埋めるようなオンボーディング施策を行えているかというと配慮が薄かったことに気付きました。 つまり、事業やプロダクトに直結する「ビジネスドメイン」や「技術ドメイン」のコンテキストを合わせる目的でのオンボーディング施策は意識的に行えていそうですが、それに比べると、組織マネジメント文脈でコンテキストを合わせる活動はあまり意識的に行えていないのでは(少なくとも自分の意識は弱かった)ということへの気づきがありました。 (組織マネジメントがど真ん中の役割を預かっておきながら・・・お恥ずかしいですが) 共有できる目標、共通言語がほしい! こうして「各マネージャーの育ってきた環境が違う」この「違い」に気づいたことで・・ チーム一丸となってパフォーマンスを高めていくためには「チームのあるべき姿」や「チームの目標」を共有することが鍵になる という、これまでスクラムマスターとして何度も伝えてきていたことが自分に跳ね返ってきた感じがしました。 そして、マネージャー間で ToBe や目標といった共通言語づくりを目指すことにしました。 それ以外の3つの理由に該当していたとしても、ToBeや目標といった共通言語を作ることが改善の鍵になると考えました。 自グループの組織とプロダクト開発のマネジメントで手一杯だから 自グループや他グループのことを話すことでメリットがあると思えないから マネージャーまで登ってきた自負があるので他人にとやかく言われたくないから どうやって作ったのか この共通言語づくりを、目標成果物に「マネジメントポリシー」を置いて進めたという経緯です。 その過程を端的に表現すると・・ マネージャ全員でプロダクト組織ビジョン「自立型組織」の因子を洗い出して、それらを実現するために、我々マネージャーに求められる行動や姿勢を言語化した。 ということになります。 もうすこし、辿ってきたプロセスを詳しく説明すると次のようになります。 <構成要素の洗い出しフェーズ> Step1 - ToBeである組織ビジョン「自立型組織」の認識合わせ Step2 - ToBeが実現している状態のイメージを具体化 <共通言語化フェーズ> Step3 - Gapの洗い出し Step4 - Gapを埋めていく登り方を検討 以下、Stepごとに行った作業イメージの紹介を試みます。 ※本文中何度となく登場するプロダクト組織ビジョン「自立型組織」は、以前の記事 (プロダクト開発体制のこれまでとこれから - Stanby Tech Blog) でご紹介しています Step1 - ToBeである組織ビジョン「自立型組織」の認識合わせ 問い 自立型組織を形づくる因子(要素)にはどんなものがあるか? 進め方 週次の定例ミーティングを何週か使って 個人ワーク→全体共有という流れを何度か行き来して進めました。 各マネージャーの個人ワークで「自立型組織」の定義を読み直してもらい、「自立型組織を形づくる因子」を洗い出してもらってから、全員で樹形図のようにマッピングすることで MECE になるよう整理を試みました。 Step2 - ToBeが実現している状態のイメージを具体化 問い 自立型組織が実現したらどういう状態になっているだろうか? 進め方 週次の定例ミーティングを何週か使って 個人ワーク→全体共有という流れで行いました。 Step1で洗い出した因子ごとに『自立型組織が実現した状態』を想像して言語化してもらうことで、より解像度の高い ToBe 状態を共有することを目指しました。 Step3 - Gapの洗い出し 問い 私が自立型組織を実現したい理由は? 自立型組織の実現に向けて、私たちが担うべき役割は? 進め方 いつものオフィスを離れて開催したロングミーティング(1日合宿)の前半で 個人ワークで行いました。 先ず「リストーリー」ワークとして、マネージャー1人1人が自立型組織を目指したい理由や得られるメリットを各自の価値観に沿って言語化してもらったうえで(図の左側)、その状態までの Gap をどう埋めていくとよいか(埋めていきたいか)を言語化するワークを行いました。 資料では明示していませんが、主語を、先ず「私」で考えたあと「私たち」で協力して進んでいくイメージへと広げる意識で進めました。 Step4 - Gapを埋めていく登り方を検討 問い 「自立型組織」の Asis / Tobe の Gap をどう埋めるかの議論と言語化 進め方 いつものオフィスを離れて開催したロングミーティング(1日合宿)の後半 4人ひと組くらいに班分けをしてグループワーク →全員で文言の統合と洗練を、週次の定例ミーティングを 2-3回かけて Step3で言語化した「自立型組織への Gap をどう埋めていくとよいか」を持ち寄って、グループワークでメッセージラインを作成しました。 これらはすなわちマネージャーがとるべき行動だろうということで、マネージャー全員で集約し、文言をブラッシュアップして、最終形を「マネジメントポリシー」として完成させました。 これらのプロセスを一緒に通り抜けてきたことで、目指す理想像の認識が合ってきたり、共通課題の自分ごと化が進んだり、というねらった効果を生み出せたように思います。 マネジメントポリシーをどう使いたいか こうして作った「マネジメントポリシー」をどう使っていきたいと考えていて、実際どう使ってきているかのあたりのお話しをさせていただきたいと思います。 for マネージャー 『マネージャーの約束事』として明示することで・・ マネージャーとしての動きに迷った際に「あるべき姿」に立ち返る指針にできる マネージャー間でマネジメントポリシーという共通言語をベースに議論ができる メンバーから指摘を受けて我がふりを直すことができる for マネージャー以外のメンバー 『マネージャーの約束事』として明示されていることで・・ マネージャーの行動や振る舞いの背景を知れる マネージャーの行動や振る舞いに対して疑問を感じた場合に指摘しやすい ポリシー自体が自分が期待していることや組織方針の理解とズレていると感じた場合は、マネージャーに説明を求めたり変更の提案ができる for これからマネージャーになる人【追加】 社内でこれからマネージャーになる人・マネージャーの仕事に関心がある人にとって マネージャーはどういう行動が求められるかを垣間見れる マネージャーを目指す際の学習や行動の具体的なイメージを持てる ※これは当時なりたてのマネージャーからもらったコメントに気づきを得て、後から追加した項目です。正直当初はそこまで考慮できていませんでしたが、有り難い指摘でした。 ただし・・・これらは全て、私たち作り手側の思いです。 以降では、実際にマネジメントポリシーを狙いどおり使っていくことに向き合っている話をさせていただきます。 マネジメントポリシーを作ってからこれまで 説明会を開催し、フィードバックを得た 直前に挙げたとおり、マネジメントポリシーは、マネージャー以外のメンバーにもしっかり知っておいてほしいものです。そこでいくつかの認知機会を設けました。 月次の「プロダクト全体会」で作成したことと内容とを報告 加えて、任意参加の「マネジメントポリシー説明会」を開催 説明会では、この記事に書いてきたような、背景、作成プロセス、使いかたの期待などを話して、質疑応答の時間を設けました。 結果的に、説明会の時間に突っ込んだ質問はもらえなかったのですが・・・数日後、説明会に参加してくれていたメンバーと 1on1 があったので「あれどう思いました?」と尋ねたところ・・・ こういうのって『作って終わり』になることが多いと思うんで・・・使えているかをどうやってふりかえるかが大事ですよね? と、なかなか鋭いツッコミを受けることができました。 私も、マネジメントポリシーを意識して使えているかとか、更新する必要がないかの定期的なチェックをすることは大切だろうと思ってはいたのですが・・・ 作成して説明会まで走り抜けたところで、正直やや『一段落した』感じで気が緩みそうになっていたので、この言葉で一気に身が引き締まりました。 このこともあって、緊張感を保ったままモニタリングの議論を続けることができたように思います。 モニタリングの議論 マネジメントポリシーに沿って行動した成果を測る観点では、エンゲージメントサーベイのスコアをモニタリングすることがすぐに想起できました。 どの Gap を埋めるために → どのスコアの改善を目論んで → どうアクションするか?という紐付けができれば、アクション前後の推移をみてモニタリングできそうですし、そもそもエンゲージメントサーベイのスコアに課題感がある場合には、そのスコアをターゲットとして改善アクションを検討・実行するというかたちでよさそうです。 逆に、1つのスコアは複合的な要因から影響を受けるはずなので、「こうアクションすれば→このスコアが上がる」という方向で紐付けるのは難しそうです。 また、サーベイスコアに効果が出るには時間がかかるため、なんらかの先行指標をモニタできないか?という議論も始まっています。 ある行動がみられているとか行動が変容したとかを測ることも考えられますが、一意に行動に現れることを追求しすぎると本末転倒するリスクもありそうです。 しばらく検討しているなかで、まず、我々マネージャー自身がマネジメントポリシーの各項目を意識して実践できているか?というセルフチェックする試みを始めています(超先行指標と言えるかもしれないものの超主観的かつ定性的ですが)。 マネジメントポリシーの活用 作ってから約6か月、マネジメントポリシーを元に次のようなことを行ってこれました。 組織運営グループの目標設定 マネジメント・アクションのバックログを共有して定例ミーティングで更新 新任マネージャーのオンボーディングセッションを開始 先に「マネジメントポリシーをどう使いたいか?」で挙げていたことを、少しは体現できているとよいと思います。 これまでのふりかえりと今後に向けて ふりかえり マネージャー全員でマネジメントポリシーを作ったことで、共通言語を得ることができ、その後は次第にマネージャー間で一緒にアクションできてきた気がします。 そして、当初は「育ってきた環境が違う」ことをデメリットに感じていましたが、今や、多様な経験から多様な視点を持ち込んでもらえること(Diversity)にメリットを感じることができ始めています。 定例ミーティングでの各グループの活動や問題点の共有も、だんぜん頻度が増えてきたと思います。 今後の課題 モニタリングについては、始めたばかりのマネージャーの実践セルフチェックを続けつつ、今後は、より客観的なモニタリングも検討していきたいと思います。先に書いた「行動変容との紐付け」モニタリングも特定の領域にはハマるかもしれないので、引き続き議論して仮説検証していければと思っています。 また、組織を俯瞰的にみて(システム思考で)アプローチすべきポイントを探す必要もあるのではと考えています。そういった議論の際も、マネジメントポリシーに照らし合わせながら進められるとよいと考えています。 「武器を手に入れた」と言うと旧来の軍事的世界観の組織論に聞こえるかもしれませんが、今回「マネジメントポリシー」を作れたことは、組織マネジメントに対する目線を合わせるための、なかなか強力なツールを手に入れた感覚があります。 というわけで、長文になりましたが、最後までお読みいただきありがとうございました。 このような組織の話も、またご報告できると嬉しいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
スタンバイアドベントカレンダー 2024 の 12/24 の記事になります。 プロダクト部の辻です。 スタンバイでは事業の成長・拡大および中長期的な事業継続のため、機能開発に加えて技術的な改善活動もいくつか実施しております。 この記事では2024年の振り返りも兼ねて、最近のスタンバイの技術的な取り組みをいくつか紹介します。 取り組み全体の概要 昨年度以前から継続しているものも含め、スタンバイ全体では ・コスト最適化 ・システムのリアーキテクチャ ・開発効率を上げるためのツールなどの導入 を進めてきました。 コスト最適化 スタンバイはサービスのインフラに AWS を利用しています。 もともとインフラコストは課題ではあり23年度も対応をしたのですが、昨今の強い円安の影響もありインフラコストの課題が大きくなってきていたため、24年度の前半は特に全体で優先度を上げて改めて対応を進めました。 ただ、コスト削減!という形で闇雲に進めてしまうと、システムの障害に繋がったり、開発活動の効率や品質が下がったり、Savings Plans の不適切な購入により長期間削減できない無駄なコストが発生したりといった問題も起きるリスクがあるため、慎重に調査や検討をして進めました。 特には開発活動の効率や品質を下げる(エンジニアの苦労が過剰に増えたり、制約事項が増える)ような削減を進めてしまうと本末転倒になるので、取り組みの呼び名も "削減" ではなく "最適化" とするなど、良くない方向に行かないように意識しながらみんなで進めました。 スタンバイでは各領域ごとの開発チームがスクラムを組んで開発活動をしています。 そのため、全体の目標と方針を共有した上で、各チーム自身が自立的にコスト目標と最適化施策を立案し実施していきました。 加えて全開発チームの代表が揃う定例会議の場なども適宜活かし、各種相談やチーム間調整などもスムーズに進められました。 具体的な各チームの目標設定と施策実施時期についてはトップダウンではなくボトムアップをベースとした形で進めたため、最終的に目標達成できるのかという懸念は最初に出ましたが、各チームが全体目標も意識しつつチーム間で連携しながら取り組み、全体で目標を超える金額のコスト最適化を達成できました! 主な実施施策の概要は以下になります。 ・求人データとその更新を管理する巨大 DB 周りのコスト削減  ・DB のクラスター再構築による容量削減やリザーブドインスタンスの活用  ・ Aurora MySQL のコストを 54% 削減 の記事の件。最も効果が大きかった施策 ・ECS, EKS で動くアプリのオートスケールの設定やリソースサイズの見直し ・S3 の保存データを精査し、安全な範囲で不要データの削除やライフサイクルの設定(一部は Intelligent-Tiering を利用) ・Compute Savings Plans の追加購入  ・今後の EC2, ECS, Lambda などのリソースの増減予測をモニタリング+各開発チームへのヒアリングで精度高く見積もり、追加購入  ・カバー率が 50% 程度だったものを 80% 程度までアップ ・検索エンジン Vespa で利用する EC2 のインスタンスタイプを AWS Arm ベースの Graviton に変更  ・サービス改善施策の実施に伴い大きなコスト増が見込まれていたが、この対応により増加幅を小さく抑えられた ・EKS の基盤を Fargate から EC2 に変更することにより、 Datadog 含めた EKS に関連するトータルコストの削減  ・昨年に実施していた施策ですが、効果があった施策なので紹介 各施策ごとに丁寧に調査やモニタリングをしながら段階的に進めたため、これらの施策を原因としたインシデントを起こさずに削減できたことも大きな成果でした。 各種システムのリアーキテクチャ スタンバイは2015年にビズリーチの新規事業として始まったサービスです。サービスとシステムの年齢はもう10年近くになることもあり、課題もたくさん出てきています。 検索エンジンについては 検索エンジンをVespaへ移行しています の記事にあるように刷新をしていますが、 検索エンジン以外のシステム群においても、扱うデータ量やトラフィックの増加への対応、各種施策のトライアンドエラーの歴史、 優先度の兼ね合いで改善活動を長期間実施できていないシステムがいくつか存在するなど、いわゆる技術負債と呼ばれるものがいくつかあります。 また、全体的なシステムアーキテクチャと技術選定に関しても、今後の開発や運用保守の効率の観点で見直したほうが良い部分も出てきていました。 これらの課題はすぐに深刻な問題に直結するものではありませんが、対応をしなければ中長期的には開発効率の低下やインシデント発生率の上昇が続き、中長期的にサービス改善の停滞や継続すら危ぶまれる状況を生み出しかねない類のものになります。 そのため、課題の影響度、緊急性、改善効果の見込みなどを考慮して優先順位をつけてロードマップを引き、いくつかのシステムのリアーキテクチャを各開発チームで進めています。 規模が大きく道半ばの案件もありますが確実に進捗しており、一部は本番リリースを迎えられ期待していた開発効率やパフォーマンスの改善を実現できました。 主には以下のような取り込みを実施しています。 開発に用いる言語の変更 スタンバイのシステムの大半は Scala で実装されてきたのですが、これについては昨今いくつかの課題が顕在化してきました。 世界的にも国内においても Scala の人気が他の言語と比較して高くない状況にあり、エンジニア採用が困難になってきていました。 また Akka のライセンス変更をはじめとする依存ライブラリやフレームワークに大きな変化があり、Scala を継続利用していくにも不確実性と諸々の対応工数が大きくなる見込みがありました。 昨年からスタンバイ内でこの課題に関する議論を重ね、 ・言語利用者数の状況 ・OSS の開発体制 ・今後のエンジニア採用 ・静的型付け言語であること(型安全性) ・並行処理の扱いやすさ ・マイクロサービス間の通信の安定性(壊れにくさ)と高パフォーマンスの実現のしやすさ ・学習コスト含めた現状からの移行コスト 等々の観点で複数の言語を比較検討し、バックエンド開発で用いるメインの言語を Go に変更していくことを決定しました。 そして、徐々にバックエンドのアプリケーションを Go での実装に切り替えることを進めています。 社内のサービス間通信についても REST から gRPC に徐々に移行していくことを進めています。 昨年の決定時点ではスタンバイ内の Go の経験者はかなり少なかったため不安要素もある大きな決定でしたが、外部の講師の方を招いた勉強会や社内でのノウハウ共有なども実施しながら、いくつかのサービスの Go への移行が完了しています。 求人取り込み・管理システムの刷新 前述のコスト最適化のパートでも触れた、巨大な DB を用いている求人データの取り込み管理システムについても、アーキテクチャの刷新を進めています。 このシステムは、求人データ本体だけでなく各種取り込み処理の状態管理についても主に1つの DB クラスターで集中管理されています。 取り扱う求人数自体の増加に加え、求人データ取り込みの処理数や内容の複雑さが増してきており、DB がパフォーマンスとコストにおいてネックになっています。 そして今後もスタンバイのサービス改善のため求人データの取り込み処理は追加や変更をし続けていく必要があります。 求人データの取り込み管理はスタンバイのサービスの改善と継続の肝となる重要な部分のため、 「パフォーマンス」、「コスト」、「開発・運用のしやすさ」を改善を目指して、DB を中心としたアーキテクチャからストリームを中心としたアーキテクチャへの刷新を進めています。 データ基盤の刷新 スタンバイのサービス改善のための各種意思決定や検索エンジン改良の材料となる各種ログや求人に関するデータを管理している「データ基盤」に関しても、DWH のリアーキテクチャを進めています。 現状のスタンバイのデータ基盤はデータ統合やモデリング、メタデータの整備などの観点で課題が多く、各種データ分析業務がとても煩雑で品質も高くできていない状況にあります。 DWH のリアーキテクチャによってこれらの課題が一気に解消されるという類の話ではなくデータの管理方法をはじめとした運用の課題も大きいですが、これらの課題解決を進めやすくするための基盤整備のためにリアーキテクチャを実施しています。 ここも時間がかかる取り組みではありますが、正確なデータと分析結果を元にした意思決定をしていくことはサービス改善において非常に重要になることのあので、取り組みを進めています。 開発効率や品質向上のためツール導入や取り組み 開発活動の効率や品質を高めるために、下記をはじめとするツールの導入や取り組みを実施しました。 ・GitHub Copilot の導入  ・AI のサポートも適宜利用し、開発の品質と効率の向上を図るため ・Renovate の導入推進による依存ライブラリの更新の効率化  ・低コストでこまめにアップデートする仕組みを整備することにより、安全性と安定性の向上を図るため ・Codecov の導入推進によるテストカバレッジの可視化  ・日々の開発運用に欠かせない自動テストの改善活動を進めやすくするため GitHub Copilot については AI からの提案内容について吟味する必要があるなど注意は必要ですが、利用しているエンジニアからは開発効率が良くなったという声が多かったです。 まとめ 上記で紹介した取り組みは一部で、他にも多数リファクタリングや各種改善活動を各開発チームで進めています。 どんな事業でも同様ですが、提供するサービスや組織を運営し成長させ続けていくためには、目には見えにくい部分の改善活動が必要になります。 こういった活動とサービス改善に直結する施策開発のバランスをどう取るのかは常に難しいことですが、スタンバイでは四半期ごとに全体での各種案件の優先度調整を実施できていることもありバランス良く取り組めていると感じています。 こういった取り組みで開発活動の地盤を固めつつ、求人検索サービスの磨き込みをしていきたいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
こんにちは、スタンバイで求人の取り込みシステムを開発・運用をしている鈴木です。 今回は Scala と Go に標準で組み込まれている正規表現エンジンの違いについてです。 概要 スタイバイでは Scala で書かれたシステムを Go にリプレイスする開発が進んでいます。 その中で Scala で実装されている正規表現が Go だと動かない事象に遭遇しました。その違いはどんなものがあるのか?まとめます。 機能差分の一例 今回発見した機能差分の例を見てみましょう。 スタンバイで扱っている求人情報を管理するために正規表現を使って色々な情報を抽出しています。 例としてこんな感じの求人情報から給与の情報を抽出したいとします。 (簡単化のため金額のカンマを削除しています。) ◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます 数字を基準に抽出すれば良いのですが、単純に数字をマッチしてしまうと 1日8h や 22日勤務 なども抽出してしまうので、それらを除外しましょう。 そうするとこんな感じの正規表現で実現できます。 [1-9]+[0-9\.十百千万億\s ]*(?!\d*[分|時|日|月|年|h|級]) ざっくりどんなマッチになるかと言いますと、前半の [1-9]+[0-9\.十百千万億\s ]* の部分は数値と金額の単位をマッチしています。 後半の (?!\d*[分|時|日|月|年|h|級]) は前半のマッチのなかでも 分|時|日|月|年|h|級 の字が続く場合は除外しています。 この ?! で表されている マッチしないこと の動きが Go の正規表現では対応しておらずエラーになってしまいます。 error parsing regexp: invalid or unsupported Perl syntax: `(?!` これは Go の正規表現エンジンが否定先読みの機能をサポートしていないためです。 このように正規表現には正規表現を解釈して実行するエンジンがいくつかあり、サポートしている機能に差があります。 バックトラッキングについて Go で標準ライブラリを使用した正規表現は RE2 エンジンで動きます。 RE2 は他の正規表現エンジンと比較してバックトラッキングを行わない特徴があります。 否定先読みができなかったのもこれが関連しているわけですね。 このバックトラッキングを行わないことで多彩な機能は使えないものの、処理時間が線形時間で動作しメモリの使用量も抑えることができます。 一方、Scala で scala.util.matching.Regex を使った場合は java 標準の正規表現が使われます。 こちらはバックトラッキングをサポートしてるので RE2 と比べて否定先読みのように複雑なマッチングができます。 バックトラッキングの様子は こちら などのサイトで正規表現のデバッグをすると確認しやすいです。 デバッグモードは PCRE 系の正規表現エンジンのみ対応しているようなので PCRE2 で見てみましょう。 画面上部の REGULAR EXPRESSION の入力に正規表現を、その下の TEST STRING にマッチさせたい文字列を入力します。 そして、左メニューの FLAVOR で PCRE2 を選び Regex Debugger からデバッグができます。 Match1 を進めていくと ◎看護師(正・准)/時給1400円 のうち 1400 の部分にマッチして1つ目が完了しています。 Match2 から Match4 までも同様に 1600 , 246400 , 281600 にマッチしています。 Match5 を進めると 20日 の 20 にマッチしていますが、その後に 20 の後に 日 が マッチしないこと を確認している様子がわかります。 さらに進めると今度は先頭の2を除外して 0 まで戻った後、再び 日 がマッチしないことを確認しています。 このようにバックトラッキングを利用すると同じ箇所に何度もマッチするか試行してしまうため、場合によっては指数関数的に処理量が増加し、それに応じて必要なメモリ量も増えてしまいます。 そのため RE2 では処理量が線型増加していくという性質はパフォーマンスに関わってくるのが分かりますね。 その他の機能比較 バックリファレンス バックリファレンスは一度マッチしたグループを再利用できます。 こんな正規表現 (\b\w+at\b).*\1 でこんな文字列 The cat sat on the mat with another cat. をマッチしてみると... \1 の部分が初回マッチした cat となることで末尾の cat にマッチしていることがわかります。 ゼロ幅マッチ 正規表現上でも特別な意味を持つメタ文字があります。 ^ は文字列の先頭 $ は末尾にマッチするなどですね。基本的には Go でもゼロ幅マッチが利用できますが利用できないパターンがいくつかあります。 例えばこんな正規表現 cat(?=\s) でこんな文字列 The cat sat on the mat with another cat. をマッチしてみると... マッチの条件に空白は含んでいますがマッチ結果には含まれていないことがわかります。 条件付きの正規表現 条件をつけて満たす場合のパターンと満たさない場合のパターンの分岐をする機能です。 (?(test) true| false) のように ?(条件) と true/false のパターンといった記述になります。 こちらは java の正規表現でもサポートされていません。 フォワードリファレンス バックリファレンスの逆で後のキャプチャグループを参照できるらしいです。 が、マッチ試行していない部分を参照するという特殊な挙動なので利用できる正規表現エンジンはかなり限られるようです。 こちらも java, go ともにこの機能は使えません。 他の正規表現エンジンを Go で使う方法 このように Go の標準パッケージを利用すると、Scala では利用できていた一部の正規表現の機能が利用できなくなりました。 Go から標準の RE2 以外にも他の正規表現エンジンを利用する方法があるようですが、日本語対応に怪しいところがあるようです。 package main import ( "fmt" "github.com/GRbit/go-pcre" ) func main() { pattern := pcre.MustCompile(`[1-9]+[0-9\.十百千万億\s ]*(?!\d*[時|日|月|年|%|h|級])`, 0) subject := "◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます" matcher := pattern.NewMatcher([]byte(subject), 0) for matcher.Matches { fmt.Printf("GroupString: %s\n", matcher.GroupString(0)) indices := matcher.Index() end := indices[1] subject = subject[end:] matcher = pattern.NewMatcher([]byte(subject[end:]), 0) } } 正規表現のパターンに 分 が含まれている場合に、入力文字列に含まれていなくてもマッチ結果が変わりました。また、マッチした文字列が文字数単位ではなくバイト単位で切り取られて文字化けしてしまう事象もありました。 現在取り組んでいるリプレイスの開発は求人情報から特定の情報を抽出するロジック全体も見直しつつ開発する方針で進めているため、正規表現エンジンに関しては外部ライブラリは採用しませんでした。 終わりに このように正規表現のエンジンは複数あり様々な特性を持っています。 今回は Scala(Java) と Go の正規表現の機能についてのみの調査になりましたが、動作速度のベンチマークを基準に比較するのも面白そうです。 必要な要件に合わせて適切な正規表現エンジンが選択できるようになれるといいですね! スタンバイでは、常に新しいアイデアや技術を調査し、試しています。新しいことに挑戦したい方や、素晴らしいプラットフォームで素晴らしい仲間と仕事をしたい方は、ぜひ 採用ページ をご覧ください! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
はじめに こんにちは、スタンバイのアプリチームでiOS開発を担当している小村祐輝と申します。 私たちスタンバイのiOSチームでは、SwiftUIやCombine、Concurrencyなどのモダンな技術を用いて日々開発を進めています。 その中で、直近で浮上した課題の1つが「UIテスト」です。 この記事では、私たちスタンバイのiOSチームがどのようにしてUIテストを構築し、運用しているのかをご紹介します。 UIテスト導入の背景や、その必要性、具体的な手法やツールについても解説していきます。 UIテストの必要性とログ送信テスト導入の背景 スタンバイは、求職者と企業をつなぐプラットフォームとして展開しており、数多くの求人サイトを一括して検索・比較できる「アグリゲーションサービス」です。 アプリのデザインは求人の検索から応募までを簡単に行えるようになっており、月間アクティブユーザー数も急増しています。 ですが、アプリの規模が大きくなるにつれて、手動テストだけでは限界を感じる場面が増えてきたのもまた事実でした。 特にユーザー行動の分析が重要なスタンバイにおいては、送信されるログが正確かつ適切なタイミングで送られているかの確認が必須です。 そこで、まずはログ送信テストを重点的に行うUIテストの導入を開始しました。 ログ送信テストにUIテストを導入する理由 とはいえ、ログ送信のテストと聞くと中には、 「ユニットテストで担保できるのでは?」 と思われる方もいらっしゃるのではないでしょうか。 確かに、ログの内容だけであればユニットテストでも十分です。 しかし、ユーザー操作を伴うログ送信は、実際のユースケースに基づいたUIテストの方が堅牢性は高まります。 私たちは、この点を重要視し、UIテストを選択しました。 UIテスト構築の課題とViewInspectorの採用 しかし、iOSチームにはUIテスト構築の経験者がいませんでした。 XCUITestは学習コストが高く、UIテストを構築するには大きな壁が立ちはだかったわけです。 そんな中で出会ったのが、ViewInspectorというライブラリです。 ViewInspectorとは? ViewInspectorとは、SwiftUIで構築されたViewに対して、プログラムから直接アクセスし、その状態や動作を確認できるライブラリです。 ViewInspector SwiftUIのViewはその構造上、内部の状態を直接参照したり、検証したりすることが難しくなっています。 XCUITestの学習コストもそうですが、これもXCUITestを導入する際の障壁の1つでした。 ViewInspectorは、この問題を解決するために作られたツールであり、開発者がSwiftUIのViewを簡単にテストできる環境を提供してくれています。 具体的には、Viewの階層構造にアクセスし、 特定のViewやそのプロパティ 表示されるテキスト ボタンのアクション などをプログラム的に検証できます。 これにより、手動でのUIテストに頼ることなく、ログ送信のような重要な機能に対しても、実際のユーザー操作を模倣したテストを効率的に行うことが可能になるわけです。 また、ViewInspectorは直感的なAPIを提供しているため、SwiftUIを使っている開発者であれば、比較的容易に導入できる点も大きなメリットに他なりません。 私たちiOSチームも、UIテストの複雑さや学習コストの高さに悩まされていた中で、このViewInspectorを採用することで、テスト環境の構築をスムーズに進めることができました。 基本的な使い方と例 この次の項目からスタンバイのiOSプロジェクトでどのようにViewInspectorを利用しているのかを解説しますが、それに先立ってまずはViewInspectorの基本的な使い方を説明します。 ここでは3つのテストパターンを用意したので、それぞれ具体的に掘り下げていきます。 1. テキスト表示の検証 まず、 Text が正しく表示されているかを確認する基本的なテストを例に説明していきます。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct SimpleTextView : View { var body : some View { Text( "Hello, ViewInspector!" ) } } class SimpleTextViewTests : XCTestCase { func testTextIsDisplayedCorrectly () throws { let view = SimpleTextView() // ViewInspectorでTextの内容を取得 let text = try view.inspect().find(text : "Hello, ViewInspector!" ).text().string() // 検証 XCTAssertEqual(text, "Hello, ViewInspector!" ) } } ViewInspectorで特定のViewにアクセスする際、まずは view.inspect() と宣言した上で、Viewを階層的に参照していく必要があります。 view.inspect() これにより、内部的に参照対象の RootView が取得され、そこからさらに指定した要素へアクセスできるようになります。 次に、特定のテキストを持つViewにアクセスするためには、以下のように find(text:) メソッドを使います。 view.inspect().find(text : "Hello, ViewInspector!" ) これで、 "Hello, ViewInspector!" というテキストを持つViewが取得できます。 さらに今回は、このテキストの正確性をテストするため、文字列そのものを取得する必要があります。 そのために、 text() メソッドを使用して次のように記述します。 view.inspect().find(text : "Hello, ViewInspector!" ).text() これで、テキスト要素の文字列データにアクセスできました。 次に、この文字列を string() メソッドで取得し、テストの期待値と比較します。 let text = try view.inspect().find(text : "Hello, ViewInspector!" ).text().string() XCTAssertEqual(text, "Hello, ViewInspector!" ) ここで、 XCTAssertEqual を使って、取得したテキストが期待される内容 "Hello, ViewInspector!" であるかどうかを検証します。 2. ボタンのタップと状態変更の検証 次に、ボタンをタップして、内部状態が更新される動作テストを解説していきます。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct CounterView : View { @State private var count = 0 var body : some View { VStack { Text( " \( count ) " ) Button( "Increment" ) { count += 1 } } } } class CounterViewTests : XCTestCase { func testButtonTapIncrementsCounter () throws { let view = CounterView() let sut = try view.inspect() // 初期状態を確認 XCTAssertEqual( try sut.find(text : "0" ).text().string(), "0" ) // ボタンをタップ try sut.find(button : "Increment" ).tap() // タップ後、カウンターが1に増えていることを検証 XCTAssertEqual( try sut.find(text : "1" ).text().string(), "1" ) } } まず、このテストでは CounterView というカウンター機能を持ったシンプルなSwiftUIビューの動作を検証しています。 特定のボタンをタップした際に、カウントが正しくインクリメントされているかを確認するテストです。 XCTAssertEqual( try sut.find(text : "0" ).text().string(), "0" ) ここでは、 CounterView が持つ Text の初期状態が 0 という事を確認しています。 view.inspect() でRootViewにアクセスした後、 find(text:) を使って Text("0") を検索しています。 この Text はカウントを表示する部分です。 次に、 text() メソッドを使って Text 要素の中身(文字列データ)を取得し、 string() メソッドでその内容を文字列として取得します。 最終的に、 XCTAssertEqual を使用して、取得した文字列が初期状態で文字列 "0" であるかどうかを確認します。 その上で、ボタンをタップしてカウンターの値をインクリメントする処理を以下で実行しています。 try sut.find(button : "Increment" ).tap() ここでは、 find(button:) メソッドを使用して、 "Increment" というテキストを持つ Button を検索しています。 tap() メソッドを使うことで、ViewInspectorはそのボタンを実際にタップし、@Stateで管理されているカウンターの状態を更新できるわけです。 XCTAssertEqual( try sut.find(text : "1" ).text().string(), "1" ) タップ操作の後、カウントが 1 に増えていることを確認する箇所が上記です。 再度、 find(text:) を使って更新された Text("1") を検索すると共に、その内容を text().string() で取得し、期待通り取得した値が "1" であることを検証しています。 3. ネストされたビューの検証 最後に、ネストされたViewの中で特定のViewにアクセスし、その状態を検証する方法を解説します。 import SwiftUI import ViewInspector import XCTest // テスト対象のView struct ParentView : View { var body : some View { VStack { ChildView() } } } struct ChildView : View { var body : some View { Text( "child view" ) } } class ParentViewTests : XCTestCase { func testNestedViewText () throws { let view = ParentView() let sut = try view.inspect() // ネストされたChildViewのTextを確認 let text = try sut.find(ChildView. self ).find(text : "child view" ).text().string() // 検証 XCTAssertEqual(text, "child view" ) } } このテストケースでは、 ParentView という親ビューの中に ChildView という子ビューがあり、その中で表示されるテキストが正しいかどうかを確認しています。 ParentView は VStack の中に ChildView を含んでおり、ChildView では "child view" という固定のテキストが表示されています。 struct ParentView : View { var body : some View { VStack { ChildView() } } } その上で以下テストコードにもあるように、 testNestedViewText というメソッドでは、最初に ParentView のインスタンスを作成し、それを view.inspect() を使って検証の対象(sut)として設定します。 class ParentViewTests : XCTestCase { func testNestedViewText () throws { let view = ParentView() let sut = try view.inspect() // ネストされたChildViewのTextを確認 let text = try sut.find(ChildView. self ).find(text : "child view" ).text().string() // 検証 XCTAssertEqual(text, "child view" ) } } 次に、 sut を通じて、 ParentView の内部にある ChildView へアクセスします。 ここでは find(ChildView.self) を使用して、親ビュー内にある ChildView を見つけ出しています。 そして、 ChildView にアクセスした後、次に行うのは、その中に表示されているテキストの確認です。 ここは既に説明した通り、 find(text: "child view") を用いて、 ChildView 内のテキストを見つけ出します。 その後、 text() メソッドで Text 要素自体を取得し、さらに string() メソッドを使ってその文字列内容を取得します。 その上で、比較対象の検証するだけです。 XCTAssertEqual(text, "child view" ) 補足:ViewInspectorによる標準Viewへの階層アクセス方法 ここでは find() を利用して ChildView にアクセスしましたが、シーンによってはSwiftUI標準のViewにアクセスしたい場合もあるのではないでしょうか。 そのような独自の型を定義していない場合は、基本的にあらかじめViewInspector側で用意してある以下のようなメソッドを用いてViewの階層を掘っていくことが可能です。 try sut.vStack().hStack().geometryReader().zStack().group() ... これらのメソッドを利用することで、Viewの階層構造を1つずつ掘り下げながら目的のViewに到達できます。 一方で、以下のように find() を用いれば、直接目的のChildViewにアクセス可能です。 let text = try sut.vStack().find(ChildView. self ) find() を利用すると、階層をたどる手間を省けるため、特定のViewにアクセスするケースでは非常に便利です。 スタンバイにおけるViewInspectorの具体的な活用例 ViewInspectorの基本的な使い方は前述の通りで、複雑なケースでない限り、これだけで多くの動作をシミュレートできます。 その上で、冒頭で触れた通り、スタンバイのiOSアプリでは、このViewInspectorを用いてログ送信のテストを実装しているわけです。 ここでは実際のプロダクトにおける利用方法を元に、具体的なViewInspectorの使い方を解説していきます。 ログアナリティクスのモック化 まず、外部サービスに依存することなく、ログ送信をテストするために、ログ送信の代わりにモッククラスを作成します。 これにより、実際のネットワーク通信やサーバーの状態に左右されず、ログ送信が正しく行われるかどうかを検証できます。 class MockAnalyticsService : AnalyticsServiceProtocol { var events : [ MockAnalyticsEvent ] = [] var didFinish : (() -> Void ) ? func logEvent (_ name : String , parameters : [ String : Any ] ?) { let event = MockAnalyticsEvent(name : name , parameters : parameters ) events.append(event) didFinish?() } } ログ送信のテストコード 次に、具体的なテストケースとして、ボタンタップにより送信されるログが正しいかどうかを検証するコードの紹介です。 func test_ サンプル画面でのログ送信テスト() { // モックのViewModelに必要な情報を設定 viewModel.isLoading = false viewModel.items = [.stub(id : "item1" , name : "itemName1" , code : "itemCode1" )] let sut = SampleView(viewModel : self.viewModel ) let exp = analyticsExp(mockAnalyticsService : mockAnalyticsService ) // ボタンをタップしてイベントをトリガー do { try sut.inspect() .find(SampleCell. self ) // SampleViewの中のSampleCellを見つける .find(CellButton. self ) // SampleCellの中のCellButtonを見つける .find(button : "" ) // CellButtonの中のButtonを見つける(テキストなし) .tap() // 見つけたボタンをタップする } catch { XCTFail( "failed with: \( error ) " ) } wait( for : exp , timeout : 2.0 ) // 送信されるべきイベントを定義 let tapEvent = MockAnalyticsEvent( name : "tap_item" , parameters : [ "info1": "sample_screen", ..., ... ] ) // 実際に送信されたイベントと期待されるイベントを比較 verify(actual : mockAnalyticsService.events , expected : [ tapEvent ] ) } このコードでは、 SampleView で特定のアイテムをタップしたときに送信されるログが正しいかどうかを検証しています。 まず、viewModelに必要なアイテム情報を設定し、モックのログアナリティクスサービスを使ってログ送信イベントを捕捉します。 その上で、まずは画面描画に必要な設定値をプロパティにアサインしているのが以下の箇所です。 viewModel.isLoading = false viewModel.items = [.stub(id : "item1" , name : "itemName1" , code : "itemCode1" )] そして、以下の SampleView をご覧になって頂くと、 isLoading の状態に応じてViewを切り替えているので、今回Itemを表示するためにあらかじめ false をセットしています。 import SwiftUI struct SampleView : View { @StateObject var viewModel : SampleViewModel var body : some View { Group { if viewModel.isLoading { LoadingView() } else if viewModel.jobs.isEmpty { EmptyCell() } else { List { ForEach( 0 ..< viewModel.items.count, id : \. self ) { index in let item = viewModel.items[index] JobCell(item : item , didTap : { viewModel.showDetail(item : item ) }) } } } } } } 以下のコードでは設定したViewModelを用いてViewを初期化し、内部的にレンダリングを実行している箇所です。 let sut = SampleView(viewModel : self.viewModel ) 上記のようにViewを初期化することで初めて、ViewInspectorによるViewの参照や検証が可能になります。 初期化により返されるViewを sut とし、それを用いてこれまでに解説してきた方法で目当てのViewまで掘り下げていくのが以下の実装です。 do { try sut.inspect() .find(SampleCell. self ) // SampleViewの中のSampleCellを見つける .find(CellButton. self ) // SampleCellの中のCellButtonを見つける .find(button : "" ) // CellButtonの中のButtonを見つける(テキストなし) .tap() // 見つけたボタンをタップする } catch { XCTFail( "failed with: \( error ) " ) } コメントにもある通り、独自で作成した型を明示的に指定して対象となる Button まで掘り下げていきます。 その上で .tap() を実行することで、内部的にユーザーが特定のItemをタップしたのと同じ動きを実現しているということです。 ここまで来ればあとは簡単で、 .tap() により実行されるログ送信イベントを補足し、あらかじめexpectとして用意したデータと比較することで簡単にテストできます。 let tapEvent = MockAnalyticsEvent( name : "tap_item" , parameters : [ "info1": "sample_screen", ... ... ] ) // 実際に送信されたイベントと期待されるイベントを比較 verify(actual : mockAnalyticsService.events , expected : [ tapEvent ] ) ちなみに、以下のようなタイムアウト処理を入れているのは、非同期に実行されるログ送信処理が完了するのを待つためです。 wait( for : exp , timeout : 2.0 ) そのため遅延時間を指定しているのですが、ここに関してはあまり参考にしてほしくはなく、テスト実行環境次第では普通に落ちてしまうこともあります... ここは解消したい課題ではありますが、ほとんどの環境ではあまり気にすることもないので、現在のプロダクトコードでは上記のような形でも特段問題はありません。 ここまでで解説した手順で当初目的としていた、 イベント内容の正確性チェック 適切なタイミングでログが送信されているかのチェック を満たすことができました。 あとは、テストが必要な箇所で上記のように実装するだけであり、慣れたら非常に簡単です。 実際、UIテストやViewInspectorを利用したことがないメンバーに新規イベントのテストを着手してもらいましたが、非常に短時間で実装できたので、手軽にUIのテストを構築したい場合は非常に価値あるもになるはずです。 ViewInspectorのデメリット ここまでViewInspectorのメリットに焦点を当ててきましたが、実際に利用してみると、いくつかのデメリットもあります。 一連のUI動作や画面遷移のテストが難しい 複雑なアニメーションやジェスチャー操作には対応していない テスト実行時にWarningが出ることもあり、原因が不明確 特に、一連のUI動作をテストする場合、ViewInspectorでは難しいと感じることがあります。 なぜなら、ViewInspectorは主にビューの内部状態やプロパティを直接検証するために設計されており、ユーザーの操作を再現したアプリ全体の流れや画面遷移、アニメーションといった動的な動作を包括的にテストするには向いていないからです。 実際、ViewInspectorはUI全体の振る舞いをテストするというより、個別のコンポーネントが正しく動作するかを確認するためのユニットテスト向けのライブラリです。 そのため、アプリ全体のユーザーインターフェースや複雑なジェスチャー、アニメーションの動作を検証したい場合は、標準のXCUITestなど他のツールを併用する必要があります。 とはいえ、特定の画面やコンポーネントに焦点を当てたテストを素早く構築したい場合、ViewInspectorは非常に有用です。 導入コストも低く、コストパフォーマンスという面でも優れているため、使い方によっては十分な価値があると感じています。 まとめ UIテストでは、iOSにおいてXCUITestが理想的ですが、ナレッジや経験が十分でないチームも多いのが現状です。 そんなチームにとって、ViewInspectorは手軽に導入できる有力な選択肢になるのではないでしょうか。 また、私たちは他にもViewInspectorを使ったテストを書いています。 それについてはまた別の機会にご紹介するつもりなので、引き続きお読みいただければ幸いです。 ここまでご精読いただき、ありがとうございました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
スタンバイアドベントカレンダー 2024 の 3 日目です! (スタンバイでは、毎年アドベントカレンダーを実施しており、そちらにもこの記事をリンクさせています。スタンバイアドベントカレンダーに興味を持っていただいた方は、そちらもご覧いただけると嬉しいです。) 株式会社スタンバイでフロントエンドエンジニアをしている川野です。 フロントエンドエンジニアという役割を担っていますが、最近では開発者体験や開発生産性というところに興味があり、そのあたりの改善にもよく取り組んでいます。 はじめに フロントエンドの機能を追加するとき、該当機能の影響を分析した上で 100% 公開に進むため、私たちはよく A/B テストを実施します。 いくつかのパターンを用意し、それぞれのパターンに対して異なる実装をします。 そして A/B テストの結果、その企画が棄却されることになると、そのコードは削除されます。 不要となったパターンのコードを削除する際に、TypeScript の AST と JSDoc を使うことで、コードの中から削除する箇所を機械的に検出し、安全に削除するプログラムを作ることができたので紹介します。 ただし、今回紹介する方法では、条件分岐を含むような複雑なケースに対応できていないため、すべてのケースには対応できません。 可能であれば、改善していきたい今後の課題です。 困っていたこと A/B テストのコードは頻繁に追加され、削除されます。 そのため、削除対象の箇所がすぐにわかるように、コメントアウトを使って目印を残していました。 たとえば、このような感じです。 const messages = { // ↓↓↓ AB テスト (KEY-042) no001: (value: string) => { return `No 001: ${value}`; }, no003: (value: string) => { return `No 003: ${value}`; }, // ↑↑↑ AB テスト (KEY-042) no002: (value: string) => { return `No 002: ${value}`; }, }; この方法にはいくつかの問題がありました。 ある A/B テストの範囲に、誤って別の A/B テストのコードが含まれてしまうことがある。 必要なコードまで消してしまうリスクがある。 順序通りにコードを書きたくても、範囲の記述により、順序通りに書けないことがある。 たとえば、オブジェクトのキーが上記のサンプルのように連番ときに、それを順序通りに記述的ないケースがある。 対応するコメントアウトを記述し忘れることがある。 たとえば、開いているが閉じていないコメントアウトがある。 人の目で確認して削除するため、削除し忘れることがある。 最初は、これらの問題を解決するための目印の残し方を考えていました。 そこで思いついたのが、JSDoc を使うことでした。 そして、JSDoc を使うのであれば、構文解析することで削除対象のコードを検出し、そのまま削除できるのではないかと考えました。 TypeScript の AST と JSDoc を使って課題を解決する AST (Abstract Syntax Tree: 抽象構文木) は、ソースコードをツリー構造で表現したものです。 AST を使うことで、削除対象のコードブロックを検出できます。 先ほど例に挙げたコードを、次のように修正します。 ここでは、JSDoc のタグを abtest にしています。 const messages = { /** @abtest KEY-042 */ no001: (value: string) => { return `No 001: ${value}`; }, no002: (value: string) => { return `No 002: ${value}`; }, /** @abtest KEY-042 */ no003: (value: string) => { return `No 003: ${value}`; }, }; この中から削除対象となるコードブロックを検出し、削除するプログラムは次のようになります。 import path from "path"; import { Node, Project } from "ts-morph"; import ts from "typescript"; const project = new Project({ tsConfigFilePath: path.join(import.meta.dirname, "/path/to/tsconfig.json"), }); const sourceFiles = project.getSourceFiles(); sourceFiles.forEach((sourceFile) => { sourceFile.forEachDescendant((node) => { // `remove` メソッドを持っていない `node` もあるため、型を絞り込む必要があります。 // 必要に応じて、絞り込む条件を追加します。 if (!Node.isPropertyAssignment(node)) return; // ts-morph で任意の `node` に対して JSDoc を取得する方法を見つけられなかったため、TypeScript の機能も併用しています。 // ts-morph で取得した `node` は `node.compilerNode` とすることで、 ts の関数に渡すことができます。 const jsDocs = ts.getJSDocTags(node.compilerNode); jsDocs.forEach((jsDoc) => { if (jsDoc.tagName.text === "abtest" && jsDoc.comment === "KEY-042") { node.remove(); } }); }); sourceFile.saveSync(); }); ts.getJSDocTags を使うことで、 node が持つ JSDoc のタグの一覧を取得できます。 このタグの中に、削除対象の条件と合致するものがあれば、その node を削除します。 これで不要になったコードを安全に削除できます。 まとめ TypeScript の AST と JSDoc を使うことで、コードの中から削除する箇所を検出し、安全に削除するプログラムを作ることができました。 これまで人間が目視で注意深く読みながらコードを削除していましたが、プログラムによって安全に行えるようになりました。 JSDoc にタグを書いておくだけで、コードを読まなくても不要になった部分を削除できるため、作業効率も良くなりました。 最初はどのように目印を残そうかと考えていただけだったのが、生産性の向上にまでつなげることができたのは嬉しい誤算でした。 取り組み自体はそこまで派手なものではないですが、このような地味に嬉しい改善をこれからも続けていきたいと考えています。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
株式会社スタンバイでフロントエンドエンジニアをしている川野です。 フロントエンドエンジニアという役割を担っていますが、最近では開発者体験や開発生産性というところに興味があり、そのあたりの改善にもよく取り組んでいます。 はじめに 私たちのチームでは、Google Apps Script (GAS) を利用して、非エンジニアの人たちがシステムにスプレッドシートのデータをアップロードできる仕組みを構築しています。 GAS は手軽に開発でき、プロジェクト開始当初の小さな要求を満たすには十分なものでした。 しかし、プロジェクトが進むにつれ次第に要件が増えていき、GAS で対応するのが大変になるほど複雑になってきました。 また GAS では、型の恩恵を受けることが難しかったり、エディタのサポートが満足いくものでなかったりし、増大していく複雑さに立ち向かうのが難しくなってきました。 このような課題を解決し、開発者体験や開発生産性を向上させるために、GAS から TypeScript への移行を決めました。 また、あわせてソフトウェアアーキテクチャも刷新しました。 その中で得られた学びや気づきなどをいくつか紹介します。 GAS から TypeScript への移行 TypeScript に移行するメリット TypeScript に移行することで次のようなメリットが得られます。 型の恩恵を受けることができる。 npm パッケージを利用できる。 ESLint で静的解析できる。 Prettier でコードフォーマットできる。 その他便利なライブラリを利用できる。 テストを書くことができる。 手慣れたエディタで開発できる。 いくつか挙げましたが、いつもの TypeScript での開発と同様の開発者体験を得られるようになります。 GAS アプリケーションの開発において、これは大きなメリットだと考えられます。 TypeScript で開発するには TypeScript で開発したものは、GAS のプラットフォーム上にデプロイする必要があります。 clasp という Google が開発している CLI ツールがあるので、それを導入する必要があります。 そして後述の理由により、TypeScript のコードを一度 JavaScript にビルドする必要があります。 私たちのプロジェクトでは、次のような npm scripts を用意し、ビルドとデプロイを実行できるようにしました。 { " scripts ": { " build:<feature> ": " vite build -c src/features/<feature>/vite.config.ts ", " deploy:<feature> ": " clasp -P <output>/<feature> push " } } 後述していますが、私たちのプロジェクトでは Package by Feature を採用しています。 そのため、それぞれの機能ごとに vite.config.ts が用意されており、そこに <output> のパスが記述されています。 clasp を使うときの注意点 clasp は、TypeScript のコードを GAS のコードに変換する機能を持っていますが、注意すべき制約があります。 それは、import/export 構文を扱うことができないことです。 そのため、デプロイする前に TypeScript のコードをビルドする必要があります。 ビルドには Vite を利用しました。 (将来的に凝った UI を作りたくなったときのことも視野に入れて。) ビルドするときの注意点 GAS にはトリガーと呼ばれる、組み込みの予約済み関数があります。 たとえば、 onOpen や doGet といった関数です。 このような関数は、TypeScript のコード上からは参照されません。 そのため、ビルド時のツリーシェイキングが有効になっていると、これらの関数が削除されてしまい、正常に動作しなくなります。 この問題に対して、専用の Vite のプラグインを作ることで解決しました。 import fs from "fs/promises"; import type { Plugin } from "vite"; interface Options { inputDir: string; outputDir: string; } export const keepGasTrigger = ({ inputDir, outputDir }: Options): Plugin => { const GAS_TRIGGER = [ "onOpen", "onInstall", "onEdit", "onSelectionChange", "doGet", "doPost", ]; // 1. トリガー関数を利用するダミーコードを生成する。 const dummyCodes = GAS_TRIGGER.map( (trigger) => `void ${trigger}.name.toString();` ).join("\n"); return { name: "vite-plugin-keep-gas-trigger", config: (config) => { return { ...config, build: { rollupOptions: { input: `${inputDir}/main.ts`, output: { dir: outputDir, entryFileNames: "[name].js", format: "commonjs", }, }, }, }; }, transform: (code, id) => { // 2. ダミーコードをコードの末尾に追加する。 if (id.includes("main.ts")) return [code, dummyCodes].join("\n"); return code; }, closeBundle: async () => { const filePath = `${outputDir}/main.js`; const code = await fs.readFile(filePath, "utf-8"); // 3. ビルド後のコードからダミーコードを削除する。 const transformed = code.replace(`${dummyCodes}\n`, ""); await fs.writeFile(filePath, transformed, "utf-8"); }, }; }; このように、ビルド前にトリガー関数を利用するダミーコードを追加し、ビルド後にそのダミーコードを削除するという、けっこう力技なことをしています。 本当にこれでいいのか?という疑問が拭えないので、良い解決策が見つかれば、乗り換えたいと思っています… ソフトウェアアーキテクチャの刷新 どのように刷新したか TypeScript への移行に伴い、ソフトウェアアーキテクチャも大幅に刷新しました。 というのも、そのまま移植したのでは複雑さや変化していく要件に立ち向かうことができないと思ったからです。 ソフトウェアアーキテクチャを見直すことで、より保守性の高いコードを書くことができるようになり、開発者体験や開発生産性を向上させることができると考えました。 アーキテクチャは、クリーンアーキテクチャ系ベースにし、以下のようなディレクトリ構成にしました。 src └── features └── awesome_feature # スプレッドシート単位で機能を作成する ├── controller # 外界とのやり取りを扱うコントローラを格納する ├── core # サービスやモデルなどビジネスロジックを扱うものを格納する ├── spreadsheet # スプレッドシートに関するものを格納する ├── main.ts # エントリーポイント(トリガー関数の記述等を行なっている) └── vite.config.ts # Vite の設定ファイル(本機能のビルド用) spreadsheet は controller や core など混ざって密結合しないように、外からコンストラクタを経由して依存注入するようにしました。 また、Package by Feature を採用し、機能ごとにディレクトリを分ける構成にしました。 こうすることで責務が整理され、コードの保守性が高まり、コードの読み書きがしやすくなると考えたからです。 スプレッドシートの扱い 今回の要件では、データをアップロードする前にバリデーションを行う必要があります。 スプレッドシートはデータベースのようなもので、今まで Repository パターンを使ってデータを取得するものだと思っていました。 しかし、その考え方だと、Repository から取得したデータに対してバリデーションを行う必要が出てきます。 個人的に、Repository から取得したデータに対してバリデーションを行うのは、違和感のある実装でした。 データベースにはバリデーション済みの安全なデータが格納されているイメージがあったからです。 そこで考え方を変え、スプレッドシートのデータをフォーム入力のようなもの、とみなすことにしました。 スプレッドシートのデータを、入力値としてコントローラで取得し、バリデーションを行うようにすることで、より自然な実装にできました。 同じスプレッドシートでも、データの扱い方次第では責務が異なり、それを見極めて適切なレイヤーに処理を書くことが重要だと感じました。 まとめ Google Apps Script の開発環境を刷新し、TypeScript へ移行しました。 その際に、ソフトウェアアーキテクチャも刷新し、責務を整理することによって保守性が高まり、コードの読み書きがしやすくなりました。 今後も、開発者体験や開発生産性を向上させるために、このような取り組みに積極的に取り組んでいきたいと考えています。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
概要 こんにちは、スタンバイで求人の取り込みシステムの開発・運用を担当している池田です。 スタンバイでは、求人データのマスターデータ管理に Aurora MySQL を使用しています。 運用開始から3年以上が経過し、その間にシステムは成長を続けてきましたが、それに伴いコストも徐々に増加していました。特に2024年は円高の影響も相まって、Aurora MySQL のインフラコストは、ますます大きな課題となりました。 実は2023年にも、Aurora MySQLのコスト削減を目指し、多くのRead操作をDynamoDBに移行することで、大幅な削減に成功しました(詳しくは こちらの記事 をご覧ください)。その時に55%のコスト削減を達成しましたが、2024年もさらなる最適化に挑戦しました! 今回のブログでは、2024年に行ったAurora MySQLのコスト最適化の取り組みと、どのようにしてさらに54%の削減を達成したのか、その詳細をご紹介します。 実施した施策と結果 まず先に結果からですが、2024年3月と2024年8月を比較した結果Aurora MySQLのインフラコストを約54%削減できました。 今回実施した施策と、その削減割合の内訳は以下の通りです。 番号 施策案 削減割合 ① Auroraクラスターのレプリカの台数を減らし、インスタンスタイプを下げる 9.6 %削減 ② Auroraクラスターの再構築により、肥大化した MySQL の ibdata1 を削除 30.5 %削減 ③ リザーブド DB インスタンスの購入 14.1 %削減 以下では、それぞれの施策について詳しく説明します。 ① Auroraクラスターのレプリカ台数を減らし、インスタンスタイプを下げる コスト最適化の前は、Auroraクラスターは6台のレプリカを使用していました。 レプリカのメトリクスを分析したところ、CPU使用率に十分な余裕があることが判明しました。これは、以前実施した対応や、過去1年にわたる仕様変更などによって、レプリカへの負荷が軽減されたためだと考えられます。そこで、レプリカ台数の削減とインスタンスタイプの変更について検討しました。 ただし、コスト削減を実施する際、パフォーマンスに悪影響が出ないようにすることが最も重要です。そのため、処理負荷のピーク時でもCPU使用率が60%を超えない範囲で、段階的にインスタンスタイプの変更とレプリカ台数の削減を進めました。 具体的には、負荷試験を事前に実施し、インスタンスの変更を段階的に行い、その都度状況を観察しました。詳細なインスタンスタイプは非公開ですが、レプリカ数を6台から5台に減らし、インスタンスタイプを一段階下げることで、約 9.6% のコスト削減を達成しました。 Auroraクラスター全体では9.6%の削減ですが、レプリカインスタンスに関しては、 変更前のインスタンス費用の約半分まで削減できました 。 なお、今回対象としているAurora MySQLは、write-heavyなアプリケーションであり、メトリクスを確認したところ、プライマリインスタンスにはコスト削減の余地がほとんどないと判断しました。 そのため、プライマリのインスタンスタイプは据え置くことにしました。 ② Auroraクラスターの再構築により、肥大化した MySQL の ibdata1 を削除 今回の施策の中でも、最も時間がかかった施策がこの「Auroraクラスターの再構築」です。 AuroraのCostExploreを確認すると、Auroraのストレージ料金であるAurora:StorageUsageという指標があります。 このAurora:StorageUsage は Auroraが使用しているストレージ容量を示す指標で、実データやインデックスだけでなく、InnoDBテーブルのメタデータやバッファ、UNDOログなどのシステムデータも含まれています。 これらの管理データは、MySQLのInnoDBストレージエンジンによってibdata1というファイルに格納されています。このibdata1ファイルは、以下のクエリで確認できます。 mysql> SELECT file_name,tablespace_name,engine,ROUND(SUM(total_extents * extent_size) / (1024 * 1024 * 1024 * 1024), 2) AS "TableSizeinTB" FROM information_schema.FILES WHERE file_name LIKE '%ibdata%'; +-----------+-----------------+--------+---------------+ | file_name | tablespace_name | engine | TableSizeinTB | +-----------+-----------------+--------+---------------+ | ./ibdata1 | innodb_system | InnoDB | 59.87 | +-----------+-----------------+--------+---------------+ 1 row in set (0.03 sec) ストレージに占める割合を調査した結果、ibdata1ファイルはAuroraが使用しているストレージ容量の 99.97% を占めていることがわかりました。 長期間にわたり、スタンバイの求人データを管理してきた結果、ibdata1は非常に肥大化していたのです。 ストレージ容量削減のために不要なテーブルやレコードを削除しましたが、ibdata1 はデータを削除してもサイズが減少しない ため、根本的な解決には一度Auroraクラスターを再構築する必要がありました。 ただし、物理バックアップ(DB クラスタースナップショット等)ではibdata1も含めて全データが復元されてしまうため、ストレージ削減の効果はありません。そこで、 論理バックアップ を使用して移行する必要がありました。しかし、テーブル数やレコード数が非常に多いため、物理バックアップに比べて時間がかかることが懸念されました。 いくつかの検証をした結果、 AWS Database Migration Service(DMS) を使用することで、ダウンタイムを最小限に抑えながらデータ移行を進められることがわかりました。 1 AWS DMS を利用する上で、工夫したこと AWS DMSは、ダウンタイムを最小限に抑えながらデータを移行できるマネージドサービスであり、MySQLを含む多くのデータベースに対応しています。 まず、 DBをサービスインしたまま移行できるか が最初の課題でした。これが可能であれば、ほぼダウンタイムなしでデータ移行が完了します。しかし、DMSを使用して検証をしたところ、フルロード完了後、レプリケーション遅延が広がり続ける問題が発生しました。 DMSは、Aurora MySQLのバイナリログを読み取ることで変更をキャプチャしますが、今回のように 更新頻度が非常に高い システムでは、 CDCソースレイテンシー (変更データキャプチャの遅延)が発生し、レプリケーションが追いつかなくなるという問題に直面しました。 ▲上記のキャプションで何度かCDCソースレイテンシーが下がっていますが、これは再度フルロードをやり直したためになります。 そこで、レプリケーションを行わず、Aurora MySQLのマスター更新を一時停止してDMSのフルロードを実行する方針に変更しました。検証の結果、マスター更新を停止した状態でのフルロードは約4時間で完了することが確認され、更新停止時間は許容範囲内に抑えられる見込みが立ちました。 最終的には社内で調整し、マスター更新を約4時間停止することで、無事DMSによるフルロード1回で再構を完了させました。 Aurora MySQL クラスター再構築の結果と効果 ibdata1のサイズを大幅に削減した結果、Aurora:StorageUsageのコストは 97.7%  削減されました。 さらに、次のような副次的効果も得られ、スタンバイのシステム全体に対して大きな改善が見られました。 Aurora MySQLのクエリレイテンシーが改善 Aurora MySQLに接続するAPIの応答速度も向上しました。 Aurora MySQLのバックアップ費用が97%低下 毎日バックアップを取得しているため、このコスト削減効果は非常に大きいです。 さらに、ibdata1 と直接的な因果関係がないのですが、Aurora:StorageIOUsage のコストも3月と比較して33%下がっておりました。(Aurora MySQLの削減割合で13.35% 削減) 明確な因果関係はわかっていないのですが、StorageUsageが下がったことに加え、インスタンス台数を減らしたことや、Aurora MySQLのメジャーバージョンをあげたことなどが影響しているかもしれません。 Aurora MySQL クラスター再構築の施策により、StorageUsage, StorageIOUsage, BackupUsageのインフラ費用が削減され、合計で3月のAuroraの費用から 30.5% のコスト削減に成功しました。 またこの施策以降、アプリケーションコードから不要なトランザクション処理を削除し、ibdata1が肥大化する速度を大幅に落としました。 ③リザーブド DB インスタンスの購入 最後に行った施策は、 リザーブド DB インスタンスの購入 です。 Amazon Aurora のリザーブド DB インスタンスは、1 年間または 3 年間の契約で特定のインスタンスタイプとリージョンを指定してインスタンスの予約購入することで、オンデマンドの DB インスタンスに比べて大幅な割引が適用されるプランです。 詳しくは、 Amazon Aurora 向けリザーブド DB インスタンス に記載されています。 リザーブドインスタンスは購入後にインスタンスタイプ、リージョン、期間の変更は不可となるため慎重さが求められます。複数メンバーで今後のAurora MySQLのキャパシティ予測を確認しながら購入するプランの検討を進めました。 また、支払い方法(「全額前払い」、「一部前払い」、「前払いなし」)によっても少し割引率が変わってくるため、支払い方法も含めて社内各所に確認と相談をし、購入を完了させました。 この施策によって全体の 14.1% のコスト削減を実現しました。 施策のまとめ 今回の記事で取り上げたこと以外にもいくつかコスト削減施策を実施しましたが、AWSのCost Explorerを活用して仮説を立て、それに基づいた施策の立案、検証、実施を進めることで大幅なコスト削減を達成できました。特に、長期間運用されていたAurora MySQLを再構築し、不要なトランザクション処理を見直すことで、大きなコスト削減を実現できたことが印象的でした。 また、Auroraクラスターの再構築によって、単にコスト削減だけでなく、DBクエリのレイテンシー改善やAPI応答速度の向上といった副次的な効果も得られました。 最後に 2023年にAurora MySQLのインフラコストを55%削減できましたが、今回の取り組みでさらにコストを54%削減し、最終的に Aurora MySQLのインフラコストが約1/5にまで下がりました。 (逆に言えば、昨年までのコストは現在の5倍もかかっていたということです。) コスト最適化は一度実施して終わりではなく、継続的に取り組むべきもの であると、改めて実感しました。そのため定期的にCost Explorerを確認し、分析することが重要かと思います。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com 論理バックアップの案として、Aurora MySQL への書き込みを停止し、mysqldump でデータをエクスポートし、新規作成した Aurora MySQL クラスターにインポートして再構築する方法も検討しました。しかし、この方法では停止時間が2日以上かかる見込みであったため、不採用としました。 ↩
2024年8月30日(金)台風10号の予報が出る中、 普段は静かなスタンバイ本社がいつもと違う熱気に包まれました。 8月27日(火)〜29日(木)の開発期間を経て、株式会社スタンバイ「ハッカソン2024」の最終プレゼン大会が開催されました。 開発期間は8時間×3日の24時間、社内公募により集まった8チーム計34名のメンバーが入賞を目指して準備しました。 開会式の様子 開会式では開催概要のほかに、審査員をChatGPTが解説する審査員紹介が披露され、 氏名の読み方など、細かなミスを除けば完璧な説明精度に、参加者から「おーっ!」と歓声があがりました。 COOの名前は「やまもと さとし」、社名は「Stanby」なんだけど・・・ 【ハッカソン2024のテーマ】 今回のハッカソンのテーマは、「時代とともに変わる『検索』に求人領域で向き合う」。 GenerativeAIを活用してそれぞれのチームが仕事探しに関わる課題を見つけ、解決につながるアイディアを出し合い、実機でデモができるアウトプットを競いました。 【★参加チーム名(チーム名の由来)と課題 / ソリューション】 ★一辻二鷹三細び(メンバーの頭文字を縁起の良い形式で) 課題:仕事探しへのアドバイスを受けられる人はなかなかいない ソリューション:自分の想いを自由入力+選択形式の超簡単フォームに入力するだけで、最適な仕事探しができる ★NA・KA・YA・MA軍団(中山リーダーの名前由来かと思ったら、メンバー全員の頭文字から) 課題:検索リテラシーが低い、自身の仕事の適正がわからないミドルシニア層を救いたい ソリューション:履歴書ファイルをアップロードすれば、適正のある仕事が提案される ★寝袋持参プログラマー(過去に寝袋持参して働いていたエンジニアだから) 課題:お金のためではなく、生きがいを見つけるために働く人を増やしたい ソリューション:人の感情に訴えられる求人情報を、AIを駆使して自動作成する   ★未来職道(AIに考えてもらった名前から) 課題:求人検索だけではなく、転職成功まで支援するサービスを求めているユーザーが多い ソリューション:LLMを通じて性格にあった業種を紹介し、就職までのリスキリングから支援する ★断罪の技術錬成者 ξ虚空に響くアルゴリズムξ(AIに考えてもらった名前だが、「ξ」は人力) 課題:異業種異職種への転職を支援する ソリューション:新しい職種を検索した際に、気付けていない条件を見つけて精度の高い検索をサポートする ★fan-LON-KUIN(メンバー頭文字の羅列から) 課題:仕事探しの潜在的な条件を言語化できない ソリューション:ユーザーが見た求人情報をもとに、新たな検索クエリを提案する ★HMMS(メンバーの頭文字の羅列から) 課題:転職のハードルが高く、現状に我慢している人が多い ソリューション:AIによる対話形式で、職歴/スキル/条件等をヒアリングしながら仕事の提案やアドバイスを行う ★なかのひとたち+a(メンバーのほとんどがハッカソン事務局メンバーだから) 課題:自分に合った仕事の探し方がわからず、手間がかかる ソリューション:AIとの音声会話だけで強みが引き出され、面接まで手軽に進める 【第1部:プレゼンテーション】 8つのチームが、3分という限られた時間の中でプレゼンテーションを行いました。自分たちが考え抜いたアイディアがなぜ実現されるべきなのかについて、言葉とスライドで熱弁しました。中には3分で語り切れずに、「もっと伝えたいことあったのに、、!」と悔しがりながら終了してしまうチームや、スライドにデモンストレーション動画を挿入しスムーズに進めるチームなど、各チームの想いが前面に出たプレゼンテーションとなりました。 「めっちゃ未来があります!」と締めくくる 求人業界の罪を背負うのではなく、断罪したいという思いを 【第2部:展示セッション】 展示セッションでは8つのブースにチームが配置され、 審査員や観客がチームブースへ訪問し、改めてサービスの紹介やより細かなデモンストレーションを確認しました。 第1部のプレゼンテーションでは確認しきれなかった、実際のデモ画面の動きや技術的な工夫・こだわりが垣間見えて「面白い着眼点!」「これは事業ロードマップに入れたいよね」といった声が審査員から挙がり盛り上がりました。 時間内に全てのブースを周りきれなかった人や、今回の発表参加者からも「他のブースも気になるので行きたいのに・・・」などの声が出るほど、興味深い内容が多く発表されました。 【表彰】 プレゼンテーションと展示セッションのデモンストレーションを終えて、いよいよ表彰グループの発表。表彰されたチームとコメントを紹介します。 CTO賞:なかのひと+a 課題:自分に合った仕事の探し方がわからず、手間がかかる ソリューション:AIとの音声会話だけで強みが引き出され、面接まで手軽に進める 評価ポイント ・選考プロセスを簡略化するという課題解決に着目している点が目を引いた ・クリアしなければいけない技術的課題があるものの、将来的にチャレンジしたいと感じた内容であった LINEヤフー執行役員賞:寝袋持参プログラマー 課題:お金のためではなく、生きがいを見つけるために働く人を増やしたい ソリューション:人の感情に訴えられる求人情報を、AIを駆使して自動作成する   評価ポイント ・求人を作る企業側に着眼点を持っていた点がユニーク ・求人のテキスト生成は、他社でもやろうとしており実現性もクリア ・画面生成部分まで検討しており、色々なバリエーションを作って企業に選んでもらう点も面白い 最優秀賞・COO賞:NA・KA・YA・MA軍団 課題:検索リテラシーが低い、自身の仕事の適正がわからないミドルシニア層を救いたい ソリューション:履歴書ファイルをアップロードすれば、適正のある仕事が提案される 評価ポイント ・非常にシンプル、レジュメを入れるだけで自分にあった求人が提案される点がUX観点で優秀 ・検索をしない世界をどう作るのか、ユーザーの情報をどう取得するのかという課題を解決するアプローチで、事業ロードマップにフィットさせられる 【総括】 CTO明石: 「受賞したかどうかに関わらず、サービスに取り込みたいアイデアがたくさんありました。最優秀賞の企画は今後のロードマップに載せて取り組むので、「NA・KA・YA・MA軍団」以外の皆さんにも関わってもらうプロダクトになります。今後も、今日のような機会で出てきたアイディアをどんどん世の中に出していきます。 事務局メンバーも急遽始まった企画だったにも関わらずお疲れ様でした。予算は少なかったですが、DIY感が出て良いイベントにしてくれました。次回は予算を確保して豪華にやりたいので期待していてください。 今朝、オフィスの雰囲気が凄くよかった!グループごとに固まって議論していて、賑やかな雰囲気だった。こういう機会を増やしてチームワークをより強化していきたいので、引き続きみんなで頑張っていきましょう!」 【ハッカソンを終えて】 スタンバイとしての初めてのハッカソンでした。 終えてみての感想をハッカソン参加メンバーに聞いてみると、 「普段の業務では接点がなかった人達と関わりを持てた」 「通常業務から一歩離れて、1からみんなで考えて作ることを楽しめた」 「他の人の考えや技術、スタンバイへの熱量を感じることができた」 など、日々の業務では体験しづらい刺激を受ける良い機会になりました。 また、同じ「仕事探し」というテーマでも、検索/求人作成/選考フロー/リスキリングなど、さまざまな課題に向き合いました。その中で最も多かった「検索」の中でも、情報の入力方法について各グループが多様な発想で考えて、アイディアが豊かに飛び交った時間でした。 そしてなにより、ハッカソンに参加したメンバーの充実した表情と「第2回があったらまた参加したい!」といった声を多数聞けたことが印象的で、今後のスタンバイの文化を象徴するイベントになる予感がしました! 企画から運営までを担当した事務局メンバーのみなさん、ありがとうございました。 第2回のハッカソン開催に、乞うご期待! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
初めまして、スタンバイのソフトウェアエンジニアを務めておりますの一般エンジニアです。 ChatGPTの流行により大規模言語モデル (LLM) が注目を集める中、生成AIの開発はさらに活発化しています。その中でも注目されているのが、RAG (Retrieval Augmented Generation) です。これは、外部ソースから関連情報を検索し、生成されたコンテンツに組み込むことで、より情報量が多く正確なテキストを生成する手法です。 本記事は、今年初めに社内向け作成したLLMとVespa活用プロトタイプの内容を再構成したものです。当時の Vespa 最新機能 (2024.03 時点) を用いてLLMの可能性を検証するデモでしたが、生成AIの開発は日々急速に進んでいるため、記事内容は最新の情報を完全に反映していない可能性があります。ご了承ください。 背景 Vespa は、検索やレコメンドなど、ハイパフォーマンスなサービングユースケース向けに設計された、オープンソースのサービングエンジンです。テキスト検索、ベクター検索、マルチフェーズ検索やランキングなどを含むハイブリッド検索など、最先端の機能を備えています。Vespaの詳細は、 公式サイト をご覧ください。 スタンバイでは、検索エンジン基盤としてVespaを採用しています。Vespa移行の詳細については、下記の同僚の 記事 をご覧ください。 プロトタイプアイデア スタンバイは、コアとなる 求人検索エンジン だけではなく、求職関連メディアサイト 「スタンバイPlus」 も運営しています。「スタンバイPlus」では専門家によるレビュー・編集済みの求職者向けの高品質なアドバイス、各種職種紹介など、様々なコンテンツを提供しています。 生成AIとサイトコンテンツを組み合わせれば、完全にセルフサービスのAI求職アドバイザーへと変貌し、求職者の疑問や不安に役立つ回答を提供できる可能性があります。 プロトタイプ 「教えて!スタンバイ太郎先生」 という名前のプロトタイプを開発しました。 このプロトタイプは以下のような機能を提供します: LLM 生成による質問に対する RAG レスポンス Vespaによる多言語セマンティック検索 LLMによる求職関連クエリの提案 質問に関連したスタンバイの求人推薦 プロトタイプの開発に使用したスタックは以下の通りです: 検索エンジン: Vespa LLMフレームワーク: LangChain 基礎モデル: Anthropic Claude 3 Haiku via AWS Bedrock Web App: Mercury プロトタイプの全体的なフローは下記の図のようになっています: 以降のセクションでは、主要部分のデザインの詳細について説明します。 ドキュメント処理 スキーマ まず、検索エンジンのスキーマを定義します。 今回はプロトタイプなので、検索用の最低限のフィールドのみ定義します。 schema article { document article { field title type string { indexing: summary | index index: enable-bm25 } field body type string { indexing: summary | index index: enable-bm25 summary : dynamic } field path type string { indexing: summary | index } } field embedding type tensor<float>(x[768]) { indexing { "passage: " . (input title || "") . " " . (input body || "") | embed e5 | attribute | index } attribute { distance-metric: angular } index: hnsw } field colbert_embs type tensor<int8>(token{}, x[16]) { indexing { (input title || "") . " " . (input body || "") | embed colbert | attribute } } fieldset default { fields: title,body } } フィールド スタンバイPlusの記事は、HTML形式からマークダウン形式に変換され、bodyフィールドに格納されます。 Embedder 新しいバージョンのVespaでは、Vespaクラスターのドキュメントプロセッサ内で直接実行されるEmbedderコンポーネントが導入されました。ドキュメントがVespaに投入されると、このプロセスで自動的にEmbeddingが生成されます。Vespaクラスター内で直接実行されるため、別のベクトル化APIを管理してEmbeddingを作成するための手間とコストが少なくて済みます。 このプロトタイプでは、ドキュメント投入時に2種類のEmbeddingを作成するための2つのEmbedderを定義しました。 1つは検索用のE5 Embedding、もう1つはColBERT Embeddingです。 Embedderは、Vespaクラスターのservices.xmlアプリケーションパッケージファイルに数行追加するだけで定義できます。 <component id = "e5" type = "hugging-face-embedder" > <transformer-model url = "/multilingual-e5-base/model.onnx" /> <tokenizer-model url = "/multilingual-e5-base/tokenizer.json" /> </component> <component id = "colbert" type = "colbert-embedder" > <transformer-model url = "/colbert/model.onnx" /> <tokenizer-model url = "/colbert/tokenizer.json" /> </component> URLは説明のためのものであり、実際に使用するURLとは異なります。 Vespa Embedderコンポーネントの詳細については、Vespa の エンベッディングドキュメント を参照してください。 ハイブリッド検索 このプロトタイプでは、従来通りの二段階ランキングを採用しました。 マッチング マッチングでは、Vespaでネイティブにサポートされている近傍ベースセマンティック検索(Nearest Neighbour Search)とレキシカル検索(Lexical Search)を組み合わせ利用します。(ハイブリッド検索として知られる) 近傍検索のほうがレキシカル検索より優れているのかという議論がよくありますが、レキシカル検索は時代遅れで退役すべきという意見もあります。実際には、レキシカル検索と近傍検索にはそれぞれ長所と短所があり、両方の検索結果を組み合わせることで、両方のメリットを享受できます。 Vespaの得意な点としては、1つの検索リクエストで同時に両方検索できるため、効率的で手間が少なくて済むことです。 Vespaのランクプロファイル関数内で、ファーストフェーズで直接同時検索できるように定義すると、ハイブリッド検索を簡単できます。 first-phase { expression: nativeRank + closeness(field, embedding) } nativeRankスコアはレキシカル検索からのテキストマッチングスコアであり、closenessはクエリとドキュメントのEmbedding間の近さを表します。 各検索からのスコアに重みを付けることもできますが、このプロトタイプでは最適化を行う時間がなかったため、単純に2つのスコアを加算しています。 このプロトタイプでは、デモ目的ですべてが同じマシン上で実行されているため、Embeddingモデルをファインチューニングする時間とリソースがありません。そのため、パフォーマンスと簡便性を考慮して、 intfloat/multilingual-e5-base テキストEmbeddingモデルを使用しています。 プロトタイプで使用したVespa検索エンジンの実際のリクエストは次のようになります。 { " yql ": " select title, body, path from article where (({targetHits:10}nearestNeighbor(embedding,q)) OR weakAnd(userQuery())) ", " ranking ": { " profile ": " colbert " } , " model ": { " locale ": " ja " } , " hits ":" 5 ", " query ":" 在宅ワークは何の仕事ですか? ", " input.query(q) ": " embed(e5, \" query: 在宅ワークは何の仕事ですか? \" ) ", " input.query(q_t) ": " embed(colbert, \" 在宅ワークは何の仕事ですか? \" ) " } このプロトタイプでは、Vespa Embedderコンポーネントを使用して、リクエスト時にユーザークエリをEmbeddingに変換し、近傍検索に使用しています。 リランク リランクステージでは、プロトタイプの目的の1つとして、最新のVespa機能を実証することだったので、ここでColBERTを使用することにしました。 ColBERT (Contextualized Late Interaction over BERT) は、効率的で効果的なドキュメント検索のために設計されたニューラルランキングモデルです。クエリとドキュメントの両方を BERT を使用して密なベクトルにエンコードします。個々のトークンEmbedding間の類似度スコアを計算することで、シーケンス全体ではなく、レイトインタラクションを実行します。 レイトインタラクション(Late Interaction)は、検索クエリとドキュメントを個別に処理し、プロセスの最終段階まで相互作用を遅らせることで、効率的で正確な検索を実現します。検索クエリとドキュメントの表現は、それぞれ独立してエンコードされ、その後相互作用が行われるため、レイトインタラクションと呼ばれています。 これにより、効率性を維持しながら、細かいマッチングが可能になります。ColBERTは、リランキングステージで使用できます。ファーストフェーズで検索された候補ドキュメントをより深い意味的な理解に基づいてランキングの精度を向上させることができます。 残念ながら、Vespa自体はColBERTで使用される類似度スコアを計算するためのMaxSim関数を提供していませんが、Vespaが提供する算術演算子を使用してカスタム関数を作成できます。 以下は、リランクフェーズのランクプロファイルのスニペットです function maxsim() { expression { sum( reduce( sum( query(q_t) * unpack_bits(attribute(colbert_embs)), x ), max, colbert_embs ), q_t ) } } second-phase { expression: maxsim() } リクエスト時に変換されたクエリEmbeddingと、フィーディング時に作成されたドキュメントEmbeddingが、セカンドフェーズで使用され、意味的類似度スコアを計算します。計算されたスコアに基づいて結果がソートされます。 レスポンスの生成 LLM Vespaから記事検索結果を取得した後、これらの記事がRAGのAG(Augmented Generation)部分で使用されます。 ファウンデーションモデルは、AWS Bedrockを介してAnthropic Claude 3 Haikuを使用しました。Claude 3 Haikuは、Claude 3 ファミリーからコンパクトなサイズで即時応答用に設計されたモデルです。個人的なテストは、Haikuを、SonnetやOpusなどのより大きなモデルを使用して生成された応答と比較して、RAGタスクに対して十分であることがわかりました。AWS Bedrockを選択した理由は、スタンバイは主にAWSインフラを使用しているからです。実際には、日本語をサポートしていれば、他のLLMモデルやプロバイダーと置き換えることができます。 プロンプト このプロトタイプで使用されたプロンプトは、時間の制約により、プロンプトの最適化が行われていません。 プロンプトは日本語ではなく英語を使用しています。他のLLMプロトタイプや内部使用ツールを開発した経験から、プロンプトは日本語ではなく英語を使用しています。Claude 3モデルは日本語のプロンプトの指示を理解できますが、英語のプロンプトの方が定量的および定性的な分析において、日本語よりも一般的に優れた応答を提供するためです。そのため、ここも主に英語をプロンプトに使用しています。 プロンプト自体は、検索結果を格納する {context} と、ユーザークエリを格納する {query} の 2 つの変数のみを受け取ります。以下は、このプロトタイプで使用された実際のプロンプトです。 You are a helpful, precise, factual Japanese speaking Job Seeking Advice expert who answers questions and user instructions about Job Seeking-related topics. The documents you are presented with are retrieved from a Japanese Job Seeking Advice Blog called Stanby Plus (スタンバイPlus). Facts about Stanby Plus: - Stanby Plus is a magazine website operated by Stanby Inc. which operate a Japanese Job Search Engine Services called Stanby (スタンバイ). - Stanby is a focus on the Japanese market. <instructions> - The retrieved documents are markdown formatted text from a Japanese Job Seeking Advice Blog operated by Stanby, a Japanese based Job Search Engine company. - Answer questions truthfully and factually using only the information presented. - If you don't know the answer, just say that you don't know, don't make up an answer! - If you can't answer with reference to the document provided, just say 「大変申し訳ございません。検索されたクエリが適切的に答えができまん。」 - You are correct, factual, precise, and reliable. - You must reply in Japanese. - You should reply in less than 500 words. </instructions> <articles> {context} </articles> Question: {query} Helpful factual answer: プロトタイプは Jupyter Notebook 上で直接実行されるため、LangChain を使用して AWS Bedrock を通じて LLM とやり取りしています。ウェブアプリ自体は、Jupyter Notebook をウェブアプリに変換する Python ライブラリであるMercuryを使用して構築されています。 また、LLMを使用してユーザーの質問から求人検索クエリを作成し、求人推薦を表示する試みも行いました。LLMは、このサブタスクでも良い性能を発揮しました。 デモ プロトタイプは内部デモ用なので、公開はしていませんが、プロトタイプのスクリーンショットをいくつか紹介します。 生成された応答のなかで、スタンバイの求人検索サービスを自然に推奨しています 👍 他の言語で質問された際に、質問に利用された言語でそのまま返答します 👍 結論 Vespaが提供する高度な機能により、このRAGプロトタイプは1日以内で作成できました (フロントエンド言語に慣れていないため、UIの作成に多くの時間を費やしました)。これは、LLMモデルとVespaの可能性を示しています。 プロトタイプの外部公開までまだ長い道があります。以下のようなアイディアでさらなる改善を検討しています。 より良いリトリーバルのために、e5 モデルを当社のデータでファインチューニングする より良いColBERTモデルを学習する (現在もColBERTをまだ実験中) プロンプトの悪用防止対策 (このプロトタイプは内部使用のため、これについては何もしていません) 生成されたコンテンツに、その基になっているものを記載します スタンバイでは、常に新しいアイデアや技術を調査し、試しています。新しいことに挑戦したい方や、素晴らしいプラットフォームで素晴らしい仲間と仕事をしたい方は、ぜひ 採用ページ をご覧ください! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
こんにちは、株式会社スタンバイのSearchグループで検索エンジンの運用・開発を担当している小野です。 今回は、社内で実施した「検索システム」の輪読会についてご紹介します。 なぜ輪読会を行ったか? 今回、輪読会を開催した理由は大きく2つあります。 検索サービスを提供する企業として、検索機能に関する体系的な知識を深めたい ディスカッションを通じて、検索システムに関する理解をさらに高めたい 私たちが使用したのは、ラムダノート社の 『検索システム』 という書籍で、検索システムの開発に必要な体系的知識を学べる一冊です。 スタンバイが提供する求人検索サービスのシステム規模はとても大きいため、コンポーネント別に担当チームが分かれて開発や運用を行っています。 輪読会では業務での担当以外を含む検索システム全体の理解を深め、共通の知識基盤を築くことで今後のプロダクト開発にも役立てたいと考えました。 ちなみに、スタンバイには必要な書籍の購入補助してもらえる「スタンバイ図書」という制度があります。 今回も輪読会実施のために制度を利用して複数冊購入してもらいました。 どのように実施したか? 進め方 週に1回、1時間オンラインで行い1章ずつ進めました。 全12章を約3ヶ月で完了することを目標にしました。 参加メンバーには事前に該当章を読んでもらい、輪読会ではその内容を共有しディスカッションを行う形式です。 進行は以下のステップで進めました。 学んだ内容や疑問点をふせん(miro)に記入 そのふせんを参加者全員に共有 深掘りしたい内容についてディスカッション 章ごとにmiroで以下のようなボードを作成しました。 工夫したこと 輪読会の価値を最大化し、参加しやすくするために以下を工夫しました。 キックオフの実施 1回目の輪読会の開催前にキックオフを行って、参加者の輪読会へのモチベーションの共有や進め方を決定しました。 キックオフで輪読会を「どういった場にしたいか?」の認識合わせをできました。 個人の負担軽減 要約を作成して発表する形式は取らず、各メンバーが自分のペースで参加できるようにしました。 ファシリテーターは各回で持ち回り制とし、特定のメンバーに負担が集中しないよう配慮しました。 本は事前に読んでくる 輪読会ではディスカッションに重点を置くために、会の中で読書や音読するのではなく事前に本を読んできてもらうようにしました。 実際のところどうだったか? 各回で約7名が参加し、全12章を無事に読破しました! 上手くいったこと 輪読会では書籍の内容から発展して実際のプロダクトについての議論ができたことです。 多様なバックグラウンドを持つメンバーが参加していたので各人の業務での知識や経験に基づいて議論は非常に有意義でした。 例えば、新しく入社したメンバーが「スタンバイではどうなっているのか?」という疑問を投げかけ、経験豊富なメンバーがそれに答える場面が多く見られました。 社内での輪読会だからこそ業務に直接リンクする内容まで深掘ることができました。 難しかったこと 章によって難易度やページ数にバラツキがあり、各章を1時間内に収めるのが難しかったです。 特に後半の章ではアルゴリズムや機械学習といった専門的な内容が増え、時間内に深い議論をすることが難しくなりました。 章の内容によっては会を分割したり、サンプルコードを使ったモブプログラミングなど、異なる進め方を採用しても良かったと感じました。 終わりに 今回の輪読会を通して、個々のスキルアップはもちろん、メンバー間で共通の知識基盤を構築し、会社全体のプロダクト開発力を高めることができました。 忙しい業務の中で参加してくれたメンバーには心から感謝しています。 今後も、メンバーと共に成長し続けられる環境を作っていきたいと思っています! もしスタンバイについて少しでも興味を持った方は、ぜひ 採用サイト からお気軽にお問い合わせください。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
はじめに こんにちは。株式会社スタンバイ QAグループ(Quality Assurance Group)の樽井です。 スタンバイは求人検索エンジンを開発・運用しており、Webとネイティブアプリ(以降App)でサービスを提供しています。Web・Appのテスト自動化にはMagicPodを利用していますが、本記事では、AppのMagicPodシナリオ 1 失敗率(シナリオの不備による失敗)を下げるための取り組みをご紹介します。 magicpod.com MagicPod導入の経緯については、過去記事「 スタンバイ QAのテスト自動化導入(MagicPod) 」をご確認ください。 なぜ失敗率を下げるのか AppでMagicPod運用を始めた頃のシナリオ失敗率(失敗数※ ÷ シナリオ数)は、Androidが16%前後、iOSが44%前後でした。 ※環境やデータなどシナリオ内容以外の失敗数を除いた数 スタンバイでは、テスト自動化の専任者を立てず、Web、AppそれぞれのQA担当者がシナリオ運用を実施しています。 一日1時間前後をMagicPod運用作業に充てていますが、既存シナリオの失敗率が高いことで、修正に時間が取られ、新規シナリオの作成が進まなかったり、新しい機能の反映が遅れたりする影響がありました。加えて、iOSはAndroidに比べて実行速度が遅く、シナリオ修正後の確認にも時間を要します。1つのロケーター 2 を確定させるのに15分ほどかかることもありました。 加えて、失敗が多いシナリオでは、不具合へ辿り着く前にシナリオが終了してしまい、十分な品質担保ができません。 そこで、シナリオを安定的に実行し、必要な機能実装を進められるよう、失敗率を下げる取り組みを行うことにしました。 MagicPod実行ログから何がわかったか まずは、シナリオ失敗理由を探るため、Android、iOSそれぞれ過去50回分のMagicPod実行ログを集計し、機能ごとに分類したシナリオの割合を算出しました(シナリオ分類)。さらに、シナリオ分類ごとの平均失敗率を算出し、失敗傾向を大まかに確認しました(シナリオごとの平均失敗率)。 OS シナリオ分類 シナリオごとの平均失敗率 Android iOS Androidの特徴 特定の機能が繰り返し失敗している 文字入力できていない 画面遷移後にタップできていない iOSの特徴 様々な機能で失敗している 画面遷移後にロケーターを取得できていない 画面遷移後にタップできていない 指定した時間内に画面描画できていない Android・iOS共通に発生していた「画面遷移後にタップできていない」という問題ですが、画面の要素が変動する場合、変動値を含むロケーターを利用していると、データにより要素数が変わることや、スクロール位置によって想定していない要素を操作することがあります。変動値を含むロケーターの方が使いやすいシーンもありますが、固定されたロケーターを利用することで、「XXXが表示されるまでスクロールする」や、「XXXが存在するか確認する」などのコマンドが利用しやすくなります。 データ量やスクロール位置の変動による、ロケーター取得の失敗は、シナリオ作成〜実行〜運用の全てにおいて手間と時間がかかることから、「安定したロケーター取得」を最優先の課題として取り組むことで、失敗率の安定を図りました。 ロケーターを安定させるにはどうすれば良いか〜Accessibility ID〜 そんな折、MagicPodのヘルプセンターで見つけた記事「 iOSアプリのテストを高速化するロケーターの選び方 」でAccessibility IDの存在を知りました。ロケーターとしての使い勝手の良さとしては、iOS Class ChainやXPathに軍配が上がりますが、今回は流動的な要素ではなく、固定要素に対しての識別を目的としていたため、Accessibility IDの付与を検討しました。 Accessibility IDを付与することで、各UI要素を一意に指定できます。これによりMagicPodから要素を特定しやすくし、ロケーター取得失敗やタップ失敗を防ぐのに役立ちます。iOS、Androidで付与方法が異なり、特にAndroidの場合はUIがViewベースか、Composeベースかにより、IDの割り当て方法が異なります。詳しくは、MagicPodヘルプセンターで見つけた記事「 自動テストを簡単にするためのアプリ実装の工夫を知りたい 」に記載がありますので、ご覧ください。 事例 記事に記載があるように、Accessibility IDを利用する上での最大のポイントは、開発者にIDを付与してもらう必要があることです。いつ、どのタイミングで、どのように付与するのか、そもそも付与して良いかをApp開発チームのPOに相談することから始めました。開発者に向けては、現在の問題点とAccessibility IDに期待する点についての説明会を実施した上で、ID付与を開始しました。 Accessibility ID付与の流れ App開発チームのPOに相談 App開発者に説明会 開発担当者とQA担当者でAccessibility IDの決定 実装1(開発者) 付与内容の確認とフィードバック1(QA) 実装2(開発者) 付与内容の確認とフィードバック2(QA) MagicPodシナリオ反映 ロケーターの変化 ID付与前後でロケーターがどのように変化するのか、TextFieldを例にして記載します。もともと、iOS Class Chainで変動的なロケーターを指定していましたが、Accessibility IDを付与したことで、ID指定やIDを起点としたiOS Class Chainを使用できます。これにより、要素を明確に表示させた状態で操作を実施することが容易になります。 Accessibility ID付与前 -ios class chain=**/XCUIElementTypeTextField[1] Accessibility ID付与後 accessibility id=input_form_abc または -ios class chain=**/XCUIElementTypeTextField[`name == "input_form_abc"`] 失敗率はどう変化したか Accessibility ID付与前後で失敗率を比較したところ、大きな変化が見られました。 Android iOS iOSの場合、ID付与ありの機能を含むシナリオにて、20%を超えていた失敗率が4%台まで下がり、単純にロケーター取得が失敗することは無くなりました。1ヶ月ほど経過観察しても安定しているため、確実な効果があったと言えます。Androidの場合、iOSよりもID付与ありの機能は限定的ですが、一度失敗率が跳ね上がったものの、現在の失敗率は1%を下回っており、こちらも良い効果があったと言えます。また、Accessiblity IDを起点としたXPathの活用ができるようになり、ロケーターの可読性や安定性が高くなりました。 終わりに 今回はロケーター取得の失敗を減らすことに注力し、Accessibility IDを付与する選択をしました。失敗率を下げることで、シナリオ修正の時間を減らし、結果的にはテスト自動化運用コスト低下につながりました。実行速度を短縮したい、データパターンを活用したい、といったテスト自動化の悩みは尽きませんが、スタンバイの取り組みをどこかでまたご紹介できればと思います。 以上、ここまでお付き合いいただきありがとうございました! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com テストシナリオは、ソフトウェアの特定の機能やフローを検証するために設定された一連の操作や条件のことを指します。シナリオは、テスト自動化ツールによって実行される具体的なステップの集まりです。スタンバイでは、「勤務地を指定して求人を検索する」といった形で、シナリオを分割しています。 ↩ ロケーターは、テスト自動化において、テスト対象のアプリケーションのユーザーインターフェース(UI)要素を識別し、操作するために使用される方法や手段です。ロケーターは、特定の要素を正確に特定し、テストスクリプトがその要素に対して適切なアクション(クリック、入力、検証など)を実行できるようにします。モバイルアプリの自動化で利用されるものに、Xpathなどがあります。 ↩
株式会社スタンバイでデザイン・フロントエンドを担当している中本です。 スタンバイではオウンドメディアとして「スタンバイplus」( https://jp.stanby.com/magazine/ )があります。 スタンバイplusでは、仕事において自分は何ができるか?私なんかでもこんなことができるんだ!という気づき・情報の提供を意識し活動をしております。 トップページのデザイン一新の背景 トップページは我々のサービスを理解するための重要な窓口と考えています。 しかし、現状スタンバイplusでは記事から入って来られる方が多いこともある為か、ただトップページが存在するだけの状態となり「トップページから記事を読ませる気が無い」のでは?と感じられるようになりました。 そこで、課題を精査し以下の点を改善することを目指しました。 全体を通じてどんな記事があるか直感的にわからない 細かい興味に沿って読み進められない 全体を通じてどんな記事があるか直感的にわからない この問題についてカテゴリの見直し(カテゴリを階層化)を行いました。 現状4つのカテゴリのみ存在し、その中に記事が分類されていました。しかし、1つ1つの分類が大きすぎるため、これだけではどんな記事があるのかわからないと感じました。 現状数百ある記事をたった4つのカテゴリに分類し、そこから興味をもつ記事を見つけるのはとても大変な作業かと考えられます。 徐々に絞り込んで探していただけるよう、新たに中カテゴリを設置し、既に存在している記事を大・中・小カテゴリへ整理し直しページ分割をしました。 それに伴い、記事一覧ページのデザインも長くただ縦に並べていたものを以下のように調整しています。 中カテゴリの一覧を設置 現状の小カテゴリの一覧 → レイアウト変更 (PCではグローバルナビゲーションから、SPではアコーディオンからもカテゴリ選択可能) トップページにおいては、記事を探す際どのようなイメージを持って記事を探せるか、各カテゴリエリアにカテゴリの特徴を伝えるように説明文を追加し、さらに細かく分類された中カテゴリへ直接遷移できるようタグを設置しました。 細かい興味に沿って読み進められない こちらの課題に関しては、解決策の1つとして前述した「全体を通じてどんな記事があるか直感的にわからない」で行ったカテゴリの見直しに(細分化)よって解決できる部分もあります。 もう1つの解決策として、トップページへ記事の掲載数増加を検討しました。 「トップページへ記事の掲載数増加」とはどういうことか? ただトップページに記事の数を増やし、現状のカテゴリを整理するだけということではなく、新たな切り口を持って読者に他にもどのような記事があるかを伝えるよう考えました。 今回新たな以下のものをトップページへ追加しました。 今月の人気記事 記事監修者 今月の人気記事 トップページにおいて、今月の人気記事を掲載することで、ほかにどのような記事が人気なのかを直感的に伝えることができるようになりました。 また、最近のアクセス数が多いものを上位に表示するため、読者にとっても興味を持っていただける記事を見つけやすくなりました。 記事監修者 記事監修者を表示することで、記事の信頼性を高めることができると考えました。 各分野での専門家が監修している記事があると言う事を知ることで、読者にとっても安心して記事を読むことができるようになります。 また、記事監修者のプロフィールへの一覧ページを設置し、監修者のプロフィールを見ることでその人物の専門性を知ることができるため、より深く記事を理解できるようにしました。 今後は、気になった監修者がどの記事を監修しているかわかるようにもしていきたいと考えています。 まとめ 今回「トップページから記事を読ませる気が無いのでは?」という疑問から、トップページから見た時どんな記事があるのか?細かい興味に沿って読み進んでもらえるようになるのか?という点を意識しました。 ただ、まだまだ改善すべき点が多くあり、コンテンツ、UI、さらにはパフォーマンス等、改善すべきところが多くあります。 今後も読者の方々にとって使いやすい、読み手を意識したメディアを目指し、改善を続けていきます。 (左:旧トップページ / 右:新トップページ) スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
株式会社スタンバイ QAグループに所属している岸です。本記事では、スタンバイの脆弱性検知の運用について紹介します。 スタンバイでは脆弱性検知のシステムとして2022年よりyamoryというツールを使っております。 参照URL: https://yamory.io/ yamoryにはいくつかの機能がありますが、スタンバイではプロダクトで利用しているソフトウェアの脆弱性検知を主として使用しています。 参照URL: https://yamory.io/service/vulnerability-management/ yamoryによる脆弱性検知の対応 【事前準備】 1.yamoryにスタンバイが使用しているGitレポジトリを登録 【運用時の対応】 1.yamoryが定期的に脆弱性データベースと照合し脆弱性の有無をチェック 2.脆弱性が検出された時にメールやSlackにて通知が行われる 3.セキュリティチームが内容を判断し、プロダクト部門に連絡 4.脆弱性の優先度により定義されたルールに従いプロダクト部門で脆弱性の内容、スタンバイでの使用方法、影響範囲を確認し、使用方法の変更、ライブラリの更新などを行い、各種自動テストや影響範囲によってはQAによる検証を通してリリースするなど必要な措置をとります。 5.対応が完了したことをセキュリティチームに報告 トリアージレベル yamoryから通知された脆弱性は社内で脆弱性の対応優先度に応じて報告ルール、対応する担当範囲、推奨する対応速度など脆弱性対応ルールが定義されております。 yamoryでは検出した脆弱性をトリアージレベルという4つのレベルが存在しています。スタンバイ社内ではこのトリアージレベルの最上位のImmediateのさらに上の区分を定義し、緊急対応として最優先対応するレベルをつけています。 参照URL: https://yamory.io/docs/auto-triage/ 脆弱性対応の重要性 対応優先度が高い脆弱性はもちろん緊急対応で対応していますが、危険度が低い脆弱性も組み合わさることでサービス運営に思わぬ影響がでる可能性もあります。スタンバイ社では危険度が低い脆弱性にも対応ルールを設けて脆弱性の撲滅に取り組んでおります。 過去に発生した脆弱性の事例では2021年のlog4jの脆弱性があり、世間的に広く使われていたライブラリなだけにIT業界を震撼させた脆弱性が記憶に残っています。 出展: Log4Shell Apache Log4jに見つかった脆弱性「Log4Shell」とは Apache Log4j の脆弱性対策について(CVE-2021-44228) 昨今のプロダクト開発において迅速で最適な開発、サービス運用を行うためにも、OSSの活用は不可欠であり、サービスの開発、運営には非常に多くのOSSを使用しています。しかし、一方でOSSの82%のコンポーネントが脆弱性やセキュリティ問題、保守性の問題など潜在的にリスクを抱えていると言われています。 出展: Lineaje Report Reveals 82% Of OSS Components Are ‘Inherently Risky’ Due To Security Issues OSSライブラリの脆弱性の情報はJVN iPedia<脆弱性対策情報データベース>や様々なIT系のニュースサイトなどで最新の情報が共有されていますが、サービスで使用している大量のOSSの脆弱性の情報収集を人力で継続的に行うことは至難であり、重要な脆弱性の抜け漏れを生み出しかねません。脆弱性の影響範囲、深刻度によってはサービスをご利用いただいているお客様にも多大なご迷惑をかけることとなり、貴重なお時間、お客様の大切なデータを危険にさらすことになります。 スタンバイの求人検索エンジンが使用しているライブラリに脆弱性が発覚した場合、ニュースで騒がれたことを社員が認識し社内で対応が始まるのではなく、検知システムにより脆弱性が検知され、担当チームが迅速に対応できる仕組みは安心してお客様が使われるサービスの運営に必要不可欠と考えています。 情報収集は受動的に できるようにしておき、 対応が必要になった時は能動的に 動けるようにしておくことが大切と考えています。 最後に 今後もお客様に快適で最適な仕事探しが行える求人検索エンジンの開発、運用に取り組んでまいります! スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
はじめに 初めまして、株式会社スタンバイSQG(Search Quality Group)の前川と申します。 SQGのミッションは検索品質の評価で、評価結果をプロダクト開発にフィードバックしています。 また、私は組織の中で別の役割を担っており、その1つにKPIの品質管理があります。この業務の目的は、組織の意思決定を安定的にサポートすることです。 本記事では、この「KPIの品質管理」において工夫した、Redashのデータ出力をより簡便にするための取り組みを紹介いたします。Redashを活用されている方には、特に参考になる内容となっておりますので、ぜひともご一読ください。 各種ツールを簡単に紹介 今回の取り組みで使用したツールはPythonとRedashです。それぞれについて、どのようなツールかを説明します。 Python Pythonはオープンソースのプログラミング言語です。多様なモジュールを用いて、数学計算、日付処理、データベース操作などの作業を行うことができます。 今回はPythonでRedashAPIを実行するモジュールを使い、Redashからデータを取得することとしました。 Redash Redashはデータを可視化し分析するのに役立つBIツールのオープンソースソフトウェアです。Redashで集計したデータはCSV等のフォーマットでダウンロードすることも可能です。 私は普段の業務でRedashを操作して指標を集計していますので、今回の作業についてもRedashを通じて指標を集計しています。 PythonでRedashのデータを抽出しようと思った経緯 困っていたこと 普段の業務においては、Redashからで取得したデータをGoogleスプレッドシートに転記し、数値チェックを行って品質管理をしています。 Redashは非常に便利なツールなのですが、日々のルーチンワークを実行しようとした場合は以下のような作業を手動で行う必要があり、煩雑になります。 Redashを実行して指標を集計する 集計した指標をダウンロードする ダウンロードしたデータを特定のフォーマットに整形する 1度の作業であればそれほど時間もかかりませんが、日々の業務となれば話は変わってきます。「なるべく簡便に集計したい。自動的に欲しいフォーマットに整形することで作業ミス自体も減らしたい。」といった思いで今回自動化に踏み切ることにしました。 今回ご紹介するプログラムを使えば、Redashからjson形式でデータを取得出来るようになります。 準備 これから具体的なコードを交えて実装方法を説明します。 以下のようなテーブルでデータを取得したいので、まずはRedash用のテーブルとSQLの準備をします。 具体例として、以下のデータが保存されているとします。テーブル名は stanby_table とします。 date user_id action 2023-12-01 id_1 view 2023-12-01 id_2 click 2023-12-02 id_1 view 2023-12-02 id_1 click 2023-12-02 id_2 click 2023-12-03 id_1 view 2023-12-03 id_3 view また、Pythonを通じて実行する際には、RedashのURLが必要となります。 今回は仮のURLとして https://redash-host-url/queries/12345 を使って進めます。 こちらのRedashのページに、以下のSQLを保存し、 stanby_table からデータを取得できる状態にします。 SELECT distinct user_id FROM stanby_table WHERE date(date) >= date('{{ start_date }}') AND date(date) <= date('{{ end_date }}') LIMIT 10 ; 上記のSQLは、 stanby_table から、任意の日付を指定して日付範囲のuser_idを重複なく取得します。 後の処理の都合上、日付の指定の変数は start_date と end_date としておきます。 実装の方法 次に、PythonからRedashAPIを利用してデータの集計を行う実装を進めます。 使用するモジュールはオープンソースの redash_dynamic_query です。このモジュールで、RedashのAPIを実行できます。なお、本記事でご紹介する redash_dynamic_query のバージョンは 1.0.4 となります。 Pythonでこのモジュールを実行して、Redashからデータを抽出したいので、まずは、Pythonのスクリプト上で以下のコードを記載し、モジュールをインポートします。 インポート とは、別のファイル(モジュール)に記述されたPythonコードを取り込む機能のことです。 以下のコードを実行することで、 redash_dynamic_query を使用できるようになります。 from redash_dynamic_query import RedashDynamicQuery 次に、RedashDynamicQueryの引数を設定します。コードは以下です。 redash = RedashDynamicQuery( endpoint = 'https://redash-host-url/', apikey = 'your_api_key', ) endpoint はRedashのURLのエンドポイントになります。今回は https://redash-host-url/ です。 apikey はRedashのアカウント編集画面で確認できます。 以下の手順で画面を進めれば確認可能です。 Redashを開く settings の画面を開く Account のタブを開く API Key欄 の API Key を確認する 記載されている API Key をコピーして、上記のプログラムの your_api_key 部分を書き換えます。 次に、クエリの指定を行います。 query_id の変数には、SQL固有の識別番号を代入します。入力する文字はRedashのURLにある queries/ 直後の番号となり、今回は 12345 です。 query_id = 12345 次に、開始日と終了日の日付を指定します。 start_date と end_date のそれぞれに、文字列で日付を記載してください。形式は yyyy-mm-dd となります。 start_date = '2023-12-01' # 開始日 end_date = '2023-12-02' # 終了日 次に、日付の型をRedashに適した形に変更します。こちらはお手元のテーブルの日付の型次第なので、必要に応じて設定してください。 start_date = datetime.strptime(start_date, '%Y-%m-%d').strftime('%Y-%m-%d') 次に、ここまで設定した内容でRedashからデータを取得します。 以下のコードを実行すると、Redashの実行結果を変数 result にjson形式で代入します。 result = redash.query(query_id, {'start_date': start_date, 'end_date': end_date}) これで、Pythonのスクリプトを用いてRedashのデータをjson形式で取得出来るようになりました。 以降は、jsonからデータフレームなど利用用途に合わせて型変換することで、スプレッドシートに簡易に転記したり、グラフ描画での利用、CSVファイル等で出力結果の保存などができます。 ここまでの内容をまとめたコード全体は以下となります。 # モジュールのインポート from redash_dynamic_query import RedashDynamicQuery # endpoint と apikey の指定 redash = RedashDynamicQuery( endpoint = 'https://redash-host-url/', apikey = 'your_api_key', ) # どのSQLを実行するかを指定 query_id = 12345 # 開始日と終了日を指定 start_date = '2023-12-01' # 開始日 end_date = '2023-12-02' # 終了日 # 日付の型を RedashDynamicQuery に適した形に変更 start_date = datetime.strptime(start_date, '%Y-%m-%d').strftime('%Y-%m-%d') # Redashの実行内容を result にjson形式で格納 result = redash.query(query_id, {'start_date': start_date, 'end_date': end_date}) スタンバイの中の活用事例 冒頭で述べたとおり、私は「KPIの品質管理」の業務を行っており、その一環で今回のプログラムを作成することで、従来困っていたRedashの実行から特定のフォーマットへの整形を、今回ご紹介したPythonのコードで完結できるようになりました。 実際の業務では、紹介したPythonのコードに加え、jsonをデータフレームに変更してGoogleスプレッドシートに転記する処理を行っています。これにより、分析を開始するまでのステップを簡略化し、結果分析のための時間を多く取ることができています。 実装した感想 実装する前は、手作業が多くあり、ヒューマンエラーが生じる危険性もありました。 今回の実装を通じて分析のプロセスまでの作業をなるべく簡略化できたので、データの確認や分析にも多くの時間を割けるようになりました。結果、KPIの品質担保に貢献できていると感じています。 今回紹介したプログラムを使って、Redashの実行結果をPythonで取得できるようにしました。これにより、Pythonを使用してデータ分析が可能となり、さらに高度な分析も行えます。 今後は、この方法を施策の分析にも応用しようと考えています。 まとめ 今回の記事では、PythonでRedashのデータを取得する方法について、具体的なコードを交えて紹介しました。 こちらの処理をすることで、手を動かしていた作業の一部を自動化でき、その結果KPIの分析に費やす時間を増やすことができました。 プログラム自体は非常にシンプルで、かつ今後発展性も見込める実装となりましたので、ご活用いただける内容でしたらとても嬉しいです。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
検索エンジンをVespaへ移行しています こんにちは、スタンバイで検索周りの開発を担当している鷹取です。 今回はスタンバイで利用している検索エンジンをVespaへ移行している話を紹介します。 検索エンジン移行の背景 Stanby Tech Blogの スタンバイ2+1年の軌跡 の記事で説明されている通り、 スタンバイでは、主に求人検索機能を提供していますが、その中でもオーガニック(無料掲載)と広告(有料掲載)という2種類の検索が存在します。 この2種類の検索ではそれぞれで異なる検索エンジンを使用しています。 オーガニック検索: Yahoo! ABYSSという検索プラットフォーム 広告検索: Elasticsearch このようになっている背景については、前述の記事に詳細が記載されていますので、興味がある方はそちらをご参照ください。これまで、この2種類の検索エンジンを運用してきましたが、それぞれに課題を抱えていました。 オーガニック検索における課題 オーガニック検索における課題としては、 まず、検索エンジン自体を利用できなくなるリスクです。 ABYSSの提供が終了した場合、スタンバイのサービス全体が停止してしまう可能性があります。 次に、開発上の制約があります。 スタンバイはABYSSという検索プラットフォームの利用者であるため、エンジン自体の開発ができません。その結果、検索エンジンの開発や精度の向上において、自社で完結できず他社に依存しています。 また、スケーリングの点でも問題が生じています。 契約の都合上、クラウドサービスのように即座にサーバ台数を増減できず、 トラフィックに応じた柔軟な変更ができません。 このため、常に最大のトラフィックに耐えられるサーバ台数を確保しておく必要があります。 最後に、経験と知識の面での課題も挙げられます。 他社の検索エンジンを利用していると、 スタンバイ内で検索エンジン開発の知識や運用経験が蓄積されず、 高度な検索エンジンの開発や運用が難しい状態にあります。 広告検索の課題 広告検索の課題としては、独自に開発しているプラグインの開発・メンテナンスの負担が挙げられます。 スタンバイでは、Elasticsearchの独自プラグインを開発し、ランキング機能を実現しています。 このプラグイン導入当時、既存のOSSプラグインでは、実現したい仕様や性能を満たせなかったため、独自で実装することにしました。 しかしながら、ランキング変更のたびにプラグインの改修をしなければならず、改善のボトルネックとなってしまっています。 また、Elasticsearchのバージョンを上げるたびに対応が必要です。加えて、インフラコストの問題もあります。 上記のプラグインの影響により、性能を満たすために必要なサーバの台数が多くインフラコストの増加につながっています。 共通の課題 さらに、オーガニックと広告で異なる検索エンジンを運用していることによる課題も存在しています。 異なるエンジンを利用していることから、精度改善のための実装がそれぞれのエンジンで共有できず、 同じ施策でもオーガニックと広告で別々に実装しなければなりません。これにより、実施できる全体の精度改善施策の数が減少しています。 また、学習コストや運用にかかるコストも2倍になり、本来の検索改善を行うための時間が減少してしまっています。 検索エンジンの統一の決定 これらの課題を解消するため、以下のような目的で検索エンジンを統一し、自社で運用する方針が決定されました。 社内の検索エンジンを統一することで、エンジニアリングリソースを集約し、検索エンジン運用と検索サービス開発の効率を上げる 自社で検索エンジンを運用することで、情報検索技術や検索エンジンに関する深い知識を社内に蓄積する システム提供、ソフトウェアライセンスに関して自社でコントロールできない制約事項を可能な限り排除し、自由度高く検索サービスの開発を行えるようにする 検索エンジンの選定 検索エンジンを統一するにあたって、スタンバイでは最終的に Vespa を採用しました。 ここからは、なぜVespaを選んだかについて説明します。 スタンバイの検索の特徴 検索エンジンの選定に当たって、まずは現状の検索の特徴を把握する必要があります。 スタンバイの検索の特徴をまとめると以下のようになります。 検索 高トラフィック 国内有数の求人検索エンジン サービスの成長に伴い今後も更に増加見込み 低レイテンシー スタンバイは検索が主体なので、検索結果画面の表示に時間がかかると、ユーザが離脱してしまいます。広告も検索なので、売上の低下に直接つながってしまします。 更新 検索対象のドキュメント量が多い 1000万件以上の求人データを扱っています。また、求人検索エンジンの特性上、特定の求人だけ検索できるのではなく、すべての求人が等しく常に検索できる状態になっている必要があります。 ドキュメントの登録と削除が頻発する 求人票は各社の採用状況に応じて、頻繁に公開、非公開が行われます。 新規公開求人の反映速度も重要ですが、特には応募時のトラブル防止のために求人票の取り下げや更新については求人データがスタンバイに連携されたら即時反映させる必要があります。 後述する機械学習に使うための、特徴量データの反映のための部分更新も必要となります。 機械学習 スタンバイでは、検索結果のランキングに機械学習モデルを活用しています。 GBDTモデルを用いたランキング two-phase ranking 機械学習モデルを作成している機械学習チームと検索基盤チームが別 独自のプラグインだと、機械学習チームが検索基盤チームに依存してしまいます それぞれで改善を行えるような体制が望ましいです。 次に検索エンジンVespaの概要と特徴について説明します。 Vespaとは Vespaとは、オープンソースのbig data searving engineです。 オンラインでビッグデータにAIを適用できることが特徴です。 Vespaは検索に限らず、レコメンドや会話AIなど、様々な用途に利用できます。 もともとは、Yahoo(米)の社内で開発されていましたが、 2017年にオープンソース化 1 され、2023年10月にはYahoo(米)からスピンアウトし独立した企業となりました。 2 Yahooの検索での長い実績があり、大規模な量のドキュメントとトラフィックに対応できる能力が証明されています。 1日に25Bのリアルタイムクエリと75Bのライティング(更新)を処理可能です。 また、Spotifyなどのグローバルに大規模なサービスを展開する企業でも採用され始めています。 3 Vespaの特徴 高速な検索と高いスケーラビリティ Vespaはリアルタイムで低レイテンシかつ高スループットが求められるユースケースに最適化されています。 数十ミリ秒以下のレイテンシでレスポンスを返すことが可能です。 また、並列にクエリを実行することで、どのようなクエリ量、データ量でも一定の応答時間を維持できるように設計されています。 さらに、クエリごとに複数のサーチャースレッドを活用し、スループットに対してレイテンシを柔軟にスケールできます。 また、Vespaは高いスケーラビリティも備えています。 Vespaのドキュメントはバケットと呼ばれる単位で管理されます。 バケットのサイズと数はVespaによって完全に管理され、 手動でシャーディングを制御する必要はありません。 そのため、ノードをクラスタに追加するだけで簡単にスケールできます。 将来的にアクセスが増加してもスケールアウトが容易です。 効率的なインデックス作成 Vespaのインデックス作成メカニズムは低レイテンシでの更新に最適化されており、常にデータが変化するシナリオに適しています。 通常の検索エンジンのインデックス作成は、リアルタイムの書き込みを実現するために、 書き込みに対してイミュータブルな転置インデックスのセグメントを構築し、 バックグラウンドでそれらをマージすることで行われます。 大きなセグメントとのマージには非常に長い時間がかかるため、 多くの場合、徐々に大きくなる複数のセグメントを使用し、複数回マージする必要があります。 この方式の場合、高い書き込みレートを維持すると、 クリーンアップしなければならない多くのゴミを作成することになります。 これにより、安定した書き込みレートとクエリレートの維持に問題が生じます。 Vespaも以前はこの方式でしたが、2010年以降は異なる方式を採用しています。 4 Vespaはイミュータブルなインデックスセグメントの前にミュータブルなインメモリインデックスを持ちます。 変更はインデックスセグメントの代わりにメモリ内のB-treeに書き込まれ、 バックグラウンドで不変なインデックスとマージされる設計に変更されました。 この設計ではインデックスセグメントを徐々に大きくしていく必要がなく、 ガベージコレクションや大きなメモリ操作の発生が非常に少ないというメリットがあります。 また、更新リクエストが完了した時点で、そのドキュメントは検索可能になります。 豊富な機械学習関連の機能 VespaはそもそもビッグデータセットにAIを適用するためのプラットフォームとして作られているため、 機械学習関連の機能が豊富にあります。 例えば、以下のような機能を持ちます。 ONNX,XGBoost,LightGBMといった複数のモデルサポート weightedsetやtensorなどのデータ型が使用可能 multi phase rankingのサポート Vespaではrank-profileとよばれる形式でランキングを設定します。 rank-profileでは、様々なランク式や特徴量を組み合わせてランキングのアルゴリズムを定義できます。 また、rank-profileは設定ファイルとしてVespaクラスタに直接デプロイします。 これにより、ランキングアルゴリズムを検索クエリとは分けて管理できます。 運用面でも、Vespaは本体に組み込みで機械学習の機能を持っているため、 プラグイン等を管理する手間がありません。 スタンバイの検索とVespaの相性 スタンバイの検索の特徴とVespaの特徴を以下にまとめました。 スタンバイの検索 Vespa 検索 低レイテンシ 高トラフィック 高速な検索 高いスケーラビリティ 更新 検索対象のドキュメントが多い ドキュメントの更新量が多く高頻度 効率的なインデックスの作成 機械学習 機械学習を活用 機械学習チームと検索基盤チームが別 豊富な機械学習関連の機能 rank-profileによるランキングアルゴリズムの指定 このように、上記で述べたスタンバイの検索の特徴とVespaの特徴がマッチしていたため、Vespaを採用しました。 次章から、具体的にVespaに移行した方法を紹介します。 Vespaへの移行 Vespaへの移行は、以下のようなステップで進めていきました。 Vespaの調査・機能検証 Vespaクラスタの構築 機能開発 テスト Vespaの調査・機能検証 現在提供している検索仕様をVespaで全て満たせるかどうかの調査を実施しました。 実際の移行可能性を確認するために、公式ドキュメントを読みこみ、その後、ローカル環境でクエリを作成して検証しました。 Vespaは公式ドキュメントが充実しているため、 ドキュメントを読めば、どのような機能があるか、どのように使えばいいかがわかります。ただし、ドキュメント量は多いので、全ては読み切れておらず、少しづつ読み進めています。また、VespaのSlackでは開発チームに直接質問を投げることができます。ここで、過去の質問を検索することでも、参考になります。 VespaはDockerイメージも公式で提供されており、ローカルでの検証も簡単にできました。 ここからは、調査したVespaの機能をかいつまんで紹介します。 詳細は、 Vespaの公式ドキュメント を参照してください。 Vespaの紹介 - Vespaのアーキテクチャ 最初に、Vespaのアーキテクチャを説明します。 Vespaのアーキテクチャは以下の図のようになっています。 画像出典: Vespa Overview( https://docs.vespa.ai/en/overview.html ) 図のように、Vespaは複数のコンポーネントから構成されています。 コンポーネント 説明 Admin/Config 設定の管理、クラスタの制御など Stateless Java Conatiner 入力データやクエリ・レスポンスを加工するステートレスなコンポーネント。クエリとデータの操作をコンテンツクラスタの適切なノードに渡す。Javaで実装されており、プラグインで容易に拡張できる 。 Content インデックスの管理を担当する。検索・ランキングはここで実行される。C++で実装されている Vespaクラスタに属する各サーバは、これらのいずれかの役割を担い協調して動作します。 Vespaの紹介 - クラスタの設定 先ほど、アーキテクチャについて紹介しました。 Vespaでは設定ファイルで、クラスタの構成を管理します。 ここでは、Vespaのクラスタ設定について説明します。 クラスタ設定は、以下の2種類のファイルで行います。 hosts.xml services.xml hosts.xml hosts.xmlはクラスタに参加するサーバを定義します。 ホスト名に対して、設定の中で使うエイリアスを指定します。 以下が、hosts.xmlの例です。 <? xml version = "1.0" encoding = "utf-8" ?> <hosts> <host name = "node0.vespanet" > <alias> node0 </alias> </host> <host name = "node1.vespanet" > <alias> node1 </alias> </host> ... </hosts> services.xml services.xmlはVespaクラスタの構成を定義します。 hosts.xmlで定義された各サーバに役割を与えます。 また、redundancyといった冗長化の設定やプラグインの設定およびスレッド数・メモリ等のリソース設定もこちらで行えます。 以下がservices.xmlの例です。 <? xml version = "1.0" encoding = "utf-8" ?> <services version = "1.0" xmlns : deploy = "vespa" xmlns : preprocess = "properties" > <admin version = "2.0" > <configservers> <configserver hostalias = "node0" /> <configserver hostalias = "node1" /> <configserver hostalias = "node2" /> </configservers> <cluster-controllers> <cluster-controller hostalias = "node0" jvm-options = "-Xms32M -Xmx64M" /> <cluster-controller hostalias = "node1" jvm-options = "-Xms32M -Xmx64M" /> <cluster-controller hostalias = "node2" jvm-options = "-Xms32M -Xmx64M" /> </cluster-controllers> <slobroks> <slobrok hostalias = "node0" /> <slobrok hostalias = "node1" /> <slobrok hostalias = "node2" /> </slobroks> <adminserver hostalias = "node3" /> </admin> <container id = "feed" version = "1.0" > <document-api/> <document-processing/> <nodes> <node hostalias = "node4" /> <node hostalias = "node5" /> </nodes> </container> <container id = "query" version = "1.0" > <search/> <nodes> <node hostalias = "node6" /> <node hostalias = "node7" /> </nodes> </container> <content id = "news" version = "1.0" > <min-redundancy> 2 </min-redundancy> <documents> <document type = "news" mode = "index" /> <document-processing cluster = "feed" /> </documents> <nodes> <node hostalias = "node8" distribution-key = "0" /> <node hostalias = "node9" distribution-key = "1" /> </nodes> </content> </services> Vespaの紹介 - ドキュメントの管理 つぎに、Vespaのドキュメント管理について紹介します。 Vespaでは、ドキュメントのスキーマを、 .sd という拡張子のスキーマ定義ファイルで管理します。 スキーマ定義ファイルの例を以下に示します。 schema news { document news { field news_id type string { indexing: summary | attribute attribute: fast-search } field title type string { indexing: index | summary index: enable-bm25 } field abstract type string { indexing: index | summary index: enable-bm25 } field url type string { indexing: index | summary } field date type int { indexing: summary | attribute attribute: fast-search } field clicks type int { indexing: summary | attribute } field tensorfield type tensor<float>(x{},y{}) { indexing: attribute | summary } } fieldset default { fields: title, abstract } } field キーワードに続けてフィールド名と型を指定します。 intやstringといった基本的な型のほか、tensorやweightedsetといった複雑な型や構造体の定義も可能です。 また、フィールドごとにインデクシングの方法や検索方法を指定できます。 例えば、 indexing パラメータを指定することで、インデックス作成時にフィールドのデータをどのように処理するかを設定します。 indexing には以下の3つの値を指定できます。また、複数組み合わせての指定も可能です。 index テキストマッチ用のインデックスを作成します。形態素解析が行われます。 attribute メモリに保持します。ソートやグルーピングに使用可能です。また、完全一致、プレフィックス一致、大文字小文字を区別する一致などが可能です。 summary 検索結果のレスポンスに含まれるドキュメントの情報(summary)に指定されたフィールドを含めます。 また、 index パラメータや attribute パラメータを指定することで、検索の高速化なども可能です。他にも、 fieldset を使うことで、検索用にフィールドをグループ化できます。 注意点として、Vespaでは動的なフィールドは作成できません。 フィールドを追加する場合は明示的に指定する必要があります。 Vespaへのドキュメントの登録方法ですが、 /document/v1/ APIにHTTPリクエストを送る、 もしくは、Java製のfeed-clientでフィードを行うことができます。 /document/v1/ APIは検索用のAPIと別のAPIで、 IDを指定したドキュメントの取得や、登録・更新・削除が可能です。 Vespaの紹介 - 検索の方法 続いて、Vespaの検索方法について紹介します。 Vespaの検索クエリはYQLというSQLに似たDSLで記述します。 select * from news where title contains "vespa" select でレスポンスに含めるフィールドを指定します。スキーマ定義で summary を指定したフィールドを選択できます。 from では検索対象のドキュメントタイプを指定します。 そして where でさまざまな検索条件を指定します。上記の例では、titleフィールドに"vespa"を含むドキュメントを検索しています。他にも、フレーズ検索や緯度経度による検索など、様々検索が可能であり、 Apache SolrやElasticsearchで提供されている基本的な検索機能は一通り揃っています。 また、ランキング後に検索結果をグルーピングする機能も提供されており、集計やdedupe 5 なども可能です。 検索リクエストをVespaに送る際には、 yql だけでなく、タイムアウト時間やヒット件数など検索に関する他のパラメータも同時に指定可能です。 特に使用するrank-profileの名前を指定することで、検索結果のランキングをクエリごとに変更できる機能は、オンラインABテスト時などに便利です。 { " hits ": 200 , " model ": { " locale ": " ja " } , " timeout ": " 1s ", " offset ": 0 , " ranking ": { " profile ": " vespa-test " } , " yql ": " select * from news where default contains \" vespa \" " } また、rank-profileとは別のquery-profileとよばれる機能を使うことで、検索パラメータのセットを名前をつけて管理できます。 これにより、検索時にquery-profile名だけ指定すれば良く、 毎回多数のパラメータを付けてリクエストせずにすみます。 query-profileは以下のように設定します。 <query-profile id = "MyProfile" > <field name = "hits" > 20 </field> <field name = "maxHits" > 2000 </field> </query-profile> Vespaの紹介 - ランキングの指定 最後にVespaのランキングの指定方法について紹介します。 本記事で何度も出てきていますが、Vespaのランキングはrank-profileで設定します。 rank-profileは以下のように設定します。 rank-profile my-rank-profile inherits base { function myfeature() { expression: fieldMatch(title).completeness * pow(0 - fieldMatch(title).earliness, 2) } first-phase { expression { attribute(quality) * freshness(timestamp) } } second-phase { expression: lightgbm("test_model.json") rerank-count: 50 } } rank-profile キーワードのあとに、rank-profileの名前を指定します。 inherits を使うことで、他のrank-profileを継承できます。 これは、ABテストなどで、ランキングの一部を変更したい場合(second-phaseのみ変更するなど)に非常に便利です。 function でランキング式の一部として、また、特徴量として使用可能な独自の関数を定義できます。 さらに、Vespaでは多段階ランキングでランキングの負荷と精度のバランスをとることが最初から可能です。 first-phase で負荷が軽いランキング式を指定し、 second-phase はfirst-phaseでランキングされた上位の結果に対して、 負荷が重いがより精度の高いランキング式を指定します。 Vespaクラスタの構築 ここまで、Vespaの機能の一部を紹介しました。 ここからは、実際にスタンバイでVespaクラスタを構築した方法を紹介します。 クラスタの構成 以下が現在のVespaクラスタの構成です。 スタンバイではAWSを使用しています。 そのため、各サーバはEC2を使用してVespaクラスタを構築しています。 high-availability(HA)のため、multi-node構成をとっており、 admin/configクラスタは3台のノードから構成されています。 containerクラスタは、フィードを処理するためのクラスタと検索クエリを処理するためクラスタを分けています。 これにより、フィード、クエリそれぞれで、負荷に応じてノードの性能・台数を 変更できるようにするとともに、フィードの負荷が検索へ影響しないようにしています。 containerクラスタはステートレスであるため、簡単にスケールアウトが可能です。 各クラスタごとのインスタンスタイプは負荷に応じて異なるものを使用しています。 contentクラスタのノードはデータを保持し検索を処理するため、他のクラスタと比べて大きなインスタンスタイプを使用しています。 また、ディスクへのアクセスを多く行うため、インスタンスストアを持つインスタンスタイプを選択しています。 クラスタの構築方法 つぎに、スタンバイでのVespaのクラスタ構築方法を説明します。 検証中何度もVespaのクラスタを構築し直す必要があったため、インフラをコード化しています。 まず、Vespaがインストールされたマシンイメージ(AMI)の作成します。 AWSのEC2 Image Builderを使用して、ゴールデンイメージを作成します。 この際、VespaだけでなくDatadogAgentなど監視や運用に必要なツールもインストールしています。このように、Vespaをインストール済みのAMIを作成しておくことで、 インスタンス起動時間を短縮できます。また、 検証環境で確認したものと全く同じ状態のノードを本番に構築でき、 以前のバージョンの環境にもどすことも容易になります。 つぎに、Vespaクラスタ用のインスタンスを起動します。 台数やインスタンスタイプ、ネットワーク構成などの設定は、すべてTerraformでコード管理しています。このため、新しいVespaクラスタを構築する際は、既存の設定をコピーし、パラメータを変更して terraform apply コマンドを実行するだけで簡単に構築できるようになっています。この時点では、Vespaクラスタの設定はまだ反映されていなため、各ノード上でVespaのプロセスは起動していますが、クラスタとして協調して動作できません。どのノードがどの役割を持つかも決まっておりません。それらの設定は前述したhosts.xmlやservices.xmlで行います。 ただし、config serverだけは、 VESPA_CONFIGSERVERS という環境変数をすべてのノードに設定して置く必要があります。これは、Vespaの設定ファイルを管理するconfig serverのホスト名を指定するための環境変数です。この環境変数を設定することで、各ノードはconfig serverに接続し、設定ファイルを取得します。 最後に、Vespaの設定ファイルを反映させます。 Vespaの設定ファイルは、アプリケーションパッケージという単位にまとめられデプロイされます。アプリケーションパッケージにはデプロイと実行に必要なすべての設定、コンポーネント、機械学習モデルが含まれています。 スタンバイでは、Jenkinsを使用してGithub上で管理しているリソースからアプリケーションパッケージを作成し、Vespaにデプロイしています。 アプリケーションパッケージのデプロイが完了すると、各ノードがVespaクラスタとして協調して動作を開始します。 機能開発 次に、検索エンジン移行のために必要だった機能開発について説明します。 以下が、今回実施した開発の一覧です。 クエリ処理を一箇所に集約 オーガニックAPI・広告API・バックエンドAPIで、クエリを変換するロジックが別々に実装されていたため、エンジンを統合する前にクエリ処理を行うAPIを作成し統一しました。 検索APIの実装 ユーザから受け取ったクエリをYQLに変換 rustでAPIを実装 Feederの実装 ドキュメント1件毎にVespaへの更新リクエストを発行 JavaでStream処理を実装 Linguisticモジュールの実装 形態素解析などの言語処理機能を実装 機械学習モデルの実装 既存のモデルで使用している特徴量を、Vespaで使用できる特徴量にマッピング rank-profileの作成 ここでは、特にLinguisticsモジュールの開発について詳しく説明します。 Linguistics VespaはLinguistics(言語処理)モジュールを使用して、 インデックス作成および検索時にドキュメントやクエリのテキストを処理します。 Linguisticsモジュールは、以下の処理を実装しています。 言語特定 tokenizing normalizing(アクセント記号の除去) stemming これらの処理がLignusiticsモジュールで行われた結果のtermがインデックスに追加されます。 また、検索時にも同様に、クエリのテキストに対して処理が行われます。 Lignusiticsモジュールは、containerクラスタで動作するため、Javaで実装されています。 Vespa公式でもいくつかの実装が同梱されていますが、カスタマイズしたい場合には、 com.yahoo.language.Linguistics インタフェースを実装します。 今回は、自分たちでカスタマイズできるように、Linguisticsモジュールも自前実装したものを使用しています。 実装にあたっては、以下のような実装を参考にしました。 SimpleLinguistics 英語のステミングのみ提供 LuceneLinguistics LuceneのAnalyzerを使用した実装 OpenNlpLinguistics OpenNLPを使用した実装 KuromojiLinguistics LINEヤフー株式会社が公開しているKuromojiを使用した実装 特にKuromojiLinguisticsは日本語の形態素解析用の実装のため、非常に参考になりました。 Linguisticsの注意点として、テキストの処理時に言語を特定しますが、 その特定が間違っているとマッチしなくなってしまいう点があります。 特に、クエリに含まれるワードなど、短い単語は言語の特定が困難なため、 検索パラメータ等で言語を指定したほうが良いです。 また、当然ですが検索とフィードで同じLingusitcsモジュールの実装をすることも重要です。 テスト 最後に、テストについても触れておきます。 テストは、一般的な検索改善と同じように、以下の種類のテストを実施しました。 QA 既存の検索機能が実現できているかの確認 負荷試験 負荷に耐えられるかの確認 選定時にある程度の性能評価は行っていたが、モデル等を本番で使用するものを用意し改めて確認 オフラインテスト 定量・定性の両方で評価 SQ(サーチクオリティ)グループという検索精度を定性的に評価するチームが存在します。 オンラインテスト ABテストを実施 検索精度を定量的に評価 特別なことはしていませんが、検索エンジンの移行ということで、入念にテストを行いました。 移行結果 上記のステップで移行を進めた結果、無事に移行できました。 現状はオーガニックのみ移行完了しており、広告は移行中です。 移行中ではあるものの、検索エンジンを統一し、自社運用するメリットが徐々に出てきています。 まず、オーガニックと広告で同じ検索エンジンを使用することで、検索に関するリソースを集約できそうです。 現段階でも、広告の検索エンジンをVespaへ移行するために、オーガニックの知見を活用できています。 また、自社運用することで、Vespaに関することが中心にはなりますが、情報検索技術や知識を蓄積し始めることができています。 難しかったポイント Vespaの移行に際し、難しかったポイントを紹介します。 まず、覚えなければならないことが多いという点です。 いままで使用していたABYSSやElasticserachとは、 大きく異なる検索エンジンであるため、仕組みを理解するのに時間がかかりました。 単に検索を行うだけであれば、クエリの書き方を覚えるだけで済みますが、 実際に運用をしていくためにはVespaの様々な概念を覚えなければなりませんでした。 Vespaの各コンポーネント上では、proton, config-proxy, config-sentinel, config server, slobrok(Service Location Broker), cluster controllerといった、複数のサービスが動作しています。 運用時、特に何かしらのトラブルが発生した場合の原因を特定する場合、 それぞれのサービスの機能を理解しておく必要があります。 また、日本語の情報が少ないという点もあります。 日本のユーザが少ないため、日本語の知見があまりネット上にありません。 特に言語処理周りは、言語に依存する部分が多く、 自前で実装するにあたっては、コードを読み込んで試行錯誤する必要がありました。 今後について Vespaへの移行は一部完了しましたが、移行しただけで活用できているとはいえません。 今後は、さらにVespaを活用していく予定です。 まず、検索精度改善についてですが、形態素解析の改善、ランキングモデルのさらなる改善、 ベクトル検索(ANN)の導入などが考えられます。 とくに、Vespaは通常の検索とベクトル検索を組み合わせたハイブリッド検索が可能であるため、 現在の検索仕様を維持しつつ、ベクトル検索を導入することができると考えています。 また、オートスケーリングの実装も検討しています。 スタンバイでは時間帯によって検索ボリュームが大きく変わるため、 コストを減らすためにインスタンス台数を最適化したいです。 VespaCloudでは提供されていますが、self-hostingの場合は機能が提供されていないので、 オートスケーリング機能を自前で実装する必要があります。 まとめ スタンバイでは検索エンジンをVespaに移行しています。 複数の検索エンジンを使用していましたが、検索エンジンを統一し、自社で運用する方針を決定しました。 統一後の検索エンジンとして、スタンバイの検索の特徴と合致したVespaを採用しました。 今後は、ベクトル検索の導入など、さらなる活用を進めていく予定です。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com https://blog.vespa.ai/open-sourcing-vespa-yahoos-big-data-processing/ ↩ https://blog.vespa.ai/vespa-is-becoming-its-own-company/ ↩ https://engineering.atspotify.com/2022/03/introducing-natural-language-search-for-podcast-episodes/ ↩ Search and Sushi;Freshness counts ↩ Vespaで検索結果のdedupeを行う方法 ↩
はじめに 初めまして、株式会社スタンバイのSEOチームの本田です。 スタンバイではElastiCache for Redis (以後 Redis と記載) の バージョン3を長く利用していましたが、 2023年7月31日にバージョン3がEOLを迎えるため、バージョン7へのアップグレードを5月に行いました。 実施したのは半年ほど前ですが、アップグレードに伴い必要だった手順などをご紹介していきます。 期限までにアップグレードしないとどうなるのか AWSからの案内によると、バージョン3は新規作成ができなくなり、稼働中のものは自動的に6.2以上にアップグレードされてしまうようです。(下記一部メールから抜粋) 2022 年 7 月 31 日から 2023 年 7 月 31 日まで - ElastiCache for Redis バージョン 3 インスタンスの Redis 6.2 以降へのアップグレードはいつでも開始できます [3]。 2023 年 5 月 1 日以降 - AWS コンソールから ElastiCache for Redis バージョン 3.x.x インスタンスを作成することはできません。 2023 年 7 月 31 日以降 - ElastiCache for Redis バージョン 3 は ElastiCache コンソール、CLI、API、または CloudFormation では利用できません。2023 年 7 月 31 日以降のメンテナンス期間内に、すべての Redis 用 ElastiCache 3.x.x クラスターを Redis 6.2 に自動的にアップグレードします。 また、 AWS公式ドキュメント によると、5.0.5以前のバージョンからそのままメジャーバージョンのアップグレードを行うとフェールオーバー時のDNS伝搬で最大1分は接続断されてしまいます。 ElastiCache クラスターは 5.0.5 より前のバージョンでアップグレードできます。アップグレードプロセスは同じですが、DNS 伝達中のフェールオーバー時間が長くなる可能性があります (30 秒~1 分)。 今回アップグレード対象となるRedisはほぼ全てのスタンバイのページ表示の際に利用されているので、1分間の瞬断でもあってもユーザーに大きく影響を与えてしまう可能性がありました。 そのため、 Redisをダウンタイムなしでアップグレードする を、対応方針としました。 アップグレードと切り替え方法 ダウンタイムなしで切り替える場合どのようにするかという話ですが、アップデート対象となるElastiCacheのエンドポイントを Amazon Route 53 で以下のようにCNAME設定しており、 redis-service.stanby(例) -> redis.xxxx.ng.0001.xxxx.cache.amazonaws.com 社内の各アプリケーションはCNAMEのホスト名を利用して該当のElastiCacheにアクセスをしています。 そのため、新規でバージョン7のクラスタを構築しCNAME先のエンドポイントを切り替えれば、各アプリケーションのソースコードを変更することなくバージョンアップされたRedisを参照するように切り替えることができます。 また、ElastiCacheはスナップショットから復元する際にエンジンバージョンをアップグレードしてクラスタを作成できます。 稼働中のElastiCacheのスナップショットを取り、そのスナップショットを用いてバージョン7のクラスタを構築することで、データ移行のための設定をすることなくバージョンのアップグレードを行えます。 幸いにもアップグレード対象のRedisに関しては、読み込みはスタンバイのページが表示される度に行いますが、書き込みは非同期になっており一時的に停止可能であるため、Redisのアップグレード作業中は書き込みを停止して新しいクラスタを構築できました。 以上からアップグレードと切り替え方法は下記のようなフローになりました。 手順が確認できたので、次は実際に行ったことをまとめていきます。 アップグレードするために行ったこと アップグレードするために行った手順は下記になります。 Redisのアップグレード内容の確認 バージョンアップしたRedisの立ち上げ 負荷試験 リリース手順書を書いてリハーサル 本番反映 Redisのアップグレード内容の確認 今回はRedisのメジャーバージョンが3から7へと一気に4つも上がるのでアップグレードの内容をよく確認する必要がありました。 主に確認したのは大きく2つ、 パラメータグループ コマンド パラメータグループに関しては地道に AWS公式ドキュメント を読んでいきました。 今回のRedisのアップグレードにおいてはアプリケーションの変更は伴わないため、追加されたパラメータの確認よりも、変更、削除されたパラメータについて重点的に確認をしました。 今回アップグレード対象のRedisはクラスター設定をしておらず、かつ利用しているコマンドも GET , SET , DEL しかないシンプルなものであったため、パラメータグループの変更に影響されるものはありませんでした。 もしクラスタ設定されているのであれば、Redis6での変更点で cluster-allow-reads-when-down : プライマリが落ちたときにレプリケーションの読み込みを許可するか(デフォルトは許可しない) は一度確認したほうが良いと考えられます。 他にもRedis4の変更点でメモリが溢れたときに取る挙動のパラメータ maxmemory-policy に選択肢が増えていたりと、パラメータの変更を見つつアプリケーションのあるべき姿と照らし合わせる必要があります。 次にコマンドですが、これはRedisコマンドの公式ドキュメントと、アプリケーションで用いているコマンドを照らし合わせる必要があります。例えば、 SETコマンド はページの下部にHistoryがあり、取りうるオプションが増えているなどの変更点があります。 Terraformでスナップショットからバージョンあげて復元 Terraformを用いてスナップショットからバージョンを上げて新規にクラスタを作成する方法ですが resourceのクラスタの設定で snapshot_name 属性があるので、そこに引数としてセットしてapplyするだけで新規に作られます。 resource " aws_elasticache_parameter_group " " default " { name = " cache-params " family = " redis7 " } # クラスタ設定はエンジンバージョンに最新バージョンを指定 # snapshot_nameに復元するスナップショット名を指定 resource " aws_elasticache_cluster " " example " { replication_group_id = " cluster-example " num_cache_clusters = 2 node_type = " cache.r6g.4xlarge " port = 6379 engine = " redis " engine_version = " 7.0 " parameter_group_name = " default.redis7 " snapshot_name = " snapshot_name " .... other } 設定中にハマった点は、Terraformプロバイダーのバージョンが古く Error: engine_version: Redis versions must match <major>.x when using version 6 or higher, or <major>.<minor>.<bug-fix> apply時に上記のエラーが出ましたが、 v5.3.0 の PR にて解消されるので、上記のエラーに遭遇したらTerraformプロバイダーのバージョンを上げましょう。 負荷試験 アップグレード対象のRedisは求人検索結果の表示など非常に重要な部分で使われているため、本番反映前に負荷試験を行いパフォーマンス低下や不具合が起きないか確認する必要がありました。 スタンバイでは求人検索機能に関する SLO を設けているため、それを基準に負荷試験を実施しました。 リリース手順書を書いてリハーサル 本番作業をスムーズに問題なく実施できるように、検証環境でリハーサルを実施してから本番作業をすることにしました。 リリース手順書を作成し、チーム内レビューを通して検証環境でのリハーサルに挑みました。 実際に検証環境でCNAME切り替えると想定外の事が起きたので、リハーサルをやってよかったと言えます。実はRedisのCNAMEを切り替えただけだとうまく切り替わってくれないという事象が発生しました。 理由はRedisの Client Timeouts はデフォルトで無制限(つまりコネクションを閉じない)だからでした。 By default recent versions of Redis don't close the connection with the client if the client is idle for many seconds: the connection will remain open forever. 解決方法はRedisに接続しているアプリケーションをデプロイし直すことで新たにコネクションを接続しに行くので解決しました。 本番反映 あとは、本番前日にスナップショットからバージョンをあげたRedisを立ち上げて当日に切り替え作業するだけでした。しっかりとリリース手順書を書いたのでスムーズに切り替えも終わり無事アップグレードできました。 よかったこと アップグレードの対応を通して個人的によかったことを列挙します。 Redis5.0.6から Graviton が対応になっており、同スペックで低コスト運用が可能になったので少しコスト削減に貢献できました。 Redis5.0.6以降は、アップグレードに伴うダウンタイムが最小限に抑えられるので、今回のような大掛かりの作業をしなくてもすむ可能性が出てきました。(参考: アップグレードにと関する考慮事項 ) Redis自体は長く使用はしていたものの、どんな機能があるのか、どんな設定ができるのかなど詳細をあまり意識していませんでしたが、今回のアップグレードの変更内容を一通り読むことで曖昧だった部分について理解が深まりました。 最後に 以上、Redis3をRedis7にアップグレードした話でした。 今回のアップグレードでは、ダウンタイムなしで切り替えを行いました。 今回のアップグレード対応は個人的にRedisについてもっと知りたいと思える良い機会になりました。 参考文献 Amazon ElastiCache for Redisのメジャーアップグレード方法をまとめてみた ダウンタイムなしでアップグレードする方法を参考させてもらいました パラメータグループの変更内容 エンジンバージョンの変更内容 Amazon ElastiCacheのTerraform アップグレードにと関する考慮事項 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
はじめに こんにちは。スタンバイで求人データ管理に関するバックエンドエンジニアをしている池田です。 スタンバイはWEB上に存在する大量の求人を一括検索できるサービスを提供しており、その求人票のマスタのデータは Amazon Aurora を使って運用しております。 以下の記事で説明をしておりますが、2021年に求人取込直後の求人情報を構造化データとして保存するために Amazon Aurora を採用しました。 スタンバイの求人情報取込の仕組みを作り直した話 〜序章〜 DBエンジンとして Aurora ( MySQL 5.7 ) を利用しております。 ストレージエンジンとして InnoDB を利用しております。 しかし作り直しから時が経ち、求人票の増加や各種機能の追加等によって Aurora のデータ量は想定上の速さで増加していき、それに比例する形でインフラコストも増加し続けていました。 今回反省点とコスト削減のために実施した施策と結果について、紹介します。 増加しているテーブルサイズのグラフ。(1つの線が1つのテーブルのテーブルサイズを示しています) データ量が増加している要因 一部のテーブルのデータ量が大きく増加し続けている理由は、レコード削除が行われたテーブルに対し、ディスク領域の解放ができていなかったためです。 MySQLやMariaDBでは OPTIMIZE TABLE や ALTER TABLE などのコマンドを実行することで、ディスク領域を解放できます。 1 求人票はかなり頻繁に追加や削除が行われるという特徴があるため、求人データを管理する一連の処理では日常的に大量の INSERT, DELETE が実行されます。 そのため求人票を扱うテーブルのレコード数はあまり変わっていないのに、テーブルサイズはどんどんと増加し続けていました。 データの増加による悪影響 またデータ量が増えるとインフラコスト( storage usage 等)が高くなるだけなく、DBからのデータ取得速度が遅くなってしまい、サービスとしてもレスポンスが遅くなるなどの悪影響が出てしまいます。 DBからデータを取得する際のインデックススキャン、テーブルスキャンが遅くなります。 2 データの取得に時間がかかるため、サービスのレスポンスが遅くなります。 データの取得に時間がかかるため、CPU使用率も増加しやすく、リードレプリカを増やす、インスタンスタイプを上げるといった対応が必要になりさらにインフラコストが上昇します。 OPTIMIZE TABLE を定期的に実行するためには では単に OPTIMIZE TABLE を実行すれば良いのかというと、そうではありません。 なぜならば OPTIMIZE TABLE を実行すると、実行時と終了時に該当のテーブルにメタデータロックがかかるからです。 メタデータロック中に、並行してSelectクエリが実行されるとブロックされます。 3 つまり定期的に OPTIMIZE TABLE を実行するためには、Aurora のメンテナンス時間を設ける必要があり、 24時間稼働しているWebサービスでは原則として停止時間は設けられないので、Webサービスは直接Auroraに依存しないようにアーキテクチャを変更する必要があります。 当時スタンバイでは、Webサービス上で求人票を表示する際にはこのAuroraを直接参照するアーキテクチャになっていました。 そのため、定期的に OPTIMIZE TABLE を実行するためには、以下のようにWebサービスからはAuroraに直接依存しないアーキテクチャにする必要があります。 上記はスタンバイのWebサービスへ提供する求人情報をAuroraからではなくDynamoDBを通して提供するアーキテクチャです。 AuroraからDynamoDBへFeederを行うサービスを作り、AuroraとDynamoDB間求人データの同期を行いました。 求人情報APIではDynamoDBの求人データを参照するようにし、Auroraへの依存をなくしました。 アーキテクチャ変更の施策と結果 Auroraに依存しないアーキテクチャにしたところで、Optimizeをテーブルを実行したところ、データ量が減少しました。 4 初回データ削除後のテーブルのサイズ。 定期的なOptimizeの実行後のテーブルのサイズ。 初回Optimize後に定期的にOptimizeを実行することで、未使用領域がshrinkされたりB-TREEインデックスの並びが整理され、結果的にストレージの使用容量が減少しました。 結果として インデックススキャン、テーブルフルスキャンが速くなり、クエリーの実行速度が上がりました。 クエリーの実行速度が向上することで、DBに対してクエリーを実行するアプリケーションの並列度を下げることができるようになり、特にSELECTクエリーの同時実行数が減りました。 並列実行されるクエリー数が減ることでDBのCPU使用率も減り、さらにDBのクラスターの台数を減らすことができました。 最終的にインフラコストが大きく削減されました。 さらにコストを削減するための施策 スタンバイの求人データの更新頻度を見直して1日の取り込み回数を制限した結果、求人の書き込み数自体が約2分の1ほど減りました。 5 この書き込み数を減らす施策によって、Auroraのインフラコストがさらに削減できました。 2023年3月の Aurora:StorageIOUsage と比較して、2023年8月以降には1/3以下に下がりました。 施策と結果について 今回実施した施策をまとめると以下となります。Auroraのインフラコストは施策実施前と比較して約55%削減できました。 更新頻度が高いテーブルを定期的にOptimizeできる状態にする。 書き込みが多い場合は、書き込みを減らす。 不要なトランザクション処理を削除する。トランザクションを使うべきところで使う。 書き込みの速度が遅くなり、かつ削除できないログ(InnoDBのログ)がたまり続けるため。 Auroraでのコスト削減の面で一番効果的な施策は、DBへの書き込み、読み込みを減らすことでした。 Aurora:StorageIOUsage に比例して、コスト全体が 下がっております。 反省点 今回2023年3月と比較して大きくコスト削減ができたのですが、AWS Aurora(MySQL)を採用する際には以下を気にしておくべきであったと痛感しております。 設計時にデータ量を正しく見積もる。 マスターテーブル、トランザクションテーブルでどれくらいのデータ量になるかを見積もる。 レコードのライフサイクルを考える。 レコードの削除を多く実行する場合には、どこかのタイミングで OPTIMIZE TABLE を実行する必要がある。 MySQLまたはMariaDBを採用する場合は、定期的に OPTIMIZE TABLE できるようなアーキテクチャにする。 書き込みが多いアプリケーションの場合は、I/Oレートに比例して料金が上がり続けない料金体系を利用する。 過剰なSLOを定めない。 今回のケースでは関係者と調整したうえで求人データの更新頻度を1日N回に制限することで書き込み数を減らすことができました。SLO自体が要求・要件に対して過剰になっていないかを確認し、適切なレベルにすることは重要でした。 求人データ管理に関するシステムのリアーキテクチャを進める仲間を募集しています 今回の施策で、求人データの管理に利用しているAuroraの費用は以前よりも半分以下に削減できましたが、まだまだ求人データ管理周りのシステムはコストを削減できる余地があります。 現在はよりcost-effectiveなアーキテクチャを目指して、求人取り込みのリアーキテクチャを進めております。 目標としては更に半分以上にインフラコストを下げたいと思っております。 そのため一緒に求人データ管理周りのシステムのリアーキテクトを進める仲間を募集していますので、少しでも興味があればぜひご連絡ください。 データストアをAuroraだけなく、DynamoDBやDocumentDBなどを適切に利用することで、よりコストを削減できるアーキテクチャにします。 毎日約800万求人の取り込みを行い、大規模なデータ量を扱った開発、保守、運用しております。 言語はJava, Goで開発を進めており、チームでGoやストリーム処理の勉強会をしながらみんなで学習しています。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com 私の Amazon RDS for MySQL DB インスタンスが想定よりも多くのストレージを使用しているのはなぜですか? ↩ 実際にOPTIMIZE TABLEの効果を実測したところインデックススキャンでのデータ取得が、OPTIMIZE TABLEの後に数十秒から0.5秒に改善されました。 ↩ AWSのサポートに確認したところ、以下の返答をいただいております。「DDL文の実行にはメタデータロックの取得が必要となる関係上、select文が並行して実行されている場合、ブロックされる動作となります。また、optimize文はInnoDBストレージエンジンの場合、ALTER TABLE ... FORCEという文となり、開始および完了時にごく短時間、メタデータロックを取得する動作となります。」 ↩ レコード数が多いテーブルではOptimizeに数日かかり正常に完了しない問題も発生したため、テーブルを作りかえて、データを移行することで対応しました。 ↩ AWSには Aurora I/O-Optimizedという I/O 集約型アプリケーションの場合に料金の上昇を抑制できる料金体系があります。 MySQL 5.7ではAurora I/O-Optimizedには対応しておらず、MySQL 8系にすることで、Aurora I/O-Optimizedを利用できるようになります。 今回のAuroraで料金をシミュレーションした結果、料金体系を変更せずに書き込みを減らす案の方がインフラコストを下げられたので、 I/O-Optimizedは利用しませんでした。 AWS が Amazon Aurora I/O 最適化をリリース ↩
こんにちは、スタンバイで検索周りの開発を担当している鷹取です。 今回は検索関連についてではなく、スタンバイの技術負債解消についての取り組みについてご紹介します。 概要 Stanby Tech Blogの スタンバイ2+1年の軌跡 の記事でも少しだけ触れられていますが、 スタンバイのシステムには複数チームでメンテしているstanby-apiと呼ばれるコンポーネントがあります。 stanby-apiはスタンバイのWeb画面を表示するための重要なコンポーネントですが、 歴史が長いこともあり、コードの複雑化や開発体制とのミスマッチにより、プロダクト全体の開発生産性の低下を招いていました。このstanby-apiの複雑化してしまったコードを、適切な粒度でモジュールに分割することにより、問題の解決を図ったというのが、本記事の内容になります。 stanby-apiとは まず最初にstanby-apiについて説明します。 スタンバイはWeb上で求人検索を提供しているサイトですが、 その実現のためには、以下のような様々な機能が必要になります。 求人の検索 広告配信 住所情報の解析 クエリの解析 クエリのサジェスト 応募機能 ABテスト機能等 これらの機能は、スタンバイ内部の複数のコンポーネントがAPIとして提供しています。 stanby-apiはフロントエンドからのリクエストを受け、 バックエンドの各API群を適切な順で呼び出し、 それらのデータを統合してフロントエンドに返却する役割を担っています。 stanby-apiが抱えていた課題 上記で述べたように、stanby-apiはフロントエンドへのレスポンスを返す役割を果たすコンポーネントですが、最初からそのように設計されたわけではありませんでした。初期段階では、現在ほど多くの機能やコンポーネントが存在せず、バックエンドの機能が直接stanby-apiに実装されていました。時間が経過するにつれ、スタンバイの機能が増え、stanby-apiにも多くの実装が追加されました。しかし、これらの変更は全体のアーキテクチャを総合的に検討せずに行われ、将来の展望に基づいて設計されたものではなかったため、必要な機能が段階的に追加され、APIが無秩序に成長してしまいました。 さらに、組織の拡大と体制の変化に伴うチームの分割や統廃合、開発者の異動などもあり現在では、stanby-apiは複数のチームの開発者によって開発される状態になりました。しかしながら、チーム間の責任分担が明確でなかったため、各チームが自由に実装したコードがいたるところで入り混じり、stanby-apiの全体像を把握する開発者の不足に繋がりました。 結果として、stanby-apiの開発においてさまざまな問題が発生しました。必要な情報の把握には多くの時間がかかり、機能の開発に取り組むまでに遅延が生じました。一見するとささいな変更であっても、無関係のテストが失敗する事例も発生しました。何人もの開発者や複数のチームが同一のコードベースに変更を加えるため、コンフリクトが発生し、リリーススケジュールの管理が煩雑となりました。このような理由から、stanby-apiの開発はスタンバイの成長において大きなボトルネックとなっていました。 モジュール分割による解決 この問題を解決するために、stanby-apiに対するリファクタリングプロジェクトを立ち上げました。 このプロジェクトでは、以下の2つをゴールとして設定しました。 絡み合ったコードを整理し、stanby-apiのコードを機能ごとにモジュールへ分割することで、依存関係を分離し、機能間の境界を明確にすること 各モジュールに担当グループを割り当て、作業範囲を限定、責任を明確にすること プロジェクトを進行する際、まず最初にモジュールの分割方針を策定しました。 モジュールを分割する際、最も検討が必要なポイントは、どの単位でモジュールを分けるかです。分割単位が小さすぎると保守性が低下し、大きすぎるとコードの複雑性の増加につながる可能性があります。しかし、モジュールの分割単位については絶対的な正解はありません。このプロジェクトを進める際、各チームと実際のコードを検討しながら、モジュールの分割単位を合意するためにいくつかの方針を設けました。 モジュールの分割単位は、担当チームが単独で開発・運用できる単位とする。 既にAPIとして独立した機能が切り出されている場合、外部API呼び出しを1つのモジュールとして分割する。 これらの方針を定めることで、モジュールの分割に関する議論を具体的に進め、合意を得る際の指針としました。 方針が固まった後、具体的なモジュール分割方法を決めました。 モジュールのインタフェース定義には、Protol Buffersを採用しました。 これまでは、特定の機能でのみ使用される想定のクラスやメソッドが、stanby-apiのどこからでも呼び出し可能になっていたため、本来関係のない箇所で使用されていることがありました。 例えば、求人情報を表すJobクラスに実装されている求人のタイトルを表示を整えるために加工するメソッドが、検索APIを呼び出しているメソッド内で使われているといったことがありました。このような状況では、変更の影響範囲が特定できず、小さな変更に対しても調査に時間がかかってしまいます。ソースコードを単にモジュールに分割しただけでは、この問題は解決できません。今いるエンジニアが注意を払って開発していても、いずれ新たに入ってきたエンジニアが、意図せずにモジュール外からモジュール内部のロジックを呼び出してしまう可能性があります。 そこで、Protocol Buffersを使用して、モジュール間でビジネスロジックが漏れ出ることを防ぐことにしました。Protocol Buffersでスキーマを定義し、ツールを用いてモジュール呼び出しのインプットとアウトプットを表すクラスのコードを生成します。このクラスには、数値や、文字列、配列、オブジェクト型のメソッドを持たないデータのみが含まれます。モジュールの実装側と呼び出し側は、このクラスにだけ依存するようにすることで、ロジックがモジュールの外に漏れ出てしまうことを防ぐことができます。 モジュールの分割の進め方として、モジュールを分割する際には、一括で全てのモジュールを切り出すのではなく、段階的に進めることにしました。最初に影響が比較的小さいモジュールをトライアルで切り出し、モジュールの分割における実績を積むことに重点を置きました。 そこで得た経験を活かしながら、他の開発に対する影響を最小限に抑えつつ、順次モジュールを切り出していきました。 モジュール分割によって得られた効果 上記の方針に従ってプロジェクトを進め、モジュール分割は約半年で完了しました。 モジュール分割により得られた効果として、複数チーム間での開発が非常にやりやすくなりました。 すべてのコードの担当グループが明確になったため、ある機能を追加しようとした際に、どこのチームに相談をすればよいかが一目でわかるようになりました。 また、スキーマ定義の変更箇所が決まってしまえば、モジュール呼び出し側とモジュールの実装側で並行作業ができるようになり、開発期間が短縮されました。 同じコードを触ることがなくなったので、コンフリクトも減少しリリースの管理も容易になりました。おまけに、誰の持ち物かわからず削除できていなかった大量のデッドコードを削除でき、コードがクリーンになりました。 今後について 今回はstanby-apiというコンポーネントのリファクタリングとして、ボトムアップのアプローチでモジュール分割を進行しました。しかしながら、このような課題は1つのコンポーネントに限らず、プロダクト全体で発生する可能性があります。このような問題を未然に防ぐため、全社的なアーキテクチャの確立が必要と考えられます。 近年、マイクロサービスやモジュラモノリスなどのアーキテクチャが広く採用されつつあります。今回の改修において、stanby-apiはモジュラモノリスとマイクロサービスの中間的なアーキテクチャとなりました。将来的に全社的なアーキテクチャがどのようになるかに関わらず、今回の改修によりスムーズな移行が可能となっています。 まとめ 本記事では、stanby-apiのモジュール分割によるリファクタリングについてご紹介しました。 Stanbyの重要なコンポーネントであるstanby-apiは、長い歴史と複雑性から生産性低下の課題を抱えていました。モジュール分割プロジェクトにより、モジュールの明確な分離と管理体制の整備が行われ、開発生産性が向上しました。将来のアーキテクチャ変更にも柔軟に対応可能な状態となりました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
株式会社スタンバイ QAグループに所属している扇谷です。 本記事では、スタンバイQAのテスト自動化の取り組みを紹介したいと思います。 2023年9月現在、導入後1年半におけるスタンバイのWebのテストで、テストシナリオ数は「100個以上」になっており、実施回数は「9000回以上」になります。 テスト自動化が標準となっていく業界の流れの中で、スタンバイQAの目的と試行錯誤から、今後、テスト自動化の導入を検討するQAの意思決定の一助となれば幸いです。 背景・課題 〜 なぜテスト自動化をするのか スタンバイQAでは、PythonやNode.jsも使用しテストの一部を自動化していますが、本記事ではサードパーティのテストツールの導入についてお話しします。 テスト自動化ツールを導入時には、まずQA費用のコストダウンに注目してしまいがちですが、その前段階として導入によって何を実現したいのか、しっかりと目標を据えることが重要です。 たとえコストダウンが行えなかったとしても、それ以上の恩恵を前提として導入した場合、より幸せなQAライフが待っていると思います。 コストダウンが一番伝わりやすい導入理由になるのを否定するものではありませんが、自動化テストのシナリオ作成やメンテナンスコストを考慮に入れると、コストダウンは必ずしも容易に達成できるものではない可能性があります。 QA予算を抑えることができるようになること 今までQAで実施できていなかった範囲の作業が行えるようになること 図のE2Eテスト部分のコストカットは数字として出しやすいですが、緑色部分はQAチーム外には実感しにくい遅効性の効果だったりしますので、各QA業務のパフォーマンスを数値として出せる(あるいは新しい取り組みを列挙できるよう)準備はしておいた方がいいかもしれません。 内部的なE2Eテストのコストカットを前提としたテスト範囲の拡大を実現し示すことができれば、成長するチームとして組織の中でより存在感のあるQAとなるでしょう。 導入検討 〜 MagicPod を選んだわけ スタンバイのE2Eテストの自動化導入では、開発状況を反映し、大きく以下の点を達成したいと考えていました。 E2Eテストを開発タスクから「QAタスク」に移行する 以前のスタンバイでは、開発スタッフがE2Eテストを担い自動テストを組んでいました。 QAでも最終的にE2Eテストを実施していましたが、開発段階におけるE2Eテストの一部を実装/実施することは開発サイクルを遅延する一因となっていましたので、開発側のテスト内容をMagicPodに組み込むことで不要なストレスが掛かっていたタスクを取り除くことができました。 ノーコードで作成でき、QA内だけで完結できる テスト自動化の持続性の点から、自動テストのシナリオ作成は技術的に難易度が低ければ低いほどいいです。 MagicPod社のサポートは手厚いですが、数時間ほどの操作で通常使用に問題ないほどのレベルで習熟ができることは、導入時のとっかかりとして重要です。 E2Eのテスト自動化は、マウスのポチポチだけで達成できる時代が既に到来しており、あらゆるレベルのQAスタッフがそれなりに使用すること自体ができますし、自社の開発スタッフのサポートがほぼ不要なほど僅少で済むことは、QAが自律的に活動できる組織になるための大きな援助となりました。 コスパが良い 機能が必要十分以上であること、継続して開発されていること、従量課金制ではないこと、他サービスと比較し安価であること、など総合的に判断しベンダーロックされても問題ないと判断できました。 従量課金制の場合、金銭的な計算や自動テストの作成や実施回数など気を使わなければいけない部分が出てきます。 テスト回数への制限がないMagicPodの料金体系は、QAが思う理想的なテスト回数を実現できる為、金銭的な憂いから解き放たれることはMagicPodを採用する大きな理由のひとつとなりました。 Android/iOSアプリ対応 2022年の導入当時から、ネイティブアプリのテスト自動化に対応していることも導入の決め手となりました。 コスト感がマッチしていたこと、他サービスを利用した場合には1.5〜2倍超ほどコストがかかる可能性があったことも後押しとなりました。 これらを持って、数社のサービスを比較しMagicPodの無料体験から導入を決めています。 恩恵 〜 望外に便利だった機能 画像差分比較 MagicPodでは、キャプチャした画像を期待値として、テスト実行時に比較し差分を検出/通知できます。 スタンバイでは、リリースサイクルの兼ね合いからQAを通さずにABテスト等を実施する必要もあった為、本番環境と検証環境で定期的に実行してくれるMagicPodは導入意義が高くありました。 リリース後の不具合を検出できるか否か以前に、画像差分でABテスト部分の差分を教えてくれることで、QAが絡まないリリース内容を開発↔︎QA間での事前/事後の情報共有/確認が不要となりました。 現行のプロダクトを早期に正確に把握できること、かつ本来必要なコミュニケーションを不要なものとして昇華してくれたことは、QA作業の負荷を軽減してくれたと思います。 メールチェック 公式サイトで外部連携のメールチェックの手法が2種紹介されていますが、弊社ではSlackのチャンネル用のメールアドレス宛にメールを送信しています。 普段使いのSlackでメールを簡易に確認できることは、シナリオ作成時においても利便性が良かったです。 日次の検索やクリックを行う スタンバイのサービスでは、クリック履歴や検索履歴から作成されるデータがあるのですが、検証環境では開発/QAスタッフがメインに触っていますので、操作履歴の絶対量が少なく確認したいタイミングでデータがない場合がありました。 前日や当日の下準備が必要となっていたのですが、MagicPodが毎日、決まった時間にアクセスやクリックを頑張るシナリオを作成することでそのような下準備が不要となり、仮に反映されない現象が発生した場合に、どのタイミングから発生したのかを遡りで原因を特定することも容易になりました。 メリット・デメリット 〜 メリットを大きく引き出す スタンバイではMagicPodの自動実行を、本番と検証の両環境で3時間ごとに回しています。 前述の「今までQAで実施できていなかった範囲の作業が行えるようになること」として可能な限り頻度を高めていきたいのと、テスト結果確認におけるタスク量を許容できる範囲に収めるバランスの落とし所として頻度を決めています。 別途、新規実装のテスト環境でも手動実行を行なっており、今まではテスト依頼ごとに広範でのリグレッションテストやE2Eテストは実施できていませんでしたが、MagicPod導入によりテストカバレッジが増加しました。 スタンバイQAとしては、コストカットよりQAが行える作業量の拡大とコストカット分の時間を他作業に当てることを主眼に置いていましたので、当初の目標は早い段階で達成できたかと思います。 なお、デメリットというデメリットはありませんが、導入時のテストシナリオ作成のタスク量は大きい為、繁忙なQAチームのタスク軽減を目指して導入したのにテストシナリオ作成が滞る可能性は起きうるかと思いますし、スタンバイでも導入直後はその状態にありました。 QAチームの置かれている状況を考慮に入れ、取捨を行いテストシナリオ作成を優先することで数ヶ月〜1年後に想像していた未来を手に入れることができると思います。 デメリットよりもメリットを享受できる未来に持っていくまでのロードマップを正しく描き、正しく実践することが重要かと思います。 反省 〜 導入に向けて気を付けること スタンバイQAのMagicPod導入時に試行錯誤の上、手戻りが発生した点は以下となります。 端的には、誤った前提から始まるテスト作成は、メンテナンス性の悪さから無用の長物と化してしまう可能性が高いです。 E2E・リグレッションのテスト項目は作り直すべき 通常、テスト項目数は機能追加と共に右肩上がりとなり取捨選択が必要となっていますが、テスト自動化の導入により基本的に整理する必要性は低下していきます。 MagicPodのシナリオはテスト手順が多くなりますので、どのようなテストを行なっているかの確認は時間を要しますので、別管理がおすすめです。 また、手作業でテスト項目を埋める際には順不同でも確認できれば問題ないですが、自動化したチェック順と実際のテスト項目の順番に齟齬が発生していると実装内容が追加/改修された時にメンテナンスが煩雑になります。 弊社の導入時を省みても、作業の流れを意識したテスト項目の完成度の高さがスムーズなテストシナリオ作成と以後のメンテナンスコストの軽減に繋がることを、当たり前のことながら強くお伝えしたいと思います。 テストシナリオは小分けにすべき 長すぎるテストシナリオは、テスト結果の確認時にどの機能でアラートされているか判断するのが手間になります。 小分けしすぎるのも問題ですが、冗長なテストシナリオは確認やメンテナンスも煩雑になるので、機能ごとに区切って適切なボリュームに収めて作成することが重要となります。 これらを実践することで、E2E・リグレッションテストの精度も向上し、MagicPodのメンテナンス性も高まります。 導入を機会に、可能であれば項目を見直し刷新すると良いでしょう。 総括 〜 MagicPod MagicPodでは、UI上の操作やHTMLの確認が可能でありますが、Cookieやネットワーク情報の整合性確認は行うことはできませんので、その確認は別途自動化する必要があります。 ■metaタグの確認はデータパターンを用いて各ページで確認 その自動化に需要があるかは置いておいて、どのようなチェックをどのようにテスト自動化するか否かを棲み分けする必要性がありますが、MagicPodの確認範囲は広範に及びますし、そのポテンシャルを最大限に引き出すことでQAの作業タスクの限界値を大きく引き上げてくれるサービスだと実感しています。 長年に渡り継続するプロダクトのQAでは、テスト自動化の導入は必須になっていくと思いますが、QAのタスクを如何に軽減していくことができるか、あるいはカットしたタスク以上のアウトプットを如何に出すことができるかが導入に失敗しないための要となります。 以上が、簡易ながらスタンバイQAのMagicPod導入の歴史となります。 長文でありながら書き足りない部分もありますが、お付き合い頂きありがとうございました。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
はじめに こんにちは、コーポレートITグループの西本です。 コーポレートITグループでは、社員のPCの管理や、各種ライセンス管理をはじめ、社内で利用するサービスの導入やサービス間の連携など幅広く業務をおこなっています。 その中で今回は、タイトルにもあるように、kickflowというワークフローシステムとGoogleスプレッドシートを GoogleAppsScript で連携させた話をご紹介いたします。 背景 スタンバイでは、購入稟議や支払い申請を管理するためのワークフローシステムとしてkickflowというサービスを利用しています。 https://kickflow.com/ kickflowで作成された各種リクエストはチケットという単位で管理され、ベルトコンベアの上を流れる出来立てのパンのように、各部署のチェックをもらいながら完了へと進んでいき、すべてのチェックの完了後、リクエストに応じて各部署で物品の購入や、支払い処理などに進んでいきます。 それぞれのチケットは、kickflowのサイト上で1つずつ確認ができますが、業務を効率的に進める上で、完了したチケットを一覧にしてGoogleスプレッドシートへの書き出しをしてほしいという要望があり、以前までZapierというノーコードで複数のサービスを連携させることのできるサービスを利用し、kickflowで完了したチケットのデータをスプレッドシートに書き込んでいました。 Zapier | Automation that moves you forward 何が課題だったのか Zapierはプログラミングに関しての知識がなくても各種サービスを連携させることができる便利なサービスなのですが、この連携についてはいくつかの問題点がありました。 書き込み時にエラーが起こった場合、対象のチケットデータの再取得に手間がかかる。 Zapierは基本的に指定したイベントをトリガーとして処理が開始します。kickflowでチケットが承認されると、そのタイミングでwebhookを送信するよう設定し、それをトリガーとしてZapierでの処理を動作させていました。しかし、webhookを受け取って処理を実行する際にエラーが発生した場合、対象のチケットの再取得に手間がかかるという問題がありました。このため、特定のチケットに対して任意のタイミングでデータ取得やデータ加工が行えるようにする必要がありました。 データの加工の問題 Zapierは基本的にノーコードプラットフォームであり、さまざまなサービスの連携を強力にサポートします。しかし、kickflowで生成されるJSON形式のデータをスプレッドシートへの書き込み用に加工する際には、多くのステップを踏む必要がありました。また、スプレッドシートのフォーマットが変更されると、そのメンテナンスに多くの時間がかかっていました。このため、変化するニーズに柔軟に対応できる連携方法に変更する必要がありました。 使用制限の超過( 昔話です。現在はアップデートにより解決しています ) 現在のkickflowではwebhookを送信するイベントを任意で選べるようになりましたが、以前は全てのイベント(全てのワークフローで作成された全てのチケットのステータス変更が対象)でwebhookが送信されおり、結果として大量の処理がZapier上で行われることになりました。これにより、契約していた月間使用可能な処理回数を超えてしまい、予期せぬタイミングで処理が停止する問題が発生しました。 このような課題を解決するためにZapierでの連携を見直す必要がありました。 GoogleAppsScriptで連携をさせるメリット このような課題を解決するためにコードで細かく処理を行えるGoogleAppsScript(以降GASと表記)で連携させることにしました。 GASを使用するメリットとしては以下のようなものがあります。 GoogleWorkSpaceを契約している場合無償で利用できる ←超重要 独立した実行環境を必要としないため、非専門家にとって業務で使用する敷居が低い GASはJavaScriptがベースになっているため、私にとってコードの記述が難しくない(過去に少しだけ触ったことがありました) このようにGASには多くのメリットがありますが、実装を進める中で同時に解消しなければいけない課題もいくつか見えてきました。 GASで連携を進める中で見えてきた課題 そんなこんなで、GASを用いてkickflowとスプレッドシートの連携を進めていったのですが、いくつか問題がありました。 データをどのように取得するか kickflowのwebhookはタイムアウトが10秒に設定されており、その時間内にデータの加工、添付ファイルのダウンロード、スプレッドシートへの書き込みを実行するには時間がかかりすぎてしまいエラーになってしまいます。そのため、どのようにkickflowからスプレッドシートに書き込む対象のチケットデータを取得するかが問題になりました。 ワークフローごとに異なる配列のインデックスをどのように指定するか これはZapierでの連携時からの課題と重なりますが、kickflowで作成したワークフローには、カスタムフィールドの設定が可能で、その入力データはオブジェクトに配列として保存されます。 Zapierで連携していた時は、"ticketData.inputs[10]" のようにワークフローごとに配列のインデックスを指定しデータを取得していましたが、これではカスタムフィールドの項目変更があった場合、どこのインデックスにどのデータが保存されているかの確認から行う必要がありメンテナンスコストがかかるためチケットデータから任意のデータの取得方法を改善する必要がありました。 次の章から具体的にどのような方法、仕組みで問題を解消しつつ連携を進めていったか説明します。 課題解決とGASでの連携方法 それではここから先述したZapier、GASの課題についてどのように対応し、連携をおこなったかを解説します。 まずこちらの課題について、添付のブログ内の'独自キャッシュシステムを作成'の方法を参考にしました。 課題1(Zapier)書き込み時にエラーが起こった場合、対象のチケットデータの再取得に手間がかかる。 課題1(GAS)データをどのように取得するか GoogleAppsScript(GAS)でのcacheあれこれ - 株式会社BEFOOL ブログ 実装手順解説 ①kickflowのwebhookの設定で、webhookの対象ワークフローを任意のものを選択し、webhookの送信条件を、'ticket_completed' に設定します。 この設定によりスプレッドシートで管理したいワークフローと、検知したいイベントの種類を選択できます。 ②次に、スプレッドシートにwebhookで送信された、完了したチケットのチケット番号とチケットのUUID(チケットごとに発行されるユニークな値)をスプレッドシートに記述するための関数を作成します。チケットのUUIDは後の処理で使用します GASでwebhookを受け取る方法については以下の記事を参考にしました。 Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法 - Qiita 作成したスクリプト const doPost = (e) => { const postData = e.postData.contents; const jsonData = JSON.parse(postData); // チケット番号とUUIDを取得 const ticketNumber = jsonData.data.ticket.ticketNumber; const ticketUuid = jsonData.data.ticket.id; //書き込み先のシートを取得 const sheetId = PropertiesService.getScriptProperties().getProperty( 'SpreadsheetId' ) const spreadSheet = SpreadsheetApp.openById(sheetId).getSheetByName( '完了済みチケットリスト' ); //スプレッドシートに書き込み spreadSheet.appendRow( [ ticketNumber, ticketUuid ] ); } kickflowからwebhookで送信された、完了したチケットの情報は画像のようにスプレッドシートに書き出されます。この処理は数秒で終了するためwebhookのタイムアウトに頭を悩ませる必要がなくなりました。 ③取得したチケットのUUIDを別のスクリプトで読み込み、kickflowのAPIを利用してチケットのデータを取得する。 使用したAPIの詳細: kickflow REST API v1 スプレッドシートからUUIDを読み込むためのコード //完了済みチケットリストからチケットのUUIDを取得 const sheetId = PropertiesService.getScriptProperties().getProperty( 'SpreadsheetId' ) const completedTicketsSheet = SpreadsheetApp.openById(sheetId).getSheetByName( '完了済みチケットリスト' ); const lastRow_completedTicketsSheet = completedTicketsSheet.getLastRow(); const ticketIdList = lastRow_completedTicketsSheet > 1 ? completedTicketsSheet.getRange(2, 1, lastRow_completedTicketsSheet - 1, 2).getValues() : [] ; GASでKickflowのAPIを呼び出すためのコード //KickflowのAPIを呼び出しチケットデータを取得するための関数 function getKickflowTicket(id) { const apiUrl = `https://api.kickflow.com/v1/tickets/ ${id[1]} ` ; const apiKey = PropertiesService.getScriptProperties().getProperty( 'KickflowToken' ); const options = { 'method' : 'get' , 'headers' : { 'Authorization' : `Bearer ${apiKey} ` , 'Content-Type' : 'application/json' } } ; try { const response = UrlFetchApp.fetch(apiUrl, options); const result = JSON.parse(response.getContentText()); return result; } catch (e) { //エラーハンドリング const adminChannel = PropertiesService.getScriptProperties().getProperty( 'corporate-it_notifications' ); const message = `KickflowAPIでのチケット情報取得時にエラーが発生しました。 \n チケット番号: ${id[0]}\n Error: ${e} ` sendMessageToSlack(adminChannel, message); } } ④取得したデータから必要なフィールドのデータを取得し、スプレッドシートに書き込む用に加工する ここでは以下の問題に対応するために新しくオブジェクトと関数を作成しました。 課題2(GAS)ワークフローごとに異なる配列のインデックスをどのように指定するか ワークフローごとにデータ取得用のkeyPathと出力時のOutput名をオブジェクトの配列で定義する 標準で設定されている項目に関しては特定のkeyが用意されているためそちらを使用します。 階層が深いものについては階層の上のkeyから配列で定義しています。 ワークフローに合わせて追加したカスタムフィールドについてはそれぞれフィールドコードが割り当てられるためそちらを使用します。 // これは購買稟議で使用しているものの一部です const keys_purchaseOrderRequest = [ { keyPath: [ "ticketNumber" ] , outputKey: "ticketNumber" } , { keyPath: [ "workflow" , "name" ] , outputKey: "applicationType" } , { keyPath: [ "title" ] , outputKey: "title" } , { keyPath: [ "authorTeam" , "fullName" ] , outputKey: "department" } , { keyPath: [ "author" , "fullName" ] , outputKey: "drafter" } , { formCode: "123456789" , outputKey: "contractStartDate" } , { formCode: "987654321" , outputKey: "contractEndDate" } , ] ここで定義したオブジェクトの配列を以下の関数で使用することで任意のデータをチケットデータから抽出することができます。 引数のticketDataにはkickflowのAPIで取得したチケットデータが、keysにはワークフローごとに設定した先述のオブジェクトの配列が渡されます。 この関数を通して任意のデータを指定したkey名で出力することができます。 //KickflowAPIで取得したチケットデータから必要なデータを抽出する関数 function extractData(ticketData, keys) { let extractedData = {} ; for ( let key of keys) { if ( 'formCode' in key) { // formField.codeを使ってデータを取得する場合 for ( let input of ticketData.inputs) { if (input.formField.code === key.formCode) { extractedData [ key.outputKey ] = input.value; break ; } } } else { // keyPathを使ってデータを取得する場合 extractedData [ key.outputKey ] = getValueByPath(ticketData, key.keyPath); } } return extractedData; } //フィールドコードが設定されていないデータについてはkeyPathを使用してデータの取得を行う function getValueByPath(obj, path) { return path.reduce((o, k) => (o || {} ) [ k ] , obj); } この手順でチケットのデータを取得することで、ワークフローのフォーマットが変更された場合でも、変更されたフィールド以外影響を受けることなくデータの取得が可能になりました。 そして、GASでチケットデータをスプレッドシートへの書き込み用に加工することで比較的自由にオブジェクトを操作できるようになり、Zapierでのデータ加工の問題もクリアされました。 課題2(Zapier)データの加工の問題 最後に ここまで読んでいただき、誠にありがとうございます。 本記事では、kickflowとスプレッドシートの連携について、またその過程で工夫した点などについて紹介しました。GASやサービスが提供しているAPIを活用することで、システム間の柔軟な連携が可能となることを実感した、貴重な経験となりました。 今後は、さらなる効率化に向けて、システムを実際に利用する社員とのコミュニケーションを密に行いつつ、社内のシステム連携を最適化していくことを目指しています