株式会社RevCommのブログ - TECH PLAY

TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

185

はじめに 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サービスが出ているので、すでに他のサービスを使って似たようなことを実現しているケースも多くあるかと思います。しかし、自分で実装してみることは面白いですし、自力で改善もできるので、試してみる価値はあると思います。 何かの参考にしてもらえたら幸いです! 最後までお読みいただき、ありがとうございました。
こんにちは、 RevComm Research Dept. Development Groupの id:tmotegi です。趣味は積読と日本酒を嗜んでおります。昨日は 仙禽の雪だるま を飲みました。現世で2度目のアドベントカレンダーなので緊張します。 この記事は RevComm Advent Calendar 2023  、15日目の記事です。昨日の記事は豊崎さんによる「 CodemagicでFlutterアプリをビルドする 」でした。 私達のチームは、 チームトポロジー のイネイブリングチームに相当するチームとして組織されており、他のチームに対してサポート・ツール・サービスを提供し、効果的かつ効率的に業務を遂行できるようにする役割を担っています。 今回は私が取り組んだ、 AWS EC2 Inf2インスタンスを使った推論の高速化 をご紹介します。 TL;DR 背景 音声感情認識 音声感情認識機能 音声感情認識モデル 実験 環境設定 データセット 指標 実験結果 推論のパフォーマンス比較 モデルサイズ・ロード時間比較 まとめ TL;DR AWS EC2 Inf2インスタンスにより、音声感情認識モデルの推論は爆速化し、コストも削減され、精度は維持されることが分かった。 G5インスタンス (ONNX) に比べ、Inf2インスタンスは2倍以上高速に推論することができる。 G5インスタンス (ONNX) に比べ、Inf2インスタンスはコストを80%弱削減できる。 背景 私達はMiiTelのコアバリューである音声認識・話者分離・音声感情認識などの機能を実現するために最新の深層学習モデルを利用しています。このような深層学習モデルは推論時に大量の演算を必要とするため、その性能向上のために一般的にGPUが利用されます。しかし、GPUインスタンスはCPUインスタンスに比べてコストが高くなる傾向があります。一方、AWSは Inf2インスタンス という推論に特化したインスタンスを提供しています。Inf2インスタンスは AWS Neuron SDK を用いて作成した専用のモデルをデプロイすることで、パフォーマンスの向上とコスト削減を同時に実現することが可能となっています。 私はGPUインスタンスのコストがかさむ問題に対して、Inf2インスタンスを導入することで解決を試みました。具体的には、MiiTelで使われている音声感情認識モデルをONNXやGPUインスタンス、Inf1およびInf2インスタンスを使った推論を行い、結果を比較することでInf2インスタンスが導入可能かどうか検討しました。 音声感情認識 私が取り上げた音声感情認識について簡単に紹介します。 音声感情認識機能 音声感情認識機能は、話し手のポジティブ・ネガティブな感情を可視化します。話し方からポジティブ・ネガティブを認識し、オレンジからブルーのグラデーションで帯として表示します。これによって会話した当事者以外でも、ネガティブな内容の会話にいち早く気づくことができます。(下記の図の赤いドットの角丸矩形が感情を示すグラデーション) emotion gradation bar 詳細は以下のプレスリリース記事で紹介されています。 音声解析AI電話「MiiTel」、音声感情認識機能をリリース 会話のポジティブ、ネガティブな感情をAIが可視化 prtimes.jp 音声感情認識モデル 紹介した音声感情認識機能で実際に使われているモデルは、論文 "Speech Emotion Recognition based on Attention Weight Correction Using Word-level Confidence Measure" 1 で提案された "confidence measure (CM) as weighting correction" になります。 詳細は以下のブログで紹介されています。 https://tech.revcomm.co.jp/2022/07/13/voice-emotion-recognition/ tech.revcomm.co.jp 実験 この実験の目的は、AWS EC2 Inf2インスタンスを使用して、音声感情認識モデルの推論速度を改善することです。これにより、同じ品質のアウトプットを保ちつつコストと時間を節約し、効率性を向上させることを期待しました。 環境設定 c6in.2xlarge, g5.xlarge, inf1.xlargeは東京リージョンで起動し、inf2.xlargeのみまだ東京リージョンでは提供されていないため、バージニア北部リージョンで起動しました。 Instance type GPU AWS Inferentia Software c6in.2xlarge No No torch, onnxruntime g5.xlarge Yes No torch, onnxruntime, onnxruntime-gpu inf1.xlarge No Yes torch, torch-neuron inf2.xlarge No Yes torch, torch-neuronx データセット プライベートなデータセットを使ってモデルの評価を行いました。データセットは3つの感情 (happiness, anger, neutral) を含み、音声の合計長は74分です。また各クラスの事例数は次の表のとおりです。 Class # Samples happiness 201 anger 206 neutral 270 指標 音声感情認識のPyTorchモデルをCPUインスタンスで実行した結果を基準 2 とし、相対的な指標で評価します。 高速化率 新しく導入したモデルが既存のモデルと比較して、どれだけ推論速度が改善したかを示します。「PyTorchモデルをCPUで実行したときの1推論あたりの平均レイテンシー / 各モデルの1推論あたりの平均レイテンシー」で計算します。 コスト削減率 新たに導入したモデルが既存のモデルと比較して、どれだけコストを削減できたかを評価します。「1 - 各モデルの1推論あたりの平均コスト / PyTorchモデルをCPUで実行したときの1推論あたりの平均コスト」で計算します。 平均コストは平均レイテンシーとオンデマンドインスタンスの価格表から算出しました。 精度変化 新たに導入したモデルが既存のモデルと比較して、どの程度精度が変化したかを評価します。「PyTorchモデルをCPUで実行したときのaccuracy(精度)- 各モデルのaccuracy」で計算します。 モデルファイルサイズ変化率 新たに導入したモデルのファイルサイズが、既存のモデルと比較してどれだけ変化したかを評価します。モデルのファイルサイズは、一般的にストレージやメモリ上のリソース使用量に影響を与えます。「 ONNX or AWS Neuronでコンバート後のモデルのファイルサイズ / PyTorchモデルのファイルサイズ」で計算します。 モデルロード時間高速化率 新たに導入したモデルをロードするのに要する時間が、既存のモデルと比較してどれだけ速くなったかを評価します。これは、モデルを使用する前に必要な初期化時間に直結します。「PyTorchモデルのロード時間 / ONNX or AWS Neuronでコンバート後のモデルのロード時間」で計算します。 実験結果 推論のパフォーマンス比較 前処理(スペクトログラムや単語ベクトル作成)以外の推論部分についてレイテンシーおよび速度を測定し比較しました。 Model PyTorch ONNX ONNX ONNX PyTorch ONNX AWS Neuron AWS Neuron Instance c6in.2xlarge c6in.2xlarge c6in.2xlarge c6in.2xlarge g5.xlarge g5.xlarge inf1.xlarge inf2.xlarge 浮動小数点数 32 bit floating point (fp32) fp32 int8 uint8 fp32 fp32 16 bit brain floating point (bf16) bf16 高速化率 - 1.19 2.01 1.93 9.57 23.57 9.58 57.55 コスト削減率 - 0.16 0.50 0.48 0.73 0.89 0.94 0.98 精度変化 - 0.000 -0.005 -0.006 -0.007 -0.007 -0.008 -0.008 高速化 音声感情認識モデルはONNXやGPU、Inf1 & Inf2インスタンスを使うことで高速化を達成できることが分かりました。特にinf2.xlargeを使った推論はONNXモデルをGPUで動かした場合の推論速度より2倍程度早くなっています。Inf1 & Inf2インスタンスではbf16以外の データタイプ も使えるのですが、推論のレイテンシーはbf16と同程度となりました。 コスト削減 Inf1 & Inf2インスタンスのコスト削減率が高いことが分かります。これは1推論あたりの平均レイテンシーが短いことや、インスタンスの価格がGPUインスタンスより安価であることが挙げられます。 精度変化 Inf1 & Inf2ではモデルパラメータの浮動小数点数としてbf16を利用しました。ONNXやInf1 & Inf2インスタンスを使った場合には、浮動小数点数の変更はモデルの精度への影響が少ないようです。Inf1 & Inf2インスタンスではbf16以外のデータタイプも使えるのですが、ほかのデータタイプを使用してもbf16を使用した場合と同程度の精度変化が見られました。 モデルサイズ・ロード時間比較 ONNXやInf1 & Inf2インスタンスを使うことで、モデルのファイルサイズやモデルのロード時間がどのように変化したか記録しました。 Model PyTorch ONNX ONNX ONNX PyTorch ONNX AWS Neuron AWS Neuron Instance c6in.2xlarge c6in.2xlarge c6in.2xlarge c6in.2xlarge g5.xlarge g5.xlarge inf1.xlarge inf2.xlarge 浮動小数点数 fp32 fp32 int8 uint8 fp32 fp32 bf16 bf16 モデルファイルサイズ変化率 - 0.96 0.24 0.24 1.00 0.96 0.52 0.50 モデルロード時間高速化率 - 28.99 133.73 143.42 0.88 29.85 17.51 7.61 モデルファイルサイズ モデルファイルサイズは浮動小数点数に連動して小さくなりました。モデルのパラメータを16ビットで表す場合は元のモデルのファイルサイズの約1/2、8ビットで表す場合は約1/4になりました。 モデルロード時間 ONNXモデルではファイルサイズと同様に浮動小数点数の精度を下げることで、モデルロード時間が短くなる結果になりました。また、AWS Neuron SDKを用いて作成したモデルは、ONNXモデルよりは遅いものの、PyTorchのモデルのロード時間よりは早くなりました。 まとめ MiiTelで使われている音声感情認識モデルの推論をInf系インスタンスで実行し、推論速度や精度について比較しました。Inf2インスタンスを利用することで、今までの精度を維持する一方で高速で低コストな推論ができることを確認しました。また、モデルファイルサイズやモデルロード時間も既存モデルに対して改善することを確認しました。 これらの結果は、既存モデルに対する改善を示しています。品質を維持したまま推論速度を上げることで、音声感情認識の速度を高め、結果としてユーザー体験の向上が期待できます。また、コスト削減により長期の運用コストを抑えることが可能となります。 今後の課題としては、既存モデルを実際にInf2インスタンスで置き換え、音声感情認識の高速化とコスト削減に取り組むことがあげられます。また、他の深層学習を使った機能やサービスに対して、同様の最適化手法を適用することも推進する予定です。 Santoso, J., Yamada, T., Makino, S., Ishizuka, K., Hiramura, T. (2021). Speech Emotion Recognition Based on Attention Weight Correction Using Word-Level Confidence Measure. Proc. INTERSPEECH, 1947-1951, doi: 10.21437/Interspeech.2021-411 ↩ 現在の音声感情認識モデルはCPUインスタンスで実行されてるため。 ↩
この記事は RevComm Advent Calendar 2023  14 日目の記事です。 RevComm でフロントエンド開発をしている豊崎 朗です。MiiTel Analytics、MiiTel Mobile Phone、MiiTel RecPod というプロダクトに携わっています。フロントエンドチームに籍を置いていますが、バックエンド、モバイルアプリ開発もやっています。 (フルスタックチームは、別であります。) MiiTel Mobile Phone、MiiTel RecPod はモバイルアプリであり、Flutter を用いて開発を行っています。この記事では、これらのプロダクトで利用している CI/CD サービスである Codemagic について紹介します。 目次 Codemagic について やってみよう 初期設定 ~ App の設定 Workflow の設定 トリガーの設定 (Build triggers) キャッシュの設定 (Dependency caching) その他の設定 テスト Codemagic の設定のバックアップについて まとめ Codemagic について Codemagic は、Flutter, ReactNative, native iOS, native Android, Unity, Kotlin Multiplatform, Ionic といったようなモバイルアプリのためのクラウドベースの CI/CD プロダクトになります。 ビルド, テスト, Apple App Store, Google Play などの App Store へのデプロイメントのプロセスを自動化することが出来ます。 プロセスのトリガーとして、GitHub などのリポジトリへ push, Tag の追加, PR のマージといったアクションを指定することが出来ます。 また、個人で利用する場合、月に無料で 500 分、mac OS (M1 マシン) を動かすことができるので個人でアプリ開発をしているユーザーにとって、大変お財布に優しくなっています。 やってみよう 今回は、以下の想定で Codemagic で Flutter アプリをビルドしてみます。 リポジトリ: GitHub トリガー: main ブランチにマージされたタイミング アプリ: Flutter アプリ 初期設定 ~ App の設定 Codemagic では、App(Application) 単位で設定を行います。 App には、一つのリポジトリが紐づくという仕様になります。 Sign up画面 から Codemagic へ Sign up を行う。 Sign up が完了すると、 管理画面 に遷移する。 App の設定を行う。 画面の右上にある、 Add application ボタンをクリック Appの設定を開始 GitHub を選択し Next: Select repository をクリック リポジトリの選択 Select repository の Github integration をクリック。ダイアログが開くので、Codemagic でビルドしたいリポジトリを選択する。 ビルドするリポジトリの選択 Select project では、 Flutter App (via Workflow Editor) を設定する 全体の設定 Finish: Add application をクリックする。 以下のようにリポジトリの設定がされていれば完了となります。 設定完了の様子 Workflow の設定 ここからは、Workflow の設定に移ります。 Codemagic では、App に複数の Workflow を設定することができ、App 作成後はデフォルトで Default workflow という名前の Workflow が存在します。 それでは、Default workflow を main ブランチに PR がマージされたタイミングで走らせるような設定をしてみましょう。 トリガーの設定 (Build triggers) Build triggers を開く。 Automatic build triggering の Trigger on push をチェック Watched branch patterns に以下を設定し、Add pattern ボタンをクリック Add new pattern: main Include or Exclude: Include Source or Target: Target トリガーの設定 キャッシュの設定 (Dependency caching) キャッシュの設定をしておくと、依存パッケージをインストール時の速度が向上するため、設定しておきましょう。 * キャッシュは、最大で 14 日間キャッシュをします。 Enable dependency caching をチェック 以下のパスを追加する。 $FLUTTER_ROOT/.pub-cache $HOME/.gradle/caches $HOME/Library/Caches/CocoaPods キャッシュの設定 その他の設定 Codemagic は、他にも様々な設定ができ柔軟性があります。ここでは紹介に留めておきます。 Workflow は以下の流れで実行され、様々な設定をすることができます。 * 太字の部分は、今回デフォルトの設定から変更していない機能になります。 ビルドトリガー (Build triggers) 環境変数 (Environment variables) キャッシュ設定 (Dependency caching) Post-clone スクリプト (Post-clone script) Pre-test スクリプト (Pre-test script) テスト (Tests) Post-test スクリプト (Post-test script) Pre-build スクリプト (Pre-build script) ビルド (Build) Flutter version, Xcode version, CocoaPods version, Android build format, Build mode, Build arguments が設定可 Post-build スクリプト (Post-build script) Pre-publish スクリプト (Pre-publish script) ディストリビューション (Distribution) Google Play, App Store Connect, Firebase App Distribution へのアプリ配信も自動化することができます。 RevComm では、weekly でアプリを自動ビルドし、それぞれへ配信しています。 通知 (Notifications) デフォルトでは、メールへビルド結果の通知が配信されるようになっています。 オプションで、Slack への通知が出来ます。 設定し終えたら、画面の右上から Save changes をクリックします。 ビルド設定は以上になります。 テスト 適当な PR を立てて、CI が走るかどうかテストしてみます。 PRをマージする様子 PR がマージされると、Codemagic の Builds ページから CI が走っているかどうか確認することができます。 workflowが起動された様子 詳細画面は、以下のような感じになります。 実行中のworkflow 完了後のworkflow 問題無く、ビルドが完了していることがわかります 🎊 今回は、Post-build スクリプトやディストリビューションを設定していないため、ビルドした成果物を詳細画面からダウンロードすることぐらいしか出来ません。 RevComm では、Post-build スクリプトに DeployGate の API を叩くようにし dev 環境を構築したり、ディストリビューションに Google Play と Apple Store Connect を連携させて、Closing testing, TestFlight で stg 環境を構築しています。 余力がありましたら、是非試してみてください。 Codemagic の設定のバックアップについて Codemagic で設定した App の Workflows を GitHub で管理したい時があると思います。 Codemagic API では、 Applications API を用いることで、Workflow Editor で設定した情報を json でバックアップすることが出来ます。 しかし、リストアに関しては対応していないため、あくまで設定した情報をバックアップするという用途でしか使えません。 * Workflow Editor を利用せず、設定したリポジトリに配置した codemagic.yaml を参照するといった設定も出来ます。この方法であれば、バックアップとリストアもすることが出来ます。細かい設定をする人は、こちらの方が向いているかもしれません。 RevComm では、GitHub Actions から Codemagic API を実行し、定期的にバックアップを取るようにしています。 ここでは、そちらを共有します。 以下の GitHub Actions ファイルを .github/workflows/sync-codemagic-settings.yaml として保存する。 name : Sync codemagic-settings on : schedule : - cron : '0 0 * * *' # Every day at 00:00 UTC workflow_dispatch : jobs : sync : name : Sync codemagic-settings runs-on : ubuntu-latest env : CODEMAGIC_APP_ID : <Your codemagic app id> steps : - name : Checkout uses : actions/checkout@v4 with : ref : main - name : Fetch codemagic-settings run : | curl -s -H "Content-Type: application/json" \ -H "x-auth-token: ${CODEMAGIC_TOKEN}" \ --request GET https://api.codemagic.io/apps/${CODEMAGIC_APP_ID} > codemagic-settings.json env : CODEMAGIC_TOKEN : ${{ secrets.CODEMAGIC_TOKEN }} - name : Diff check continue-on-error : true id : diff_check run : git diff --exit-code # only run if there are changes - name : Commit changes and create pull request if : ${{ steps.diff_check.outcome == 'failure' }} run : | NOW=$(date +"%Y%m%d%H%M") git config --global user.name "action@github.com" git config --global user.email "65916846+actions-user@users.noreply.github.com" git checkout -b feature/update-codemagic-settings-${NOW} git add codemagic/workflows.json git commit -m "Update codemagic-settings" git push origin feature/update-codemagic-settings-${NOW} gh pr create -B develop -H feature/update-codemagic-settings-${NOW} --title 'Update codemagic-settings' --body 'Updated codemagic-settings by github-actions' env : GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }} sync-codemagic-settings.yaml の CODEMAGIC_APP_ID: <Your codemagic app id> の部分の置き換え Codemagic の App の設定ページのアドレスバー ( https://codemagic.io/app/<app id>/workflow/<workflow id>/settings ) から、 <app id> を取得し、そちらを利用する。 Codemagic の API トークンを取得し、GitHub Actions の Secret として設定する。 Codemagic の左のメニューから、Teams をクリック → Personal Account をクリック → General settings の Integrations をクリック → Codemagic API から API トークンを取得する。 Codemagic apiトークンの取得 GitHub リポジトリの Settings ページ → Secrets and variables → Repository secrets に Name: CODEMAGIC_TOKEN, Secret: 先ほど取得したトークンを設定する。 設定は以上になります。 毎日 0 時に GitHub Actions が定期実行され、リポジトリに保存されている設定と Codemagic の Workflow に差分があった時のみ、PR が自動的に作成されます。 また、 workflow_dispatch の設定も入れているため、任意のタイミングで GitHub Actions を実行することもできます。 まとめ Codemagic で Flutter アプリをビルドし、Codemagic の設定ファイルを GitHub Actions でバックアップをしました。 Codemagic を使っていると、手元でアプリをビルドするという運用には戻れなくなります。(私は、戻れなくなりました 😊) Codemagic は、設定も柔軟に出来ますし、App Store へ成果物を自動的にアップロードしてくれるので、個人的には満足しています。 まだ、モバイルアプリ開発者で CI/CD を導入していない方は、是非 Codemagic を使ってみてください! それでは、また 👋
Introduction As a professional developer, you encounter something new every day: new coding techniques, new ways of organizing projects, new bugs, new tools, etc. The amount of knowledge the world has to offer is too much, so we write it down as a note in a Jira ticket or as a comment in a PR. We recall certain patterns and internalize the frequent ones; we unconsciously discard the rare to the bottom of our long-term memory. And then it happens. That little hack comes to bite back again, and you have a hunch of how to solve it. You may have bookmarked the solution in Stack Overflow, or was it a ChatGPT conversation? When did that bug happen anyway? What was the context? I’ve experienced this many times. In this post, I’ll describe a solution to this conundrum: “a knowledge portfolio.” A knowledge portfolio Your knowledge portfolio describes all the information you’ve encountered throughout your career. In a sense, it determines your identity as an engineer. The Pragmatic Programmer [1] offers clear-cut guidelines to build a successful portfolio. In this post, I’ll comment on the most impactful points that kept mine robust and updated. Tips Write an engineering daybook We use daybooks to take notes in meetings, to jot down what we’re working on, to note variable values when debugging, to leave reminders where we put things, to record wild ideas, and sometimes just to doodle. - Andy and Dave, The Pragmatic Programmer An engineering daybook is a recount of your day as an engineer. It could be ramblings about what you’ve done, a loose set of links of all you’ve seen, or wild ideas barely connected. Whatever the form, storing that information is crucial so it can be accessed anywhere, anytime. I especially recommend writing your thoughts, as it’s like teaching something. Just by doing it, you organize and consolidate the ideas floating around. If you hate writing, just a one-liner or a link is a good starting point. Choose tools you’re comfortable with I use Evernote to keep my portfolio, but any tool you’re familiar with is enough. Some essential functions any such tool should have are: Export to plain text (food for your future super-intelligent AI butler) Synchronization across devices (you might want that info in your phone) Tagging system (to categorize your knowledge) Task reminding feature (to plan your learning) I also recommend Readwise to highlight anything from the internet. You can write a script to export everything to Evernote. Tag everything Was that some new feature in React? Ok, append the React tag. Tag everything so that you can find things more easily. If you can subcategorize tags, it’s even better. Evernote does it like this: Maintain a "bugdex" Because of optimism, we usually expect the number of bugs to be smaller than it turns out to be. Therefore, testing is the most mis-scheduled part of programming. - Frederik P. Brooks Jr., The Mythical Man-Month [2] Bugs are Software Engineering’s necessary evil. We spent a great deal of our time fixing them. Document every bug; you’ll never know when it’ll pop up again. Isolate the bug and track it on your favorite source control. There are two caveats. First, only some bugs can be isolated. In that case, at the very least, describe what happened (context, cause, and solution), your future self will be grateful. Furthermore, start to classify them by framework or programming language. Make time for testing wild ideas The most daunting piece of paper is the one with nothing written on it - Andy and Dave, The Pragmatic Programmer [1] Take advantage of your knowledge. If you find some interesting idea, don’t let it float around. Use your chosen tool’s reminder feature to test that idea later or to practice something you’ve bookmarked recently. Conclusion In this post, I’ve described my experience with maintaining a knowledge portfolio. Every person is different: the crafters who keep everything clean and organized, the pragmatics who just want the information to be stored somewhere. Find what works for you; in the end, storing and centralizing the data is the essential part. And if you never use that data, at least you now have a kind of work memoir for your family and friends: old traditions are getting cooler again. About the author Hi, I'm Jose @juanjo12x , and I work as a Backend Engineer here at Revcomm. I spend my days writing Python and thinking about what to learn next. References [1] Andy Hunt and Dave Thomas. The Pragmatic Programmer 20th Anniversary Edition. [2] Frederik P.Brooks, Jr. The Mythical Man-Month.
こんにちは! RevComm のフロントエンドエンジニアの小山功二です。 私が RevComm に入社する前に担当した開発案件は、どれも国内のユーザーにしか使われていないものばかりでした。一方で、RevComm の提供する MiiTel は、日本はもちろんインドネシアやアメリカでも使われています。 私の担当する MiiTel CallCenter というプロダクトは今年リリースしたのですが、こちらもリリース当初から海外で利用できることが求められていました。 開発時からタイムゾーンを扱うのは大変そうだよねというのは感じていたのですが、想定よりも大変でした。 そこで今回はタイムゾーン周りの理解を深めるために、Day.js のタイムゾーンを変更する関数である tz という関数について整理していきたいと思います。 似たような4種の書き方ができる Day.js の tz 関数 まず tz 関数を使えるようにする準備をしましょう。 Day.js でtz関数を使えるようにするには timezone パッケージをインストールする必要があります。また、場合によって customParseFormat パッケージが必要になるケースもあります。 ここでは yarn を使っています。 $ yarn add dayjs timezone $ yarn add customParseFormat dayjsを拡張します。 import dayjs from 'dayjs' ; import timezone from 'dayjs/plugin/timezone' ; import timezone from 'dayjs/plugin/customParseFormat' ; dayjs.extend ( timezone ); dayjs.extend ( customParseFormat ) これで tz 関数を使えるようになりました。 この tz 関数は公式ドキュメントの中では3つページに記載があり、それぞれ別の使い方があることが示されています。 Time Zone Parsing in Zone Converting to Zone 私はこの公式ドキュメントにない書き方をしてしまったのですが、それが Parsing in Zone に近い書き方で第一引数にDate型の値を入れてしまった書き方です。 Parsing in Zone には以下のように書いてあるので、第一引数がstring でくる前提のように見えます。 Parse date-time string in the given timezone and return a Day.js object instance. 一方で、tzの型をVSCode上で見てみると、以下のようになっていました。 const tz: dayjs.DayjsTimezone ( date: string | number | dayjs.Dayjs | Date | null | undefined , timezone?: string | undefined ) => dayjs.Dayjs ( + 1 overload ) 第一引数はstring以外も許容しています。 この点、具体的なコードをみた方が比較しやすいと思うので、 いくつかの記法を並べてみましょう。 // [記法1] ローカルタイムゾーンで '2023-12-25 00:00:00'を'Pacific/Honolulu'のタイムゾーンに変換 dayjs ( "2023-12-25 00:00:00" ) .tz ( "Pacific/Honolulu" ) // [記法2] '2023-12-25 00:00:00'を'Pacific/Honolulu'のタイムゾーンでパース dayjs.tz ( "2023-12-25 00:00:00" , "Pacific/Honolulu" ) // [記法3] 記法2と同じ日時をdate型で指定したもの dayjs.tz (new Date ( '2023-12-25 00:00:00' ), "Pacific/Honolulu" ) // [記法4] 文字列のフォーマットを解析して'Pacific/Honolulu'のタイムゾーンを設定(customParseFormatが必要) dayjs.tz ( "12-25-2023 00:00:00" , "MM-DD-YYYY ss:mm:HH" , "Pacific/Honolulu" ) 記法2が Parsing in Zone に記載がある使い方で、記法3 が私が書いてしまったコードと同様の書き方です。 試しに、実際にどんな値が返ってくるか format 関数を使って見てみましょう。 なお、日本のタイムゾーンであるAsia/TokyoはUTC+09:00であり、以下のコードの中に出てくるPacific/Honoluluの中で設定しているPacific/HonoluluはUTC-10:00です。2つのタイムゾーンの時差は19時間です。 // [記法1] 日本時間2023-12-25 00:00:00のPacific/Honoluluでの時間を返す。 dayjs ( "2023-12-25 00:00:00" ) .tz ( "Pacific/Honolulu" ) .format ( "YYYY-MM-DDTHH:mm:ssZ" ) => "2023-12-24T05:00:00-10:00" // [記法2] Pacific/Honoluluのタイムゾーンの2023-12-25 00:00:00を返す。 dayjs.tz ( "2023-12-25 00:00:00" , "Pacific/Honolulu" ) .format ( "YYYY-MM-DDTHH:mm:ssZ" ) => "2023-12-25T00:00:00-10:00" // [記法3] Pacific/Honoluluのタイムゾーンの2023-12-25 00:00:00を返して欲しかったのですが、そうなっていない... dayjs.tz (new Date ( '2023-12-25 00:00:00' ), "Pacific/Honolulu" ) .format ( "YYYY-MM-DDTHH:mm:ssZ" ) => "2023-12-24T05:00:00-10:00" // [記法4] Pacific/Honoluluのタイムゾーンにて、“12-25-2023” という文字列が "MM-DD-YYYY" というフォーマットになっていると解釈した値を返す。 dayjs.tz ( "12-25-2023" , "MM-DD-YYYY" , "Pacific/Honolulu" ) .format ( "YYYY-MM-DDTHH:mm:ssZ" ) => "2023-12-25T00:00:00-10:00" 記法2と3の結果が一致しませんでした。 tz 関数の実処理をコードから確認 なぜこうなるかわからなかったので、Day.jsのコードをみてみました。 https://github.com/iamkun/dayjs/blob/dev/src/plugin/timezone/index.js 以下は tz 関数の該当コードの抜粋です。 d.tz = function ( input , arg1 , arg2 ) { const parseFormat = arg2 && arg1 const timezone = arg2 || arg1 || defaultTimezone const previousOffset = tzOffset ( +d (), timezone ) if (typeof input !== 'string' ) { // timestamp number || js Date || Day.js return d ( input ) .tz ( timezone ) } const localTs = d.utc ( input , parseFormat ) .valueOf () const [ targetTs , targetOffset ] = fixOffset ( localTs , previousOffset , timezone ) const ins = d ( targetTs ) .utcOffset ( targetOffset ) ins.$x.$timezone = timezone return ins } typeof input !== 'string' のとき、たとえば date 型を引数として設定した場合、 d(input).tz(timezone) を返す処理になっています。これは記法1のタイムゾーンを変換する処理と同じ結果になります。 ドキュメントにはこの点の記載がないので、Day.jsのリポジトリに改善提案の issue を立てました。 終わりに まとめると以下のようになります。 // [記法1] dateTimeString を timezone に変換 dayjs ( dateTimeString ) .tz ( timezone ) // [記法2] dateTimeString を timezone でパース dayjs.tz ( dateTimeString , timezone ) // [記法3] dateObject を timezone に変換(記法2に似てますが、記法1と同じ結果なので注意が必要) dayjs.tz ( dateObject , timezone ) // [記法4] dateTimeStringをcustomParseFormatで解析して、timezoneでパース dayjs.tz ( dateTimeString , customParseFormat , timezone ) 今回は、Day.js の tz 関数について整理をすることができました。 これからもタイムゾーンとしっかり向き合い、日本でも国外でも多くの方々に使われるプロダクトに成長させられるように向き合っていきたいです。
概要 想定読者 MiiTelのOutgoingWebhook 機能について 本記事で紹介しているGoogleCalendar連携について 利用想定 開発者向け情報 概要 全体の処理シーケンス GoogleCalendarAPI利用時に認証tokenを保存するための処理 通話完了からGoogleCalendarへイベントを登録する処理 事前準備 GoogleCalendarAPIの利用設定 OutgoingWebhookの利用設定 連携サーバの構築 構成情報 GCPでサーバ構築 PHPのインストール Nginxの設定 OAuth用の処理 カレンダー登録処理 おわりに 概要 株式会社RevCommのCorporateEngineeringチームの登尾です。 この記事は 株式会社RevComm Advent Calendar 2023 の 11日目の記事です。 MiiTelのOutgoingWebhook機能を使い応対履歴をGoogleCalendarに残す方法について紹介します。 想定読者 MiiTel製品の導入検討中の方 : MiiTelのOutgoingWebhook機能でどのようなことができるか知りたい方 MiiTel製品導入済みの方 : 自社の業務にあわせてMiiTelのカスタマイズを検討している方。カスタマイズの作業を行う開発者の方 MiiTelのOutgoingWebhook 機能について トーク解析AI の MiiTelには様々な機能があります。 詳しくは MiiTel 機能紹介ページをご覧ください。 様々な機能の中の1つの OutgoingWebhook は、「MiiTel Analytics」プラットフォームで解析した結果を、他社システムへ連携可能にします。 https://miitel.com/jp/archives/4907 本記事で紹介しているGoogleCalendar連携について 本記事では「MiiTel Analytics」プラットフォームで解析した結果をGoogleCalendarに連携する方法について紹介します。 具体的には、電話が完了し、音声解析が終了すると、GoogleCalendarに通話の応対履歴が自動で登録されます。 流れをスクリーンショットと共に見ていきましょう。 MiiTelPhoneで電話をかける 2. 応対履歴が作成される 3. GoogleCalendarに応対履歴が連携される。GoogleCalendarの説明文に登録されたリンクからMiiTelの応対履歴ページに戻ることができる 利用想定 この仕組みを使うことで、営業支援システムを導入していない企業様が電話による営業活動を効率化できます。営業担当者の応対履歴がGoogleCalendarに同期され、GoogleCalendarを見ることで誰がどの取引先にどのくらい時間を使ったかを簡易的に見ることができるようになります。 開発者向け情報 概要 OutgoingWebhookを使ってGoogleCalendar連携するには下の2つの実装が必要となります。 GoogleCalendarにイベント(タイトル、開始時間、終了時間、参加者などの情報を含むカレンダー上のイベント) を登録するための処理 GoogleCalendarAPI を利用します。GoogleCalendarAPIを使って イベント を登録するには Google の OAuth認証 も必要です。OAuth認証用の処理も作成する必要があります。 Google の OAuth Appについての詳細は OAuth App Verification Help Center を確認してください。 MiiTelのOutgoingWebhookのリクエストを受けつけるための処理 実装する際には MiiTel OutgoingWebhookのサポートページ で詳細をご確認ください。 全体の処理シーケンス GoogleCalendarAPI利用時に認証tokenを保存するための処理 今回は簡単に構築するために、 tokenを連携サーバ内にファイルとして保存しました。実際に運用をする際には検討が必要な部分です(図の9番の処理) 通話完了からGoogleCalendarへイベントを登録する処理 事前準備 GoogleCalendarAPIの利用設定 GoogleCloudの左メニューからGoogleCalendarAPIを選択し GoogleCalendarAPIを有効にします 右上のボタンより認証情報を作成します 承認済みの JavaScript 生成元 と 承認済みのリダイレクト URI に 連携サーバのものを指定します OAuth同意画面で公開ステータスをテスト、テストユーザーにこの連携で利用するメールアドレスを追加します OAuthのスコープを指定します OutgoingWebhookの利用設定 MiiTelAdminの外部連携機能で事前にOutgoingWebhookの設定を行います 設定完了画面 連携サーバの構築 💡 セキュリティ上の注意: サーバを構築する際は、Firewallで不要なポートへのアクセスやアクセス元IPアドレスを制限すること、脆弱性のある古いバージョンのライブラリは利用しない等、セキュリティには十分注意してください。 構成情報 構築環境: GCP(Google Cloud Platform) アプリケーション: PHP (フレームワークは Slim を利用), nginx ドメイン: お名前.comで取得 GCPでサーバ構築 Compute Engine → Compute Engineを有効化 → VM インスタンス → 新規作成 からサーバを作成します CloudDNSでお名前.comで取得したドメインと紐付けます https://over40.work/entry/gcp-clouddns/ (こちらのページを参考にさせていただきました。ありがごうございました) PHPのインストール sudo apt update sudo apt install php-cli php-fpm php-json php-common php-mbstring curl unzip curl -sS https://getcomposer.org/installer | php sudo mv composer.phar /usr/local/bin/composer sudo timedatectl set-timezone Asia/Tokyo mkdir relation_app cd relation_app composer require slim/slim:"4.*" composer require slim/psr7 composer require guzzlehttp/guzzle composer require google/auth composer require google/apiclient Nginxの設定 インストール sudo apt install nginx sudo systemctl start nginx sudo systemctl enable nginx # 証明書に無料の Let’s encript を利用 sudo apt install certbot python3-certbot-nginx nginxのconf設定 # HTTPのリクエストをHTTPSにリダイレクト server { listen 80; listen [::]:80; server_name **********; location / { return 301 https://$host$request_uri; } } # HTTPSの設定 server { listen 443 ssl; server_name **********; root /home/sh-noborio/relation_app; index index.php index.html index.htm index.nginx-debian.html; ssl_certificate /etc/letsencrypt/live/**********/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/**********/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { try_files $uri =404; fastcgi_pass unix:/run/php/php7.4-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } OAuth用の処理 シーケンス図 PHPのコード(一部抜粋) ルーティング処理、他APIの呼び出し <?php namespace calendar; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Factory\AppFactory; require __DIR__ . '/vendor/autoload.php' ; $ app = AppFactory :: create () ; // GoogleOAuthLogin画面へ遷移するためのページ // シーケンス図 3番,4番の処理 $ app -> get ( '/google_oauth' , function ( Request $ request , Response $ response , $ args ) { $ client = new GoogleOAuthClient () ; $ url = $ client -> getAuthUrl () ; $ response -> getBody () -> write ( '<button onclick="window.location.href= \' ' . $ url . ' \' ;">Google OAuth</button>' ) ; return $ response ; }) ; // OAuthのcodeを受け取ってtokenを保存するための処理 // シーケンス図 6番〜9番の処理 $ app -> get ( '/google_oauth_callback' , function ( Request $ request , Response $ response , $ args ) { $ params = $ request -> getQueryParams () ; if ( isset ( $ params [ 'code' ])) { $ authCode = $ params [ 'code' ] ; $ client = new GoogleOAuthClient () ; $ token = $ client -> fetchAndSaveToken ( $ authCode ) ; $ response -> getBody () -> write ( "Token saved successfully!" ) ; return $ response ; } else { $ response -> getBody () -> write ( "Error: No authorization code received." ) ; return $ response -> withStatus ( 400 ) ; } }) ; GoogleOAuth用の処理 <?php namespace calendar; use GuzzleHttp\Client; use Google\Auth\OAuth2; class GoogleOAuthClient { const CLIENT_ID = '************' ; const SCOPES = 'https://www.googleapis.com/auth/calendar openid email' ; const REDIRECT_URI = 'https://**********/google_oauth_callback' ; const CLIENT_SECRET = '***' ; public function getAuthUrl () { $ oauth2 = new OAuth2 ([ 'clientId' => self :: CLIENT_ID, 'authorizationUri' => 'https://accounts.google.com/o/oauth2/v2/auth' , 'redirectUri' => self :: REDIRECT_URI, 'tokenCredentialUri' => 'https://oauth2.googleapis.com/token' , 'scope' => self :: SCOPES, ]) ; return $ oauth2 -> buildFullAuthorizationUri () ; } /** * codeを使ってアクセストークンを取得しファイルとして保存する * シーケンス図 7番〜9番の処理 */ public function fetchAndSaveToken ( $ authCode ) { $ oauth2 = new OAuth2 ([ 'clientId' => self :: CLIENT_ID, 'redirectUri' => self :: REDIRECT_URI, 'tokenCredentialUri' => 'https://oauth2.googleapis.com/token' , 'grant_type' => 'authorization_code' , ]) ; $ oauth2 -> setCode ( $ authCode ) ; // アクセストークンを取得 $ client = new Client () ; $ response = $ client -> post ( $ oauth2 -> getTokenCredentialUri () , [ 'form_params' => [ 'code' => $ authCode , 'client_id' => self :: CLIENT_ID, 'client_secret' => self :: CLIENT_SECRET, 'redirect_uri' => self :: REDIRECT_URI, 'grant_type' => 'authorization_code' , 'access_type' => 'offline' , 'prompt' => 'consent' , ] ]) ; $ token = json_decode ( $ response -> getBody () , true ) ; $ mail = $ this -> getEmailFromToken ( $ token ) ; // トークンをファイルに保存 file_put_contents ( $ this -> getTokenPath ( $ mail ) , $ token [ 'access_token' ]) ; return $ token ; } public function getEmailFromToken ( $ token ) { if ( isset ( $ token [ 'id_token' ])) { $ idToken = $ token [ 'id_token' ] ; list ( $ header , $ payload , $ signature ) = explode ( '.' , $ idToken ) ; // Decode payload $ decodedPayload = json_decode ( base64_decode ( strtr ( $ payload , '-_' , '+/' )) , true ) ; return $ decodedPayload [ 'email' ] ?? null ; } return null ; } カレンダー登録処理 シーケンス図 PHPのコード(一部抜粋) ルーティング処理、他APIの呼び出し <?php namespace calendar; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Factory\AppFactory; require __DIR__ . '/vendor/autoload.php' ; $ app = AppFactory :: create () ; // webhookリクエストを受け付け // シーケンス図 3番〜6番の処理 $ app -> post ( '/webhook' , function ( Request $ request , Response $ response , $ args ) { $ body = $ request -> getBody () -> getContents () ; $ webhook_response = new OutgoingWebhookResponse ( $ body ) ; // 外線発信以外 または Email が取れない場合は処理停止 if ( !$ webhook_response -> isOutGoingCall ()   || !$ webhook_response -> getEmailAddress ()) { return $ response ; } // 初回のチャレンジレスポンスのための処理 if ( $ webhook_response -> getChallenge ()) { $ response -> getBody () -> write ( $ webhook_response -> getChallenge ()) ;          $ response -> withHeader ( 'Content-Type' , 'text/plain' ) ;          return $ response ; } $ title = $ webhook_response -> getCompanyName () . ':' . $ webhook_response -> getName () . '様' ; $ start_date = $ webhook_response -> getAnsweredAt () ; $ end_date = $ webhook_response -> getEndsAt () ; $ id = $ webhook_response -> getId () ; // GoogleCalendarにEventを登録 $ googleOAuthClient = new GoogleOAuthClient () ; $ event = $ googleOAuthClient -> createEvent ( $ mail , $ title , $ id , $ start_date , $ end_date ) ; $ response -> withHeader ( 'Content-Type' , 'text/plain' ) ; return $ response ; }) ; OutgoingWebhookのResponse用の処理 <?php namespace calendar; /** * @see 音声認識終了時にチェックを入れた場合のペイロード https://support.miitel.jp/hc/ja/articles/13050493066905-Outgoing-Webhook */ class OutgoingWebhookResponse { private $ data ; public function __construct ( $ json ) { $ this -> data = json_decode ( $ json , true ) ; } public function getEmailAddress () { if ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'call_type' ] === 'OUTGOING_CALL' ) { foreach ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'participants' ] as $ participant ) { if ( $ participant [ 'from_to' ] === 'FROM' ) { return $ participant [ 'name' ] ?? null ; } } } return null ; } public function getChallenge () { return $ this -> data [ 'challenge' ] ?? null ; } public function getAnsweredAt () { return $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'dial_answered_at' ] ?? null ; } public function getEndsAt () { return $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'dial_ends_at' ] ?? null ; } public function getCompanyName () { if ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'call_type' ] === 'OUTGOING_CALL' ) { foreach ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'participants' ] as $ participant ) { if ( $ participant [ 'from_to' ] === 'TO' ) { return $ participant [ 'company_name' ] ?? '' ; } } } } public function getName () { if ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'call_type' ] === 'OUTGOING_CALL' ) { foreach ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'participants' ] as $ participant ) { if ( $ participant [ 'from_to' ] === 'TO' ) { return $ participant [ 'name' ] ?? '' ; } } } } public function getId () { return $ this -> data [ 'call' ][ 'id' ] ?? '' ; } public function isOutGoingCall () { return isset ( $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'call_type' ]) && $ this -> data [ 'call' ][ 'details' ][ 0 ][ 'call_type' ] === 'OUTGOING_CALL' ; } } GoogleCalendarにEventを登録する処理 <?php namespace calendar; use GuzzleHttp\Client; use Google\Auth\OAuth2; class GoogleOAuthClient { const CLIENT_ID = '************' ; const SCOPES = 'https://www.googleapis.com/auth/calendar openid email' ; const REDIRECT_URI = 'https://**********/google_oauth_callback' ; const CLIENT_SECRET = '***' ; const MIITEL_URL = 'https://***********/miitel.jp' ; /** * @see https://developers.google.com/calendar/api/v3/reference/events/insert?hl=ja */ public function createEvent ( $ mail , $ title , $ id , $ startDateTime , $ endDateTime ) { // Googleクライアントの初期化 $ client = new \Google_Client () ; $ client -> setClientId ( self :: CLIENT_ID ) ; $ client -> setClientSecret ( self :: CLIENT_SECRET ) ; $ client -> setRedirectUri ( self :: REDIRECT_URI ) ; $ client -> addScope ( self :: SCOPES ) ; // 保存されているトークンの読み込み $ tokenPath = $ this -> getTokenPath ( $ mail ) ; $ accessToken = file_get_contents ( $ tokenPath ) ; $ client -> setAccessToken ( $ accessToken ) ; // Googleカレンダーにイベントを登録 $ service = new \Google_Service_Calendar ( $ client ) ; $ url = self :: MIITEL_URL . "/app/calls/ { $ id } " ; $ event = new \Google_Service_Calendar_Event ([ 'summary' => $ title , 'description' => $ url , 'start' => [ 'dateTime' => $ startDateTime ] , 'end' => [ 'dateTime' => $ endDateTime ] ]) ; $ createdEvent = $ service -> events -> insert ( 'primary' , $ event ) ; return $ createdEvent ; } } おわりに いかがでしたでしょうか。 OutgoingWebhookを使えば、他のサービスとの連携ができ、様々な応用が可能になります。 例えばAsanaとの連携によるタスク管理の実現も可能です。 MiiTel Outgoing Webhook の使い方: タスク管理ツールとの連携サンプル をご覧ください。 MiiTelをより効果的に、より深く活用したい企業様は、OutgoingWebhookの機能をぜひご活用ください。
こんにちは。PBXチームの山崎です。 振り返ると前回のブログからちょうど1年経ってしまいました。来年はブログのアウトプットも増やしていきたいですね。 さて早速ですが、今回のブログの概要です。 死活監視の一環で、STUNというバイナリベースのプロトコルのクライアントを実装してみた Python3.10で入ったパターンマッチングがバイナリプロトコルの解析に便利だった 前半でSTUNを軽く触って動作を確認し、後半でPythonを使って実装してみます。 目次 目次 STUNについて STUN のパケット構造 やってみよう Pythonのパターンマッチングについて バイナリデータに対するパターンマッチング PythonでSTUNやってみる まとめ STUNについて STUNは主に以下の特徴を持つプロトコルです。 WebRTCでよく使われる、NAT越しに通信するためのプロトコル(の一部) 相手から見た自分のグローバルアドレスなどを知ることができる RFC 8489 バイナリベース STUN のパケット構造 プロトコルの理解には、実際にリクエスト・レスポンスを観察してみると捗ります。 実際にパケットを送信するために、必要な情報を集めていきましょう。 RFC 8489の "2. Overview of Operation” を読むと、まずはクライアントからBinding Requestを送りたまえ、と書かれています。 Binding Requestとは何ぞ?と読み進めると、5章にその構造が定義されています。 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0 0| STUN Message Type | Message Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Magic Cookie | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Transaction ID (96 bits) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 2: Format of STUN Message Header 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Type | Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Value (variable) .... +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 4: Format of STUN Attributes 20バイト(固定長)のヘッダの後ろに、0個以上のアトリビュートが続く構成です。 ヘッダの各フィールドの定義を以下に抜粋します STUN Message Type: Binding Requestは 0x0001、Responseは 0x0101 Message Length: ヘッダを除いた STUNメッセージの長さ Magic Cookie: 0x2112_A442 (固定値) Transaction ID: 12bytes の乱数 そしてアトリビュートはタイプに長さと(タイプごとに定義される)データが続く、よくある構成ですね。 アトリビュートタイプが取りうる値は、18.3. STUN Attributes Registry に定義されています。 今回使う値を以下に抜粋します。 0x0020: XOR-MAPPED-ADDRESS やってみよう なんとなく構造がわかったので、試しにリクエストを送ってみましょう。GoogleがSTUNサーバーを公開してくれているので、ありがたく利用させていただきます。 # リクエストデータは先頭から... # 00 01: Binding Request # 00 00: Length # 21 12 a4 42: Magic Cookie # 00 01 ... 11: Transaction ID (乱数作るの面倒なので適当に) # RFCにはリクエストにSOFTWARE Attributeを含めたまえ (SHOULD) とあるけど、面倒なので省略 bash$ printf ' \x00\x01\x00\x00\x21\x12\xa4\x42\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11 ' | \ socat - UDP:stun.l.google.com:19302 | hexdump -C 00000000 01 01 00 0c 21 12 a4 42 00 01 02 03 04 05 06 07 |....!..B........| 00000010 08 09 10 11 00 20 00 08 00 01 99 a3 17 ba 65 4b |..... ........eK| 00000020 手抜きをしてSOFTWARE Attributeを省略しましたが、ちゃんとレスポンスを返してくれました。読んでみましょう。先頭から... ヘッダ部 01 01 : Binding Response 00 0c : 長さは12 (Big Endian) 21 12 a4 42 : Magic Cookie 00 01 ... 11 : Transaction ID アトリビュート部 00 20 : XOR-MAPPED-ADDRESS 00 08 : 長さは8 00 01 99 a3 17 ba 65 4b : アトリビュートの中身 XOR-MAPPED-ADDRESS なるものとして 00 01 99 a3 17 ba 65 4b というデータが取得できました。 そろそろゴールが見えてきそうですね。心躍らせながらXOR-MAPPED-ADDRESSの仕様を確認して読み解いてみましょう。 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0 0 0 0 0 0 0 0| Family | X-Port | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | X-Address (Variable) +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 6: Format of XOR-MAPPED-ADDRE SS Attribute 今回は 99 a3 が(サーバから見た)ポート番号、その後ろ 17 ba 65 4b が(サーバから見た)IP アドレスになります。 どちらもMagic CookieとXORをとった値とあるので戻してみましょう。XORはもう一回かけると元の値に戻りますね。 bash$ echo $((0x17 ^ 0x21)).$(( 0xba ^ 0x12 )).$(( 0x65 ^ 0xa4)).$(( 0x4b ^ 0x42 )) 54.168.193.9 私のIPアドレスは 54.168.193.9 のようです。 答え合わせをしてみましょう。 bash$ curl httpbin.org/ip # 自分の IP アドレスを返してくれるWebAPI { " origin " : " 54.168.193.9 " } 正解でした! Pythonのパターンマッチングについて ここからはもう一つの主題である、パターンマッチングについてみていきます。 PythonのパターンマッチングはPEP 622で定義され、Python3.10で実装されました。 例をPEP 622から転載します。if elseよりもすっきりと表現できていますね。 match response.status: case 200 : do_something(response.data) # OK case 301 | 302 : retry(response.location) # Redirect case 401 : retry(auth=get_credentials()) # Login first case 426 : sleep(DELAY) # Server is swamped, try after a bit retry() case _: raise RequestError( "we couldn't get the data" ) バイナリデータに対するパターンマッチング Pythonでバイナリデータを扱う場合はbytes型がよく登場します。 ところが、パターンマッチングではbytes型を扱うことができません。 PEP 622から引用: To match a sequence pattern the subject must be an instance of collections.abc.Sequence, and it cannot be any kind of string (str, bytes, bytearray). It cannot be an iterator. collections.abc.Sequenceであれとのことなので、組み込み関数の memoryview() を使います。 memoryview() を使うと、コピーせずにシーケンスとして扱うことができます。 例として、「先頭2bytesがメッセージタイプ、その後ろ2bytesが長さ、その後ろにボディ」というデータを考えます。 これは以下のようにパースできます。 msg = b ' \x01\x02 ' + b ' \x00\x02 ' + b ' \x02\x03 ' match memoryview (msg): # "_" で読み飛ばすことができる # *data のように書くと、残り全てを受け入れる case [ 0x01 , 0x01 , _, _, *data]: print (f "type=Hello data={data}" ) # if を続けてバリデーションを書くこともできる # 2*len みたいに、長さを指定することはできない case [ 0x01 , 0x02 , len0, len1, *data] if len (data) == (len0 << 8 ) + len1: print (f "type=NewTransaction data={data}" ) case _: print ( "invalid message" ) # => type=NewTransaction data=[2, 3] サンプルデータ (msg) の先頭が 0x0102 なので、2つ目のcaseにマッチしています。 これをif文で書いたものと比較してみます。match文ではデータ構造が表現されていて、見通しがいいですね。 # 2つ目の case です if msg[: 2 ] == b ' \x01\x02 ' and len (msg[ 4 :]) == (msg[ 2 ] << 8 ) + msg[ 3 ] data = msg[ 4 :] print (f "type=NewTransaction data={data}" ) PythonでSTUNやってみる これで必要なパーツが揃いました。組み上げていきましょう。 まず、リクエストを送信してレスポンスを受け取る部分です。 def stun_binding_request_udp (sock: socket.socket, hostname: str , port: int ) \ -> tuple [ bytes , bytes ]: message_type = b ' \x00\x01 ' length = b ' \x00\x00 ' magic = bytes (MAGIC_COOKIE) transaction_id = RAND_bytes( 12 ) req = message_type + length + magic + transaction_id sock.sendto(req, (hostname, port)) res = sock.recv( 2048 ) return res, transaction_id 実際に作る際はTransport classみたいなのを作ってレイヤを分けるとか色々考えると思いますが、今回はサンプルなのでベタっと書いていきます。 でもってこのレスポンスの解析をパターンマッチングを使って書いてみます def stun_parse_response (message: bytes , transaction_id: list [ int ]) -> None : header = message[: 20 ] attr_data = message[ 20 :] # ヘッダの情報を元に、レスポンスが壊れていないかチェック match memoryview (header): case [ 0x01 , 0x01 , length0, length1, 0x21 , 0x12 , 0xA4 , 0x42 , *_tid] \ if _tid == transaction_id and \ len (attr_data) == (length0 << 8 ) + length1: logger.debug( "valid stun response" ) case _: logger.warning(f "invalid response: {message}" ) return # XOR-MAPPED-ADDRESS Attribute を抽出 # TODO : ~~面倒~~サンプルなので先頭に XOR-MAPPED-ADDRESS があると仮定 match memoryview (attr_data): case [ 0x00 , 0x20 , length0, length1, *value]: length = (length0 << 8 ) + length1 body = value[ 0 :length] logger.info(f "type: XOR-MAPPED-ADDRESS" ) _stun_parse_xor_mapped_address(value) case [type0, type1, *_]: logger.warning( f "unknown attribute: type=0x{type0:02X}{type1:02X}" ) そして最後に _stun_parse_xor_mapped_address() を実装したら完成です def _stun_parse_xor_mapped_address (attribute: list [ int ]) -> tuple [ bytes , int ]: match attribute: case [ 0x00 , 0x01 , xport0, xport1, *xaddress] if len (xaddress) == 4 : xport = (xport0 << 8 ) + xport1 port = xport ^ int .from_bytes( MAGIC_COOKIE[: 2 ], byteorder= "big" , signed= False ) address: str = "." .join( [ str (x ^ y) for x, y in zip (xaddress, MAGIC_COOKIE)] ) logger.info(f "global address: {address}:{port}" ) case _: logger.warning(f "unknown data: {attribute}" ) 足りない部分を補って動かしてみましょう。 import socket from logging import getLogger, StreamHandler, INFO, Formatter from ssl import RAND_bytes logger = getLogger(__name__) handler = StreamHandler() formatter = Formatter( "%(filename)s:%(lineno)s - %(levelname)s - %(message)s" ) logger.setLevel(INFO) handler.setFormatter(formatter) logger.addHandler(handler) MAGIC_COOKIE = ( 0x21 , 0x12 , 0xA4 , 0x42 ) # snip. def main (): stun_server = "stun.l.google.com" stun_port = 19302 af = socket.AF_INET with socket.socket(af, socket.SOCK_DGRAM) as sock: sock.settimeout( 5 ) response, transaction_id = stun_binding_request_udp( sock, stun_server, stun_port ) logger.info(f "local address: {sock.getsockname()}" ) stun_parse_response(response, list (transaction_id)) main() bash$ python3. 10 ~/stun-check0.py stun-check0.py:79 - INFO - local address: ( ' 0.0.0.0 ' , 53603 ) stun-check0.py:47 - INFO - type: XOR-MAPPED-ADDRESS stun-check0.py:26 - INFO - global address: 54 . 168 . 193 .9:53603 よさそうですね。 まとめ 実際にプロトコルを実装することで、普段漫然と使っていたSTUNの理解が深まりました。 また、パターンマッチングを使うことで、データの構造を表現し、if elseを見通しよく記述できました。積極的に使っていきたい機能ですね。