TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

171

概要 こんにちは、RevCommのエンジニア、加藤(涼)です。今回は、私たちが参加した最高のイベントについてお話しします! 2024年08月26日(月)に弊社から4人1チームが AWS Startup GenAI GameDay に挑戦してきました。 普段はそれぞれ異なるプロジェクトに携わっている私たちですが、この日は一丸となってAWSの最新技術に挑戦しました。 この記事ではその参加レポートを共有します! AWS GameDayとは そもそもAWS GameDayとは何かをご紹介します。 AWSのサイトから引用します。 AWS GameDay は、チームベースの環境で、AWS ソリューションを利用して現実世界の技術的問題を解決することを参加者に課題として提示する、ゲーム化された学習イベントです。従来のワークショップとは異なり、GameDay は自由で緩やかな形式で、参加者は固定概念にとらわれずに探索し、考えることができます。 また、AWSでは様々なGameDayが開催されていますが、今回のAWS Startup GenAI GameDayでは、スタートアップ企業向けで、特にGenerative AI に特化した内容でした。 ↓詳しくはこちらのリンクから aws.amazon.com 参加の動機 今回のAWS Startup GenAI GameDayへの参加を決めた理由は主に2つあります。 1. AWS の Generative AI サービスへの興味 MiiTelでは多くのチームが生成AIを活用していますが、自分のチームでもどのように生成AIを取り入れられるか興味がありました。 そのためこのイベントは、最新の AI 技術を実践的に学べる絶好の機会だと考え、迷わず参加を決意しました! 2. AWS GameDay への初挑戦 過去数年間、AWS Summit には参加してきましたが、同時開催の AWS GameDay にはタイミングを逃して参加できずにいました。 毎回キャンセル待ちの長蛇の列を見て、「次こそは!」と思っていたので、今回はその思いを実現できる絶好のチャンスでした! 参加するまでにやったこと 何が出るかは分からなかったですが、GameDayに向けて、以下のような準備を行いました: AWS公式ドキュメント・ブログを読む Generative AI関連サービスについて重点的に学習しました。特に最近だと Amazon Bedrock , その派生の PartyRock あたりがアツいです。 また、Bedrockはlambda, DynamoDBなどの他サービスとの連携が多いです。 AWS の公式ブログ のように構成図が上がっているので事前に読むようにしてみました。 コミュニケーションツールの準備 当日のスムーズな情報共有のため、Slackのプライベートチャンネルを作成し、URLやコード共有を素早くできるように準備しておきました。 当日の様子 当日は日本から8社が参加しました。 到着してから知ったのですが、実はインド・韓国からも同時参加していて、計26社がこのGameDayに参加していたそうです。 ルールは各国のスタートアップとリアルタイムでポイントを競うというもので、そのポイントはダッシュボードで確認できるようになっています。 私たちのチーム名は「JP_Under30」にしました。参加したメンバーが全員20代なので、この名前にしました笑 そしてGameDayの開始です。 (AWSの公式ブログはこちら!) aws.amazon.com 最初は何をして良いか分からず、それぞれのメンバーで課題を分担することにしました。最初の30分ぐらいですでに大量のポイントが入っているチームもあり、この時は少し焦っていましたね。 開始約30分後の順位 順調に進めていき、ダッシュボードを眺めたときがこちら 途中で確認した順位 下がっている。。19位。。やはり、他のチームもどんどんポイントを獲得しているようです! 何とか色々な課題を解いてポイントを獲得して残り10分の時 残り10分での順位 6位まで上がっていました!すごい! この後順位が前後しましたが、最終的に全体で5位、国内で3位という結果になりました。 ということで国内で入賞しました! 入賞賞品として、AWSのBuildersCardsというものを頂きました!これは、AWSのサービスや構成を学ぶことができるカードゲームです。嬉しいですね!( AWS BuilderCards ) 記念写真 記念写真 (左から、瀧山、中島、加藤、ホセ、) 感想 今回のAWS Gamedayを通じて、新しい技術を試すことの楽しさや、チームで課題を解決することの重要性を改めて実感しました。 また、普段は異なるプロジェクトに取り組んでいるメンバーと協力することで、新しい視点やアイデアを得ることができました。限られた時間内で効率的に作業を進めるためには、各メンバーの強みを活かしつつ、互いにサポートし合うことが不可欠でした!みなさんありがとうございます!! まとめ 今回のAWS Startup GenAI GameDayを通じて、多くのことを学び、成長することができました! 結果は全体で5位、国内で3位という素晴らしい成績を収めることができました! チームワークの重要性、新しい技術への挑戦、そして時間管理の大切さ - これらすべてが、今後の仕事や技術開発に活かしていきたいと思います!!
アバター
概要  こんにちは、RevCommでMiiTelの音声解析機能に関する研究開発を担当している石塚です。 前回のRevComm Tech Blog にて、2023年時点でSOTAの精度であったE-Branchformer[ 1 ]を利用して日本語の音声認識モデルを構築する記事について書きました。  前回の実験において、E-Branchformerで構築したモデルは、精度ではConformerで構築したモデルより優れていましたが、スピードはConformerで構築したモデルよりも少し遅いものとなっていました。音声認識システムの実運用を考えると、音声認識のスピードは非常に重要です。  そこで今回は、高速な非自己回帰型のアーキテクチャでE-Branchformerの音声認識モデルを構築し、どの程度のスピードで音声認識可能かを確かめたいと思います。 石塚賢吉(いしづか けんきち) プリンシパルリサーチエンジニア。筑波大学大学院博士後期課程卒業。博士(工学)。日本HP株式会社にて通信事業者向けのシステム開発、株式会社ドワンゴで全文検索システムの開発などに従事。2019年12月、株式会社RevComm入社。音声認識、音声感情認識、全文検索システムの研究開発を行なっている。 → 過去記事一覧 非自己回帰型の音声認識モデルについて  E2E音声認識モデルは、大きく分けると自己回帰モデルと非自己回帰モデルに分類できます。前回の実験で構築したモデルは、 EncoderにE-Branchformer、DecoderにTransformerを用いたものであり、Attention Encoder-Decoderと呼ばれる自己回帰型に分類されるモデルでした。このタイプのモデルでは、下記の図のように過去の出力トークンに基づいて現在の出力トークンを生成するため、N個のトークンの生成のためにN回の計算が必要になります。 一方、非自己回帰型の音声認識モデルでは、音響特徴量のシーケンスからテキストトークンのシーケンスを直接生成し、一定の計算コストで処理できるため、高速に推論することが可能です。ESPnetでは、Mask-CTC[ 2 ]と呼ばれる非自己回帰型の音声認識モデルを利用することができます。  Mask-CTCでは、下記のように入力された音声からEncoderで音響特徴量を抽出し、Connectionist Temporal Classification(CTC)という手法で、音響特徴量から発話テキストを直接推定します。CTCでは、Blankラベルトークンを導入することで、長さが大きく異なる音響特徴量シーケンスとテキストのトークンのシーケンスとの対応関係を表現しています。さらにMask-CTCでは、CTCの出力トークンのうち信頼度の低いトークンをマスキングし、TransformerのMasked Language Model(MLM)のMask-Predictionを用いて誤りを修正することで、音声認識精度を改善します。Mask-CTCのアルゴリズムの詳細については、 論文 を参照ください。  今回は、E-BranchformerでMask-CTCの非自己回帰型の音声認識モデルを構築したいので、EncoderにE-Branchformer、DecoderにCTC、およびMLM Decoderを用いて、音声認識モデルを構築します。 実験方法 前回 と同様にCSJデータセットでモデルを構築します。 E-Branchformerは ESPNetのv.202301 から利用できる状態になっていますが、Mask-CTCと組み合わせて利用するレシピはまだ存在しない状態です。そこで、WSJのMask-CTCのレシピの学習の 設定ファイル と、LibriSpeechのE-Branchformerのレシピの学習の 設定ファイル を元に、EncoderにE-Branchformer、DecoderにCTCとMLMを利用するモデルの学習の設定ファイルを作成します。今回作成した学習の設定ファイルは下記になります。 train_asr_e_branchformer_small_mask_ctc.yaml batch_type: folded batch_size: 32 accum_grad: 8 max_epoch: 300 patience: none init: none best_model_criterion: - - valid - cer_ctc - min keep_nbest_models: 10 # specify model type as "maskctc" model: maskctc model_conf: ctc_weight: 0.3 lsm_weight: 0.1 length_normalized_loss: false use_amp: true unused_parameters: true num_workers: 4 encoder: e_branchformer encoder_conf: output_size: 256 attention_heads: 4 attention_layer_type: rel_selfattn pos_enc_layer_type: rel_pos rel_pos_type: latest cgmlp_linear_units: 3072 cgmlp_conv_kernel: 31 use_linear_after_conv: false gate_activation: identity num_blocks: 12 dropout_rate: 0.1 positional_dropout_rate: 0.1 attention_dropout_rate: 0.1 input_layer: conv2d layer_drop_rate: 0.1 linear_units: 1024 positionwise_layer_type: linear macaron_ffn: true use_ffn: true merge_conv_kernel: 31 # Masked Language Model (MLM)-based decoder decoder: mlm decoder_conf: attention_heads: 4 linear_units: 2048 num_blocks: 6 dropout_rate: 0.1 positional_dropout_rate: 0.1 self_attention_dropout_rate: 0.1 src_attention_dropout_rate: 0.1 optim: adam optim_conf: lr: 0.002 weight_decay: 0.000001 scheduler: warmuplr scheduler_conf: warmup_steps: 15000 num_att_plot: 0 specaug: specaug specaug_conf: apply_time_warp: true time_warp_window: 5 time_warp_mode: bicubic apply_freq_mask: true freq_mask_width_range: - 0 - 27 num_freq_mask: 2 apply_time_mask: true time_mask_width_ratio_range: - 0. - 0.05 num_time_mask: 5 ESPNet v.202301の学習環境とCSJのセットアップが完了した状態から、E-Branchformerを用いた音声認識モデルを構築する手順は下記のとおりです。 作成した学習の設定ファイルtrain_asr_e_branchformer_small_mask_ctc.yamlを CSJレシピのconfディレクトリ に配置する CSJレシピの学習スクリプトの学習設定ファイルの参照先 をasr_config=conf/ train_asr_e_branchformer_small_mask_ctc.yaml にする Mask-CTCの推論の設定ファイル を CSJレシピのconfディレクトリ の配下にコピーする。 CSJレシピの推論設定ファイルの参照先 をinference_config=conf/inference_asr_maskctc.yaml にする。 ./asr.sh で --use_maskctc true を設定して run.sh を実行する 得られたモデルの音声認識の精度とスピード モデルの学習  ABCIのrt_Fインスタンス (NVidia V100*4)でCSJの学習データを用いて50エポックまで音声認識モデルの学習を行ったところ、CSJのValidセットのCharacter Error Rate(CER; 文字誤り率)とEpochの関係は下記のようになりました。 音声認識精度の評価   前回 学習した、EncoderがE-BranchformerでDecoderがTransformerのモデルと、今回学習したEncoderがE-BranchformerでDecoderがCTC・MLMのモデルで、CSJのテストセットを音声認識したときのCERを下記の表に示します (%)。Decoderの列が CTC の行は、CTCの出力トークンそのままの精度で、 CTC+MLM の行は、CTCの出力トークンをMLMのMask-Predictionで修正した時の精度です。  なお、比較対象として、 Mask-CTCの論文 にあるEncoderがTransformerでDecoderがCTCとMLMの構成のモデルを50Epochまで学習したものと、EncoderとDecoderの両方がTransformerの プリトレインドモデル を用意しました。なお、DecoderがCTC・MLMのモデルは、 Mask-CTCの論文 に合わせて、Encoderの中間層のニューロンの数が256となっていることにご注意ください。また、DecoderがTransformerの音声認識モデルでは、言語モデルは利用していません。 Encoder Decoder Type CER(eval1) CER(eval2) CER(eval3) E-Branchformer (Output:512) Transformer 自己回帰型 3.8 2.9 3.2 E-Branchformer (Output:256) CTC 非自己回帰型 4.5 3.2 3.8 E-Branchformer (Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 4.5 3.2 3.8 Transformer (Output:512) Transformer 自己回帰型 4.9 3.8 3.8 Transformer (Output:256) CTC 非自己回帰型 6.2 4.6 5.4 Transformer (Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 6.2 4.4 5.3  表を見ると、E-Branchformer の非自己回帰型の音声認識モデルは、精度において自己回帰型のE-Branchformerの音声認識モデルには及ばないようです。しかし、E-Branchformer の非自己回帰型の音声認識モデルは、Transformerの自己回帰型の音声認識モデルよりも良い精度となっています。なお、今回の実験では、EncoderがTransformerの非自己回帰型の音声認識モデルでは、MLMによる修正で精度が上がりましたが、EncoderがE-Branchformerの非自己回帰型の音声認識モデルについては、MLMによる修正を行なっても精度が向上しませんでした。 音声認識スピードの評価  次に、CSJのeval1からeval3のテストセットをCPUまたはGPUで音声認識するときのスピードを下記のReal Time Factor (RTF) と、Inverse Real Time Factor (iRTF) の指標で確認しました。iRTFは、1秒間で何秒の長さの音声を認識できるかを表す指標です。 CPUでのデコードについて  AWSのc5.xlargeのCPUで1Threadでデコードした時のRTFとiRTFを下記の表に示します。なお、DecoderがTransformerの自己回帰型のモデルでは、beam size=2でデコードしています。 Encoder Decoder Type RTF iRTF E-Branchformer (Output:512) Transformer 自己回帰型 2.291 0.436 E-Branchformer (Output:256) CTC 非自己回帰型 0.664 1.506 E-Branchformer (Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 0.805 1.242 Transformer (Output:512) Transformer 自己回帰型 0.383 2.611 Transformer (Output:256) CTC 非自己回帰型 0.031 32.258 Transformer (Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 0.042 23.810  EncoderがE-Branchformerの自己回帰型の音声認識モデルの音声認識スピードは、RTFの値が1以上となってしまいました。また、EncoderがE-Branchformerの非自己回帰型の音声認識モデルは、EncoderがTransformerの非自己回帰型の音声認識モデルよりも音声認識スピードが遅いようでした。E-Branchformerのエンコーダは、CPUでは計算処理が重いようです。 GPUでのデコードについて  NVIDIA A10を搭載するAWSのg5.xlargeインスタンスを用いて、デコードした時のRTFを下記の表に示します。なお、バッチデコードはしていません。 Encoder Decoder Type RTF iRTF CPUに対するスピード倍率 [iRTF(GPU)/iRTF(CPU)] E-Branchformer(Output:512) Transformer 自己回帰型 0.235 4.255 9.749 E-Branchformer(Output:256) CTC 非自己回帰型 0.011 90.909 60.364 E-Branchformer(Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 0.013 76.923 61.923 Transformer(Output:512) Transformer 自己回帰型 0.198 5.051 1.934 Transformer(Output:256) CTC 非自己回帰型 0.009 111.111 3.444 Transformer(Output:256) CTC+MLM (p=0.9, it=5) 非自己回帰型 0.011 90.909 3.818  GPUによるデコードでは、E-Branchformerの非自己回帰型の音声認識モデルの音声認識スピードがCPUの時と比べ大幅(約62倍)に向上し、Transformerの非自己回帰型の音声認識モデルに近いスピードとなっています。非自己回帰型の音声認識モデルは、GPUで音声認識した時に大きくスピードが向上するようです。一方で、自己回帰型の音声認識モデルは、GPUで音声認識しても、非自己回帰型の音声認識モデルの時ほど音声認識スピードが向上しないようです。原因は深く追っていませんが、TransformerのDecoderでの自己回帰的な処理とBeamsearchなどのポストプロセスの処理がボトルネックになっているものと思われます。 まとめ  本記事では、高速な非自己回帰型の音声認識モデルである、E-BranchformerのMaskCTCの音声認識モデルを構築し、音声認識精度とスピードを確認しました。E-BranchformerのMaskCTCの音声認識モデルは、非自己回帰型の音声認識モデルでありながら、Transformerの自己回帰型の音声認識モデルよりも精度が高く、GPUを用いた時に高速に音声認識できることがわかりました。  なお、本記事では触れませんでしたが、今回構築した非自己回帰型の音声認識モデルをONNXなどの推論エンジンの形式に変換することで、さらに音声認識スピードを向上できるようでした。また、今回の実験でのGPUによる音声認識はバッチデコードになっていませんでしたが、おそらくバッチデコードを行うことで、さらなる高速化が期待できます。 参考文献 [1] Kim, K., Wu, F., Peng, Y., Pan, J., Sridhar, P., Han, K. J., & Watanabe, S. (2023). E-Branchformer: Branchformer with Enhanced Merging for Speech Recognition. Proc. IEEE Spoken Language Technology Workshop (SLT), 84–91. [2] Yosuke Higuchi, Shinji Watanabe, Nanxin Chen, Tetsuji Ogawa, Tetsunori Kobayashi, Mask CTC: Non-Autoregressive End-to-End ASR with CTC and Mask Predict, Proc. INTERSPEECH 2020, 3655-3659
アバター
はじめに こんにちは。ここ最近、React v19が注目を集めているように感じます。 最近ではついにReact v19のRCバージョンもリリースされました。 Just published React 19.0.0-rc.0. This is the exact build we'll release as 19.0, unless an issue is reported that requires a breaking change. Thank you to everyone who helped us get the release into shape! — Andrew Clark (@acdlite) 2024年6月3日 そう遠くないうちにReact v19がリリースされる可能性もありそうです。そこで、React v19のリリースによる周辺ライブラリへの影響などについて、弊社で採用しているライブラリを中心に調査をしてみました。この記事ではその内容をまとめます。 ESLint React Compiler向けのESLintプラグインが公開されているようです。 www.npmjs.com React Compilerとは? React CompilerとはReactアプリケーションの最適化をおこなってくれるツールです。このReact Compilerの利用には、現状ではReact v19のRCバージョンが必要とされます。 ※React Compilerはまだ実験的ツールであり、今後、大きな変更などが入る可能性もあります。 react.dev Reactには useMemo や React.memo などの最適化方法が提供されていますが、正しく扱うのは難しかったり、また、適用をし忘れることなどもあります。React Compilerはこれらの適用を自動化してくれます。 ただし、React Compilerが動作するためには、コードベースが Rules of React に従っている必要があります。Rules of Reactとは自然なReactコードを記述するために従うべきとされているルールです。 react.dev eslint-plugin-react-compiler について Reactの公式から eslint-plugin-react-compiler というプラグインが公開されています。このプラグインはRules of Reactに従っていないコードを検出してくれます。そのため、このプラグインを導入しておくことで、将来的にReact Compilerの導入などがスムーズに行いやすくなりそうです。React Compilerそのものはまだexperimentalなので現時点での導入は避けた方が安全だと思いますが、この eslint-plugin-react-compiler については比較的試しやすいとも思うので、早いタイミングで導入してしまってもよいのかもしれません。 ( 注意 ) Reactの公式ドキュメントでは、「 eslint-plugin-react-compiler によってRules of Reactへの違反が検出されたとしても急いで直す必要はない」ということが言及されています。少しずつ段階的に修正をしていくのがよさそうです。 You don’t have to fix all eslint violations straight away. You can address them at your own pace to increase the amount of components and hooks being optimized, but it is not required to fix everything before you can use the compiler. TanStack Query React v19への対応状況について 以下のPRでReact v19のサポートに向けた対応が入っており、すでにリリースもされているようです。( v5.39.0 ) github.com また、上記のPRでは eslint-plugin-react-compiler も導入されており、React Compilerに向けた準備なども既に考慮されているようです。 TanStack Queryについては既にかなり準備が進んでいそうな印象です。 React v19による影響について github.com 上記のDiscussionページによると、React v19で導入予定の use との統合のため useQuery の戻り値に promise プロパティを追加することなどが検討されているようです ( https://github.com/TanStack/query/discussions/7074#discussioncomment-8748245 ) const { promise } = useQuery ( options ) ; const data = use ( promise ) ; React Router React v19による影響について React v19におけるtransitionでのasync関数のサポートに合わせて、React Routerでは navigate や submit などの各種APIが Promise を返すようにする対応が検討されているようです。 github.com github.com これらの変更はReact Routerのv7で入る想定のようです。 React Hook Form React v19への対応状況について peerDependencies でReact v19を許可する対応が導入されており、すでにReact v19への対応が視野に入れられているようです。( v7.52.0 ) github.com React v19による影響について React v19で導入予定のAPIとの統合に関するDiscussionページが作成されています。 github.com まだ具体的な計画や構想などはなさそうなのですが、 https://github.com/orgs/react-hook-form/discussions/11832#discussioncomment-9450350 のコメントでは以下の動画が紹介されています www.youtube.com github.com この動画では useActionState とReact Hook Formを統合する方法などについて紹介されています。現状、ある程度工夫が必要そうな状況のようです。先ほどのDiscussionページにおいてもReact Hook Formからも何かしらの解決策があるとよさそうというような意見はありますが、まだ現状ではどうなるのかはわかりません。 @sentry/react React v19による影響について React v19サポートに向けたissueが作成されています。 github.com React v19では onCaughtError や onUncaughtError , onRecoverableError などのオプションが createRoot や hydrateRoot に追加されます。これらのオプションでの利用が想定された Sentry.reactErrorHandler() というAPIが追加されています ( v8.6.0 ) import { createRoot } from 'react-dom/client' ; import * as Sentry from '@sentry/react' ; const root = createRoot( document . getElementById ( 'app' ), { onCaughtError : Sentry.reactErrorHandler(), onUncaughtError : Sentry.reactErrorHandler(), onRecoverableError : Sentry.reactErrorHandler(), } , ); onCaughtError や onUncaughtError は全てのコンポーネントに対して適用されるため、もしよりきめ細かな制御が必要な際は、既存の Sentry.ErrorBoundary の利用が推奨されるようです。 github.com sentry.io React Testing Library React v19への対応状況について すでにReact v19のCanary版を使ってテストが行われているようです。 github.com また、React v19のアップグレードガイドには react-dom/test-utils が react へ移動されると記述されています。React Testing Libraryはこの変更の影響を受けそうです。 react.dev この変更についても既に対応が行われているようです。 github.com github.com Recoil React v19への対応状況について 以下のissueでReact v19サポートに関する要望があります github.com ただし、対応についてはまだ進められているわけではなさそうです。このissueで積極的に発言されている wojtekmaj 氏によると、やや開発も停滞しており別の選択肢への移行も考慮するよう提案されています。やや芳しくはなさそうな状況には見えました…( https://github.com/facebookexperimental/Recoil/issues/2318#issuecomment-2120383312 ) React Redux React v19への対応状況について 以下のPRでReact v19に関する対応が進められていそうです。PR上のTODOリストについては一通り消化されている状態のようなので、着実に対応が進められているような印象です。 github.com MUI (Material UI) React v19への対応状況について React v19の対応に関するissueが先月に公開されています。React v19の型定義との互換性の改善やReact Compilerによる最適化を活用するためにRules of Reactへの対応などが計画されているようです。 github.com eslint-plugin-react-compiler についてはすでに導入( #42555 )されており、少しずつ対応が進められていく想定のようです。 github.com @apollo/client React v19への対応状況について 最近リリースされた v3.10.5 から、React v19を使ったテストが開始されています。 github.com また、 ロードマップ によると、React Compilerのサポートに向けて useQuery と useSubscription を書き直す計画があるようです おわりに 今回、調査にあたって色々と調べて見たのですが、その中でも特に eslint-plugin-react-compiler は便利そうに感じました。Rules of Reactにできる限り準拠をしておくことで、将来的なReactに導入される様々な変更にも追従しやすくなることが考えられます。またESLintプラグインとして提供されているため、比較的、導入などのハードルが低めなことも魅力的に感じました。そのため、早めのタイミングで導入を検討してみてもよいのかもしれないと思いました。 この記事で紹介したもの以外にも、様々なライブラリでReact v19へ向けた対応や新機能の追加などが想定されます。より開発に便利な機能などが追加される可能性もあるため、今後も引き続き注目していきたいです。 参考 github.com github.com react.dev react.dev zenn.dev
アバター
Analytics Teamの山内健二です。RevCommの解析基盤に導入されているAmazon EKSクラスタでBlue/Greenアップグレードを導入し、合わせて今後の工数削減のために自動化を行ったので、その内容をご紹介します。 RevCommの解析基盤の概要 アップグレードの内容の前に、簡単にRevCommの解析基盤を簡単に紹介します。RevCommはMiiTel、MiiTel Meetingsなど、複数のプロダクトを提供しておりますが、文字起こしなどの解析を実施する基盤は共通して単一のものとなっています。 この解析基盤は単一のアプリケーションから動いているのではなく、音声認識など解析機能等の単位で分割された複数のアプリケーションから構成されており、現在これらが1つのEKSクラスタの中にホストされています。 EKSクラスタを含めたAWSリソースや、AWS Load Balancer ControllerなどのミドルウェアはTerraformによりIaC化して管理しています。また、クラスタ内で稼働しているアプリケーション用のKubernetesマニフェストは別途リポジトリを用意して管理しており、クラスタ内で動いているArgo CDでmainブランチを参照させてデプロイしています(Pull型のGitOps)。 EKSのアップグレード方針 RevCommの解析基盤では、EKSを導入してからこれまでin-place方式、すなわち既存クラスタのバージョンを直接アップグレードしていました。前述したようにRevCommの解析基盤はIaC化しているため、いくつかの設定値の変更のみで簡便に行えるのですが、欠点もあり、今回Blue/Green方式でのアップグレードを導入することにしました。 具体的にはin-place方式では EKS Best Practices Guide でも解説があるように順次ノードグループ等のリソースをアップグレードします。そのため、サービスのダウンタイムが発生する可能性があり、また、アップグレード後に問題が発生しても切り戻しができません。さらに、クラスタ内で稼働しているノード数の増加に伴い、アップグレード自体の時間も最大数時間に及んでいました。 一方、Blue/Green方式では現在のクラスタとは別に新しいバージョンのクラスタを用意し、こちらへトラフィックを順次誘導していきます。問題が起きないことを確認してから古いバージョンのクラスタを破棄するため、前述したような問題は起こりません。しかし、新しいクラスタの用意が必要なため、その分の工数が必要です。今回RevCommでは工数を考慮してもin-place方式で述べた欠点の克服にメリットがあると考え、Blue/Green方式を採用しました。 実装方法 クラスタ切り替えの概要 今回の実装にあたっては、構成が類似していることもあり Amazon EKS Blueprints for TerraformのBlue Green Migration を参考にしました。 基本的には下記の流れで行います。現行クラスタがバージョン1.25で、新規クラスタをバージョン1.28で実施する際の例を図で示しています。 新規のクラスタ作成とミドルウェアのインストールをTerraform側で実施 新規クラスタにArgo CDでアプリケーションをデプロイ マニフェスト側の変更 external-dnsを利用し、Ingressのアノテーション経由でRoute 53の加重ルーティングによりトラフィックを分配 set-identifierとaws-weightを設定します これらの変更はマニフェストのリポジトリのフィーチャーブランチとして作成し、新規クラスタ側のArgoCDは一旦そちらを参照するようにします。現行クラスタはmainブランチを参照したままです。 新規クラスタでの挙動が問題ないことを確認したら現行クラスターを削除 前述したフィーチャーブランチをmainブランチへマージし、新規クラスタのArgoCDもmainブランチを参照するようにします 自動化 これらの流れはTerraformでのvariableの値やマニフェストの一部の調整のみで実施できるものの、手順が複数工程にまたがることもあり、オペレーションミスや属人化を防ぐためにGitHub Actionsなどにより極力自動化させました。具体的に実施した自動化は、主にクラスタ作成・削除の2つです。図に流れの概要を示しています。 前提として、解析基盤のTerraformのコードは図に示すように各環境(開発、ステージング、本番)ごとでbackendとvariableを管理するためのディレクトリ(以下単に環境設定と呼びます)をvarsディレクトリ以下に配置しています。各環境設定はcluster-YYYYMMのように、環境ごとの識別子をつけて管理しています。例えば図左ではcluster-202403が存在しています。また、現行クラスタ用の環境設定は、図左のclusterのようにシンボリックリンクで指すようにしています。 ここで新規クラスタを作るとき、新しい環境設定の生成・Terraformコードの適用によるクラスタ作成・シンボリックリンクの張替えが必要になります。逆に現行クラスタを削除する場合は、現行クラスタからのアプリケーションとミドルウェアの削除・現行クラスタの削除・現行クラスタの環境設定の削除を行う必要があります。 これらを手作業で行うのはオペレーションミスを誘発するので、GitHub Actionsにて識別子やバージョンを入力として行えるようにしました。 このような自動化により、クラスタの作成から切り替え、削除までをローカルでの手作業なしにできるようになり、時間的にも新規クラスタとそこへのアプリケーションデプロイまで数十分でおこなえるようになりました。 まとめ 今回はRevCommの解析基盤のEKSクラスターのアップグレード方式をBlue/Greenに変更した際の紹介でした。 in-place方式と比べて工数を悪化させないための自動化や手順の確立は必要でしたが、IaC化の素地もあったため、メリットを最大限享受した上でBlue/Green方式を導入できたと感じています。解析基盤自体が、ステートフルな要素が少なく、アプリケーション側で考慮するべきことが少なかったのも親和性がありました。参考になれば幸いです。
アバター
Analytics Teamの山内健二です。RevCommの解析基盤に導入されているAmazon EKSクラスタでBlue/Greenアップグレードを導入し、合わせて今後の工数削減のために自動化を行ったので、その内容をご紹介します。 RevCommの解析基盤の概要 アップグレードの内容の前に、簡単にRevCommの解析基盤を簡単に紹介します。RevCommはMiiTel、MiiTel Meetingsなど、複数のプロダクトを提供しておりますが、文字起こしなどの解析を実施する基盤は共通して単一のものとなっています。 この解析基盤は単一のアプリケーションから動いているのではなく、音声認識など解析機能等の単位で分割された複数のアプリケーションから構成されており、現在これらが1つのEKSクラスタの中にホストされています。 EKSクラスタを含めたAWSリソースや、AWS Load Balancer ControllerなどのミドルウェアはTerraformによりIaC化して管理しています。また、クラスタ内で稼働しているアプリケーション用のKubernetesマニフェストは別途リポジトリを用意して管理しており、クラスタ内で動いているArgo CDでmainブランチを参照させてデプロイしています(Pull型のGitOps)。 EKSのアップグレード方針 RevCommの解析基盤では、EKSを導入してからこれまでin-place方式、すなわち既存クラスタのバージョンを直接アップグレードしていました。前述したようにRevCommの解析基盤はIaC化しているため、いくつかの設定値の変更のみで簡便に行えるのですが、欠点もあり、今回Blue/Green方式でのアップグレードを導入することにしました。 具体的にはin-place方式では EKS Best Practices Guide でも解説があるように順次ノードグループ等のリソースをアップグレードします。そのため、サービスのダウンタイムが発生する可能性があり、また、アップグレード後に問題が発生しても切り戻しができません。さらに、クラスタ内で稼働しているノード数の増加に伴い、アップグレード自体の時間も最大数時間に及んでいました。 一方、Blue/Green方式では現在のクラスタとは別に新しいバージョンのクラスタを用意し、こちらへトラフィックを順次誘導していきます。問題が起きないことを確認してから古いバージョンのクラスタを破棄するため、前述したような問題は起こりません。しかし、新しいクラスタの用意が必要なため、その分の工数が必要です。今回RevCommでは工数を考慮してもin-place方式で述べた欠点の克服にメリットがあると考え、Blue/Green方式を採用しました。 実装方法 クラスタ切り替えの概要 今回の実装にあたっては、構成が類似していることもあり Amazon EKS Blueprints for TerraformのBlue Green Migration を参考にしました。 基本的には下記の流れで行います。現行クラスタがバージョン1.25で、新規クラスタをバージョン1.28で実施する際の例を図で示しています。 新規のクラスタ作成とミドルウェアのインストールをTerraform側で実施 新規クラスタにArgo CDでアプリケーションをデプロイ マニフェスト側の変更 external-dnsを利用し、Ingressのアノテーション経由でRoute 53の加重ルーティングによりトラフィックを分配 set-identifierとaws-weightを設定します これらの変更はマニフェストのリポジトリのフィーチャーブランチとして作成し、新規クラスタ側のArgoCDは一旦そちらを参照するようにします。現行クラスタはmainブランチを参照したままです。 新規クラスタでの挙動が問題ないことを確認したら現行クラスターを削除 前述したフィーチャーブランチをmainブランチへマージし、新規クラスタのArgoCDもmainブランチを参照するようにします 自動化 これらの流れはTerraformでのvariableの値やマニフェストの一部の調整のみで実施できるものの、手順が複数工程にまたがることもあり、オペレーションミスや属人化を防ぐためにGitHub Actionsなどにより極力自動化させました。具体的に実施した自動化は、主にクラスタ作成・削除の2つです。図に流れの概要を示しています。 前提として、解析基盤のTerraformのコードは図に示すように各環境(開発、ステージング、本番)ごとでbackendとvariableを管理するためのディレクトリ(以下単に環境設定と呼びます)をvarsディレクトリ以下に配置しています。各環境設定はcluster-YYYYMMのように、環境ごとの識別子をつけて管理しています。例えば図左ではcluster-202403が存在しています。また、現行クラスタ用の環境設定は、図左のclusterのようにシンボリックリンクで指すようにしています。 ここで新規クラスタを作るとき、新しい環境設定の生成・Terraformコードの適用によるクラスタ作成・シンボリックリンクの張替えが必要になります。逆に現行クラスタを削除する場合は、現行クラスタからのアプリケーションとミドルウェアの削除・現行クラスタの削除・現行クラスタの環境設定の削除を行う必要があります。 これらを手作業で行うのはオペレーションミスを誘発するので、GitHub Actionsにて識別子やバージョンを入力として行えるようにしました。 このような自動化により、クラスタの作成から切り替え、削除までをローカルでの手作業なしにできるようになり、時間的にも新規クラスタとそこへのアプリケーションデプロイまで数十分でおこなえるようになりました。 まとめ 今回はRevCommの解析基盤のEKSクラスターのアップグレード方式をBlue/Greenに変更した際の紹介でした。 in-place方式と比べて工数を悪化させないための自動化や手順の確立は必要でしたが、IaC化の素地もあったため、メリットを最大限享受した上でBlue/Green方式を導入できたと感じています。解析基盤自体が、ステートフルな要素が少なく、アプリケーション側で考慮するべきことが少なかったのも親和性がありました。参考になれば幸いです。
アバター
バックエンドエンジニアの小門です。 この記事ではグローバルインタプリタロック (GIL) が解消されたPythonを動かしてみた検証の方法と結果について書きます。 なおGIL自体の説明や詳しい仕組みについてこの記事ではほとんど説明しないのでご了承ください。 準備として開発バージョンを取得してソースコードからビルドし、ビルド成果物のPythonランタイムを使って検証します。 追記: 2024/6/14 時点で最新の 3.13.0 beta2 を使ってベンチマークを再疎検証しました。また、一部の内容の訂正を行いました。 準備(ビルド) Pythonにおける「GIL廃止」の第一歩として、CPython本家のリポジトリにおいてGILを無効化できるようにするための修正が2024年3月12日mainブランチへマージされました。 gh-116167: Allow disabling the GIL with PYTHON_GIL=0 or -X gil=0 #116338 また同日、上記の変更を取り込んだ開発バージョンが v3.13.0a5 としてリリースされました。 https://www.python.org/downloads/release/python-3130a5/ https://github.com/python/cpython/releases/tag/v3.13.0a5 まだ開発途中のアルファ版ですが、今回はこのバージョンを使って検証していきます。 なお筆者の動作環境は以下の通りです。 CPU: AMD Ryzen 7 3700X(8コア) OS: Ubuntu 22.04 (on WSL2 / Windows10) gcc: 11.4.0 また、本記事の手順ではソースコードをビルドするためのツール群が必要になります。 お使いの環境に応じて必要な準備をしてください。 参考: Python Developer's Guide - Setup and building ビルド/インストール手順 GILを無効化するにはビルド時にオプションを指定しておく必要があります。 オプションは --disable-gil とのこと。分かりやすいですね。 https://github.com/python/cpython/blob/076d169ebbe59f7035eaa28d33d517bcb375f342/configure#L1815-L1816 一連のコマンド手順は以下のようになります。 $ pwd /home/skokado/playground-py313 $ # インストール用ディレクトリを作成 $ mkdir -p local/python-3. 13 $ # ソースコードを取得 $ wget https://www.python.org/ftp/python/ 3 . 13 . 0 /Python-3. 13 .0a5.tgz $ tar xf Python-3. 13 .0a5.tgz $ cd Python-3. 13 .0a5/ $ # オプションの確認 $ ./configure --help | grep gil --disable-gil enable experimental support for running without the $ # ビルド、インストール $ ./configure --disable-gil --prefix $( pwd ) /../local/python -3 . 13 && make install checking build system type ... x86_64-pc-linux-gnu checking host system type ... x86_64-pc-linux-gnu checking for Python interpreter freezing... ./_bootstrap_python ... ( 略 ) Successfully installed pip-24. 0 $ # インストールできたことを確認 $ cd ../local/python -3 . 13 $ ./bin/python3. 13 -VV Python 3 . 13 .0a5 ( main, Mar 14 2024 , 18:37:25 ) [ GCC 11 . 4 . 0 ] ベンチマーク検証 GILはCPUバウンドなマルチスレッド処理において実行可能なスレッドが制限されるものです。 したがってマルチスレッド処理を行うスクリプトで実行結果を比較してみます。 検証スクリプトは以下です。 # test_gil.py from concurrent.futures import ThreadPoolExecutor import time import math def get_primes ( max : int ) -> list [ int ]: # (あえて低速な) n以下の素数一覧を返す関数 if max < 2 : raise ValueError () primes = [ 2 ] for n in range ( 3 , max + 1 ): is_prime = True for i in range ( 2 , int (math.sqrt(n)) + 1 ): if n % i == 0 : is_prime = False break if is_prime: primes.append(n) return primes if __name__ == "__main__" : print ( "concurrency,time" ) for concurrency in range ( 1 , 10 + 1 ): start = time.monotonic() with ThreadPoolExecutor(max_workers=concurrency) as executor: futures = [executor.submit(get_primes, 500000 ) for _ in range (concurrency)] for f in futures: f.result() end = time.monotonic() duration = end - start print (f "{concurrency},{duration:.2f}" ) 「CPUバウンド処理」として素数判定をメインにした関数をThreadPoolExecutorでマルチスレッド処理する ※引数 max は筆者の環境で1、2秒程度かかる値を選択 concurrency で指定されたスレッド数分だけ get_primes を並列に起動する concurrency を1から10まで変化させて所要時間を計測する ※それぞれ3回ずつ実行し、平均時間を取得 ちなみに、上記でビルドしたv3.13.0a5において実際の処理でGILを無効にするには環境変数 PYTHON_GIL=0 とともに実行する必要があります。 $ PYTHON_GIL = 0 ./bin/python3. 13 test_gil.py 結果 比較対象は以下の通りです。 v3.12.2 : 執筆時点の最新リリースバージョン v3.13.0a5 : "--disable-gil" オプション 無し でビルドしたランタイム v3.13.0a5 & --disable-gil : "--disable-gil" オプション付きでビルドかつ "PYTHON_GIL=0" 無し で実行 v3.13.0a5 & --disable-gil & PYTHON_GIL=0 : GILを無効化して実行 (単位: 秒) cocurrency v.3.12.2 v3.13.0a5 v3.13.0a5 & --disable-gil v3.13.0a5 & --disable-gil & PYTHON_GIL=0 1 1.12 1.01 1.47 1.46 2 2.29 2.07 3.00 1.56 3 3.46 3.13 4.56 1.74 4 4.62 4.17 6.02 1.78 5 5.74 5.22 7.37 1.98 6 6.91 6.35 8.82 2.06 7 8.08 7.40 10.29 2.23 8 9.22 8.42 11.84 2.40 9 10.38 9.47 13.26 2.56 10 11.53 10.52 14.80 2.71 GIL無効化( --disable-gil オプションでビルトかつ PYTHON_GIL=0 )の場合のみ所要時間が並列度に単純比例せず、期待した結果となりました。 また、GILが有効な v3.12.2 と v3.13.0a5 では単純に約10%程度高速になりました。 バージョンアップに伴って性能が改善されるのは嬉しいですね。 (6/14 訂正) 上記の3.12.2 と 3.13.0a5 の比較は正確なものではありませんでした。 筆者の環境で用意したランタイムが同じ条件でビルドされたものではありませんでした。 一方非マルチスレッド処理( concurrency=1 )においては性能が悪化しました。 上記の検証スクリプトの場合、データ構造の安全性に関するオーバーヘッドの影響が考えられます。 コレクション - python.jp PythonでGILの排除が難しい理由として、参照カウントの存在に加えて、Pythonインタープリタが辞書やリストなどの複雑なコレクションに依存している、という点もよく挙げられます。 残念ながら、手放しに喜べる検証結果とはなりませんでした。 今後のベータ版やrc版でも引き続き検証してみたいです。 (6/14追記) 追加検証: 3.13.0b2 2024/6/14 時点で最新バージョンである 3.13.0b2 を使って追加の検証を行いました。 また検証環境と手順を見直しました。 検証環境 専用環境として AWS Amazon EC2 インスタンス (仮想マシン) を用意しました。 リージョン: us-west-2 OS: Debian 12 (ami-0c2644caf041bb6de) インスタンスタイプ: c7a.2xlarge (8vCPU / 16GB) 手順 Docker Official Image のランタイムと同じ手順を再現しました。 python/3.13-rc/bookworm/Dockerfile at 748d6e9b44c0ee63e766a7c601d471e0763383d6 · docker-library/python 上記のベースイメージを辿っていくと必要パッケージのインストール、ビルド手順は以下の通りとなります。 検証手順 # 1. Prepare Build dependencies ## https://github.com/docker-library/buildpack-deps/blob/d0ecd4b7313e9bc6b00d9a4fe62ad5787bc197ae/debian/bookworm/curl/Dockerfile sudo apt-get install -y --no-install-recommends \ ca-certificates \ curl \ gnupg \ netbase \ sq \ wget ## https://github.com/docker-library/buildpack-deps/blob/d0ecd4b7313e9bc6b00d9a4fe62ad5787bc197ae/debian/bookworm/scm/Dockerfile sudo apt-get install -y --no-install-recommends \ git \ mercurial \ openssh-client \ subversion \ procps ## https://github.com/docker-library/buildpack-deps/blob/d0ecd4b7313e9bc6b00d9a4fe62ad5787bc197ae/debian/bookworm/Dockerfile sudo apt-get install -y --no-install-recommends \ autoconf \ automake \ bzip2 \ default-libmysqlclient-dev \ dpkg-dev \ file \ g++ \ gcc \ imagemagick \ libbz2-dev \ libc6-dev \ libcurl4-openssl-dev \ libdb-dev \ libevent-dev \ libffi-dev \ libgdbm-dev \ libglib2.0-dev \ libgmp-dev \ libjpeg-dev \ libkrb5-dev \ liblzma-dev \ libmagickcore-dev \ libmagickwand-dev \ libmaxminddb-dev \ libncurses5-dev \ libncursesw5-dev \ libpng-dev \ libpq-dev \ libreadline-dev \ libsqlite3-dev \ libssl-dev \ libtool \ libwebp-dev \ libxml2-dev \ libxslt-dev \ libyaml-dev \ make \ patch \ unzip \ xz-utils \ zlib1g-dev ## https://github.com/docker-library/python/blob/748d6e9b44c0ee63e766a7c601d471e0763383d6/3.13-rc/bookworm/Dockerfile sudo apt-get install -y --no-install-recommends \ libbluetooth-dev \ tk-dev \ uuid-dev # 2. Build / Install mkdir sandbox cd sandbox/ ## https://github.com/docker-library/python/blob/748d6e9b44c0ee63e766a7c601d471e0763383d6/3.13-rc/bookworm/Dockerfile#L22-L87 ## and `--disable-gil` export GPG_KEY =7169605F62C751356D054A26A821E680E5FA6305 && \ export PYTHON_VERSION = 3 . 13 .0b2 && \ wget -O python.tar.xz " https://www.python.org/ftp/python/ ${PYTHON_VERSION %% [a-z]* } /Python- $PYTHON_VERSION .tar.xz " && \ wget -O python.tar.xz.asc " https://www.python.org/ftp/python/ ${PYTHON_VERSION %% [a-z]* } /Python- $PYTHON_VERSION .tar.xz.asc " && \ GNUPGHOME = " $( mktemp -d ) " ; export GNUPGHOME && \ gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys " $GPG_KEY " && \ gpg --batch --verify python.tar.xz.asc python.tar.xz && \ gpgconf --kill all && \ rm -rf " $GNUPGHOME " python.tar.xz.asc && \ mkdir -p ./python && \ tar --extract --directory ./python --strip-components = 1 --file python.tar.xz && \ rm python.tar.xz && \ cd ./python && \ gnuArch = " $( dpkg-architecture --query DEB_BUILD_GNU_TYPE ) " && \ ./configure \ --build =" $gnuArch " \ --disable-gil \ --enable-loadable-sqlite-extensions \ --enable-optimizations \ --enable-option-checking = fatal \ --enable-shared \ --with-lto \ --with-system-expat \ --without-ensurepip \ && \ nproc = " $( nproc ) " && \ EXTRA_CFLAGS = " $( dpkg-buildflags --get CFLAGS ) " && \ LDFLAGS = " $( dpkg-buildflags --get LDFLAGS ) " && \ make -j " $nproc " \ " EXTRA_CFLAGS= ${EXTRA_CFLAGS :- } " \ " LDFLAGS= ${LDFLAGS :- } " \ " PROFILE_TASK= ${PROFILE_TASK :- } " \ && \ rm python && \ make -j " $nproc " \ " EXTRA_CFLAGS= ${EXTRA_CFLAGS :- } " \ " LDFLAGS= ${LDFLAGS :- -Wl } ,-rpath=' \$\$ ORIGIN/../lib' " \ " PROFILE_TASK= ${PROFILE_TASK :- } " \ python \ && \ make install && \ cd ../ && ./local/python3. 13 && ./local/python-3. 13 /bin/python3 -VV 結果 以下の4通りで比較しました。 Python 3.12.4 docker run python:3.12.4 python -c "$(< test_gil.py)" Python 3.13.0b2 docker run python:3.13.0b2 python -c "$(< test_gil.py)" Python 3.13.0b2 (GIL diabled): 環境変数 PYTHON_GIL=1 Python 3.13.0b2 (GIL diabled): 環境変数 PYTHON_GIL=0 リリース版である 3.12.4 および「GIL 有効化の 3.13.0b2」はDocker コンテナで実行しました (ランタイムの条件をなるべく揃えるため)。 また実行したスクリプト test_gil.py は前述のものとほぼ同じですが、最大並列度はコア数の2倍 (= 16) まで上げてみました。 cocurrency v.3.12.4 v3.13.0b2 v3.13.0b2 & --disable-gil & PYTHON_GIL=1 v3.13.0b2 & --disable-gil & PYTHON_GIL=0 1 0.96 0.88 1.16 1.13 2 1.55 1.72 2.17 1.13 3 2.33 2.57 3.23 1.13 4 3.10 3.43 4.27 1.14 5 3.87 4.28 5.31 1.14 6 4.72 5.14 6.36 1.15 7 5.45 6.01 7.41 1.16 8 6.26 6.87 8.44 1.19 9 7.06 7.73 9.52 1.47 10 7.90 8.58 10.65 1.87 11 8.61 9.43 11.65 1.84 12 9.43 10.29 12.67 1.93 13 10.28 11.13 13.85 1.93 14 11.16 11.99 14.90 2.13 15 11.97 12.86 15.91 2.22 16 12.68 13.70 17.13 2.36 概ね同様の結果になりました。 GIL 無効化の場合では CPU コア数 (= 8) を超えたあたりで所要時間のベースラインが上がっていることが分かります。 また、今回の結果では僅かですが 3.12.4 が 3.13.0b2 の結果を上回りました。 まとめ Python3.13の開発バージョンを用いてのGILの解消を確認しました。 プロセスあたりのマルチスレッド処理の性能向上が期待できますね。 GILの解消を提案したPEP 703によるとターゲットバージョンはPython3.13であり、順調に開発が進めば2024年10月にリリースされることになりそうです。 参考 PEP 703 – Making the Global Interpreter Lock Optional in CPython PEP 719 – Python 3.13 Release Schedule
アバター
はじめに RevComm CTO Office 高田です。 今回は RevComm が提供するクラウドIP電話サービス「MiiTel」の基盤となっている AWS のコストに関するお話です。 MiiTel は多くの音声データ・映像データを保持しているサービスになります。リリースされてから数年が経ち、またユーザも増加しサービス維持費が無視できないものとなってきました。そこでコスト削減の対象として S3 に保管されている音声データに白羽の矢が立ちました。 作業計画 最初にざっくりと作業計画を立てましょう。以下のような流れを想定しました。 データの削除&低コストへの移動対象ピックアップ アプリケーションへの影響確認&改修 削除&移動の設定 効果測定 S3 のメトリクスを確認するためのツール さて、作業に移る前にAWSのツールを確認していきましょう。 今回役立ちそうなのはこの3つです。 S3 Storage Lens https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage_lens.html S3 のストレージクラスごとのメトリクスやリクエストのメトリクスを確認できます。 コスト削減の概算値の計算や効果測定で利用します。 S3 Storage Class Analysis https://docs.aws.amazon.com/AmazonS3/latest/userguide/analytics-storage-class.html S3 のストレージクラスごとのアクセス頻度のメトリクスを確認できます。 ライフサイクルの期間決めで利用します。 Cost Explorer https://docs.aws.amazon.com/cost-management/latest/userguide/ce-what-is.html 効果測定で利用します。 データの削除&低コストへの移動対象ピックアップ MiiTelでは大きく3つのデータを保管しています。 生データ 音声解析用の中間データ UIでの再生用データ 今回はアプリケーションの仕様と相談し、 ①生データと②音声解析用の中間データを削除、③UIでの再生用データをGracier Instant Retrievalへ変更することとします。 ストレージクラスの選択はクラスメソッドさんのブログの S3 ストレージクラス選択チャートが便利でした。AWS公式のドキュメントも確認した上で参考にしましょう。 概算では費用を ⅓ くらいに抑えられそうです。 参考: https://aws.amazon.com/jp/s3/storage-classes/ https://dev.classmethod.jp/articles/should_i_choice_s3_storage_class_2023/ さて、次はどのくらいの期間を経て削除またはストレージクラスの変更をするかを決めましょう。 S3 Storage Class Analysis という機能で確認します。 画像を見ると MiiTel では30日経過後での利用以外はほぼないようです。 アプリケーションへの影響確認&改修 これまではなんらかの事情で再度解析の処理を行いたいといった場合に備え、長期間に渡り生データを保管していました。 しかしながら、今回の対応により保管期間が短くなるためエラー時の復旧や自動的なリトライをより厳密に行う必要がでました。 この対応のお話は長くなるので割愛します。 削除&移動の設定 S3 のライフサイクルを設定します。 MiiTel では 1 つの s3 bucket で先に挙げた 3 つのデータを prefix 別に管理しているため、prefixで指定してすることになります。余談ですが、イベントトリガと違いライフサイクルでは postfix が使えないことに注意しましょう。私はライフサイクルを設定するときに気付きました。 ここで注意したいのは設定次第では不可逆になりかねないことです。 特に削除に関する設定では、他の s3 bucket を用意しての動作確認は必須です。 今回はライフサイクルの適用を中間データの削除と生データの削除とで2ステップに分けて行うこととしました。 効果測定 S3 Storage Lens で残ったファイル数やサイズを確認しましょう。 また、Cost Explorer を使って実際のコストも確認します。 画像を見ると、10月の終わりに1回目のコスト減、11月の頭にGracier Instant Retrievalへ移動したことによる瞬間的なコスト増、12月の終わりに2回目のコスト減がわかります。 ストレージクラスの変更により瞬間的なコストは発生しましたが、予想通りだいたい ⅓ になりました。 重箱の隅をつつけば、もっと削減できるかと思いますが短期間でできる対応としては十分かと思います。 終わりに 以上、RevComm で行った S3 のコスト削減の計画から実施、効果測定までと利用したツールの紹介でした。皆さまのお役に立てれば幸いです。
アバター
はじめに こんにちは!RevCommでフロントエンドエンジニアをしている田中です。 最近、MiiTel Phone Webというプロダクトに openapi-typescript とRedoclyというツールを使用してOpenAPIドキュメントからTypeScriptの型定義の管理を効率化する仕組みを導入しました。それらのツールの導入背景や使い方などについて説明します。 この記事は以下のバージョンを想定して記述されています。 ツール バージョン Node.js 20.11.0 openapi-typescript 6.7.5 @redocly/cli 1.10.4 導入の経緯について MiiTel Phone WebではAxiosを使ってREST APIを叩いています。 今までREST APIに関する型定義は、OpenAPIドキュメントを参考に手動でTypeScriptの型を定義して運用していました。 import type { AxiosResponse } from 'axios' ; import axios , { API_PATHS } from 'apis/axios' ; // 以下のようなinterfaceをOpenAPIの定義を元に用意します export interface Tag { id?: string ; name: string ; } export interface CreateTagResponse { id: string ; name: string ; } // 用意したinterfaceを元に関数を定義します export const createTag = async ( tag: Tag , ) : Promise < CreateTagResponse > => { // APIを叩く処理... } ; このように型を定義することで、APIを呼ぶ際に誤ったパラメータを指定することを防止していました。この仕組みはうまく機能していたものの、プロダクトを開発していく中で、OpenAPIの更新に対してTypeScriptの型の更新が追いつかない箇所が生じるようになりました。 また、OpenAPIドキュメントを確認しつつ、手動でTypeScriptの型定義を定義していく作業は煩雑になりがちであり、ミスも生じやすいです。 OpenAPIの定義からTypeScriptの型を自動生成すれば、これらの課題を改善できるのではないかと思い、仕組みを入れてみることにしました。 実現したいこと 今回、仕組みを導入する上で、以下の点を重視して検討しました。 現状の実装を保ちつつ、部分的に自動生成した型を導入していきたい 先ほど紹介したように、MiiTel Phone WebにはすでにAxiosをベースにREST APIを叩く仕組みが存在します。 export const createTag = async ( tag: Tag , ) : Promise < CreateTagResponse > => { // APIを叩く処理... } ; 新しく仕組みを導入する上で、大掛かりなリライトなどが必要になってしまうと大変です。既存の仕組みをベースにできる限り移行コストやリスクを抑えつつ、段階的に導入していけるとよさそうです。 プロダクトに必要なAPIに関するコードのみを生成したい MiiTel Phone Webが参照しているOpenAPIドキュメントには、MiiTel Phone Web以外のプロダクトから利用されているAPIの定義も含まれています。利用していないものも含めたすべてのAPIに関する型定義を生成しようとすると、未使用の型定義が大量にできてしまいそうです。そのため、MiiTel Phone Webから利用している特定のAPIに関する型定義のみを参照できると理想的です。 以上の2点を念頭に選択肢を探ることにしました。 選択肢について OpenAPIからTypeScriptのコードを生成するにあたっていくつか選択肢がありそうです。 検討したものをいくつか紹介します。 openapi-generator github.com openapi-generator はOpenAPIドキュメントからAPIクライアントを自動生成してくれるツールです。おそらく、OpenAPIからコードを自動生成するツールとしては最も有名なのではないかと思います。 ただし、MiiTel Phone Webでの採用にあたっては、 openapi-generator の利用のためにJavaの導入が必要なことが気にかかりました。 (開発環境やCIでのセットアップなどのコストが増加してしまう) 便利なツールではあるものの、今回実現したいことに対してはややtoo muchであると感じたため、別の選択肢も探ることにしました。 openapi-typescript github.com openapi-typescript はOpenAPIドキュメントからTypeScriptの型定義を自動生成してくれるnpmパッケージです。 openapi-typescript の特徴として、APIクライアントの生成はサポートせず※、TypeScriptの型定義のみを生成してくれます。 openapi-generator と比較するとかなりシンプルなツールです。(※ openapi-typescript の作者の方により openapi-fetch というライブラリが開発されていて、こちらのパッケージによりAPIクライアントが提供されています) openapi-typescript は下記の理由からとても魅力的に感じました。 Node.jsで実行できるため、導入コストが低いこと 既存のAxiosを使ってAPIを叩いているコードに対して openapi-typescript で生成された型定義を段階的に適用していけるため、比較的低リスク・低コストでの移行が見込めること 型定義のみを生成してくれるので取り回しがしやすく柔軟性が高い 型定義以外は生成されないのでバンドルサイズも増加しない この openapi-typescript を活用することで、実現したいことの一つとして挙げた「 できる限り低コスト・リスクで段階的に移行する 」ことは実現できそうです。 しかし、現時点では openapi-typescript は指定した特定のAPIに関する型定義のみを生成する仕組みが存在せず、 2つ目の点 に関しては実現ができなさそうです。これについては別途解決策を探ってみることにしました。 OpenAPIドキュメントを縮小する MiiTel Phone Webが参照しているOpenAPIドキュメントは、MiiTel Phone Webで利用していないAPIに関する定義もたくさん含まれています。このOpenAPIドキュメントからMiiTel Phone Webで利用しているAPIに関する定義のみを抽出できると理想的です。これについてはRedocly CLIというツールを導入して実現することにしました。 Redocly CLIとは? 以下のようなOpenAPIに関するさまざまな機能を提供してくれる高機能なツールです。Node.jsで実装されています。 OpenAPIドキュメントのlint OpenAPIドキュメントのvalidation 複数のOpenAPIドキュメントのバンドル ファイルの分割 APIドキュメントの生成 Redocly CLIを採用した背景 Redocly CLIには bundle コマンド( redocly bundle )というものがあります。このコマンドを使うことで $ref を使って分割された複数のOpenAPIファイルを単一のファイルにまとめることができます。 github.com note.com また、Redocly CLIにはデコレーターという機能があります。 github.com 詳細については後ほど紹介しますが、このデコレーターを利用することでRedocly CLIがOpenAPIファイルをバンドルする際の挙動をカスタマイズすることが可能で、例えば、OpenAPIドキュメントから特定のAPIの定義などを取り除くこともできます。 そのため、Redocly CLIの bundle コマンドとデコレーターの機能を併用することで、OpenAPIドキュメントを縮小することができそうです。 また、 openapi-typescript の次のメジャーバージョンであるv7ではこのRedoclyを採用することが検討されています。 github.com そのため、将来的にRedoclyと openapi-typescript の併用がよりしやすくなることが想定されるため、そういった点も魅力的に感じてRedocly CLIを採用することにしました。 openapi-typescript とRedocly CLIを連携させる とはいえ、現在の openapi-typescript の最新メジャーバージョンであるv6では、まだRedoclyのサポートが導入されていません。 そのため、自前で簡単なスクリプトを用意してこれらのツールを連携させることにしました。以下がスクリプトのイメージです。 // @ts-check import { Buffer } from 'node:buffer' ; import { exec } from 'node:child_process' ; import { mkdir , readFile , writeFile } from 'node:fs/promises' ; import { dirname , join } from 'node:path' ; import process from 'node:process' ; import { promisify } from 'node:util' ; import openapiTS from 'openapi-typescript' ; async function main () { // プロジェクトのルートディレクトリ const rootDir = join ( dirname ( new URL ( import . meta . url ) . pathname ) , '../' ) ; const tmpDir = join ( rootDir , 'tmp' ) ; const pathToOpenAPIDocument = join ( tmpDir , 'openapi.json' ) ; const pathToMinifiedOpenAPIDocument = join ( tmpDir , 'openapi.min.json' ) ; const pathToRedoclyConfig = join ( rootDir , 'redocly.yaml' ) ; const pathToGeneratedTypeDefinitions = join ( rootDir , 'src/apis/types. generated.ts' ) ; await mkdir ( tmpDir , { recursive : true }) ; // (1) 最新のOpenAPIドキュメントの定義をダウンロード await downloadLatestOpenAPIDocument ( pathToOpenAPIDocument ) ; // (2) Redocly CLIを使用して1でダウンロードしたOpenAPIドキュメントを最小化したドキュメントを生成します await minifyOpenAPIDocument ({ cwd : rootDir , output : pathToMinifiedOpenAPIJSON , config : pathToRedoclyConfig }) ; // (3) 2で生成されたOpenAPIに対してopenapi-typescriptを適用して、TypeScriptの型定義を生成します const document = JSON . parse ( await readFile ( pathToMinifiedOpenAPIDocument , { encoding : 'utf-8' })) ; await generateTypeDefinitions ({ document , output : pathToGeneratedTypeDefinitions }) ; } async function minifyOpenAPIDocument ({ cwd , output , config }) { const result = await promisify ( exec )( `npx @redocly/cli bundle --output= ${ output } --config= ${ config } --remove-unused-components` , { cwd } ) ; if ( result . stdout ) { console . info ( result . stdout ) ; } if ( result . stderr ) { console . error ( result . stderr ) ; } } async function generateTypeDefinitions ({ document , output }) { const generatedCode = await openapiTS ( document , { commentHeader : [ '/* eslint-disable */' , '// This file was automatically generated by `scripts/generate-openapi-types.mjs`.' , `// Do not edit this file directly.` , ] . join ( '\\n' ) }) ; await writeFile ( output , generatedCode , { encoding : 'utf-8' }) ; } main () . catch (( error ) => { console . error ( error ) ; process . exit ( 1 ) ; }) ; 要点をいくつか挙げると、まずスクリプトの実行時に最新のOpenAPIドキュメントをダウンロードします。OpenAPIドキュメントはフロントエンドのリポジトリとは別に管理されているため、都度、最新の定義をダウンロードしています。 // (1) 最新のOpenAPIドキュメントの定義をダウンロード await downloadLatestOpenAPIDocument ( pathToOpenAPIDocument ) ; 次に、ダウンロードしたOpenAPIドキュメントをRedocly CLIを使って最小化します。 // (2) Redocly CLIを使用して1でダウンロードしたOpenAPIドキュメントを最小化したドキュメントを生成します await minifyOpenAPIDocument ({ cwd : rootDir , output : pathToMinifiedOpenAPIJSON , config : pathToRedoclyConfig }) ; ここで呼ばれている minifyOpenAPIDocument では redocly bundle コマンドを実行しています。重要なのが --remove-unused-component オプションで、これによって redocly bundle コマンドがOpenAPIドキュメントを生成する際に、デコレーターにより除外されたエンドポイントに関する定義が取り除かれます。 async function minifyOpenAPIDocument ({ cwd , output , config }) { const result = await promisify ( exec )( `npx @redocly/cli bundle --output= ${ output } --config= ${ config } --remove-unused-components` , { cwd } ) ; if ( result . stdout ) { console . info ( result . stdout ) ; } if ( result . stderr ) { console . error ( result . stderr ) ; } } --config オプションにはプロジェクト直下に配置している redocly.yaml というファイルへのパスを指定しています。このファイルにはRedocly CLIの設定が記述されており、デコレーターの設定が記述されています。具体的には、以下のように filter-in デコレーターというものを指定しています。 extends : - recommended apis : rest : root : ./tmp/openapi.json # (1)でダウンロードしてきたOpenAPIドキュメントのパス decorators : filter-in : property : operationId # MiiTel Phone Webで利用するAPIに関するoperationIdのみを列挙します value : - authenticate - getMe # ... - listUsers filter-in デコレーターを使用することで、 redocly bundle コマンドを実行する際に、指定した条件にマッチするAPIエンドポイントのみを抽出することができます。ここではMiiTel Phone Webで利用されているAPIに関する operationId を指定してフィルタリングを行なっています。 最後に openapi-typescript を使って、(2)でRedocly CLIによって生成されたOpenAPIドキュメントをベースにTypeScriptの型定義を生成します。 // (3) 2で生成されたOpenAPIに対してopenapi-typescriptを適用して、TypeScriptの型定義を生成します const document = JSON . parse ( await readFile ( pathToMinifiedOpenAPIDocument , { encoding : 'utf-8' })) ; await generateTypeDefinitions ({ document , output : pathToGeneratedTypeDefinitions }) ; ここで呼ばれている generateTypeDefinitions は以下のように定義されていて、 openapi-typescript が提供するAPIを利用してTypeScriptの型定義を生成しています。 async function generateTypeDefinitions ({ document , output }) { const generatedCode = await openapiTS ( document , { commentHeader : [ '/* eslint-disable */' , '// This file was automatically generated by `scripts/generate-openapi-types.mjs`.' , `// Do not edit this file directly.` , ] . join ( '\\n' ) }) ; await writeFile ( output , generatedCode , { encoding : 'utf-8' }) ; } ここでは openapi-typescript をライブラリとして利用していますが、以下のようにCLIとして利用することも可能です。用途に応じて使い分けると便利だと思います。 $ npx openapi-typescript ./tmp/openapi.json -o ./apis/types.ts openapi-typescript が公開しているexampleを掲載しますが、以下のようなイメージで型定義が生成されます。 github.com Axiosに型をつける パラメータ・レスポンスの型付け まず、今まで手で作っていたAPIの型定義は単純に openapi-typescript で置き換えることができそうです。 // 置き換え前のイメージ import type { AxiosResponse } from 'axios' ; import axios , { API_PATHS } from 'apis/axios' ; export interface Tag { id?: string ; name: string ; } export interface CreateTagResponse { id: string ; name: string ; } export const createTag = async ( tag: Tag , ) : Promise < CreateTagResponse > => { // APIを叩く処理... } ; 例えば、上記のコードは以下のように置き換えることができます。 import type { AxiosResponse } from 'axios' ; import axios , { API_PATHS } from 'apis/axios' ; // openapi-typescriptによって生成された型定義を読み込みます import type { paths } from 'apis/types.generated' ; type CreateTagAPI = paths [ '/api/tags' ][ 'post' ] ; export type CreateTagParams = NonNullable < CreateTagAPI [ 'requestBody' ] > [ 'content' ][ 'application/json' ] ; export type CreateTagResponse = CreateTagAPI [ 'responses' ][ '200' ][ 'content' ][ 'application/json' ] ; export const createTag = async ( params: CreateTagParams , ) : Promise < CreateTagResponse > => { // APIを叩く処理... } ; openapi-typescript は paths という型を生成します。この型は各エンドポイントのURLをキー、そのエンドポイントに関する定義が値に設定された interface です。 github.com このは paths 型を使うと、以下のようなイメージで特定のエンドポイントに関する型定義を取得できます。 // `POST /api/tags`に関する定義を取得 type CreateTagAPI = paths [ '/api/tags' ][ 'post' ] ; // リクエストボディに関する型定義を取得 export type CreateTagParams = NonNullable < CreateTagAPI [ 'requestBody' ] > [ 'content' ][ 'application/json' ] ; // レスポンスボディに関する型定義を取得 export type CreateTagResponse = CreateTagAPI [ 'responses' ][ '200' ][ 'content' ][ 'application/json' ] ; あとはこれらの型を使って、関数の型定義を置き換えていきます。段階的に移行がしやすいため、開発途中から導入するケースにおいても openapi-typescript は融通が利いて使いやすい印象です。 URLの型付け 先ほど紹介したように、 openapi-typescript は paths という型を生成します。この型をうまく活用すればURLについても型安全に指定する仕組みが用意できそうに思いました。 まずAxiosでAPIを実行する際にURLの型がきちんとチェックされるようにするため、以下のような型を用意することにしました。 import type { AxiosInstance , AxiosRequestConfig , AxiosResponse } from 'axios' ; // axiosが提供するAxiosInstanceをベースに、URLに対して型チェックが適用される型を用意します interface TypedAxiosInstance extends Pick < AxiosInstance , 'defaults' | 'interceptors' | 'request' > { // openapi-typescriptで定義された型を活用して`url`プロパティに対して型チェックが効くようにします (AllowedPathについては後述します) // eslint-disable-next-line @typescript-eslint/no-explicit-any < T = any , R = AxiosResponse < T >, D = any , URL extends string = string >( config: Omit < AxiosRequestConfig < D >, 'url' > & { url: URL extends AllowedPath ? URL : never } , ) : Promise < R >; // こちらも上記と同様に、url引数に対して型チェックが効くようにします // eslint-disable-next-line @typescript-eslint/no-explicit-any < T = any , R = AxiosResponse < T >, D = any , URL extends string = string >( url: URL extends AllowedPath ? URL : never , config?: AxiosRequestConfig < D >, ) : Promise < R >; } 重要なのがここで利用されている AllowedPath 型です。この型は openapi-typescript で生成された paths のキーに合致する文字列以外はエラーとするように定義されています。 import type { paths } from 'apis/types.generated' ; // `/api/users/{id}`を`/api/users/${string}`というような型へ置き換えます // 例) `/api/users/{id}`を`/api/users/${string}`のような型に変換します export type OpenAPIPathPlaceholderToTSType < T extends string > = T extends ` ${ infer Prefix } /{ ${ string } } ${ infer Next } ` ? ` ${ Prefix } / ${ string }${ OpenAPIPathPlaceholderToTSType<Next> } ` : T ; export type AllowedPath = OpenAPIPathPlaceholderToTSType < ` ${ string }${ keyof paths } ` >; Axiosのインスタンスを生成する際に先ほどの TypedAxiosInstance を利用します。 const axios = Axios. default .create ( axiosConfig ) as TypedAxiosInstance ; これによりAxiosによりAPIを実行する際に、パスがOpenAPIで定義されたものであるかどうかを自動でチェックしてくれます。 axios ( `/api/users/ ${ userId } /profile` as const ); // => OK axios ( `/api/no_such_endpoint` as const ); // => 型エラー!!😊 ただこれには少し制限があって、例えばOpenAPIに /api/users/{id} と /api/users/{id}/profile というAPIが定義されていた場合に、以下のようなケースで意図せずして型チェックが通ってしまう問題がありました... import { expectTypeOf } from 'expect-type' ; expectTypeOf ( '/api/users/123' as const ) .toMatchTypeOf < AllowedPath >(); // => OK (意図どおり) expectTypeOf ( '/api/users/123/profile' as const ) .toMatchTypeOf < AllowedPath >(); // => OK (意図どおり) expectTypeOf ( '/api/users/123/no_such_endpoint' as const ) .not.toMatchTypeOf < AllowedPath >(); // => NG (OpenAPIで未定義のパスにも関わらず、意図せずして型チェックが通ってしまう...) これは OpenAPIPathPlaceholderToTSType<'/api/users/{id}'> が /api/users/${string} として解釈されることが原因です。課題はあるものの、大抵のケースではうまくワークするはずなので、ひとまず妥協することにしました… URLの型定義を改善する 先ほどの課題は AllowedPath を以下のような型定義に変えると解決できることがわかりました。 type WithoutSlash < T extends string > = T extends ` ${ string } / ${ string } ` ? never : T ; type OpenAPIPathPlaceholderToTSType < T extends string , Param extends string , > = T extends ` ${ infer Prefix } { ${ string } } ${ infer Next } ` ? ` ${ Prefix }${ WithoutSlash<Param> }${ OpenAPIPathPlaceholderToTSType<Next, Param> } ` : T ; export type AllowedPath < Param extends string > = OpenAPIPathPlaceholderToTSType < ` ${ string }${ keyof paths } ` , Param >; そして、 TypedAxiosInstance の型も以下のように変更します。新しく導入された WithoutSlash 型と以下の AllowedPath の型パラメータに指定している点が重要で、これらを組み合わせることにより意図した通りに型の推論が効くようになりました! interface TypedAxiosInstance extends Pick < AxiosInstance , 'defaults' | 'interceptors' | 'request' > { // eslint-disable-next-line @typescript-eslint/no-explicit-any < T = any , R = AxiosResponse < T >, D = any , URL extends string = string >( config: Omit < AxiosRequestConfig < D >, 'url' > & { url: URL extends AllowedPath < infer _ > ? URL : never } , ) : Promise < R >; // eslint-disable-next-line @typescript-eslint/no-explicit-any < T = any , R = AxiosResponse < T >, D = any , URL extends string = string >( url: URL extends AllowedPath < infer _ > ? URL : never , config?: AxiosRequestConfig < D >, ) : Promise < R >; } TypeScriptはとても柔軟で驚きました。 これにより、先ほど意図せずして型チェックが通ってしまっていたケースも解消することができました。 import { expectTypeOf } from 'expect-type' ; expectTypeOf ( '/api/users/123' as const ) .toMatchTypeOf < AllowedPath >(); // => OK (意図どおり) expectTypeOf ( '/api/users/123/profile' as const ) .toMatchTypeOf < AllowedPath >(); // => OK (意図どおり) expectTypeOf ( '/api/users/123/no_such_endpoint' as const ) .not.toMatchTypeOf < AllowedPath >(); // => OK (ちゃんと型エラーが発生してくれる) ちなみにここでは型のテストに expect-type というライブラリを利用しています。Vitestではこの expect-type が初めから組み込まれており、自前でユーティリティタイプや複雑な型定義を実装する必要が出てきた際などの型定義のテストで活用すると便利だと思います。 今後について まだ仕組みを導入し始めたばかりなので、いくつか課題などが残っています。 openapi-typescript で生成された型を元に、型生成の効率化やURLに対する型チェックなどができるようになったので、今後はURLから適用すべきパラメータやレスポンスの型なども自動で推論する仕組みなどを用意できるとさらによさそうです。 また、 openapi-typescript のv7がリリースされるとRedoclyのサポートが入る予定なので、もしかしたらRedocly CLIを使ってOpenAPIドキュメントを縮小する手順などをより簡略化できるのではないかと思っています。 おわりに この記事では openapi-typescript やRedoclyなどを活用した仕組みの導入について解説いたしました。もし今後、OpenAPIやSwaggerのドキュメントからTypeScriptコードを生成したい場合に参考になりましたら幸いです。
アバター
はじめに RevComm でエンジニアリングマネージャーをしている服部 ( @keigohtr ) です。RevComm のエンジニア評価制度は2023年1月に改定しました。新制度を運用して既に1年が経過しました。この記事では、現在のエンジニア評価制度を紹介するとともに、評価制度の改定をどのようなプロセスで行ったのかについて紹介したいと思います。 タイムライン 2022年10月 - 評価制度改定 WG (Working Group) を発足。 2023年1月 - 新評価制度の導入。半期(1月~6月)のスタート。 2023年7月 - 半期(7月~12月)のスタート。 2023年9月 - 先期に寄せられたフィードバックをもとに改善。 2024年1月 - 半期(1月~6月)のスタート。 2024年3月 - 先期に寄せられたフィードバックをもとに改善。 現在のエンジニア評価制度 現在のエンジニア評価制度は「実績評価」と「行動評価」で構成されています。そして計画外の従業員の貢献を評価する「プラスワン評価」を設置しています。評価の割合は、実績評価:行動評価 = 1:1 で、プラスワン評価は補助的な位置づけとしています。 実績評価 いわゆる OKR や MBO と呼ばれるものです。期初に目標を設定し、期末に実績を書いたレポートを提出します。 行動評価 いわゆる360度評価と呼ばれるものです。IC (Individual Contributor) 職と EM (Engineering Manager) 職とで評価項目は分けています。 IC 職用の行動評価は RevComm の行動特性 を評価軸にしています。被評価者の同僚の IC 職を被評価者自身で3名以上(人数の上限なし)指名してもらい、彼らに被評価者についてのレポートを書いてもらいます。 EM 職用の行動評価は Google の Re: Work で定義された Manager Feedback Survey の項目を評価軸にしています。被評価者が管理する IC 職全員に被評価者についてのレポートを書いてもらいます。 RevComm の Value RevComm の Credo Value と Credo を元に作成した RevComm の行動特性 プラスワン評価 実績評価で立てた計画以外に個人として出した成果を評価する仕組みです。プラスワン評価はあくまで補助的な位置づけで、実績評価と行動評価がメインの評価になります。プラスワン評価は、例えばPyConなどの社外カンファレンスでの登壇や15%ルール (=RevComm 版の 20%ルール ) で出した成果を評価します。 どのようなプロセスで評価制度を改定したか? 最初は現場からの声 当時の評価制度について疑問を持つメンバーの声がちらほら slack に上がっていました。評価制度についての課題感はトップマネジメントも認識していたところだったので、現状把握に乗り出しました。具体的には、各チームのリーダーに依頼して所属チームでワークショップを開いてもらい、現行の評価制度の課題について議論してもらいました。そして、集まった声の中でも課題感が大きかったキャリアラダーの改定が決まりました。 当時使っていたキャリアラダー Working Group の立ち上げ キャリアラダーを改定するにあたって誰がそのプロジェクトをリードするかを決める必要があります。ありがたいことに今回は私に任せてもらえることになりました。 私が最初にしたことは、WG (Working Group) を立ち上げることでした。具体的には以下のことをしました: WG の参加者を募った。 WG の活動の目的とスコープを決めたドキュメントを作成した。 WG の定例会議を設定し、議事録を作った。 WG の活動を公開した。 WG の活動の目的とスコープを決めたドキュメント WG を運営する上で意識したこととしては: WG の目的を明らかにし、やることとやらないことを明確にする。 活動はオープンに行い、透明性を高く保つ。 意思決定者を明らかにし、効率的な進行を行う。 定例会議では報告をメインにし、議論は定例以外で必要に応じて行う。 定例会議ではアクションアイテムと担当者を決める。 この仕組みはうまく機能し、キャリアラダーの改定活動は順調に進行しました。 評価制度の改定も WG で担当することに キャリアラダーは人事評価でも使っていたので、キャリアラダーの改定は評価制度にも強く関係します。WG が活動を開始して1.5ヶ月経過したタイミングで、マネジメントから評価制度の改定を WG の活動のスコープに含めるように依頼がありました。 評価制度の改定が WG のスコープに WG 発足から3ヶ月で新評価制度が制定、運用開始 あらためて振り返ってみると、変化が早いですね。 評価制度を改定する上で必要なことをまとめると: 優れた評価制度を設計すること 優れた評価プロセスを設計すること 関係各所から承認と理解を得ること 優れた評価制度を設計すること 冒頭のエンジニア評価制度に至るまでにしたことをまとめると: 現行制度の課題(=現場の声)を整理した 現行制度を設計した意図を上位マネジメント層からヒアリングした 古今東西の評価制度を調査した 現場の声は大事です。改定作業は課題を元に行う必要があります。当時の評価制度の課題については、本記事の目的から外れるので今回は割愛します。 「現行制度を設計した意図を上位マネジメント層からヒアリングした」理由としては、評価制度というのは会社の文化に紐づく必要があると我々は信じたからです。RevCommが大事にしたい想いだったり、マネジメント層がエンジニア組織に根付かせたい文化だったりが、評価制度に反映されている必要があると我々は考えました。 関係各所と協力し、最終的に冒頭に述べた成果物が出来ました。 優れた評価プロセスを設計すること 評価制度はできる限り少ないコストで運用可能でなければなりません。やったこととしては: 評価ガイドラインを作成した 人事評価システム (当時は HRBrain) を設定した フィードバックフォームを設置した WG で評価ガイドラインを作成しました。適切なドキュメントの存在はいつでも重要です。最終的には24ページにもおよぶドキュメントを作りました。 評価ガイドライン また、WG で人事評価システムの設定も行いました。当時、RevComm では人事評価システムに HRBrain を使っていました。新評価制度を導入するにあたって、人事から HRBrain の管理者アカウントをもらい新評価制度の設定と試験を WG で行いました。この理由としては、優れた評価制度を設計できたとしても、人事評価システムで実現できない、あるいは実現できてもメンバーの作業負荷が高ければ、評価プロセスは組織に根付かないと考えたからです。 最後に、評価制度を継続的に改善できるようにするため、評価制度に対するフィードバックフォームを設置しました。フィードバックは常時受け付けるようにして、フィードバックが来たら WG で議論するようにしています。 評価制度フィードバック 関係各所から承認と理解を得ること 承認や理解を得る必要があるステークホルダーは: 上位マネジメント 人事 現場のマネージャー やったことは対話です。 現状の課題と新制度での変更点を簡潔に述べ、質疑応答の時間を長く取りました。頂いたコメントやフィードバックを WG に持ち帰り、評価制度に反映しました。 評価制度の継続的な改善 制度の改定は実施したら終わりではありません。課題を探し、継続的に改善する体質が作れれば、従業員にとってもマネジメント層にとっても利益があります。 具体的には、上で述べた「評価制度フィードバック」のフォームに寄せられたコメントへの対応をしています。WG の運用としては以下を行っています: 閑散期は WG の定例会議の開催頻度を減らす 寄せられたフィードバックのうち改善が必要な項目がある場合、 WG の定例会議の開催頻度を増やす 新制度導入 (2023年1月) から現在 (2024年3月) までに WG が行ったこととしては: 評価ガイドラインの充実 理解を深めるための説明会の実施 人事評価期間の見直し 部門間での評価キャリブレーション会議の設定 そして現在はキャリアラダーの再改定を行っています。 おわりに 本記事では RevComm のエンジニア評価制度の改定について紹介しました。現在の評価制度もまだまだ改善できる点はありますが、制度の継続的な改善をできるプロセスを組織に作ったことは非常に大きい貢献だったと思っています。今回の活動は非常に多くの関係者の方の協力によって実現しており、RevComm はこのような協力体制があるということで非常に誇らしく思っています。
アバター
RevCommでバックエンド開発をしている小門です。 最近、CSVファイルのアップロードを受け付けて処理するバックエンドAPIの機能開発を担いました。 CSVファイルのパース、バリデーションにPydanticが便利でしたので紹介したいと思います。 なお開発言語はPython、コードの動作バージョンは以下です。 Python 3.12 Pydantic: 2.6.0 PythonでCSVファイルの取り扱い Pythonでは組み込みモジュールcsvを使うことで基本的なCSVファイルの読み取り・書き込みができます。 # persons.csv の例 """ "name","age" "alice",20 "bob",21 """ import csv with open ( "persons.csv" , newline= "" ) as csvfile: reader = csv.DictReader(csvfile) for row in reader: print (row) # {"name": "alice", "age": "20"} # {"name": "bob", "age": "21"} また、取り扱うCSVファイルのカラム形式が決まっている場合はその情報をクラスに定義することで仕様が明確になり、開発時にエディターの支援を受けられるメリットがあります。 これにはPythonの組み込み機能で3つの方法が考えられます。 typing.NamedTuple typing.TypedDict dataclass from typing import NamedTuple, TypedDict from dataclasses import dataclass class Person (NamedTuple): name: str age: str class Person (TypedDict): name: str age: str @ dataclass class Person : name: str age: str data = { "name" : "Alice" , "age" : "20" } # いずれのPersonクラスでも以下で初期化可能 person = Person(**data) 本機能のユースケース 今回開発したCSVファイルの機能では重要な考慮点が大きく2つありました。 データバリデーション CSVファイルはサービスのエンドユーザーから直接アップロードされるものであるため、ファイルの中身を厳密にバリデーション(検証)する必要がありました。 csv.reader と csv.DictReader は文字列型でデータを読み取るため、上記の例においては age カラムも str 型で定義しました。 しかし実際の開発シーンではもちろん age は整数型で扱う必要があるでしょう。他にも浮動小数や日付などのデータを扱う場合はそれぞれ変換する必要があります。 またバリデーションとしてはデータ型以外にも値自体の検証や必須項目のチェックなども必要です。 動的なCSVヘッダー MiiTelは海外にサービス展開しているため多言語対応しています(現在は日本語と英語)。 そのため、ユーザーの言語設定によってCSVファイルのヘッダー行(先頭行)が変化することが要件でした。 例 言語設定:日本語 "氏名","メールアドレス","管理者権限の有無" "Kokado","shota.kokado@example.com","true" 言語設定:英語 "Name","Email","Administrative Privilege" "Kokado","shota.kokado@example.com","true" Pydanticの導入 以上の要件を実現するためには上述した組み込みモジュールの機能だけでは不十分と考え、ライブラリの利用を検討しました。 結論としてはあまり悩まずにPydanticを採用することに決めました。 ポイント: 型アノテーションを活用して型変換やリッチなバリデーションを少ないコードで実装できる FastAPIで採用されており利用事例が多く、ライブラリの信頼度が高い データバリデーション Pydanticは公式ドキュメントで謳われている通り型・データのバリデーションが主機能の一つであるため、ダイレクトに恩恵を享受することができました。 Pydanticを使うと冒頭の例は以下のように実装できます。 from pydantic import BaseModel class Person (BaseModel): name: str age: int # <== str ではなく int data = { "name" : "Alice" , "age" : "20" } person = Person(**data) print (person.name, type (person.name)) # Alice <class 'str'> print (person.age, type (person.age)) # 20 <class 'int'> また発展として、Enum型も活用することで入力値に意味を持たせることができるようになります。 from enum import IntEnum class Sex (IntEnum): MALE = 0 FEMALE = 1 class Person (BaseModel): name: str age: int sex: Sex # <== data = { "name" : "Alice" , "age" : "20" , "sex" : "1" } person = Person(**data) print (person.sex) # <Sex.FEMALE: 1> 動的なCSVヘッダー 動的なCSVヘッダーを受け付けるためのPydanticの使い方として2つの方法を考えました。 CSV例を再掲します。 言語設定:日本語 "氏名","メールアドレス","管理者権限の有無" "Kokado","shota.kokado@example.com","true" 言語設定:英語 "Name","Email","Administrative Privilege" "Kokado","shota.kokado@example.com","true" 一つ目は各言語ごとのCSVファイルを表現するためのPydanticモデルクラスをそれぞれ定義することです。 from pydantic import BaseModel, Field # 1. 言語設定:日本語用 class CsvDataJa (BaseModel): name: str = Field(alias= "氏名" ) email: str = Field(alias= "メールアドレス" ) is_administrator: bool = Field(alias= "管理者権限の有無" ) # 1. 言語設定:英語用 class CsvDataEn (BaseModel): name: str = Field(alias= "Name" ) email: str = Field(alias= "Email" ) is_administrator: bool = Field(alias= "Administrative Privilege" ) 2つ目は pydantic.AliasChoices を使って複数のエイリアスを許容するようにして一つのPydanticモデルクラスを定義することです。 Alias - Pydantic from enum import Enum from pydantic import AliasChoices, BaseModel, Field class CsvField (Enum): # (ja, en) NAME = ( "氏名" , "Name" ) EMAIL = ( "メールアドレス" , "Email" ) ADMINISTRATIVE_PRIVILEGE = ( "管理者権限の有無" , "Administrative Privilege" ) class CsvData (BaseModel): name: str = Field(validation_alias=AliasChoices(*CsvField.NAME.value)) email: str = Field(validation_alias=AliasChoices(*CsvField.EMAIL.value)) is_administrator: bool = Field(validation_alias=AliasChoices(*CsvField.ADMINISTRATIVE_PRIVILEGE.value)) 結論としては2つ目の方法を採用しました。クラス毎の責務を適切に分割できたと考えたためです。 CsvField : 言語毎のCSVファイルのカラム名を管理する CsvData : CSVカラム毎のデータ型、バリデーションロジックを管理する 例えば新しい別の言語に対応する必要が出た場合は CsvField クラスのみ、既存カラムのバリデーションロジックの仕様変更する際は CsvData クラスのみの更新で済みます。 ただ一点、 AliasChoices は利用可能な複数のaliasを定義するのみであるため、Field同士でaliasの組み合わせは任意になることが注意事項です。 今回の例では日本語、英語の組み合わせを許容することになります。 from .types.csv_data import CsvData # 上述のCsvDataクラス # 以下のdataから等価なCsvDataが作られる data = { "氏名" : "Alice" , "メールアドレス" : "xxx@example.com" , "管理者権限の有無" : "true" } data = { "氏名" : "Alice" , "Email" : "xxx@example.com" , "Administrative Privilege" : "true" } csv_data = CsvData(**data) print (csv_data) # CsvData(name='Alice', email='xxx@example.com', is_administrator=True) 以上を踏まえて、CSVの読み込みを行う処理を以下のように実装しました。 ※同時にバリデーションロジックも実際をイメージしたものにアップデート # types/csv_data.py from enum import Enum from pydantic import AliasChoices, BaseModel, EmailStr, Field class CsvField (Enum): NAME = ( "氏名" , "Name" ) EMAIL = ( "メールアドレス" , "Email" ) ADMINISTRATIVE_PRIVILEGE = ( "管理者権限の有無" , "Administrative Privilege" ) class CsvData (BaseModel): name: str = Field(validation_alias=AliasChoices(*CsvField.NAME.value, min_length= 1 )) email: EmailStr = Field(validation_alias=AliasChoices(*CsvField.EMAIL.value)) is_administrator: bool = Field(validation_alias=AliasChoices(*CsvField.ADMINISTRATIVE_PRIVILEGE.value)) # main.py import csv from pydantic_core import ValidationError from .types.csv_data import CsvData filename = "path/to/file.csv" with open (filename, newline= "" ) as csvfile: reader = csv.DictReader(csvfile) for row in reader: try : csv_data = CsvData(**row) except ValidationError: handle_error() raise # 以降の処理 まとめ CSVファイルを扱うバックエンド機能の開発でPydanticを活用した事例を紹介しました。 データの型変換やバリデーションに関する処理を自前で実装する量が減り、サービスのドメインロジックに基づく実装に集中することができました。 Pydanticは他にもJSON形式のシリアライズ、エクスポートなど豊富な機能を持っているので使いこなしていきたいです。
アバター
2024年3月15日(金)に開催されたYa8 2024 - ヤパチー 令和六年最新版(仮)にバックエンドエンジニアの大谷が登壇しました。 今回はイベントの振り返りとして登壇資料と登壇者の感想を紹介します。 登壇振り返り 【供養】DynamoDBでも部分一致検索したかった DynamoDBのパフォーマンスを活かしつつ、どこまで柔軟な検索が可能なのか検証しました。 設計例と合わせてします。 登壇者: @sara_ohtani_mt2 資料: https://speakerdeck.com/smatsu/gong-yang-dynamodbdemobu-fen-zhi-jian-suo-sitakatuta speakerdeck.com 登壇の感想 バックエンドエンジニアの大谷です。 Ya8への参加は初めてでしたが、イベント概要を見て、これは参加者全員で作っていくイベントだと感じました。 トークに応募することで一緒に盛り上がれたらと思い、プロポーザルを出させていただきました。 『【供養】DynamoDBでも部分一致検索したかった』は去年末にブログ記事として公開しました。 tech.revcomm.co.jp 記事を書きながら「これはもしかしたらスライドの方がわかりやすくなるのではないか」と少し感じていました。 今回スライド形式でまとめ直すにあたり、改めて自分自身も理解を深めることがもでき、とても良い機会になりました。 参考になる事例を見つけるのに苦労しながら検証したので、今回の登壇スライドやブログの記事が誰かのお役に立てば幸いです。 また良いテーマを見つけてお話ができるように、今後も励みたいと思います。
アバター
2月7日(水)19:00よりオンラインにて開催されるイベント「 DevRel/Tokyo #89 〜テックブログ運営〜 」に、RevComm シニアリサーチエンジニアの加藤集平が登壇します。 DevRelとは Amazon、Google、Facebook、Evernote、GitHub…多数の企業が実践しているマーケティング手法がDevRel(Developer Relations)です。外部の開発者とのつながりを形成し、製品やサービスを知ってもらうこと、さらに彼らの声を聞くことでサービスの改善や機能追加に活かしていく活動になります。 日本でもエバンジェリストやデベロッパーアドボケイトと呼ばれる方が増えており、製品やサービスを紹介しています。DevRel Meetup ではそうしたエバンジェリスト、DevRel活動を行っている方が集まり、知見を共有したり情報交換をする場にしたいと考えています。イベントを繰り返すことでDevRelやエバンジェリスト、アドボケイトの認知度向上をはかりたいと考えています。 ( イベントサイト より引用) イベント内容 「テックブログ運営」をテーマに、テックブログを運営している企業から3名が登壇します。弊社からは、シニアリサーチエンジニアの加藤 集平が「ソフトウェアエンジニアリングの枠を超えて:テックブログ運営で見つけた自分の役割」について話す予定です。 登壇者 加藤 集平(かとう しゅうへい)シニアリサーチエンジニア X LinkedIn 総合研究大学院大学 複合科学研究科 情報学専攻 博士後期課程修了。博士(情報学)。RevCommには2019年11月に参画、音声合成を中心に研究開発を担当。テックブログの運営には1年半あまり従事している。 参加登録 参加登録は、connpassにて受け付けております 。奮ってご参加ください。
アバター
この記事は RevComm Advent Calendar 2023 25日目の記事です。 RevCommでCTOを務めています平村健勝です。 この記事では、2023年のMiiTel開発チームの変化や印象に残った出来事について振り返りたいと思います。 組織構成とNon-Japanese Speakerの採用開始 2023年12月1日時点で全従業員256名中、エンジニア、デザイナー、リサーチエンジニアをあわせたメンバー数は114名でエンジニア比率は約45%の構成になっています。 ビジネスの成長に合わせてエンジニアの採用活動を続けていますが、優秀なエンジニアの採用には各社苦労していると思います。そこで、RevCommでは採用目標を達成するためにスキルや経験面の採用基準を引き下げるのではなく、コミュニケーション可能な言語の基準を緩和することとしました。 Non-Japanese Speaker(日本語話者ではないメンバー)へ採用を広げることで、技術力を落とさずに採用目標を達成しつつ、もともと在籍している日本語話者も必要な場面で英語を使ってコミュニケーションすることで組織全体の高い技術力を維持できると考えたからです。 9月には、IT Mediaの記事『米Amazon辞めて日本のスタートアップに とある外国人エンジニアに理由を聞いた』でソフトウェアエンジニアAndrewのインタビュー記事が掲載されました。 www.itmedia.co.jp 12月現在で15名のメンバーが在籍しています。Non-Japanese Speakerの在籍しているチームではチャットやミーティングなどをすべて英語で進めています。エンジニア全体宛のドキュメントも日本語と英語の2言語で行うようになっており、スピードだけでなく技術力も落とさない体制を目指しています。 プロダクトの海外展開 RevCommのCorporate Missionは「コミュニケーションを再発明し、人が人を想う社会を創る」です。特にエンジニア組織では、ユーザーに寄り添ったイノベーティブなサービスの提供を通して、世界中の人々が「人が人を想う生活」を実現することを組織の使命と考えています。 このため、RevCommが開発するすべてのプロダクトは(国ごとに異なる料金計算システムなどを除いて)グローバル化を前提として開発しています。 英語のグローバル版をベースに開発しそれを日本語にローカライズすることで、スムーズなプロダクトの海外展開が可能な構成としています。 インドネシアでは2月に 現地法人を設立 し、3月にサービスのユーザー数は1000名を越えました。RevComm Indonesiaのメンバーやユーザーの雰囲気は以下のnote記事で紹介されています。 note.com このような取り組みが奏を功し4月に米国Forbes、Sequoia CapitalならびにMeritech CapitalがAIを活用してビジネスを展開する最も有望な未上場企業を表彰する 「Forbes AI 50 2023」にアジアの企業として唯一選出 されました。 よりグローバルな視点を持って取り組めるよう、国際的なイベントに参加し情報収集や情報発信を行っています。国際学会での研究発表のほか、 PyCon APAC 2023 、米国フロリダで開催された Asteriskの国際会議Astricon 、米国サンフランシスコで開催されたSalesforce主催のDreamforce、米国ラスベガスで開催されたAWS主催のre:Inventにメンバーが参加してきました。 大企業向けのプロダクト改善、新規サービスのリリース SaaSビジネスを成長させるためには、ユーザーを増やす、製品に高い価値を感じていただき、ARPU(1ユーザーあたりの単価)を高める、既存の技術や顧客基盤を活かした新サービスを企画する、満足度を向上し解約率を減らすのが主な売上を伸ばす方法です。 このため、エンタープライズ(大企業)やこれまで導入実績の多くなかった官公庁や地方自治体、金融機関などの業界へも活用頂けるよう機能の拡充を進めています。 具体的には、厳しいセキュリティ要件を満たすために閲覧範囲や利用可能な機能を細かくコントロールできる機能やシングルサインオン、限界性能を高める改善などを順次リリースしています。さらに、システム間の連携を容易にするIncoming Webhook、Outgoing Webhook機能をリリースしました。 この結果、公共案件としては 2021年の東京都保健福祉局への導入 に続いて インドネシア社会保険庁への導入 を実現しました。 4月にはPBX(音声通信インフラ、つまり電話の交換機)のインフラとアプリケーションの大規模リニューアルを実施しました。 シード期にスピード重視でJavaとPHPで開発したリソースを音声認識や自然言語処理などの機械学習と親和性の高いPythonに全てのコードを書き直し、同時に高機能化、高性能化、安定化、低コスト化(約33%減)に成功しました。 リリース作業は2022年12月から30回に分けて順次、段階的に移行し4月にすべてのテナントを移行完了しました。 6月に移行プロジェクトの成功祝いとして、プロジェクトに関わった全メンバー25名が日本中から集合して相模湖プレジャーフォレストでバーベキューを行いました。 6月にはMiiTel Call Centerをリリースし、10月には通話内容をリアルタイムで確認できる機能を追加リリースし、コールセンター業務においても便利に活用いただけるようになっています。 www.revcomm.co.jp まとめ 2023年も組織、プロダクトともに成長してきました。2024年も複数の新規プロダクトのリリースや既存プロダクトの大規模リニューアルに向けて企画、進行中です。世界中のユーザーがコミュニケーションの再発明を通して人が人を想う社会を実現できるような世界を目指して、革新的な取り組みを次々に進める計画です。 ご興味があれば、ぜひ以下のリンクから採用情報をご覧ください。 www.revcomm.co.jp
アバター
はじめに RevCommのフロントエンドエンジニアの上川康太です。 MiiTel Call Center というプロダクトの開発を担当しています。 私たちは2023年の6月にMiiTel Call Centerを正式リリースしてから、スピード感を持って新機能の開発を進めてきました。 開発スピードを維持するためにも自動テストを増やして、デグレを防ぐことが重要だと考えています。そのため、PlaywrightによるE2Eテストを充実させてきました。 その中で得られたPlaywrightのコツについて共有したいと思います。 想定読者 Playwrightを使用したE2Eテストの作成に興味がある開発者 実践的なテストコーディングのコツや、より効率的なデバッグ方法について学びたい方 Playwrightとは Playwright とはE2Eテストを実行できるOSSのツールです。 複数のブラウザ(Chromium、WebKit、Firefoxなど)と複数のプラットフォーム(Windows、Linux、macOSなど)に対応しています。 ユーザーアクションを模倣するプログラムを簡単に作成できます。これによりページナビゲーション、要素検索、テキスト入力やクリック操作などを利用したテストシナリオを実行することが可能です。 また、自動待機、スクリーンショット取得や複数タブなどを利用でき機能が充実していることが特徴です。 ローカルでの開発のコツ VS Code拡張機能でデバッグ Playwright Test for VSCode というVS Code拡張機能で、テスト実行時に Trace Viewer の起動をONにしておくとローカルでのデバッグが非常にやりやすくなるのでおすすめです。 VS CodeでShow trace viewerにチェックを入れると、テスト実行時にTrace Viewerが自動的に立ち上がります。 Trace Viewerでは、各アクションによる状態を確認する事ができます。画面のスナップショットや、コンソールのログ、ネットワークの状態を確認することで素早くデバッグを行う事ができます。 ローカルサーバーを別で立てる playwright.config.tsの webServer の設定で、Playwright実行時にローカルで立ち上がるサーバーのportを指定できます。普段の開発で使用しているportと異なるものを指定し、お互い干渉しないようにする事で、スムーズなテスト開発を実現できます。 /* Run your local dev server before starting the tests */ webServer: { command: 'yarn dev:e2e' , //E2E用のdevテナントを起動するコマンド port: 3333 , //E2E用にポート番号を設定し、開発用のローカルサーバーと干渉しないようにする timeout: 120 * 1000 , reuseExistingServer: ! process .env.CI , } , CIでPlaywrightを実行する時のコツ CI失敗時のレポートをコメント 通常、CIでE2Eテストが失敗した場合、ログは確認できますがどの画面で失敗したのかを確認できず、デバッグが難しくなります。 Playwrightの場合は HTML Report を出力することができます。Call Centerのフロントエンドチームでは、E2Eテスト失敗時にレポートをS3にデプロイし、PRに対して自動的にURLをコメントするGitHub Actionsを作成しています。これにより素早いデバッグを実現しています。 社内のリポジトリで汎用的な reusable workflowsが管理されており、その中のS3にデプロイするworkflowを利用して実現しています。 timeout値を増やす playwright.config.tsでテストの timeout の設定ができます。 ローカルでテストは全て成功するのに、GitHub Actionsでテストを実行した時にtimeoutで失敗する事が起きていました。 ローカル環境とGitHub Actionsのスペックの違いによる実行スピードの遅延が主な原因と考えられるため、timeout値を増やす事で対応しました。 ただし根本的な解決策ではないので、今後の高速化対応が必要だとは思います。 /* Maximum time one test can run for. */ timeout: 120 * 1000 , expect: { /** * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ timeout: 60 * 1000 , } , Playwrightでテストコードを書く時のコツ getBy〇〇を使う 基本的に要素を指定する際に Locators のgetBy〇〇を使用するのが推奨されています。これらを使用することで、ユーザーの使い方にできるだけ近い形のテストが実現できます。また、可読性の高いテストコードとなります。 button、a、inputなどの要素を見つけるには、getByRoleが使用できます。 await page.getByRole ( 'button' , { name: 'Save' } ) .click (); div、span、pなどの要素を見つけるには、getByTextが使用できます。 await expect ( page.getByText ( 'user name' )) .toBeVisible (); iconのsvgにaria-labelが付与されている場合などはgetByLabelで指定できます。 await expect ( page.getByLabel ( 'icon-label' )) .toBeVisible (); 他のロケーターが使用できない場合は、getByTestIdを使用します。ただし、実際にユーザーはtestIdを見ることはできないため、上記のロケーターを使用する事が推奨されます。 await expect ( page.getByTestId ( 'test-id' )) .toBeVisible (); test.stepを使う test.step を使用すると細かい単位でテストに名前をつけて可読性を上げる事ができます。 import { test , expect } from '@playwright/test' ; test ( 'テスト' , async () => { await test.step ( 'ログイン' , async () => { // ... } ); await test.step ( 'データを作成する' , async () => { // ... expect ( true ) .toBe ( false ); } ); await test.step ( 'ログアウト' , async () => { // ... } ); } ); さらに、名前はレポートに表示され、デバッグ時にどの段階で失敗したかが分かりやすくなるのでおすすめです。 効果的なE2Eテストを書くコツ デグレが発生しやすいケースをテスト チームでは、E2Eテストの基本方針として、実行時間、コストを考慮し、各機能に対してハッピーパス(正常系の基本的な使用ケース)のテストを書くこととしています。ここに加えて、確認が漏れやすいケースや、実際にデグレが発生しやすいケースに対して、E2Eテストを書くことが効果的だと感じています。 例えば、影響範囲の多い共通のコンポーネントを修正した際に、意図しない画面スクロールが発生してしまうケースがありました。 対策として、下記の関数を作成して、各ページで呼び出しています。意図しない画面スクロールが発生した場合は、テストが失敗して検知できるようになりました。 export const expectNotScrollablePage = async ( page: Page ) => { // ページの高さを取得 const pageHeight = await page.evaluate (() => document .documentElement.scrollHeight ); // ビューポートの高さを取得 const viewportHeight = await page.evaluate (() => window .innerHeight ); // ページの高さがビューポートの高さと一致している (スクロールバーが表示されない) ことを確認 expect ( pageHeight , 'should be not scrollable page' ) .toBe ( viewportHeight ); } ; こういう影響範囲が多く、確認が漏れやすい部分に対しての1つの打ち手としてE2Eテストを整備する事が効果的だと感じました。 おわりに 以上のコツを活用することで、PlaywrightによるE2Eテスト開発が少しでも楽になれば嬉しいです。 今後の課題としては、Playwrightの実行時間が長くなっていたり実行結果が不安定な部分もあったりするので、それらを解決していきたいです。
アバター
はじめに RevCommの宇佐美です。最近スタンディングデスクを買って、立ったり座ったりしながら仕事をしています。 RevCommでは、音声解析AI電話「MiiTel(ミーテル)」やAI搭載オンライン会議解析ツール「MiiTel Meetings」などを開発・提供しています。私は今年10月までMiiTelの認証基盤 (MiiTel Account) 開発プロジェクトで、Project Manager兼Sortware Engineerとして活動していました。 直近では希望によりプロジェクト異動をして、コールセンター機能とリアルタイム通信基盤を開発するチームに参加しています。今まで扱っていたものとは全く異なる技術を触っているので、日々わからないことだらけでエキサイティングです。 過去記事: MiiTel AccountのSLO: 測定と継続的な最適化の方法 Cognito user pool で OpenID Connect を利用した外部 ID Provider によるサインインを実現する 今回はMiiTel Account在籍時にチームで行った、 サービスの本番リリース(デプロイ)を週1回から日中随時に切り替える という施策について紹介します。準備段階を含めて数ヶ月程度かかり、実際にリリースフローを切り替えたのは2023年9月半ばからでした。 すべてのサービス・チームでこういった施策を行うことができるかどうかは考慮が必要なところですが、この過程でリリースに関する体験や生産性が大きく向上したため、この機会に詳しく紹介したいと思います。 モチベーション RevCommではMicroservices architectureを採用しており、リリースのタイミングは原則として各サービス・チームに委ねられています。とはいえ、多くのサービスでは週1回の決まったタイミングで、アクセスも比較的少ない夜間にリリース作業をしています。MiiTel Accountでも、元々は毎週水曜日の夜間9時ころにリリース作業を持ち回りで行っていました。 このやり方には、夜間なのでリリース後に障害が起きたときにユーザー影響を狭められる、開発者以外のステークホルダーからデリバリーのタイミングが予見・調整しやすいといったメリットがありました。 一方で、以下のようなデメリットも感じていました。 夜間作業が常態化する 週によってはリリースが大きくなりやすい(多くのPRが同時にリリースされる) 障害や想定外の挙動が発生したときの原因切り分けが難しくなる こういったことを解決するため、日中かつ随時(オンデマンド)に、PR単位の本番リリースができないか模索し始めました。そのために行った施策については後述します。 あわせて狙った効果 日中リリースへの移行を考えたモチベーションとしては上記のとおりですが、この他にも副次的な効果として狙っていたのが開発生産性指標の向上です。 昨今、ソフトウェア開発の世界では開発生産性という言葉が脚光を浴びています。この開発生産性を計測する上で重要視されているのが Four keys metrics という指標です。 詳細は割愛しますが、チームの開発生産性を測る上で最も重要な4つの指標を定義したもので、一般的にはそれぞれ以下のように定義されます。今回の施策は、以下のデプロイの頻度向上を目的としたものということもできます。 デプロイの頻度: 組織による正常な本番環境へのリリースの頻度 変更のリードタイム: commitから本番環境稼働までの所要時間 変更障害率: デプロイが原因で本番環境で障害が発生する割合 (%) サービス復元時間: 組織が本番環境での障害から回復するのにかかる時間 ( エリート DevOps チームであることを Four Keys プロジェクトで確認する - Google Cloud ) これらの指標は密接に関連しているものとされます。一見すると直感に反するような気もしますが、デプロイ頻度が高いチームは変更障害率が低く、サービス復元時間も短いことが多いようです。 これは推測ですが、デプロイ(リリース)頻度を上げるためにはCI/CD環境などの運用自動化を推進したり、レビュー体制などを整える必要があることから、他の指標もこれらの施策の結果として向上する傾向がある、と理解しています。 復元時間に関しては、デプロイの粒度を細かくできることからビッグバンリリースを避けられ、障害発生時の原因特定が容易になる、という側面が影響していると考えられます。 また、これらの開発生産性指標が高いチームが多いと、 そのサービスによる売上や収益といったビジネス上の価値にも直結する という指摘もあります。開発生産性が高いと、高速にフィードバックサイクルを回したり、市場の変化に柔軟に対応することが可能になるので、これは納得がいくものです。 このあたりの詳細はLeanとDevOpsの科学(原題: Accelerate)という書籍にまとまっていて、統計的手法によって検証されているため、開発生産性に関心がある方はぜひ手にとってみてください。 LeanとDevOpsの科学[Accelerate] テクノロジーの戦略的活用が組織変革を加速する (impress top gear) | Nicole Forsgren Ph.D., Jez Humble, Gene Kim, 武舎広幸, 武舎るみ |本 | 通販 | Amazon 行った施策 ここからは、実際にリリースを週1回から日中随時に切り替えるために取った施策について紹介していきます。細かいものも入れるとたくさんありますが、大きいものとしては以下のようなことをしました。 E2Eテストの自動実行 MiiTel AccountではAPIの統合テストを用意していて、リリースの前後に本番環境やステージング環境などで実行していました。これによってリグレッションテストができ、デグレや予期しないバグなどを軽減することができていました。 ただ、テストの実行は手動でキックしていたのと、ローカルマシンからの実行だったため、環境差異によってテスト結果が一定にならないといった問題がありました。 リリースを随時に切り替えるための前提として、この統合テストをリリース時に自動で実行することに加えて、Autifyを使ってUIのE2Eテストも行うようにしました。この施策はMiiTel AccountチームのRaman Yachi (r-ym) が担当したもので、過去にブログ記事にまとめているので、興味がある方は一読いただければと思います。 MiiTel AccountチームのE2Eテスト自動化 - RevComm Tech Blog これによって、リリース前後でサービスが正常に稼働していることを高い確度で手作業をほぼすることなく保証できるようになりました。 SLOによる性能担保 E2Eテストがあったとしても、リリース時点では検知できないようなレアケースや、負荷による性能劣化などはなかなか防ぎきれません。リリースを日中・随時で行うためにはサービスの安定稼働が大前提と考えていたので、SLO (Service Level Objective) が保てていることをまず確認した上で、もしリリース戦略の変更で数字が悪化することがあれば切り戻しも検討することを想定していました。 SLOに関する取り組みについては、前掲のブログ記事で詳説したのでこちらを参照ください。 MiiTel AccountのSLO: 測定と継続的な最適化の方法 幸いにして今のところ、リリース戦略の切り替え後もSLOのメトリクスが悪化していることはないようです。 リリースガイドラインの策定 日中随時にリリースするとはいっても、制限なくいつでもリリースOKとすることは考えていませんでした。休前日のリリースはトラブル時のサポート体制が整いづらかったり、PRによってはデータベースマイグレーションなど重要かつロールバック困難な変更を伴う場合もあるためです。 そこで、リリースの指針となるドキュメントを作成して、チームメンバーとも相談しながら最適なリリース戦略を策定しました。その中では主に以下のようなことを規定しています。 休前日のリリースを避ける 大規模障害発生日のリリースを避ける インフラやDBなどの変更は引き続き夜間に行う ユーザー影響が大きいリリースはQAを実施してからリリースする 今後も実際の運用を行っていく中で、よりよいリリースフローを模索しながらガイドラインも改善されていくものと思っています。 リリースノートの作成と投稿の自動化 RevCommでは、本番リリースを行ったあとにリリースノートをChange logとしてSlackチャンネルに投稿するという決まり事があります。これによって、他サービスの開発チームやサポート・プロダクトチーム、ビジネスサイドを含む関係者に変更内容を通知しています。 MiiTel Accountでは、リリースノートの作成自体はGitHubのRelease機能のおかげでほぼ自動でしたが、これをコピーしてSlackに投稿するという部分は手作業でした。 リリースが週1回であればそこまで手間になる作業ではありませんが、毎日のようにリリースがあると、これを手作業で全てやっていると明らかに非効率なうえ、うっかりリリースノートの投稿を忘れてしまう可能性もあります。 そこで、以下のようなフローで一連の処理を自動化しました。 PRをmainからリリース用ブランチにマージ GitHub ActionsでRelease(リリースノート含む)を作成 同時にGitHub ActionsでCodeDeployをキックしてデプロイ開始 デプロイ後、CodeBuildによるE2Eテストが成功したらLambdaをキック Lambdaが2.のReleaseを取得してリリースノートを作成し、Slackに投稿 Mermaidで図示するとこういう感じになります。 リリースノート投稿フロー LambdaからGitHubのReleaseを取得するところでは、カスタムGitHub Actionsを使ってGitHub Apps tokenで認証しています。 このフローにより、こういった形でリリースノートがSlackに自動でポストされるようになりました。 実際のリリースノート 細かい部分ではありますが、これも手作業を減らしてリリース作業を楽にすることに貢献した施策のひとつだと思っています。 (Slackアイコンはオンラインの商用フリー生成AIツールを使って作成しました🚀) 結果 これらのことを整備したあとに社内アナウンスをして、9月半ば頃から日中リリースへの切り替えを行いました。MiiTel Accountチームでは Looker Studio を使ってFour keys metricsの集計を行っていますが、 9−11月の合計リリース数が6–8月と比較して3倍ほどに増えました 。 月平均リリース数の推移 一方で、変更後もSLOを始めとしたサービスの性能面をキープできており、サービスの停止や遅延などのメジャーインシデントはゼロを保っています。 まとめ 実際にリリースを日中に変更してみて、やはり一番体感として大きいのは定常的な夜間作業がほぼなくなったことです。また、記載したような施策を行う過程でリリースに関するフローの大部分を省力化・自動化することができたため、 リリース作業というもの自体がほぼなくなった ような印象もあります。これはかなり体験としてよくて、ルーティン業務が大きく減って本来の開発に避ける時間が増えたのを感じます。 冒頭に記載したように、すべてのサービスで日中随時のリリースができるとは限りません。ただ、MiiTel AccountはMiiTel全体の認証を担っているミッションクリティカルなサービスで、わずかな間でも停止してしまうとMiiTel全体に影響を及ぼします。 それでも上記のような施策を一つずつ着実に実行していったことで、安定運営を保ったままリリース戦略を改善していくことができたと思っています。そして、こういった施策はリリース頻度だけではなくその他の開発生産性を改善したり、チームメンバーの健康や精神衛生、モチベーションも向上する可能性もあるので、検討する価値は大いにあるはずです。 RevCommでは機能開発はもちろん、運用改善や開発生産性向上などにも開発者が責任と裁量を持って取り組むことができます。興味がある方は、下記から採用情報をチェックしてみてください。 採用情報|株式会社RevComm(レブコム) 最後までお読みいただきありがとうございました。
アバター
はじめに RevComm, Front-end team の熊谷です。今回は vue-facing-decorator を使って Vue2/Nuxt2 のクラスコンポーネントを Vue3/Nuxt3 に移行した話をします。 各コンポーネントでは既存のソースコードを活かせるところも多かったですが、個別に書き換えが必要なところもありましたのでまとめたいと思います。 なぜ vue-facing-decorator を使用したか 弊社の Vue2/Nuxt2 環境では、 nuxt-property-decorator と、 vue-property-decorator を使用したクラスコンポーネントを採用していました。nuxt-property-decorator が Nuxt3 への対応をしないことを決定したため、nuxt-property-decorator が推奨している vue-facing-decorator を使用することにしました。 一気に Vue3 の composition api に書き換えることも検討しましたが、ビックバンリリースになってしまうと通常の機能開発との同時並行作業が難しくなってしまうため、クラスコンポーネントのままで一旦最小限のアップデートを目指すことにしました。 各コンポーネントで書き換えが必要だった所 クラス定義 package のアップデートをして Vue と Nuxt の breaking changes に対応後、 まずは、import の書き換えと mixins の書き換えをしました。 // *** vue2 ************************************************** import { Component , mixins } from 'nuxt-property-decorator' ; @Component ( { components: { SomeComponent , } , } ) export default class SomePage extends mixins ( PageMixin ) { // *** vue3 ************************************************** import { Component , Vue } from 'vue-facing-decorator' ; @Component ( { components: { SomeComponent , } , mixins: [ PageMixin ] , } ) export default class SomePage extends Vue { /pages /pages の下のコンポーネントは head() や layout() といった Nuxt の便利な機能が使えなくなってしまいました( Nuxt 向けではなく Vue 向けのライブラリに移行したため)。 またなぜか /pages の下だけは原因不明のエラーが頻発したため、pages をラップする親を作成したら回避できることがわかりました。 親は /pages とは別ディレクトリに配置し Nuxt の設定を変更して、新しいディレクトリを Nuxt pages のルートとしました。 この方法により、従来の /pages のディレクトリにはギリギリまで vue2 に対する機能追加・変更などを行いつつ、移行作業を安全に進めることができました。 /layouts も同じ問題があったので、同様にしました。 // *** vue3 ************************************************** // nuxt.config.ts export default defineNuxtConfig ( { dir: { pages: 'pagesV3' , layouts: 'layoutsV3' , } , // 以下略 } ); // /pagesV3/user/index.vue < template > < User / > < /template > < script lang = "ts" setup > import { useHead } from 'vue' ; import User from '@/pages/user/' ; useHead (() => ( { title: 'ページタイトル' , } )); < /script > hooks created はいい感じに解釈してくれていましたが destroyed は使えなくなっていました。vue3 のライフサイクルに合わせてhooksは変更したほうが良さそうです。 // *** vue2 ************************************************** private created () { console .log ( 'created' ); } private destroyed () { console .log ( 'destroyed' ); } // *** vue3 ************************************************** private mounted () { console .log ( 'mounted' ); } private unmounted () { console .log ( 'unmounted' ); } @Emit nuxt-property-decorator は return を省略可能でしたが、移行後は returnを書く必要がありました。これは細かい内容ですが全体の作業量は多かったです。(でもこちらの方が正しい印象) // *** vue2 ************************************************** @Emit ( 'change' ) private handleTextAreaChange () {} // *** vue3 ************************************************** @Emit ( 'change' ) private handleTextAreaChange ( e: Event ) { return e ; } Function nuxt-property-decorator ではアロー関数が使えていましたが vue-facing-decorator では動作しませんでした。(これもこちらの方が正しい印象) // *** vue2 ************************************************** private created () { this .initialize (); } private initialize = () => { console .log ( 'initialize' ); } ; // *** vue3 ************************************************** private mounted () { this .initialize (); } private initialize () { console .log ( 'initialize' ); } 今後について 無事に Vue3/Nuxt3 への移行が完了したので、今後は composition api を使って新機能開発をパワーアップさせたいです。 また既存機能も composition api に置き換えて安定した運用をしていきたいです。
アバター
はじめに この記事は RevComm Advent Calendar 2023 の 19 日目の記事です。 こんにちは @sara_ohtani_mt2 です。 バックエンド開発をしています。 最近は、いわゆる電話帳のような連絡先を管理する機能のリニューアルに取り組んでいます。 これは現在、処理速度やシステムの拡張性の向上が求められている機能で、その改善を図るためのリニューアルプロジェクトです。 大きなモノリスだったところから機能を切り出して、新しい基盤構築から行っています。 今後他機能にも知見を展開できるよう、様々な選択肢を検討しながら技術の選定を進めています。 改善にあたり、ポイントの1つとなっているのがDB選定・設計です。 弊社サービスの MiiTel はマルチテナント型サービスであり、テナントごとの連絡先データ数が数十万件にも及ぶ規模のものもあります。 クエリレスポンスのスピードの向上を目指す中で、今回AuroraからDynamoDBへの載せ替えを検討しました。 残念ながら結果的には部分一致検索の実用性の点で課題を感じたため、現時点ではDynamoDBの利用を見送ることになりました。 しかし「前方一致で良い」など特定のユースケースにおいては有望だと感じたので、テーブル設計例と合わせてご紹介したいと思います。 DynamoDBが夢に出るようになってきた — sara.ohtani.mt2 (@sara_ohtani_mt2) 2023年8月13日 はじめに DynamoDBがマッチすると思われるユースケース DynamoDBベストプラクティスと設計する上での注意事項 ベストプラクティス 部分一致検索をしたいと思ったときの注意事項 テーブル設計の例 サンプル要件 機能概要 アクセスパターンと要件 その他要件 テーブル定義 テーブルとGSI データの種類の定義と種類ごとの各項目の値の意味 データ取得イメージ 結論 条件ふりかえり その他おまけ 参考 DynamoDBがマッチすると思われるユースケース DynamoDBの基本的なユースケースについては最新の公式ドキュメント *1 を参照してください。 マッチすると思われる条件のうち、今回特に注目する点です。 データ抽出条件が完全一致、あるいは前方一致で良い データ件数が多く、今後もさらに増加が予想される アクセスパターンや要件がはっきりしている データ検索する際の絞り込み条件となる項目が多くない 基本的にテーブルをjoinする必要がない 並び順は問わない DynamoDBベストプラクティスと設計する上での注意事項 ベストプラクティス 公式ベストプラクティス *2 から今回特に意識したことを抜粋します。 出典: https://aws.typepad.com/sajp/2017/02/choosing-the-right-dynamodb-partition-key.html GSIの数を抑える GSIは基本となるテーブルを同期している別テーブルみたいなものなので増やすと書き込みコストがその分増えてしまう RDBならテーブル分割するようなものも全て1つのテーブルで表現する キャパシティを効率的に使うため そもそも1 アカウントあたりのテーブル数は 10,000 が上限 つまりテーブルあたりのデータ量の多さが悩みだからといってどんどん分割してテーブルを増やしていくような設計は合わない 特定のデータ範囲に対してアクセスが集中するようなホットパーテーションが発生しないようにする いかにホットパーテーションを生まないかが設計のポイントの1つ 1処理内のアクセスだけでなく、パラレルでの処理でそれぞれのアクセスがパーテーションに集中してもホットになる オンデマンドキャパシティーモードでも、例えば数千万件を一気に書き込もうとすると Throughput exceeds the current capacity of your table or index というエラーが出て、調べていくとホットパーテーションが問題だとわかったことがあった 1回のクエリで取得できるサイズ上限が1MBであることに注意 1回で取得できなかったときにはLastEvaluatedKeyに値が入ってくる クエリで取得するitemの1itemごとのサイズに大きなばらつきがあると1回のクエリで取得できるデータ件数が変わってわかりづらい 部分一致検索をしたいと思ったときの注意事項 Scan検索は部分一致検索が可能だが、全体のデータ数が多いと遅い keyとattributeはQueryで扱う上で全く別物といっていい Queryでの検索の場合、絞り込み条件としてkeyは必ず指定しなければならない Query検索にもcontainsという部分一致検索できるものはあるがScan同様データが多いと遅い さらにattributeに対してしか使えない、keyに対しては使えない テーブル設計の例 サンプル要件 連絡先リニューアルプロジェクトの要件をもとにしたブログ説明用の架空のサンプル要件です。 DynamoDBの仕様に合うようにアレンジを加えているため、実際の弊社サービスの連絡先機能とは異なります。 機能概要 マルチテナントサービスで、テナント内で共有する顧客の連絡先を管理する電話帳のような機能 アクセスパターンと要件 連絡先情報を一覧で表示する画面があり、キーワード検索する機能がある contact_idで紐づく他データと合わせて表示する 初期表示時はテナント内の全連絡先を表示 連絡先の情報(名前、会社名、電話番号)とカテゴリ名の前方一致検索をしたい 画面に出す100件ずつ取れれば良い データ作成日順で表示したい 余談ですが、アクセスパターンを整理するのが大事ということなので整理の手法には RDRA *3 を使ってみました。 その他要件 1テナントにつき連絡先が最大50万件扱えるようにしたい テナントごとに管理している項目としてカテゴリがあり、連絡先とは多対多の関係 テーブル定義 ▶ 表形式の記載を見たい方はこちら Key Attribute PK SK GSI-1-PK GSI-SK GSI-Attribute ID DataType SearchType SearchValue CreatedAt PhoneNumber CompanyName ContactName ContactSample Contact_{contact_id} Contacts#Tenant_{tenant_id}#Contact_{contact_id} {yyyy-mm-dd hh:mm} {phone_number} {company_name} {contact_name} {contact_sample} ID DataType SearchType SearchValue CreatedAt Contact_{contact_id} TenantId#Contact_{contact_id} Contacts#TenantId#Tenant_{tenant_id} {yyyy-mm-dd hh:mm} ID DataType SearchType SearchValue CreatedAt Contact_{contact_id} CategoryName#Contact_{contact_id}#Category_{category_id} FreeWord#Tenant_{tenant_id} {category_name} {yyyy-mm-dd hh:mm} Contact_{contact_id} PhoneNumber#Contact_{contact_id} FreeWord#Tenant_{tenant_id} {phone_number} {yyyy-mm-dd hh:mm} Contact_{contact_id} CompanyName#Contact_{contact_id} FreeWord#Tenant_{tenant_id} {company_name} {yyyy-mm-dd hh:mm} Contact_{contact_id} ContactName#Contact_{contact_id} FreeWord#Tenant_{tenant_id} {contact_name} {yyyy-mm-dd hh:mm} ID DataType SearchType SearchValue CreatedAt CategoryId Name Contact_{contact_id} CategoryId#Contact_{contact_id}#Category_{category_id} Contacts#CategoryId#Tenant_{tenant_id}#Category_{category_id} Category_{category_id} Category_{category_id} Categories#Category_{category_id} Categories#TenantId#Tenant_{tenant_id} {CategoryName} Attributeがデータの種類ごとに変わるのでそれぞれに見出しを書いているが全て1テーブル内に格納するデータです。 {}には該当する値がセットされる想定です。 (例: Contact_{contact_id} → Contact_064d0d85-9d74-7f2f-8000-644443c7c8dc) テーブルとGSI 1. 基本テーブル データを重複なく保管し、ホットパーテーションが発生しないようにするためのPK, SKを設定します。 2. GSI GSIは検索のためのものを1つだけ作成しています。 検索のためのPK, SKをGSI用に設定しています。 GSIでは全カラムをもつ必要がないので検索とソートに必要な項目だけ基本テーブルから射影します。 データの種類の定義と種類ごとの各項目の値の意味 データの種類の名称はブログ説明するためのもので公式な呼び方ではありません。 1. 基本データ IDを絞り込んだ結果、取って来たいデータです。 DataType: なんのデータ#どのテナントと紐づいてるか#どの連絡先と紐づいてるか DataTypeはいずれも基本的にデータ重複ないように保持するためのものとしている ここのconact_idはアプリケーション上必要な情報ではないが、ホットパーテーション回避するために入れている tenant_idはあってもなくてもいいのですが、ConactsのItemの情報としてどのテナントのデータかあった方があとで調査とかしやすそうなので置いてみている 各idに Contact_〜 などの見出しをつけるのは、見やすさ向上のためと、全てのデータが1つのテーブルに入っているため他項目間のidの重複を避けるため call_sampleのようにcontact_idと紐付けられるデータは、できれば1itemに入れてしまったほうがクエリが楽になる 2. 検索用データ DataType: なんのデータ#どの連絡先と紐づいてるか カテゴリの場合は複数紐づくのでさらにcategory_idも SearchType: 検索の種類#テナントid SearchValue: "なんのデータ"の値 CreatedAt(アプリケーションとしてのソートしたい項目): id取得のための検索itemと基本情報itemにだけセットすればいい 3. 多対多の紐づけ用データ DataType: なんのデータ#どの連絡先と紐づいてるか#紐づくデータ SearchType: 検索の種類#どのデータと紐付けるか(ON的な)#紐付ける値 多対多のデータの考え方については最後に記載した参考ブログがわかりやすくておすすめです。 データ取得イメージ 連絡先一覧画面でキーワード検索したときのデータ取得の流れのイメージです。 GSI経由で検索用データから条件に一致するcontact_id一覧を取得する 取得したcontact_id一覧から基本データを取得する 動作確認環境: AWS Lambda ランタイム Python 3.11 import boto3 def main ( dynamodb_client, table_name, index_name, partition_key, sort_key, gsi_partition_key, gsi_sort_key, tenant_id, app_sort_key, keyword ): items = [] exclusiveStartKey = "" # クエリパラメータの設定 params = { "TableName" : table_name, "IndexName" : index_name, "KeyConditionExpression" : f "#pk = :pkval and begins_with(#sk, :skval)" , "ExpressionAttributeNames" : { "#pk" : gsi_partition_key, "#sk" : gsi_sort_key, }, "ExpressionAttributeValues" : { ":pkval" : { "S" : "FreeWord#" +tenant_id}, ":skval" : { "S" : keyword}, } } # できるだけ少ない回数で取得できるようにGSIの項目は少なくしたい while True : if exclusiveStartKey: params[ "ExclusiveStartKey" ] = exclusiveStartKey print ( "exclusiveStartKey: " , exclusiveStartKey) response = dynamodb_client.query(**params) items.extend(response[ "Items" ]) if "LastEvaluatedKey" not in response: break exclusiveStartKey = response[ "LastEvaluatedKey" ] # 重複を取り除くために一時的なセットを使用 temp_set = set () filtered_items = [item for item in items if item[partition_key[ "name" ]][partition_key[ "type" ]] not in temp_set and not temp_set.add(item[partition_key[ "name" ]][partition_key[ "type" ]])] # 先頭100件を取得するために並び替え sorted_items = sorted (filtered_items, key= lambda x: x[app_sort_key[ "name" ]][app_sort_key[ "type" ]], reverse= True ) contact_list = [item[partition_key[ "name" ]][partition_key[ "type" ]] for item in sorted_items] if not contact_list: return chunk_size = 100 items_par_page = 100 page = 1 first_element = (page - 1 ) * items_par_page # ページネーション chunk = contact_list[first_element:first_element+chunk_size] # SQLのinのような絞り込み条件を設定したい場合は batch_get_item() を使う contacts = batch_get_item(dynamodb_client, table_name, {table_name: { "Keys" : [{partition_key[ "name" ]: {partition_key[ "type" ]: contact_id}, sort_key[ "name" ]: {sort_key[ "type" ]: "Contacts#" +tenant_id+ "#" +contact_id}} for contact_id in chunk]}}) # アプリケーションの表示順にするために並び替え contacts = sorted (contacts, key= lambda x: x[app_sort_key[ "name" ]][app_sort_key[ "type" ]], reverse= True ) return { "contacts" : contacts } def batch_get_item (dynamodb_client, table_name, request_items): max_retry = 100 results = [] for _ in range (max_retry): batch_get_item_response = dynamodb_client.batch_get_item(RequestItems=request_items) results.extend(batch_get_item_response[ "Responses" ].get(table_name, [])) unprocessed_key = batch_get_item_response[ "UnprocessedKeys" ] if unprocessed_key: request_items = unprocessed_key else : break return results def lambda_handler (event, context): dynamodb_client = boto3.client( "dynamodb" ) return main( dynamodb_client, table_name= "sample-table" , index_name= "SearchType-SearchValue-index" , partition_key={ "name" : "ID" , "type" : "S" }, sort_key={ "name" : "DataType" , "type" : "S" }, gsi_partition_key= "SearchType" , gsi_sort_key= "SearchValue" , tenant_id= "Tenant_uuid1" , app_sort_key={ "name" : "CreatedAt" , "type" : "S" }, keyword= "0" ) 結論 条件ふりかえり データ抽出条件が完全一致、あるいは前方一致で良い → 実際のプロジェクトの技術選定で今回DynamoDBを採用しなかった一番の理由が、部分一致だと大量データの検索がスピーディにできないことでした。 完全一致、あるいは前方一致で良いものにはぜひ採用を検討したいです。 データ件数が多く、今後もさらに増加が予想される → データ数が多くてもユースケースがマッチしていて設計がうまくいけばかなり速いです。 アクセスパターンや要件がはっきりしている → ホットパーテーションが生まれないようになど事前の設計がかなり大事です。 アクセスパターンが決まっていない状態でこのような検索の仕組みにするのはおすすめできません。 データ検索する際の絞り込み条件となる項目が多くない → 今回のような設計にすると項目が多ければ多いほど1件の連絡先あたりのitem数が増えることになり、書き込み・読み込み時のコストも増えていくことになります。 基本的にテーブルをjoinする必要がない → これもjoinするテーブルが多いほど1件の連絡先あたりのitem数が増えることになります。 全体のクエリも複雑になるのでできればない方がいいです。 並び順は問わない → SQLでいうところのorder byがないのでソートキー順以外の並び順にしたいときは一度全検索結果のidと並び順条件の情報を取得した上でアプリ側でソートしていました。 そうすると本当は100件のデータ取得でいいところが全結果を取得しないといけなくなるので並び順は選べないと思っていたほうが効率が良さそうです。 その他おまけ むちゃしてQueryの前方一致検索を使いながら任意のキーワードのHit対象を増やそうとするとなんか最終的に自分で全文検索のための数文字おきに区切ったデータ作るみたいなことになっちゃう 例えば会社名で検索したときに「株式会社」などは入力しないでもHitするようにSearchValueから取り除いたデータも作るなど やたら分割するとデータ量が爆発的に増えていくのでコストもかかる RDBでは考えないようなことを色々やることになる 基本となるデータに変更があった場合にStreamなどで関連レコードを更新する必要がある emailなど今後連絡先で管理する新しい項目が増えるたびにStreamで更新するレコードもどんどん増えていくことになってコストがあがっていく SQLでいうところのdistinctがないのでアプリ側で重複を省いてる この設計だと複数の検索条件にヒットするとid取得の段階でidの重複が発生することになる ライブラリを使ったらもっとすっきり書けたり便利なのかもしれないが今回はそこまで調査していない 部分一致のクエリの書き方を検索していると今は非推奨の古い書き方例がかなりよく出てくるので注意が必要 SDKのresourceは古いため非推奨となっており、代わりにclientを使うことが推奨されている *4 参考 特に参考にした記事 https://speakerdeck.com/_kensh/dynamodb-design-practice https://speakerdeck.com/handslabinc/dynamodbdemojian-suo-sitai DynamoDBのテーブル設計における多対多の考え方 https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html https://hack-le.com/dynamodb-many-to-many/ クエリのパフォーマンスと継続した負荷に対してDynamoDBはどのように対応するかについての検証記事 https://aws.amazon.com/jp/blogs/news/part-2-scaling-dynamodb-how-partitions-hot-keys-and-split-for-heat-impact-performance/ 今回は触れなかったけどlimitを使うときのハマりそうな注意点 https://www.denzow.me/entry/2018/02/04/130419 *1 : https://aws.amazon.com/jp/dynamodb/resources *2 : https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/best-practices.html *3 : 要件定義手法 https://www.rdra.jp/ *4 : https://boto3.amazonaws.com/v1/documentation/api/latest/guide/resources.html
アバター
こんにちは! RevCommのフロントエンドエンジニアの楽桑です。 フロントエンドパフォーマンスチューニングを経験した方ならご存じのとおり、レンダリング効率は常に重要です。データをスピーディかつ効率的に画面に表示することは、フロントエンド最適化の核心です。 本記事では、すでにリリースされているプロジェクトにおいて、コードの変更を最小限に抑えつつ、効果的なテーブルパフォーマンスチューニングをどのように実施するかをご紹介します。 背景 僕が担当しているプロジェクトでは、システム内に配置された2つのインタラクティブなテーブルがあります。 これらのテーブルは、ユーザーが操作するディバイダーによって高さが調整される設計になっています このような設計は、ユーザーによりよいコントロールを提供する一方で、データ量が増加するとパフォーマンスに影響を与える可能性があります。特に、ディバイダーの動きがスムーズでなくなると、全体のユーザー体験が損なわれます。 この問題を解決するために、どのようにフロントエンドのパフォーマンスを最適化し、ユーザーインターフェースの応答性を保つかを探求します。 技術選択 テーブルパフォーマンスチューニングにおいて、テーブルのバーチャル化は一般的に用いられる手法です。 このアプローチでは、従来のテーブル描画方法とは異なり、ユーザーのビューポート(画面に表示されている範囲)に現れる行のみを描画することに焦点を置いています。 この手法を用いることで、一度に描画される行の数を減らすことができます。これは、スクロール時の描画コストを低減する効果も持ち合わせています。 dev.to ただし、今回のアプローチでは基本的な実装のみを採用し、動的な行の描画の最適化は次段階の課題として残します。 ライブラリ ライブラリとして候補に上がったのは React-Virtualized 、 React-Window 、 React-Virtual 、 React-Table 4つのライブラリです。 今回は、すでに実装されQAも完了しているテーブルコンポーネントにバーチャルスクロールを追加することが目標です。できるだけ軽量な実装を望んでいるため、 React Table や React-virtualized といったライブラリを使用する方法も考慮しましたが、いずれにせよ既存のコンポーネントをカスタマイズする必要があるため、一旦見送ることにしました。 その結果、 React-Window と React-Virtual の2つの選択肢が残ります。今回は React-Virtual を選択しました。既存のコンポーネントをそのまま使用し、ライブラリが提供するhooksを用いて実装できるためです。これにより、実装コストをかなり抑えることができました。 ただし、このアプローチのデメリットとして、テーブル内でバーチャル化を行うためにテーブルヘッダーを適切に表示する必要があり、表示行の前後の余白高さを計算する必要が生じます。結果としてJavaScriptの計算コストが増加します。 実装 React-Virtual が提供したHook // The virtualizer const rowVirtualizer = useVirtualizer ( { count: 10000 , getScrollElement: () => parentRef.current , estimateSize: () => 35 , } ) count : テーブル全体のサイズです。 getScrollElement : スクロール対象のElementを指定します。 estimateSize : 各テーブル行の予想高さを設定します。 これらの3つのパラメータに加えて、よく使用されるのは以下の2つです: overscan : ビューポートの前後に予め描画する行数を指定します。 horizontal : trueに設定すると、水平方向に対してのスクロールが有効になります。 useVirtualizedTable を用いて実装したHook export const useVirtualizedTable = ( { tableSize , scrollable , } : VirtualizedTableProps ) => { // The virtualizer const rowVirtualizer = useVirtualizer ( { count: tableSize , getScrollElement: () => scrollable.current , estimateSize: () => TABLE_ROW_HEIGHT , overscan: 5 , } ); const items = rowVirtualizer.getVirtualItems (); // Calculate the space before and after the virtual items const [ before , after ] = items.length > 0 ? [ notUndefined ( items [ 0 ] ) .start - rowVirtualizer.options.scrollMargin , rowVirtualizer.getTotalSize () - notUndefined ( items [ items.length - 1 ] ) .end , ] : [ 0 , 0 ] ; const totalSize = rowVirtualizer.getTotalSize () + TABLE_HEADER_HEIGHT ; return { items , totalSize , before , after } ; } ; Table 前後の余白高さ計算 const before = notUndefined ( items [ 0 ] ) .start - rowVirtualizer.options.scrollMargin , const after = rowVirtualizer.getTotalSize () - notUndefined ( items [ items.length - 1 ] ) .end , beforeは表示中のアイテムの最初の要素の上部からスタート位置までの高さからスクロールマージンを差し引いた値です。 afterはバーチャルスクロール全体の高さから、表示中の最後のアイテムの下部のエンド位置までの高さを差し引いた値です。 このアプローチでは、現在ビューポートに表示されている行の実データのみを描画し、その他のスクロール可能な範囲は空白のtrタグ 要素で埋められています。これにより、現在のビューに対する描画負荷を最小限に抑えつつ、ユーザーにスムーズなスクロール体験を提供することが可能になります。(画像のように、最初と最後のtrタグは高さのみのダミータグになります) それを適用した実際のコード // Hook展開 const { items , totalSize , before , after } = useVirtualizedTable ( { tableCount: body.length , scrollable: parentRef , } ); // 高さをテーブル全体に適用 < table css = { css ` table-layout: fixed; height: ${ totalSize } px; width: 100%; border-collapse: separate; border-spacing: 0; ` } > .... < /table >; // itemの展開適用、beforeとafterを表示中のRow前後の行に高さ適用 { before > 0 && ( < tr css = { css ` height: ${ before } px; ` } / > ); } { items.map (( virtualItem ) => ( < TableDataRow key = { virtualItem.index } whiteSpace = "nowrap" data = { body [ virtualItem.index ] .data } // Indexを使ってDataをマッピング / > )); } { after > 0 && ( < tr css = { css ` height: ${ after } px; ` } / > ); } テーブルのバーチャルスクロール時のヘッダー幅の動的変更 テーブルでバーチャルスクロールを使用している場合、もしヘッダーが固定されていなければ、表示中の行の中で最も幅が大きいものに合わせてヘッダーがレンダリングされます。つまり、スクロール中に最も幅が広い行がマウントまたはアンマウントされるたびに、テーブルの全体の幅が変動してしまうことになります。 対策: この問題を解決するために、ヘッダーの幅を固定し、テーブル全体の幅が変動しないように設定することが一つの対策となります。 ただし、ヘッダを固定することにより、新しい項目を追加するたびに、幅の値を追加する必要があります。幅計算の関数を作成することもおすすめです。 パフォーマンス計測 パフォーマンスレポート 対応前 対応後 画像1・2はパフォーマンスチューニング前後のテーブルの高さ変更時の実行時間を表しています。 画像1のようにレンダリング時間は実行時間の多くを占めており、およそ16000msになってます。そして、スクリプティング時間は5000msになっています。この2つで、高さ移動時の実行時間のおよそ71%を占めています。 一方、画像2ではレンダリング時間が7000msと半分程度になっています。そのかわり、スクリプティング時間は9000msくらいになりましたが、全体に占める実行時間は71%から52%へと19ポイント (26%) 縮小したことがわかります。 終わりに 本記事では、リリース済みのプロジェクトにおけるテーブルのパフォーマンスチューニングに取り組みました。主な目標は、変更量を最小限に抑えつつ、効率的なコードを実現することでした。結果として、全体のコード実行時間を約26%削減することに成功しました。 今後の課題としては、スクリプティングの計算量を可能な限り抑えることを目指しています。一つの案として、非表示のデータ行の動的描画コスト最適化を検討していく予定です。
アバター
この記事は RevComm Advent Calendar 2023  18 日目の記事です。 はじめに フロントエンドでの正規化のメリット GraphQL クライアントでの正規化 RESTful API での正規化 おわりに 参考 はじめに 2023 年 12 月現在、フロントエンド GraphQL クライアントの多くはデータを正規化してキャッシュをする機能を持っています。参考に GraphQL 利用成熟度モデル では GraphQL のクエリ結果を正規化して活用することは 6 番目 に取り上げられていました。 キャッシュと聞くと必ずしも必要な要素ではないように思われるかもしれませんが、もしあなたが API レスポンスを保存して状態管理しているならそれと同じことです。 フロントエンドでの 正規化のメリット 正規化とは重複データをなくすことです。正規化された構造でキャッシュするということは、キャッシュ内のすべてのデータが一意であることを意味します。フロントエンド、特に宣言的 UI 下においては、UI に表示するデータソースが宣言されているとき常に最新のクエリ結果が UI に表示されるということを意味します。 正規化されていない場合を考えます。例えば Todo 一覧のクエリ結果に含まれる Todo:1 と Todo:1 単体のクエリ結果が重複して保存されるようになっていると、クエリ発行の間に Todo:1 が更新されている場合、一覧と単体のページで内容が異なるということが起きます。正規化して上書き保存できていれば内容は同じになります。 GraphQL クライアントでの正規化 GraphQL のクエリ結果は、名前のとおりグラフとして構成されているのでノード単位で正規化することができます。GraphQL はスキーマをもとに実装されているので、ライブラリがスキーマから判断して自動で正規化を行うことができます。(Apollo Client の場合はノードの境界を判別するのに __typename 、識別子に id が使用されます。 id が存在しない場合はどの値を識別子として扱うかを定義する必要があります。) Apollo の公式ブログよりクエリ取得から正規化されるまでの図を引用します。 クエリを発行する レスポンスを受け取る 正規化する 正規化されたデータを保存する こうして正規化して保存することで、後から Todo 単体を取得した際に自動的に対象のキャッシュを特定して更新することができるのです。さらに、サーバーへ更新処理を行う際も結果として更新のあったノードを返せば GraphQL Client は自動的に対象のキャッシュを特定して更新することもできます。 また、例として簡単な「Todo 一覧」のクエリ結果を取り上げましたが、一般的な GraphQL の使い方として「ユーザーに紐づく Todo 一覧」のようなクエリの結果であってもスキーマをもとに正規化が可能です。 // 正規化前 { id : " user1 ", __typename: " User ", name : " User 001 ", todos : [ { id : 1 , __typename: " Todo ", text : " First todo ", completed : false } ] } // 正規化後 // User:user1 { name : " User 001 ", todos : [ " Todo:1 " ] } // 正規化後 // Todo:1 { text : " First todo ", completed : false } RESTful API での正規化 ここまでの正規化の話は __typename と id により非正規化されたデータ(JSON)から正規化されたデータに分解して保存できるというだけの話なので、特に GraphQL である必要はありません。実際に、正規化してキャッシュを保存するという話は GraphQL に限ったものではなく、Redux のドキュメントにも 推奨事項として書かれています。 しかし、RESTful API で返す JSON についてモデル毎に __typename をつけること、一意に識別可能な id をつけること、と約束を進めるうちに GraphQL のスキーマとクライアントが担っていた機能を再発明することになります。 したがって、最初から GraphQL の仕様に沿って開発を進めていくことが効率的だと考えます。 おわりに スキーマをもとに実装されるかつスキーマをもとに正規化可能であることはフロントエンジニアにとって GraphQL を使用する大きなメリットになると考えています。最近 GraphQL が React Server Components や BFF と比較されるケース を見ましたが、この特徴は他の技術にはない要素です。 効率よく便利にキャッシュを持てるという点で GraphQL は有用な技術であり続けると考えています。 参考 Apollo Client https://www.apollographql.com/blog/demystifying-cache-normalization URQL https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/ Relay https://relay.dev/docs/principles-and-architecture/thinking-in-graphql/#caching-a-graph
アバター
はじめに こんにちは。 RevCommでCorporate EngineeringチームおよびFull Stackチームで活動している川添です。 社内の情報管理、うまくできているでしょうか?ルールやナレッジを共有しあっているけれども、過去に話した内容を何度も確認しあっている、過去の情報をうまく検索できない、などの問題が起きてないでしょうか? どの会社でもこのような問題は起きているかと思いますが、RevCommでもやはり起きています。 今回は、そのような問題に対する一つのソリューションとして、 RAG (Retrieval-Augmented Generation) を用いたナレッジチャットボットを作ってみましたので、紹介させていただきます! RAG (Retrieval-Augmented Generation) とは? ざっくり言うと、文書のデータセットに対してキーワードや文章をもとにベクトル検索を行い、抽出された文書をもとに生成AIで回答を行うものです。 回答の元となるデータを生成AIに与えることで、間違った情報や関係ない情報の出力を減らすことができます。 システムの構成 今回は、Google CloudのVertex AI Search and Conversationを用いました。 RAGシステム構成 ポイントを解説していきます。 BigQuery 各データソースの情報を集約する場所です。Google Vertex AI Search and Conversationに連携するためにBigQueryである必要はありましたが、BigQuery内でのデータの保存方法は、一般的なテーブルの形式であればどのような形でも大丈夫そうでした。 今回は、MiiTelのサポート記事であるZendeskと、社内のナレッジベースであるNotionの一部データを一つのデータセット・テーブルに保管して接続することにしました。 Google Vertex AI Search and Conversation 今回のRAGを実装するに当たって、情報検索を司る部分です。このサービスは、Google BigQueryなどの情報を読み込ませて、キーワードによる検索などを実現することが出来ます。つまり、 自社の情報に対してググる ことができるというわけです。 実はSearch and Conversationという名前の通り、文章で検索を行って文章で回答を生成してくれる機能もあります。ただ、この機能はBigQueryの正規化されたデータには現時点(2023年12月14日時点)では提供されておりません。 PDFなどや画像などの非正規化データをGCSに保管して、その情報を接続した上であれば、文章検索・回答が実現できます。 こちらはサポートにも確認しましたが、残念ながらまだとのことでした。 😭 ただ、ロードマップにはあるらしいので、今後に期待ですね! PDFで情報を保存して上記の機能を使うこともできましたが、正規化した形で情報を持っておきたかったのもあり、今回はBigQueryに情報を保存して、文章による検索・文章による要約生成・回答を別の形で実現することにしました。 詳細は、後述していきます。 Slack Bolt による Bot 作成 他の記事でも多く解説されているので詳細は省きますが、今回はSlack Botでメンションを受けると、それに対して反応するような形で作りました。 その中で、Google Vertex AI Search and Conversationを使ってRAGを実現するための工夫をいくつか行っています。 それらを、Bot内での処理にそって説明していきます。 問い合わせの文章から、検索に用いる検索キーワードを抽出する 問い合わせの文章は一般的に、「MiiTelとSalesforceの連携をする方法を教えてください。」のようになりますが、このままではBigQueryに保管されているデータを抽出することができません。文章での検索は、うまく検索できる場合もあった一方で、「教えてください」などのワードが入った途端に検索が出てこないケースがほとんどでした。 ですので、ChatGPTを使って、文章から検索に用いるワードを抽出するようにしています。 「MiiTelとSalesforce の連携をする方法を教えてください。」の場合、「MiiTel Salesforce 連携 方法」のように、スペースか何かで区切ってあり、語尾が省かれているような形が理想のようでした。 そのようなフレーズを抽出するために、色々試してみた結果、「次の文から検索に用いるための主要なキーワードやフレーズのみをスペース区切りで抜き出してください。」というプロンプトが一番効果的に感じたので、こちらを用いることにしました。 具体的には、以下のような実装です。 from openai import OpenAI openai_client = OpenAI(api_key=os.getenv( "OPENAI_API_KEY" )) # 質問から主要キーワードのみを抽出 message = ( f """次の文から検索に用いるための主要なキーワードやフレーズのみをスペース区切りで抜き出してください。: {question_user}""" ) messages = [ { "role" : "user" , "content" : message, } ] response = openai_client.chat.completions.create( model= "gpt-4" , messages=messages ) message_response = response.choices[ 0 ].message.content print (message_response) これで、検索のためのワードの抽出ができました。 ナレッジ検索をする 検索のためのワードの抽出ができたので、それらを用いてGoogle Vertex AI Search and Conversationで検索を行います。 基本的には素直に検索を行えばいいのですが、キーワードの数が多い場合にはうまく抽出できないことがありました。検索結果が0件になってしまうような状態です。 素直に検索をやり直してもらう方法などもあるとは思いますが、今回は、検索結果が0件の場合はワードを減らしつつ検索をする方法と採用し、より広範囲で検索できるようにしてみました。あくまで社内の便利ツールなので、何かしら拾えるほうを優先しました。 実装は下記のような形です。 実装例が公式ドキュメント以外にあまり見つけられませんでしたので、もしかしたらもっとよい実装方法があるかもしれません。 credentials = service_account.Credentials.from_service_account_info( google_credential ) discoveryengine_client = discoveryengine.SearchServiceClient( credentials=credentials ) total_size = 0 while total_size == 0 : # Discovery Engineで検索-------------------------------------------------- # Initialize request argument(s) request = discoveryengine.SearchRequest( serving_config= "projects/xxxxxxxx/locations/global/collections/default_collection/dataStores/revcomm-knowledge-base_xxxxxxx/servingConfigs/default_search" , query=message_response, ) # Make the request page_result = discoveryengine_client.search(request=request) print (page_result) total_size = page_result.total_size # 空白区切りで抽出されたキーワードの後ろを削除 message_response = re.split( r"\s+" , message_response) message_response = message_response[:- 1 ] # 空白区切りに戻す message_response = " " .join(message_response) if message_response == "" : response = slack_utils.post_message( "質問から適切な文書を検索できませんでした。他の質問を入力してください。" , thread_ts=thread_ts, ) return None 文章の中身の抽出 次に、各文章のタイトルや内容、URLなどを抽出して後で使えるようにしていきます。 上記の検索で取得したデータは独自のデータ構造で保管されているので、それを適宜辞書形式などに変換しながら抽出したりしています。 また、サービスのFAQページなどの情報はHTMLのまま保管してあるので、 HTMLのタグは除外するようにもしています。 # 文書のフォーマット count = 0 ## Handle the response support_content_list = [] for response in page_result: title = response.document.struct_data.__dict__[ "_pb" ][ "title" ].string_value content = response.document.struct_data.__dict__[ "_pb" ][ "body" ].string_value # contentから全てのhtmlタグを削除 content = re.sub( r"<[^>]*?>" , "" , content) # contentから全ての改行を削除 content = re.sub( r"\n" , "" , content) url = response.document.struct_data.__dict__[ "_pb" ][ "url" ].string_value support_content_list.append([title, content, url]) count += 1 if count > 5 : break print (support_content_list) ChatGPTに読み込ませるための準備 抽出したデータから、ChatGPTに読み込ませるためのデータを生成します。 また、参考にした情報のデータソースにアクセスできるようにURLのリストも作成しておきます。 # 中身の抽出 read_responses = [] support_content_urls_message = "" for support_content in support_content_list: message += f """--------- ## タイトル {support_content[0]} ## 内容 {support_content[1]} ## URL {support_content[2]} """ support_content_urls_message += ( f "・[{support_content[0]}]({support_content[2]}) \n " ) print (read_responses) 回答の生成 最後に、これらの情報を読み込ませつつ、元の質問に対する回答を生成します。 特段の工夫は行っていませんが、仮に読み込ませる文章が多くなった場合はトークン数が制限を超えてしまう可能性がありますので、工夫の余地があるポイントかもしれません。 もしやるとすれば、4.の段階で各文章の要約を作ってしまうのも一つの手だと思います。 # Generate Summary Response message = f """次の文章を元に、「{question_user}」 という質問に対する回答を作成してください。 """ for read_response in read_responses: message += f """--------- {read_response} """ messages = [ { "role" : "user" , "content" : message, } ] response = openai_client.chat.completions.create( model= "gpt-4" , messages=messages ) message_response = response.choices[ 0 ].message.content print (message_response) output_message_for_slack = ( message_response + " \n\n 参考: \n " + support_content_urls_message ) 詳細の実装は省いている部分もありますが、以上が今回実装した内容となります! 作ってみたシステムへの評価 ある程度適切に情報抽出・回答ができるようになりましたが、実際はまだまだ改善余地がありそうです。 大きな問題の一つとして、サービスのFAQページの数に対して社内情報のNotionのデータ数が相対的に少なく、サービスのFAQページの内容がメインで検索されてしまう傾向などが出てきました。 原因として検索語句や情報の質の問題が考えられますが、例えばサービスのFAQページのデータとNotionのデータを別々にBigQueryに保存し、別々に検索するようにするなどの工夫をするる余地はあるかなと思っています。 おわりに いかがでしたか? 今回紹介したもの以外にもさまざまなAIサービスが出ているので、すでに他のサービスを使って似たようなことを実現しているケースも多くあるかと思います。しかし、自分で実装してみることは面白いですし、自力で改善もできるので、試してみる価値はあると思います。 何かの参考にしてもらえたら幸いです! 最後までお読みいただき、ありがとうございました。
アバター