TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

411

新規プロダクトのリポジトリ構成にモノレポを採用してみた お久しぶりです ,DELISH KITCHEN開発部でSoftware Engineer(SE)をしている 鈴木 です. every Tech Blog Advent Calendar 2024(夏) の15日目を担当する事になりましたので,鈴木が開発に携わっている新規プロダクトで採用しているリポジトリ構成についてお話させていただきます. はじめに 私事ですが,夏の兆しを感じ始めたタイミングでトモニテ開発部からDELISH KITCHEN開発部に異動しました. 異動後も新規プロダクトの開発に携わっており,有り難いことに大部分を任せていただいているので, トモニテ相談室 の立ち上げ時に感じていた課題をユーザーへの影響を気にせずに払拭できる絶好の機会を楽しんでいます. 課題はいくつもあり,どれも払拭に向けて奔走中なのですが,大きなところとしてポリレポ構成をやめ,モノレポ構成を採用している点があります. 今回はポリレポ構成時に感じていた課題感を共有し,モノレポ構成だとどのように解決されるのかをお伝えしていこうと思います. ポリレポやモノレポとは? ポリレポとは,Git等でプロダクトに必要になるソースコードを管理する際,webやserverなどの各モジュールを別リポジトリとして管理する方式のことです. 一方でモノレポはポリレポと対を成す言葉であり,プロダクトに必要になる各モジュールを単一のリポジトリで管理する方式のことです. 従って,ポリレポではプロダクトに必要なモジュールの数だけリポジトリが作成されるのに対し,モノレポでは常に1つのリポジトリが作成されることになります. ポリレポとモノレポのイメージ ポリレポ運用時に感じていた課題 トモニテ相談室はポリレポ方式でモジュールを管理しています. 立ち上げ時の私の経験値や社内での知見の豊富さから当時は最適解であったのですが,開発を続けるにつれ以下のような点を課題に感じるようになりました. OpenAPI ファイルがserverモジュールに配置されており,server以外のモジュールがスキーマ駆動開発を実践できない(しづらい) 共通の定数を各モジュールがそれぞれ定義する必要があり不毛 順に詳細をお伝えしていこうと思います. server以外のモジュールがスキーマ駆動開発を実践できない トモニテ相談室ではOpenAPIファイルをserverモジュールに配置しています. OpenAPIはAPIの仕様を表現するためのものであるため,ポリレポ運用をしているプロダクトでserverモジュールに配置したくなるのは自然な流れかと思います. しかし,このような配置にすることでOpenAPIファイルを参照可能なモジュールがserverに限定されてしまうため,他のモジュールが単純にはスキーマ駆動開発を実践できなくなってしまいます. 共通の定数を各モジュールがそれぞれ定義する必要があり不毛 ポリレポ構成のプロダクトではユーザーのステータス等の共通で利用する定数は各モジュール毎に定義する必要があります. 各モジュールで言語を変えて同じ定数を定義していくのはいささか不毛であり,実装者もコードレビュワーも値に誤りがないかに神経をとがらせる必要があります. また,その値を変更しなければならなくなった際,定義されている全てのモジュールを調べ上げ,再び値に誤りが無いように変更しコードレビューをしていく必要があります. モノレポ構成による課題解決 現在私が開発している新規プロダクトは以下のようなモノレポ構成をとっています. . ├── web ├── server ├── constant └── openapi 定数やOpenAPIなどの共通で使われる部分をそれぞれconstantおよびopenapiという独立したモジュールに集約させており,以下のイメージのようにwebやserverなどが必要に応じてこれらに依存するようにしています. このような構成を取ることにより,ポリレポ時に感じていた課題を解決出来ています. 依存関係のイメージ どのように解決しているのかを一緒に見ていこうと思いますが,定数管理に対して感じていた課題もOpenAPIに対して感じていた課題も,本質的には各モジュールでシェアして利用すべきものをそのように出来ていなかったという点で共通しており,解決方法は酷似しています. 従って,本記事ではファイルの管理方法に特徴があるconstantモジュールをピックアップし,どのように解決したのかを見ていこうと思います. constantモジュールによる課題解決 constantモジュールは言語に依存しない形で定数を集約し,webやserver等のモジュールがタイプセーフに定数にアクセスすることを可能にする必要があります. 以下でどのように言語に依存しない形で定数を集約し,どのようにwebやserverからタイプセーフに定数にアクセス可能にしているかを紹介していきます. 言語に依存しない定数の集約方法 constantモジュールでは以下のように言語に依存しないで定数を集約するようにしています. constant/ └── model/ ├── status .json └── status .schema.json model/ 配下にはモデルに関連する定数を配置しており, (filename).json には実際の定数を, (filename).schema.json には定数のJSON Schemaを記述しています. status.json と status.schema.json の例はそれぞれ以下のようになります. { " deactive ": 0 , " active ": 1 } { " $schema ": " http://json-schema.org/draft-07/schema# ", " title ": " ステータス ", " description ": " ステータスを表現する定数を管理するオブジェクト ", " type ": " object ", " additionalProperties ": false , " required ": [ " deactive ", " active " ] , " properties ": { " deactive ": { " type ": " integer ", " example ": 0 } , " active ": { " type ": " integer ", " example ": 1 } } } webやserverから定数にアクセスする方法 quicktype というCLIツールを介してアクセス可能にしています. quicktypeはJSONやJSON Schema等から様々な言語のコードを自動生成するツールです. 以下のようにquicktypeを実行する(※)と, $ quicktype status .schema.json -s schema -o status .ts --prefer-types --readonly --no-runtime-typecheck 以下のようなコードが自動生成されます. なお短縮のために一部コメントを削除しているのでご留意下さい. export type Status = { readonly active: number; readonly deactive: number; } // Converts JSON strings to/from your types export class Convert { public static toStatus(json: string): Status { return JSON.parse(json); } public static statusToJson(value: Status): string { return JSON.stringify(value); } } このように quicktype を用いて (filename).schema.json をベースにコードを自動生成し,生成されたコードを利用し (filename).json を参照する事でwebやserverからアクセス可能にしています. openapiモジュールにおいても同様であり,OpenAPIファイルからコードを自動生成し,serverやweb等のモジュールでそれらを参照しています. ※ 短縮のために --no-runtime-typecheck フラグを利用していますが,このフラグを外すと (filename).json に記述されている値に誤りが有る際にバリデーションエラーが発生するようになり,ローカル開発時にミスを発見しやすくなるため,外すことをおすすめします. モノレポを採用してみて感じたメリット・デメリット 本記事執筆時点ではモノレポ歴は1ヶ月程度なのでまだまだ掴みきれていないと思いますが,上述の課題を解決できた以外にも以下のようなモノレポのメリットを感じています. issueが1つのリポジトリに集約されて分かりやすい プロダクトに必要なコードが1つのリポジトリに集約されていて楽.いつも同じリポジトリにアクセスすれば良い ローカル開発時に各モジュールの起動が楽.Docker Composeを用いて必要なモジュールをすぐに起動できる 一方で以下のようなデメリットも有ると思います. CI/CDが複雑になる 共通化に対して常に意識しなければならない 共通利用するものをどのように集約しどのようにアクセス可能にするかに一定のコストがかかる まとめ 本記事では私が新規プロダクトのリポジトリ構成にモノレポを採用した背景とその詳細,及びモノレポに対する所感を紹介させていただきました. ポリレポはその特徴故にプロダクトに関わる各モジュールで共通して利用すべきものをシェアすることが難しくなっています. その一方でモノレポは工夫こそ必要なものの,共通して利用すべきものを集約し,各モジュールでシェアすることが可能になっており,モノレポを採用することにより私は課題を解決することが出来ました. この記事が私と同じようにポリレポならではの課題を感じており,モノレポに対して興味を持っていらっしゃる開発者の方々のお役に立てたら大変嬉しいです. ここまでお読みいただきありがとうございました!
アバター
はじめに この記事は、 every Tech Blog Advent Calendar 2024(夏) の14日目の記事です。 株式会社エブリーでソフトウェアエンジニアをしている桝村です。 子育てメディア「MAMADAYS」は、2023年に「トモニテ」に名称変更しつつ、ロゴやアプリアイコンのデザインを刷新しました。 tomonite.com トモニテのサービス名称変更については、以下の記事でも詳しく紹介しています。 tech.every.tv また、サービス名称変更における対応の一つとして、Web メディアのドメインを mamadays.tv から tomonite.com へ変更しました。 本記事では、Web メディアのドメイン変更に伴う作業内容やそれによって得られた知見について紹介します。 この記事のゴールは、ドメイン変更を検討、または実施される方にとって、ドメイン変更の際に必要な作業や Google 検索結果への影響をできる限り抑えるためのポイント、およびドメイン変更で得た知見を共有することです。 前提 Web メディアのドメイン変更に際して、主な要件は以下の通りでした。 ・ 新ドメインでサービスを公開できていること ・ 旧ドメインへのアクセスは、新ドメインへ 301 リダイレクトされること ・ ドメインで紐付ける各種サービスを新ドメインへ再登録すること (ex. Search Console, Ad Manager) 要件を踏まえつつ、今後の Web メディアのサービス拡大を見据えて、今回は以下を意識・考慮しながらドメイン変更を進めることにしました。 ・ 大きな不具合や遅延なくドメイン変更をやり遂げること ・ Google 検索結果への影響をできる限り抑えること ちなみに、Google 検索結果への影響をできる限り抑えることにあたって、サイト移転に関する Google の公式ドキュメントがあったので、これに従って必要な作業を進めていきました。 developers.google.com また、AWS リソースの管理には Terraform を使用しているため、今回のドメイン変更においても Terraform でリソースを追加・変更する作業を進めていきます。 ドメイン変更の準備 今回のドメイン変更の関係箇所をイメージしやすいように構成図にまとめました。 アーキテクチャ図 新ドメインの公開の準備 Amazon Route 53 により、新ドメインの公開の準備を行います。 新ドメインの DNS を Route 53 に変更 Route 53 にて新ドメインのホストゾーンを作成し、お名前.com などのドメイン登録サービスにて Route 53 のネームサーバーを利用するように設定を変更 新ドメインの公開の準備 Route 53 にて 新ドメイン用の A レコードを追加する Pull Request を作成・レビュー A レコードの追加自体は、ドメイン変更の実施当日に行いますが、作業の正確性・一貫性の担保のため、事前に Terraform で PR 作成・レビューまで済ませておきます。 新ドメインへの通信を HTTPS で保護 ACM (AWS Certificate Manager) により、新ドメインへの通信を HTTPS で保護します。 証明書のリクエスト ACM から SSL/TLS 証明書をリクエスト 証明書の検証 リクエストしたドメインに対して所有権を証明するため、DNS レコードを追加 証明書のデプロイ ACMから取得した証明書を ELB (Elastic Load Balancer) にデプロイ また、Datadog などの監視ツールを使って証明書の有効期限が切れる前に通知を受け取る設定をしておきます。 旧ドメインから新ドメインへのリダイレクトの準備 AWS ALB (Application Load Balancer)により、旧ドメインから新ドメインへのリダイレクトの準備を行います。 AWS ALB のリスナールールにて、旧ドメイン mamadays.tv から新ドメイン tomonite.com へのステータスコード 301 でリダイレクトするルールを追加する Pull Request を作成・レビューします。 新ドメインへリダイレクトさせるルール 今回は、Aレコードと同様に 事前に Terraform で PR 作成・レビューまで済ませておきます。 resource "aws_alb_listener_rule" "mama_web_to_tomonite_web_rule" { listener_arn = aws_alb_listener.mamadays_ecs_lb_https.arn priority = 4 action { type = "redirect" redirect { host = "tomonite.com" port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } condition { host_header { values = [ "mamadays.tv" ] } } } Search Console にて、新ドメインのプロパティを作成 Web メディアのドメイン変更を実施した際は、SEO への影響が大きな懸念事項の一つです。 今回は Google Search Console を使って、新ドメインと旧ドメインでクローラーによるページのインデックスやユーザーのトラフィックがどのように変化するかを監視します。 それを実現すべく、予め新ドメインのプロパティを作成しておきます。 support.google.com ただし、手順の一つであるサイトの所有権を確認は、DNS レコードの追加による方法で行う方針のため、新ドメインの公開後に対応します。 support.google.com 当日のドメイン変更の手順書を作成 作業の正確性・一貫性の担保や社内への共有のため、当日のドメイン変更の手順書を作成します。 # 1. 新ドメインの公開・リダイレクト # 2. Search Console にて、元のサイトのアドレス変更の通知を送信 # 3. 新ドメインをベースとしたサイトマップを構築・配置・送信 ※ 他にも細かい手順はありますが、ここではわかりやすさを重視して簡潔にまとめています。 ドメイン変更の実施 1. 新ドメインの公開・リダイレクト 「ドメイン変更の準備」にて事前に準備しておいた Aレコードの追加や ALB のリスナールールの追加を実施します。 今回は Terraform の PR をもとに terraform apply を実行するのみでした。 これでついに新ドメインが公開されました! 旧ドメインへのアクセスも新ドメインへリダイレクトされるようになります。 2. Search Console にて、元のサイトのアドレス変更の通知を送信 Google 検索の検索結果について旧ドメインから新ドメインへの移行を促すため、Search Console にて、元のサイトのアドレス変更の通知を送信しました。 support.google.com 新ドメイン側の Search Console にて以下の画面が表示されていると、アドレス変更が正常に処理されたことを示していると思われます。 アドレス変更の通知が送信完了 3. 新ドメインをベースとしたサイトマップを構築・配置・送信 Google 検索エンジンに旧ドメインでなく新ドメインのページを認識してもらう必要があります。 そのために、新ドメインをベースとしたサイトマップを構築・配置した上で、Search Console にてサイトマップを送信しました。 support.google.com 全体を通して振り返り トラフィック数は2ヶ月弱で変更前の水準に回復 ドメイン変更での大きな関心ごとの一つは、SEO への影響です。 新ドメインのページのインデックス登録は、ドメイン移行後の 1 ~ 2週間で完了しました。 また、Google 経由のトラフィック数は、ドメイン移行後の最初の2週間にはドメイン移行前の3分の2 ほどに減少しましたが、その後は徐々に回復し、ドメイン移行後の2ヶ月弱後にはドメイン移行前のトラフィックとほぼ同等の水準に戻りました! 目立った不具合なくドメイン変更を実施できたことや、Google 検索のドキュメントに従って丁寧に作業を進められたことも、この結果に寄与したのではないかと考えています。 ドメイン変更後のGoogle検索のトラフィックの推移 ※ ドメイン変更前の過去2ヶ月の平均 本番での作業を最小限にできた 当日の本番環境での作業が多かったり複雑であるほど、ヒューマンエラー等により作業の不具合や遅延が発生する可能性は高くなると思います。 今回はドメインの登録・リダイレクトを一つの CLI コマンドのみで実行できるようにする等、できるだけ本番環境での作業を最小限にでき、その結果目立った不具合などなく作業を完了できました。 事前に動作確認できる仕組みがあればよかった 今回開発工数の都合上開発環境での動作確認をメインに行いました。 しかし、理想としては本番環境との潜在的な差異を考慮してユーザーへの展開をする前に本番環境での動作確認も行うべきだったと考えています。 具体的には、特定の IP やユーザーエージェントからのアクセスのみ新ドメインへアクセスできるようにして動作確認を行うなどの方法が考えられます。 監視ツールの設定変更が漏れていた ドメイン変更を実施したタイミングにて、監視ツール Datadog により WEB の死活監視に失敗したアラートが飛んでしまいました。 ページ自体は正常に見れており、原因としては 旧ドメインはリダイレクトさせたため、Datadog の死活監視の設定もステータスコード 200 に加えて 301 も許容するように変更する必要がありました。 本番環境での作業中にアラートが飛ぶことは、作業の遅延やミスに繋がる可能性があるので、監視ツールの設定も網羅的に見直すべきでした。 おわりに 今回は Web メディアのドメイン変更に伴う作業内容やそれによって得られた知見について紹介しました。 これから ドメイン変更を検討されている方、またはドメイン変更を実施される方にとって、参考になれば幸いです。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024(夏) 13日目の記事です。 こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 今回は将来的なMLモデルのサービス組み込みに向けた調査の一環として、Databricks Model ServingとAWS API Gatewayを利用してML APIを作成するPoC行ったので、その取り組みについて紹介します。 Databricks Model Serving Databricks Model Servingは、Databricksが提供するAIモデルをデプロイ・管理するためのサービスです。 Databricks上で構築・学習したモデルや、Databricks Marketplace上で公開されているモデルをデプロイし、REST APIを通じて推論を行うことができます。 https://docs.databricks.com/ja/machine-learning/model-serving/index.html データ基盤としてDatabricksを利用している現環境では、Databricks Model Servingを利用することで、モデル構築からデプロイまでの一貫した開発フローを提供できると考えました。 DatabricksでのML開発フロー AWS API Gateway AWS API Gatewayは、APIを作成・公開・管理するためのサービスです。 https://aws.amazon.com/jp/api-gateway/ 構成 今回のPoCでは、以下を参考に、Databricks Model ServingとAWS API Gatewayを組み合わせてML APIを作成しました。 https://aws.amazon.com/jp/blogs/news/creating-a-machine-learning-powered-rest-api-with-amazon-api-gateway-mapping-templates-and-amazon-sagemaker/ 以下のような構成になります。 - Databricks Model Servingでモデルをデプロイ - Model Servingで提供されるServing EndpointのRest APIをAWS API Gatewayでラップ - API Gatewayで提供されるエンドポイントにリクエストを送信し、推論結果を取得 Databricks Model Servingが提供するServing EndpointをAWS API Gatewayでラップすることで、AWS API Gatewayが持つAPIの管理や認証、モニタリングなどの便利な機能が利用できます。 構成 実装 モデルの構築 今回はサンプルとして、入力を二倍にして返す単純なモデルを構築します。 モデルの構築 import mlflow class MyModel (mlflow.pyfunc.PythonModel): def predict (self, context, model_input, params= None ): # in [1,2] -> out [2,4] return model_input * 2 モデルの登録 import mlflow from mlflow.models import infer_signature import pandas as pd model = MyModel() input_data = pd.DataFrame([{ "q" : 2 }, { "q" : 10 }]) output = model.predict( None , input_data) # 4, 20 signature = infer_signature(input_data, output) with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( "test_model" , python_model=MyModel(), input_example=input_data, signature=signature, registered_model_name= "test_model" , ) model_info.model_uri databricks上で mlflow.pyfunc.log_model を実行することで、Databricks Model Registryにモデルが登録されます。 databricks models モデルのデプロイ Databricks Model ServingのUIからモデルをデプロイします。 エンドポイント名、利用するモデル、コンピュートリソースなどを設定しデプロイします。 デプロイが完了すると、Serving Endpointが提供されます。 databricks model serving Serving Endpointのテスト Serving Endpointに対してリクエストを送信し、推論結果を取得します。 REST APIを利用するため、 curl コマンドなどでリクエストを送信することができます。 また、Databricks Model ServingのUIや、Databricks SQLからもリクエストを送信することができます。 query curl \ -X POST \ -H "Content-Type: application/json" \ -d '{"dataframe_split": {"columns": ["q"], "data":[100,20]}}' \ https://{databricks-host}/serving-endpoints/test-model/invocations | jq . result { " predictions ": [ { " q ": 200 } , { " q ": 40 } ] } API Gatewayの設定 Databricks Model ServingのServing EndpointをAWS API Gatewayでラップします。 AWS API Gatewayのマッピングテンプレート機能を使い、リクエスト/レスポンスの変換を行います。 API Gatewayのマッピングテンプレート機能 統合リクエストのマッピングテンプレート # set ( $ queries = $ input . params (" query ") ) { " dataframe_split " : { " columns " : [ " q " ] , " data " : [ # foreach ( $ query in $ queries . split (","))$ query # if ($ foreach . hasNext ),# end # end ] } } 統合レスポンスのマッピングテンプレート # set ($ predictions = $ input . path ("$. predictions ")) { " results " : [ # foreach ( $ item in $ predictions )$ item . q # if ($ foreach . hasNext ),# end # end ] } APIの呼び出し API Gatewayのエンドポイントに対してリクエストを送信し、推論結果を取得します。 query curl "<invoke-url>/test?query=1,2" result { " results ": [ 2 , 4 ] } まとめ 今回はDatabricks Model ServingとAWS API Gatewayを組み合わせてML APIを作成するPoCを行いました。 Databricks Model Servingを利用することで、モデルの構築からデプロイまでの一貫した開発フローを提供できます。 また、AWS API Gatewayを利用することで、APIの管理や認証、モニタリングなどの便利な機能が利用できます。 Databricks Vector Searchなどと組み合わせることで、RAGアプリケーションの提供や、検索エンジンの構築など、様々なサービスに活用できると考えられます。
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 12 日目の記事です。 今週はWWDCでiOS 18やXcode 16の情報が公開されていますが、この記事では昨年9月にリリースされたXcode 15で実装されたアセットカタログの画像/色のシンボル自動生成機能についての説明と、トモニテアプリへの適用(途上です)について書きます。 Xcode 15の画像/色のシンボル自動生成機能 Xcode 15から、アセットカタログ内の画像/色に対してSwiftのシンボルを生成できるようになりました。今までの名前文字列で指定する方法と比較すると、コード補完が効き、コンパイラの型チェックがされるため安全です。 例えば post_icon という画像がアセットカタログに含まれる時、これまでは以下のようにアセットの名前を文字列で記述していました。 // Swift UI Image( "post_icon" ) // UIKit UIImage(named : "post_icon" ) Xcode 15では、build settingsの Generate Asset Symbols (ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS) がYESの時、アセットカタログに含まれる画像/色に対応するシンボルが自動的に生成されます。(デフォルトでYESですが、NOに設定することで無効化できます) 生成されたシンボルを使って以下のように書くことができます。 // SwiftUI Image(.postIcon) // UIKit UIImage(resouce : .postIcon) さらに、 Generate Swift Asset Symbol Extensions」(ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS) をYESにするとUIImageのExtensionが生成されて以下のように書けるようになります。(デフォルトは無効です) // UIKit UIImage.postIcon (ここまで画像の場合しか説明していませんが、色についてもほぼ同様です) SwiftGenなどを導入しているプロジェクトも多いと思いますが、Xcode 15からは画像/色に関してはXcode単体で同様のことが可能です。 トモニテアプリへの適用 トモニテアプリはSPMによるマルチモジュール構成になっており、機能別に分割され独立したパッケージになっています。 一方アセットカタログはメインバンドルに1つだけ存在し、アプリで使う全てのアセットを含んでおり、各パッケージから参照されています。 この環境で、生成されたシンボルを利用しようとした時に問題がありました。 生成されたシンボルはアセットカタログが含まれるモジュール内でしか利用できないため、各機能モジュールから画像を参照できません。 // モジュールから Image(.postIcon) // Type 'ImageResource' has no member 'postIcon' この問題には以下の方針で対応することにしました。 特定のモジュールだけで利用するアセットは、モジュール毎に作成したアセットカタログに移動する。 複数のモジュールで利用するアセットは、 Common モジュールに作成したアセットカタログに移動する。Common にはアセットにアクセスするためのpublicなextensionを用意する 元々の構成ではパッケージ外のアセットに暗黙的に依存しているという問題意識もあったので、この際依存関係の整理を兼ねて修正したいと考えています。 各モジュールのアセットカタログ 各パッケージのSource以下にアセットカタログファイルを作成し、画像を格納します。 SPMパッケージのSources以下のxcassetsファイルは自動的にシンボル生成の対象となるようです。そのため同パッケージ内では以下のように画像にアクセスできるようになります。 // 名前で指定 Image( "post_icon" , bundle : .module) //シンボルで指定 Image(.postIcon) // bundle指定は不要 ただしSPMパッケージではExtensionsは生成されないようなので、 UIImage.postIcon といった記述はできません。 Commonのアセットカタログ Commonパッケージ(UIの共通部分、デザインシステムを扱う)にアセットカタログを作成し、共通アセットを格納します。 また、以下のようなExtensionを定義して外部から画像にアクセスできるようにします。 extension Image { public static var close : Image { Image(.close) } } extension UIImage { public static var close : UIImage { UIImage(resource : .close) } } 共通画像は数が少なく、変更頻度も低いのでこのような運用でも許容できると考えていますが、画像が多くなったら課題になりそうです。 最後に Xcode 15の画像/色シンボル自動生成機能をSPMによるマルチモジュール環境へ適用する方法について書きました。どなたかの参考になれば幸いです。
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 11 日目の記事です。 エブリーで小売業界向き合いの開発を行っている @kosukeohmura といいます。 エブリーでは retail HUB という小売業界向けのサービスを展開しており、その開発を行う中でイベントログを収集する API を作る機会がありました。この記事ではその中でも表題の点にフォーカスして詳細をお伝えできればと思います。 イベントログを収集する API の概観 クライアントからのイベントログを API Gateway で作成した API で受け、Amazon Data Firehose ストリーム経由で S3 に保存します。 イベントログデータの流れ API では一度のリクエストで複数のイベントを受け取り、その後 Amazon Data Firehose の PutRecordBatch API を使用し、受け取ったイベントをまとめて Firehose ストリーム へと送信します。Amazon Data Firehose では受け取ったデータをバッファしながら、動的にパーティショニングを行いつつ、S3 に保存します。 ここで API Gateway から Amazon Data Firehose ストリームへデータを送信する際に、データ構造を少し変換する必要があります。次節で詳しく説明します。 API Gateway から Amazon Data Firehose ストリームへデータを送信する Amazon Data Firehose 配信ストリームへ送信する際 PutRecordBatch API を使用しますが、その API へのリクエストでは次のシンタックスを要求されます。ここで blob はレコード 1 つを Base64 エンコードしたものです。 { " DeliveryStreamName ": " string ", " Records ": [ { " Data ": blob } ] } 具体的に、API で受け取る JSON が下記のような値であったとすると、 [ { " field1 ": " value11 ", " field2 ": " value12 " } , { " field1 ": " value21 ", " field2 ": " value22 " } ] このような PutRecordBatch API へのリクエストに適合する JSON に変換する必要があります。 { " DeliveryStreamName ": " string ", " Records ": [ { " Data ": " eyJmaWVsZDEiOiAidmFsdWUxMSIsICJmaWVsZDIiOiAidmFsdWUxMiJ9Cg== " } , { " Data ": " eyJmaWVsZDEiOiAidmFsdWUyMSIsICJmaWVsZDIiOiAidmFsdWUyMiJ9Cg== " } ] } Lambda を挟んで変換処理しようかと考えましたが、その場合、管理・運用する対象が Lambda 関数のソースコード 使用メモリ量等の設定 実行ログを流す CloudWatch Logs ストリーム 関数実行時の IAM ロール その他、実行失敗時の通知機構など と増えます。加えて、Lambda ランタイムのサポート切れや実行時の料金も考慮を要します。 なので Lambda の利用をできれば避けたいと思っていたところ、API Gateway の マッピングテンプレート を利用することでリクエストボディの変換ができることを知り、今回はその方法を取りました。 マッピングテンプレートを使用してリクエストを書き換える マッピングテンプレートは Velocity Template Language (VTL) で記述します。実際に API Gateway の統合リクエストで使用しているテンプレートほぼそのままを下記に記します。 ## 1. メタデータ付加を行うパート #set($records = []) #foreach($inputRecord in $input.path('$')) #set($record = '') #foreach($key in $inputRecord.keySet()) ## 1-a. 値の型に応じて、クォートでくくったりします #set($value = $inputRecord.get($key)) #if($value == $null) #set($value = 'null') #elseif($value.getClass().getName().equals('java.lang.String')) #set($value = '"' + $value + '"') #end #set($record = $record + '"' + $key + '"' + ':' + $value + ',') #end ## 1-b. 必要なメタデータをログに付加します #set($record = $record + '"server_time":' + $context.requestTimeEpoch / 1000 + ",") #set($record = $record + '"source_ip":' + '"' + $context.identity.sourceIp + '"' + ",") #set($record = $record + '"user_agent":' + '"' + $context.identity.userAgent + '"' + ",") #set($record = $record + '"request_id":' + '"' + $context.requestId + '"') #set($record = '{' + $record + '}') ## 1-c. エラー回避のため空代入しています (もっとスマートな方法をご存じの方、教えて下さい!) #set($dummy = $records.add($record)) #end ## 2. PutRecordBatch API へのシンタックスへと変換し出力するパート { "DeliveryStreamName": <your_firehose_stream_name>, "Records": [ #foreach($record in $records) {"Data": "$util.base64Encode($record)"}#if($foreach.hasNext),#end #end ] } なおこのマッピングテンプレートでは、API のリクエストボディの JSON の形式として次を想定しています: 最上位は配列 その配下にフラットなオブジェクトが並ぶ また、テンプレート内の $ から始まる変数リストはこのリファレンスに載っています。 docs.aws.amazon.com 以下、テンプレートの内容を 2 つに分けて簡単に解説します。 1. メタデータ付加を行うパート サーバー側で取得するのが望ましいリクエスト日時や IP アドレス、User-Agent など、イベントログと合わせて保存したいメタデータを付加しています。そのため一旦 JSON をパースしますが、そこから元の JSON 文字列に戻すために value をクォートでくくりなおしたり、value が null の場合に欠落してしまう ( "key":, となる) のを避けるなど、ひと手間かかっています。 2. PutRecordBatch API へのシンタックスへと変換し出力するパート PutRecordBatch API の仕様通り、各イベントログを Base64 エンコードしつつリクエストボディを組み立てます。 Lambda を挟んでのデータ変換との良し悪し 今回 API Gateway 内で簡単なデータ変換を行いつつ、Amazon Data Firehose ストリームへとデータを送信することができました。 マッピングテンプレートでは VTL を使う必要があり、これに不慣れな方も多いと思います。ただ VTL はテンプレート言語であり、動的な処理を行うには限界があるため、VTL 自体の習得はさほど難しくはないと感じています。 今回マッピングテンプレートを完成させる中で問題だと感じたのは、API Gateway のテスト実行時に VTL の実行結果のエラーの詳細を見る方法がない(と思われる)点です。エラー原因の見当がつかない場合、マネジメントコンソール上の VTL を少しずつ修正しながら、トライアンドエラーを繰り返すような泥臭い作業が必要でした。 このことから、実装したい変換がある程度複雑であれば、管理・運用する対象を増やすことを受け入れ、Lambda 関数を呼び出し加工したデータを Firehose 配信ストリームに送信する形を検討したほうが良いかもしれません。一方 VTL で書いても十分にシンプルであれば、マッピングテンプレートを使用する方針を取りたいと思います。 さいごに ここまで読んでいただきありがとうございました。 every Tech Blog Advent Calendar 2024(夏) はまだまだ続きます!
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 10 日目の記事です。 はじめに こんにちは。DELISH KITCHEN 開発部の村上です。 エブリーでは4月に第4回挑戦weekを実施しました。挑戦week5日間の中で私たちのチームはナレッジ活用のために社内ChatAppに社内ドキュメントを参照できる仕組みづくりに取り組みを行いました。今回はその中でRAG基盤のPoCを行ったので、その取り組みについて紹介します。 挑戦weekについてはこれらの記事で初回の取り組みの様子やCTOの挑戦weekに対する考えが知れるのでぜひ読んでみてください。 https://everything.every.tv/20230428 tech.every.tv PoCの背景 まずは、なぜ社内ナレッジ活用のためのRAG基盤のPoCを行うに至ったか、その背景について説明します。 エブリーでは社内のナレッジを蓄積する場所として、ConfluenceやGoogle Docs、Google Slideを活用しています。こうしたナレッジが溜まっていくこと自体は良いことですが、以下のような問題が社内でも起こることがありました。 ドキュメント自体の量が多くなってきており、ある程度整理していても欲しい情報に簡単に辿りつけない 探す手間を省くために担当者への問い合わせが増え、コミュニケーションコストがかかってしまう 元々社内ではStreamlitを用いて作られたOpenAI APIベースの社内ChatAppがあり、業務効率化に活用されています。しかし、現状のChatAppは業務利用で安全に生成AIを活用するための基盤であり、社内ドキュメントに溜まったナレッジを外部データソースとして読み込んで、それらのデータに基づいて質問に回答することはできませんでした。 そこで今回はこうした課題を解決すべく、社内で溜まったナレッジに基づいて回答できる機能をChatAppに組み込むためのRAG基盤のPoCを実施することになりました。 RAGとは RAGとはRetrieval Augmented Generativeのことで、Retrievalの『検索』工程とGenerativeの『生成』工程を組み合わせることによってLLMが内部では持たない外部の知識に基づいた回答の生成を行うことができます。Retrivalでは与えられた質問に対して外部データソースから関連する情報を取り出し、GenerativeではRetrivalで抽出したデータをpromptにコンテキストとして渡すことでその内容に沿った回答を生成します。 RAGの詳しい動作の解説に関しては以前エブリーのテックブログで紹介されたこちらの記事をみていただけると理解が進むと思います。 tech.every.tv RAG基盤の検討 RAG基盤を構築していく中で4つの選択肢を検討し、比較を行いました。 Amazon Q Business Amazon Q BusinessとはAWSが企業用に提供するフルマネージドな生成AIサービスで、RAG基盤の提供からセキュアなChatインターフェースの提供までを全てサポートしてくれます。 docs.aws.amazon.com 行ったこと data source connector機能でのConfluence Connectorを使った外部データソースの準備 Amazon Q Businessのチャット画面での社内ドキュメントに関するQ&Aを実施 メリット RAG基盤の構築や独自のChatインターフェースの提供を行わなくても良いため、工数が削減できる 外部サービスとのデータコネクタが豊富で接続のための実装をせずに導入することが容易 デメリット 現状では日本語対応がされておらず、使用には翻訳工程を挟む必要がある フルマネージドなため、他の選択肢と比較してもカスタマイズの余地が少ない 結果 Amazon Q Businessでは特に 日本語のサポートがされておらず 、日本語でのデータ保存とそれに対する質問に正常に動作することが困難な点が採用するサービスとしては厳しかったです。 実際にこの状態で利用するためには以下の各パートで翻訳工程を独自に挟む必要があります。 data enrichment機能によるデータコネクタから取得したデータの前処理 質問、回答の入出力 こうなるとAmazon Q Businessのメリットがなくなってしまいます。とはいえ、メリットとして挙げられるデータコネクタ機能は魅力的で40以上ものサービスをサポートしているので、今後の日本語対応にも期待したいです。 Amazon Bedrock + Knowledge base Amazon BedrockとはAWSが提供する基盤モデルを活用した生成AIアプリケーションを構築するサービスです。RAGを使わずとも基盤モデルを使うことはできますが、RAGの機能としてKnowledge baseがあり、今回は一緒に検証を行いました。 docs.aws.amazon.com 行ったこと 基盤モデルはClaude 3 Sonnetを採用 Knowledge baseとしてデフォルト構成のS3 + OpenSearch Serverlessを採用 API経由での社内ドキュメントに関するQ&Aを実施 メリット 自分で実装せずにRAGに関する様々な手法を簡単に組み込める 埋め込みモデルを使ったベクトル化 ハイブリット検索の適用 参照ファイル名の返却 AWS完結でリソース連携ができる 普段使ってるS3やOpenSearch、他サービスとの連携もスムーズ デメリット RAGでの最新の手法を使いたい場合は精度向上でのカスタマイズの余地が限定的 リージョンはオレゴン、バージニア北部限定 結果 Amazon Bedrockでは自由な基盤モデルの選択とKnowledge baseを活用することでRAGで試したい手法がすでに内部ロジックとして入っていたり、オプションとして用意されているのはかなりエンジニアの工数削減になりそうです。 例えば、直近ではAmazon Bedrockで ハイブリット検索のサポート が発表されましたが、こういったRAG手法のアップデートにAWS側が追従してくれてすぐに使用可能な状態になるのは開発者としては嬉しいです。特にかなり早いスピードで手法が研究されているこの分野では日々世の中に出てくる新しい手法を自分たちで取り入れてついていくことはエンジニアのリソースが限られている場合には難しいので、Amazon Bedrockを使用する大きな理由になると感じました。 OpenAI Assistant API v2 Assistant APIはOpenAIが提供するアシスタント開発のためのRetrievalやCode Interpreterを利用できるAPIです。4月にはv2のリリースが行われ、RAGにおいてもfile_searchやvector_store機能の追加など大幅に強化されました。 https://platform.openai.com/docs/assistants/whats-new platform.openai.com 行ったこと vector_storeへのデータ格納 file_search機能を使ったRAGの処理を実装 API経由での社内ドキュメントに関するQ&Aを実施 メリット OpenAIを利用したい場合のRAGの選択肢として一番工数が削減できる 埋め込みモデルを使ったベクトル化 リランキング クエリRewrite デメリット 後述する回答精度の検証においても精度が悪く、誤回答や参照ファイルの引用ができないケースが目立つ 1アシスタントにつき、1万件がファイル上限となる RAG周りの処理でのチューニングはかなり限定的 結果 Assistant APIを使うことでAmazon Bedrockと同じようにfile_searchを使うことでRAG周りの処理での最適化が行われるのは強みに感じました。ただ、Amazon Bedrockと比較するとファイルサイズでの制限事項があったり、オプションとして選択できる余地も限定的なため、その辺りは要件に合わせて判断する必要があります。 特に今回の検証で気になったのは精度面の部分です。他のRAG基盤と同じ質問をした場合にAmazon Qを除くと最も誤回答が多く、精度改善のためにはデータ投入の仕方などいくつか試しながら試行錯誤していくことになりそうです。 OpenAI Chat API + VectorDB(pinecone) 最後はOpenAIが提供するChatAPIとVectorDBを使った自前でのRAG基盤の構築になります。今回はVectorDBとしてpineconeを選択して検証を進めました。 https://platform.openai.com/docs/api-reference/chat platform.openai.com www.pinecone.io 行ったこと ドキュメントデータをembeddings APIでベクトル化してpineconeに保存 LangChain を使ったRAGの処理を実装 社内ドキュメントに関するQ&Aを実施 メリット 自前実装のため、ブラックボックス化されているところが少なくチューニングの自由度が高い すでにChatAPIを使用している基盤がある場合は追加実装で導入可能 デメリット 専属でメンテナンスできるチームがいない場合には、実装・運用コストが高い 結果 自前でVectorDBと組み合わせて実装を行う場合、その工夫の余地は大きいのが強みです。一方でその実装の自由度の高さはチーム状況によって大きなメリットになるか、デメリットになるか大きく分かれていきそうに感じました。Amazon Bedrockでも記載の通り、RAG周りでの手法の研究が急速に進展する中で、高精度を求めてチューニングできるチーム体制があれば非常に有効ですが、そうではない場合にはAmazon Bedrockのサービスでのサポートの方が自分たちが対応するより早いという結果になり、採用するメリットが薄れると思います。 各基盤の比較 ここまでそれぞれの選択肢のメリット、デメリットを記載してきましたが、最後に回答精度の検証も含めた比較を行いました。 質問と模範回答からなるテストデータを準備 社内のドキュメントデータとしてConfluenceの特定のワークスペースをHTMLで保存する形で用意しました。精度評価に関してはより厳密にやるのであればベクトル変換した結果の意味の近さで判定する手法もありますが、今回に関してはそれぞれのConfluenceページに対する質問とそれに対して回答する場合に正しくそのページIDを引用できたかを精度の指標としています。下記のようなデータセットを50問ほど用意しました。 [ { " question ": " Google アカウントにログインできない場合、どうすればいいですか? ", " answerID ": " 1990623256 " } , { " question ": " Zoom Roomを利用する具体的な手順を教えてください。 ", " answerID ": " 2027815464 " } , { " question ": " ビルの入館証を発行するにはどうすればいいですか ", " answerID ": " 2182709419 " } , ... ] 特に今回の社内独自で溜まった知識をもとに答えさせるような場合に間違った情報をさも正しい回答かのように振る舞ってしまうハルシネーションは極力避けたいですし、回答文言が間違えていたとしても参照するページを正しく引用できていれば利用者は自分でその回答の正しさを評価できるので重要な比較基準になります。 2024年4月時点での回答精度も含めたそれぞれのRAG基盤の比較を表にしてまとめました。 RAG基盤 カスタマイズ性 実装の容易さ 正答率 Amazon Q Business × △/× - Amazon Bedrock + Knowledge base △ ○ 90% Assistant API v2 × ○ 50% OpenAI API + pinecone ○ △ 85% ※Amazon Q Businessは日本語未対応のため、精度評価は実施していません Amazon BedrockとOpenAI API + pineconeでは正答率に大きい差は今回では生まれず、どちらもほとんどチューニングをせずともある程度高い回答精度が出る結果となりました。一方で前述したようにAssistant API v2にはまだ回答精度面でいい結果が出ず、うまく参照ファイルを引用できないものも多かったです。 こうした比較検討の結果、現状の選択肢としては以下2点になりそうです。 RAG基盤のメンテナンスにリソースを十分に取れる場合にはOpenAI API + VectorDBを使った自前開発 AWSリソースとの連携を重視したり、RAG基盤開発でのリソースが十分に取れない場合にはAmazon Bedrock Amazon Bedrockと社内ChatAppの繋ぎこみ これまでの検討からエブリーでRAG基盤を構築する場合、Amazon Bedrockの導入の可能性が高い一方で社内での知見も少なかったため、Amazon Bedrockを選択した場合の社内ChatAppへの繋ぎこみを検証しました。 質問への回答テキストの生成 回答テキストの生成部分は以下のようにKnowledge baseとモデルを指定することで参照ファイルと回答を同時に取得することができます。参照ファイルが1件も見つからない場合には回答文言があってもそれを引用せずに固定文言を返すことで回答できない場合の案内も誘導できます。 def generate_text_from_knowledge_base (self, query): response = self.bedrock_client.retrieve_and_generate( input ={ 'text' : query}, retrieveAndGenerateConfiguration={ 'type' : 'KNOWLEDGE_BASE' , 'knowledgeBaseConfiguration' : { 'knowledgeBaseId' : self.kb_id, 'modelArn' : self.model_arn, } } ) text_response = response[ 'output' ][ 'text' ] urls = set () # セットを使ってURLの重複を防ぐ # citationsからretrievedReferencesを処理する if 'citations' in response: for citation in response[ 'citations' ]: if 'retrievedReferences' in citation: for ref in citation[ 'retrievedReferences' ]: if 'location' in ref and 's3Location' in ref[ 'location' ]: uri = ref[ 'location' ][ 's3Location' ][ 'uri' ] file_name = urlparse(uri).path.split( '/' )[- 1 ] # .html拡張子を削除 file_name = file_name.replace( '.html' , '' ) url = "https://xxxxxx.atlassian.net/wiki/spaces/hr/pages/" + file_name urls.add(url) # 重複しないようにセットに追加 if len (urls) > 0 : text_response += " \n\n 参考URL: \n\n " + " \n " .join(urls) else : text_response = """ 申し訳ありません。入力した質問に関する回答が見つかりませんでした。質問内容を変えるか、#all_citで担当者に問い合わせてください。 """ return text_response 社内ドキュメントのKnowledge baseへのデータ投入 今回はConfluenceのAPIを使って、特定のワークスペースの全ページのHTMLをS3に保存する形で検証を行いました。Knowledge baseではS3に投入しただけでは反映されないので同期処理を実行する必要があります。データ取得の詳細は省きますが、以下のような処理を定期実行することによって、常に最新の情報を更新することができます。 def resource_data_sync (bedrock_data_source_id, bedrock_knowledge_base_id): try : boto3.client( 'bedrock-agent' ).start_ingestion_job( dataSourceId=bedrock_data_source_id, knowledgeBaseId=bedrock_knowledge_base_id ) except ClientError: print ( "Couldn't resource data sync" ) raise def main (): try : # データ更新に必要な値をセット confluence_user = os.environ.get( "CONFLUENCE_USER" ) confluence_api_token = os.environ.get( "CONFLUENCE_API_TOKEN" ) confluence_domain = os.environ.get( "CONFLUENCE_DOMAIN" ) confluence_export_root_page_id = os.environ.get( "CONFLUENCE_EXPORT_ROOT_PAGE_ID" ) s3_bucket = os.environ.get( "S3_BUCKET" ) s3_bucket_prefix = os.environ.get( "S3_BUCKET_PREFIX" ) bedrock_data_source_id = os.environ.get( "BEDROCK_DATA_SOURCE_ID" ) bedrock_knowledge_base_id = os.environ.get( "BEDROCK_KNOWLEDGE_BASE_ID" ) # 既存で投入済みのs3データを全て削除 clear_files(s3_bucket, s3_bucket_prefix) # コンフルのページをs3にアップロード download_page(s3_bucket, s3_bucket_prefix, confluence_export_root_page_id, confluence_domain, confluence_user, confluence_api_token) # bedrockの同期を呼び出す resource_data_sync(bedrock_data_source_id, bedrock_knowledge_base_id) except Exception as e: print (f "An error occurred: {e}" ) デモページ 以上のような基盤を整えつつ、社内ChatAppにデモページの作成を行いました。RAG基盤でのチューニングやデータ前処理が中心で導入自体はそこまで手間ではなく簡単に行うことができます。 社内ChatAppのデモページ 導入上の注意点 Knowledge baseはデフォルトでOpenSearch Serverlessを使用しますが、最低スペックであってもデータ量に関わらず、月200ドル以上がコストとしてかかってきます。エブリーで今後、本格導入を検討する際には、このコストに見合うように社内の課題を整理し、費用対効果を十分に考慮した導入を進める必要があります。また、Knowledge baseでは他のベクトルDBも選択肢として利用可能なので、コスト面も含めて比較検討を進めていきたいです。 RAG基盤開発での課題 Amazon Bedrockに限らず、今回RAG基盤のPoCを行った中で以下の点は開発する中で課題と感じました。 社内ドキュメントに埋め込まれたスライドや画像などテキスト以外の部分の回答精度 テストデータでの検証を行っていく中で誤回答をするケースで一番問題だったのが、ドキュメント内にテキスト以外の埋め込みスライド、画像があるページに基づいた回答でした。今回のConfluence APIでのデータ取得ではデータをHTMLとして保存しましたが、そのままだとテキスト以外の情報のリンクまで辿って解釈できるわけではありません。実際により精度を高めるとなると前処理として参照スライドや画像情報も読み取って保存する必要が出てきそうです。 回答結果の評価 適切な質問と回答のテストデータを用意するのにまず労力がかかります。今回は工数を削減するために、社内ドキュメント情報から生成AIを活用してそれぞれのページごとに想定質問を作成しましたが、それでも適切な質問ではないものもあり、人間が評価しながら修正する必要があります。 また生成された回答の評価もより細かく行うのであればベクトル的な意味の近さで評価するといった工夫も考えないといけません。 精度改善のフィードバックループを回しにくい 精度改善するためには1パラメータでも変更したらテストを回していくようなフィードバックループを回していきたいところですが、それを実行すること自体でも基盤モデルの利用料がかかるため、積み重なると大きなコストになります。 ただ、この問題は直近のGPT-4oの登場でも起こっているように、今後より高精度で安価に利用できるモデルが次々出てくることを考えると無視できる問題になるかもしれません。 終わりに 今回の挑戦weekではチームを組んで、社内ナレッジ活用のためのRAG基盤のPoCを行いました。RAG基盤の構築はすでにいくつもの選択肢があり、専門的な知識が十分になくとも導入自体は簡単に行うことができそうです。 一方でこういったRAG基盤自体も手段の一つでしかなく、とりあえず導入してみるだと結果的に使われずにコストだけかかるものになっていたというケースも少なくなさそうです。今後は前述した通り、こうした技術検証を参考にしつつ、実際に本格導入して社内で使われるツールにするためにしっかりと課題や解決方法を整理して作業の効率化を考えていきたいです。
アバター
はじめに DevEnableグループの羽馬( @NaokiHaba ) です。 この度、エブリーは2024年6月22日(土)に開催される『Kotlin Fest 2024』に、ひよこスポンサーとして協賛することになりました! www.kotlinfest.dev エブリーでは、Ver.1.0からKotlinを使用してDELISH KITCHENを構築してきました! 今回の協賛を通して、さらなるKotlinコミュニティの発展に貢献できればと考えております。 今年も、 「Kotlin を愛でる」 をテーマに、Kotlinに関する情報交換や交流を通じて、新たな出会いや気づきを得ることができるでしょう。 ぜひ、タイムテーブルをご覧いただき、気になるセッションに参加してみてください。 fortee.jp 私たちのブースでは、Kotlinの活用事例等をご紹介する予定です。エブリーのエンジニアが直接皆様からのご質問にお答えしますので、ぜひお立ち寄りください。 エブリーにおけるKotlinの活用 エブリーでは、DELISH KITCHEN アプリ Ver.1.0 から Kotlin を採用してきました。 Kotlin の採用は、当時としては比較的早い判断でしたが、結果として開発効率を大きく向上させることができました。 特に、Null安全性や拡張関数、スコープ関数など、Kotlinの優れた言語機能により、 簡潔で安全なコードを高い生産性で書くことができるようになりました。 Ver.1.0からの⻑い開発の中で、Kotlinは常に⼒強い味方であり続けてくれました。 アプリの規模が⼤きくなるにつれ、その恩恵はさらに際⽴ったものになってきています。 Kotlinは、エブリーにとってなくてはならない存在であり、これからも積極的に活⽤していきたいと考えています。 エブリーのテックブログでも、Kotlinに関する記事を随時公開していますので、ぜひご覧ください。 tech.every.tv 皆様とお会いできることを楽しみにしています! Kotlin Fest 2024 では、当社がどのようにKotlinを活用しているか、ブースでお話しできる機会を楽しみにしています。 ぜひ、お気軽にお立ち寄りください! エブリーでは、ともに働く仲間を募集しています。 エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター
はじめに DELISH KITCHENでデータサイエンティストをやっている山西です。 今回は レシピ動画のサムネイル画像の自動抽出の取り組み について紹介いたします。 OpenCVを用いた画像処理 画像とテキスト情報のペアを扱う大規模モデル 等を用いつつそれを試みた事例になります。 ※記事後半で具体実装を扱っている部分では、周辺知識がある前提で説明を進めていることをご了承ください。 every Tech Blog Advent Calendar 2024(夏) 9日目の記事になります。 出来たもののイメージ どんなものが出来たかを先に紹介します。 一言で表すと、 レシピ動画の中から「調理手順を表すのに良い感じのサムネイル画像」をAI的振る舞いで自動で抽出してくれるシステム になります。 これを ワンパンカルボナーラ というレシピに適用した例を以下に載せています。 図1: AIシステムによるレシピサムネイル抽出例 このように、5つの”手順”別にサムネイル画像の候補が抽出され、最終的に1枚が選定されます。 取り組みの背景 なぜこれを作ろうと思ったかを説明していきます。 レシピ手順説明文と共にサムネイル画像を追加したい DELISH KITCHENでは約5万本のレシピ(2024/6月現在)を提供しており、全てのレシピに 調理工程を撮影、解説した動画 が付いています。 そして、 調理手順ごとに区切られた説明文 をレシピページ上で読むことが出来ます。 しかし、従来のスマホブラウザ版のDELISH KITCHENでは、文字情報だけでここの手順を読み進める作りになっていました。 そんな中、「各手順に動画から抽出したサムネイル画像を加えることで、工程がよりイメージしやすくなる」という仮説のもとで、サムネイルを自動付与する施策が企画されました。 結果、DELISH KITCHENの全レシピにサムネイル画像が機械的に施されることとなりました。 AWS Elemental MediaConvert を用いたこの取り組みは以下の記事に詳しいです。 tech.every.tv 図2: スマホブラウザ版のDELISH KITCHENの手順欄 困りごと 自動処理により全レシピにサムネイルを付与出来たのは良いものの、これらはあくまで機械的なルールで付与されているため、「必ずしも調理手順の説明文に合った画像とは限らない」問題が発生し、その品質には課題が残ることとなりました。 図3: イマイチなサムネイルの例 そのため現在(2024/6月時点)、「イマイチなサムネイル」を人力で毎日少しずつ入稿して差し替える手間がかかっています。 しかし、約5万本もあるレシピを対象にこれらを行うのも骨が折れる作業です。 PoCの実施 こうした取り組みを横目で見ている中、「画像処理や大規模モデル等の技術スタックを使えば、”AI”として良い感じのサムネイルを抽出可能なのでは」という閃きが生まれました。 このアイデアを エブリー社内エンジニアで定期開催している挑戦WEEK の企画として提案したところ好評だったので、1週間PoCとして取り組んでみました。 システム構成 ここから成果物の具体の説明になります。 このAIシステムは、 ①画像処理パート 、 ②AI処理パート の2段階で構成されます。 ①事前に良さそうなサムネイル候補画像を数枚ピックアップ しておき、 ②その中から最も良いものをAIに選ばせる という思想になります。 図4: レシピサムネイル抽出(再掲) ① OpenCV画像処理パート 各手順の「サムネイル画像候補」を画像処理にて抽出する(最大10件ほど) 全動画フレームに対してOpenCVによる画像処理を行い、それを実現する ② AI処理パート ①で抽出された「サムネイル画像候補」の中から、その手順に相応しい1枚を選び出す 手順説明文のテキスト情報とサムネイル候補の画像情報を共に解釈し、「サムネイルとしての相応しさ」を判定できるようなAIモデル(画像とテキスト情報を共に処理できるマルチモーダルモデル)を採用する これから、実装詳細について説明します。 実装詳細 ①画像処理パート: サムネイル候補画像の抽出 「良いサムネイル候補」を満たす要件の仮説をまず立て、それを実装に起こしつつ検証していきました。 これを、処理の流れと共に追っていきます。 仮説1. 動画の前後のフレームで「動き」が大きいほど、候補として重要な場面である サムネイルとして見栄えのするシーンは大抵、「ダイナミックな動きがあったり、画角の中に多くの要素があったりする」ものではないかと考えました。 具体的には以下のような例が挙げられます。 フライパンの上でかき混ぜているようなシーン フライパンの上に肉や野菜等の具材がたくさん盛り付けられているシーン そして、「動画の前後のフレームで画像データ間の”違い”が大きいシーンを見つけ出す」ことで、上記アイデアを OpenCVを用いた動画像処理 に落とし込めるのではないかと仮説立てました。 そこで、今回は AKAZEアルゴリズム によって各フレーム画像の特徴点を抽出し、動画内の前後のフレームの 特徴点の総当たりマッチング によって「距離」を算出する という実装に落とし込みました。 平たく言えば、「特徴点という”違いの判断材料"を各フレーム画像ごとに作り、前後のフレーム間でそれらの “違い度合い”を数値(距離)として表現する」アプローチです。 詳細な説明は本記事の対象外とします。 代わりといっては何ですが実装の雰囲気や参考記事を以下に載せます。 コード例 # 動画ファイルを読み込む cap = cv2.VideoCapture(target_recipe_video_path) # フレームレートと総フレーム数を取得 fps = cap.get(cv2.CAP_PROP_FPS) total_frames = int (cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 開始フレームと終了フレームを計算 start_frame = int ((step_start_msec / 1000 ) * fps) end_frame = int ((step_end_msec / 1000 ) * fps) # 動画を読み込み、指定された範囲のフレームを書き込む cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) print ( 'start_frame: ' , start_frame) print ( 'end_frame: ' , end_frame) previous_frame = None previous_target_des = None previous_mean_distance = None for frame_num in range (start_frame, end_frame): ret, frame = cap.read() # Crop the frame # frame = crop_frame(frame) # 明暗変化による動体誤判定を防ぐために、グレースケール化 frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # BFMatcherオブジェクトの生成 bf = cv2.BFMatcher(cv2.NORM_HAMMING) # AKAZEを適用、特徴点を検出 detector = cv2.AKAZE_create() (_, target_des) = detector.detectAndCompute(frame, None ) if previous_target_des is not None : try : # BFMatcherで総当たりマッチングを行う matches = bf.match(target_des, previous_target_des) #特徴量の距離を出し、平均を取る distance = [m.distance for m in matches] mean_distance = sum (distance) / len (distance) print ( 'frame:' , frame_num, 'ret' , mean_distance) frame_and_mean_distances[step_num][frame_num] = mean_distance except : # エラーが出た場合は、直前の距離指標で補完する print ( 'frame:' , frame_num, 'error occured.' , 'distance: ' ,[m.distance for m in matches]) frame_and_mean_distances[step_num][frame_num] = previous_mean_distance previous_mean_distance = mean_distance previous_frame = frame previous_target_des = target_des 参考記事 aicam.jp こうして、「ある時点のフレームが、1つ前のフレームに対してどの程度”違い”があるか」が距離値として算出されることになります。 その結果を時系列でグラフ化したものが以下の図です。 図5: 前後フレームの距離値の時系列グラフ 動画の進行の中で、どのあたりでシーンの移り変わりがあったか を時系列的なデータとして表現出来ました。 ここの値が大きいシーンは、「ダイナミックな調理の動きや、多く食材があるシーンかもしれないフレーム」つまり、良いサムネイルの候補になりそうだと見立てることが出来ます。 仮説2: それぞれの候補画像が、調理手順内の多様なシーンを切り取ったものになっている 「前のフレームとの"違い"」とは別に考慮すべき要因として「シーンの多様性」があります。 各調理手順の中から良い感じの候補画像を抽出するには、「手順内全体を俯瞰してみたときに、なるべくさまざまな調理シーンが切り取られている」のが良いという考えです。 ※ 例えば、同じ調理手順内といっても、まな板の上で別々の野菜を順に切ったり、調味料を加えたり混ぜたりするようなシーンが連なっていることが多々あります。こういう"多様性"をなるべく網羅したいという話です。 そこで、信号処理の視点を応用してみました。 下図6のように、移動平均で慣らしてピークとなった部分をサムネイルとして抽出すれば、「動画全体の移り変わり」の視点も加味しつつ「前後の”違い”が大きいシーン」を選べるのではないかと考え、実装に落とし込みました。 scipyに find_peak という便利なライブラリがあったので、図6の雰囲気で使ってみました。 図6: ピーク地点検出の例 ここで特定されたピーク地点付近に相当する画像が、各手順のサムネイル候補になります。 (上図の場合は、手順1で6枚, 手順2で7枚, ...といった具合に抽出されていきます。) 仮説3: 候補画像は、なるべく鮮明で、ブレていないものである フレームをサムネイル候補として選ぶ以上、ブレているシーンは見栄えが悪いのでなるべく避けたいです。 そこで、画像のエッジ検出に用いられる ラプラシアンフィルタ を用いて、なるべく鮮明な輪郭を持つフレームを優先的に採用するルールを処理に加えました。 図7: ブレた画像の例 コード例 # 「ピーク検出されたフレームの前後のフレーム」の番号をまとめてframe_candidate_num_listに格納 # これらに該当するフレームに順繰りにフィルタを適用し、結果得られる値が最大のものを「ブレてない」画像として採用 for frame_candidate_num in frame_candidate_num_list: # フレームを取得 frame_candidate = tmp_frame_dict[frame_candidate_num] # グレースケール化 frame_candidate = cv2.cvtColor(frame_candidate, cv2.COLOR_BGR2GRAY) # ラプラシアンフィルタを適用 v = cv2.Laplacian(frame_candidate, cv2.CV_64F, ksize= 7 ).var() # 分散値をリストに追加 laplacian_var_dict[frame_candidate_num] = v # vが最大となるフレームを抽出 max_frame_num = max (laplacian_var_dict, key=laplacian_var_dict.get) picked_frames[max_frame_num] = tmp_frame_dict[max_frame_num] 参考記事 piccalog.net サムネイル候補の抽出結果 こうして組み上げた仕組みをいくつかのレシピに適用した例を紹介します。 図8: サムネイル候補の例 ※実際の動画やページが↓で閲覧出来ます↓ サクッとほくほく♪ かぼちゃの天ぷらのレシピ動画・作り方 | DELISH KITCHEN こんがり焼くだけ! 豆腐とキムチのチーズ焼きのレシピ動画・作り方 | DELISH KITCHEN なんとなくの所感ですが、「多様なシーン、かつ、意味のありそうな候補を抽出できている」気がします。 ※ ぶれていたり不鮮明だったりする画像が完全に無いわけでは無いですが、ラプラシアンフィルタを仕込まない場合に比べるとその発生頻度や質は改善されている所感でした。 ②AI処理パート: 「サムネイル画像候補」の中から一番良い1枚を選び出す ここから、手順の説明文に最も見合う画像をサムネイル候補の中から1枚選定するパートです。 今回の用途だと、 入力された画像とテキスト情報の関係をマルチモーダルに処理、判断できる大規模モデル が相性が良いのではと考えました。 そこで、 日本語特化版CLOOBモデル の利用に至りました。 これは、画像×テキスト情報を判断可能な大規模モデル CLIP の改良版として、rinna社によって提供されているモデルとなります。 CLOOBの事前学習済みモデル は、既に画像と日本語テキスト同士の兼ね合いを判断する能力を内部表現として獲得していると予想されます。 この能力を、レシピデータに用いてみてどうなるかを試してみました。 提供元 huggingface.co 画像特徴量とテキスト特徴量のコサイン類似度の計算 今回、 サムネイル候補と手順説明文の当てはまり度合い は コサイン類似度 として表現することとなります。 CLOOBモデルの画像Encoder(ViT-Bベース) 、 テキストEncoder(BERTベース) それぞれから得た特徴ベクトル間のコサイン類似度を計算することでこれを実現します。 以下図9がその図解です。 「複数のサムネイル候補画像の中から、"にんにくは粗みじん切りにする。"というテキストに対して、最もコサイン類似度の高いものを選ぶイメージです。 図9: サムネイル候補画像と手順説明文間のコサイン類似度計算の例 コード例 model, preprocess = ja_clip.load( "rinna/japanese-cloob-vit-b-16" , device=device) tokenizer = ja_clip.load_tokenizer() # 中略 # 画像とテキストそれぞれの特徴ベクトルを各種Encoderから抽出し、コサイン類似度を計算する # content: サムネイル候補画像のバイナリ, description: 手順説明文の文字列 def calc_cosine_similarity (content, description): with torch.no_grad(): # サムネイル候補画像の読み込み nparr = np.frombuffer(content, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) img = Image.fromarray(img) img = preprocess(img).unsqueeze( 0 ).to(device) # cloobモデルにサムネイル候補を渡し、画像特徴量を得る image_features = model.get_image_features(img) # cloobモデルに手順説明文を渡し、テキスト特徴量を得る description_encodings = ja_clip.tokenize( texts=description, max_seq_len= 150 , device=device, tokenizer=tokenizer, ) description_features = model.get_text_features(**description_encodings) # 画像特徴量とテキスト特徴量間のコサイン類似度を計算する probs = torch.cosine_similarity(image_features, description_features) return probs.tolist()[ 0 ] # scalar 参考記事 cedro3.com サムネイル選定結果 この仕組みを用いて、具体的にどんなサムネイルが採用されたのか、これまたレシピ例で見てみます。 図8で紹介したサムネイル候補の中から、最終的に選定された画像を赤枠で囲っています↓ 図10: サムネイル選定結果 図11: 「かぼちゃ天ぷら」のサムネイル選定結果 図12: 「豆腐とキムチのチーズ焼き」のサムネイル選定結果 これまた定性的な評価になりますが、実態にそぐうサムネイルが選定されている印象を受けます。 (全てでは無いですが)、「混ぜる」「揚げる」「載せる」などの動きを表すシーンを汲み取ってくれているような気がします。 やってみた所感 今回は思いつきドリブンで、やりたいことてんこ盛りで色々試しましたが、想像以上に"それっぽい"ものが出来て手応えを得ました。以下、所感をまとめています。 OpenCV等々を組み合わせた比較的シンプルな(Not機械学習の)アルゴリズムだけでも、「多様なシーンを切り取る機構」が作れて手応えを得ました。 レシピの情報を何も与えていない事前学習済みモデルを用いただけでも、想像以上に「レシピ手順説明文の文脈」をCLOOBが読み取ってくれたことに感銘しました。大規模モデルの可能性を改めて実感することになりました。 一方、実運用を見越すとなるとコスト面での課題はあるなと感じました。 今構築している環境で平均約1分のレシピ動画を捌くとなると、計10分弱(①画像処理パートで4分、②CLIPパートで6分ほど)費やすこととなります。 これを如何にして、数万本もあるレシピ処理に計算/コストの観点で最適化し、スケールさせていくかが課題となります。 終わりに 今までレシピ動画メディアでありながら、あまりデータサイエンスの文脈で動画像データを活用できていなかったので、こういう取り組みが出来て新鮮でした。 まだまだデータに眠る価値はあるなと思いました。 社内でも割と好評だったので、今は本取り組みを実用化出来ないか整理しています。PoCから実運用への昇華を目指したいところです。 この記事が何かの参考になれば幸いです。
アバター
はじめに こんにちは、株式会社 エブリー DevEnableグループです。 本日、6年ぶりのオフライン開催となった Go Conference 2024 にプラチナGoルドスポンサーとして参加してきました! Go Conference運営の皆様および参加された皆様、お疲れ様でした! 今回はオフラインのみの開催となったので、参加されていない皆さんにもGo Conference 2024の盛り上がりをいち早くお伝えしたく、早速参加レポートをさせていただきます。 エブリー初のスポンサーブースを出しました! 今回、エブリーとしては初めてスポンサーブースを出させていただきました。足を運んでいただいた皆様、本当にありがとうございました! 今回は、弊社が提供するDELISH KITCHENのサービスをイメージしてブースの雰囲気を作っていきました。 多くの方から「DELISH KITCHENを使っています!」とのお声をかけていただいたり、DELISH KITCHENで使う技術について意見交換ができたりと開発者としてもとても貴重な機会となりました。会場では、『DELISH KITCHENのAPIサーバーとGoの歩み』などこれまでの取り組みを赤裸々に綴ったパネルも用意しました。 ノベルティ 今回は以下のようなノベルティを用意させていただきました。 クッキー ドリップバックコーヒー 会社・サービスのステッカー DELISH KITCHENグッズ DELISH KITCHENグッズに関してはXフォローでの抽選プレゼントキャンペーンを行い、多くの方に参加していただきました。 (弊社エンジニアXアカウントは こちら です) DELISH KITCHENグッズに関してはたくさんの商品があるのですが、その中でも人気のある商品を中心に5つ準備させていただきました。 レンジ調理鍋 まな板 計量スプーン 鍋つかみ しゃもじ アンケート 今回のGo Conferenceのテーマは『一期一会』です。参加者の方々がコミュニケーションを取れるようなきっかけを作りたく、アンケートボードを用意しました。 お題はGoでもあまり決まったデファクトスタンダードがないORMに関して、いくつかの選択肢を用意して『GoのORM、何を使ってる?』としました。回答いただいた多くの皆様、ありがとうございました! 最終結果はこちら...! 1位👑: go-gorm/gorm 2位 : jmoiron/sqlx 3位 : sqlc-dev/sqlc やはりgormは多くの方が採用している結果となりましたが、それ以降に関してはどれも僅差の結果となっており、改めてGoでのORMの選択肢の広さを実感しました。 また、これをきっかけにブースを訪れていただいた方々とのコミュニケーションもたくさん取れて、各社での知見を聞けるいい機会ともなりました。 各社スポンサーブースの様子 スポンサーブースでは、各社趣向を凝らしたブースが展開されました。 ガチャやクイズ、アンケートボードなど様々な企画が用意されていて、会場全体が賑わっていました。 特に、「最近買ってよかったもの」をアンケートしていたブースでは、多くの回答が集まっており、エンジニアに馴染み深い「HHKB」から、「家の購入」といった意外な回答まで様々な回答がありました。 また、スポンサーブースでは、各社のエンジニアと直接話すことができる機会もあり、普段なかなか話すことができないような話もできてとても楽しかったです。 セッションの紹介 今回発表されたセッションの中から気になったものをいくつかまとめさせていただきました。 イテレータによってGoはどう変わるのか 発表者: tenntennさん ( https://twitter.com/tenntenn ) https://audience.ahaslides.com/cl965inb88/review?lookback-tab=slides こちらのセッションでは、Go1.22で一部がリリースされ、Go1.23でリリース予定のイテレータについて紹介されていました。 Goにおけるイテレータは任意の構造体に対して関数を通してシーケンシャルにアクセスする仕組みのことという定義の部分から、具体的にどのように使われるのかまで説明してくださっていました。 セッションの前はイテレータが導入されることによる具体的なメリットがあまりわかっていなかったのですが、イテレータが導入されることでデータ構造へのアクセスや一連の処理の結果をまとめるといった点で便利になるというお話を聞いたことでイテレータのメリットについて実感が湧きました。 特にデータ構造へのアクセスの仕方でmapにkeysが導入されるという話は、mapのkeysがないことは普段から不便に感じていたので期待が持てると思いました。 これは完全に余談ですが、tenntennさんの会社でGoのスキルを測定してくれるサービスがβリリースされたらしく個人的には興味を惹かれました。 https://yourwork.knowledgework.com Dive into gomock 発表者: utgwkkさん( https://twitter.com/utgwkk ) speakerdeck.com こちらのセッションでは、Goのユニットテストのモックに使われるgomockについて実際の実装を通して紹介されていました。 gomockの中で使用されているmatcherやgomock.Controllerがどのような役割なのか普段はなかなか意識しない部分もあり目から鱗でした。 印象に残ったのは発表の中でテクい実装と紹介されていた WantFormatter() の実装です。 matcherはinterfaceとして構造体に渡してfmt.Stringerはそのまま渡すなど普段実装をしているとあまり思い付かないこともライブラリの実装を通して知ることができるのは面白いと思いました。 下記のコードはuber-go/mockからの引用です。 https://github.com/uber-go/mock/blob/v0.4.0/gomock/matchers.go#L37 func WantFormatter(s fmt.Stringer, m Matcher) Matcher { type matcher interface { Matches(x any) bool } return struct { matcher fmt.Stringer }{ matcher: m, Stringer: s, } } gomockは弊社でも普段から使われていてどのように使うのかは知っているつもりでしたが、ライブラリの裏側について知ることができて勉強になりました。 セッションの最後になぜライブラリの実装を読むべきかというお話もしてくださったのですが、腑に落ちる部分も多くライブラリの実装を読んでいかなければと思いました。 バイナリを眺めてわかる gob enconding の仕様と性質、適切な使い方 発表者: convtoさん( https://twitter.com/convto ) speakerdeck.com こちらのセッションでは、gobのencoding結果であるバイナリを確認していくことで、gobの仕様や性質が解説されていました。 gobとはGoが標準パッケージで実装している独自のエンコーディングのことです。 メリットとしては、Goのプログラム上から特別な宣言なしに利用できたり、エンコーディング後の情報転送効率が高いことです。 また、gobは自己言及的であるため、メッセージ自身にどのような構造をしているのか送信できます。 そのため、メッセージ一つで構造が解釈可能で、事前に準備するものは不要といった点は非常に強力だと感じました。 バイナリを実際に確認して仕様を理解するというアプローチは面白く、またスライドも分かりやすく丁寧に解説されており非常に勉強になりました。 Mapのパフォーマンス向上のために検討されているSwissTableを理解する 発表者: replu5さん ( https://twitter.com/replu5 ) speakerdeck.com こちらのセッションでは、現状のMapの実装とは異なるSwissTableという仕組みを導入することでパフォーマンスを向上させる仕組みについて解説されていました。 現状Mapは作成するとbucketという箱が用意され、その中にあるtophashというものと用いて比較をしていく仕組みになっています。 一方、ここで挙げられているSwissTableとは、8 または 16要素分の追加情報をまとめてmetadataとして扱いマッチングを行うことで高速化を図ります。 現状ランタイムのMapの実装にSwissTableを使用したものが議論されているようで、要素数が少ないパターン以外はパフォーマンスが向上している点は興味深いと感じました。 既存のMapの実装を学ぶことができ、またそれを向上させるためのアイデアを学ぶことができた貴重な機会でした。 弊社エンジニアのセッション エブリーからは、DELISH KITCHENヘルスケア開発部、兼TIMELINE開発部の内原がスポンサーセッションのスピーカーとして登壇しました。 セッションでは、「DELISH KITCHENにおけるマスタデータキャッシュ戦略とその歴史的変遷」というタイトルで話をしました。 speakerdeck.com DELISH KITCHENは2016年のサービス最初期からバックエンドにGoを使用し続けているプロダクトですが、様々な要素を考慮してデータキャッシュへの向き合い方を考えてきました。 そんなキャッシュ戦略の歴史と展望について、Goでどのように実装されているのかを踏まえて、実際に直面してきた課題とともに解説しました。 最後に 最後になりますが、Go Conference の運営の皆さん、カンファレンスの運営をしていただき本当にありがとうございました! また、参加者の皆さん、カンファレンスへの参加お疲れ様でした! 今年は、6年ぶりのオフライン開催で暑いなか多くの方が参加されて、改めてGoのコミュニティの盛り上がりを感じることができました! アフターイベント「Go Bash」のお知らせ Go Conference 2024 にスポンサー and 参加した エブリー / アンドパッド / LayerX / STORES の Gopher たちが Go Conference 2024 に刺激を受け、トークや感想戦を繰り広げ、 Beer ではなく Go で盛り上がるイベントを開催します! andpad.connpass.com Go Conference 2024 に参加された方も、参加されなかった方も、ぜひご参加ください! エブリーでは、ともに働く仲間を募集しています。 エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 7日目の記事です。 はじめに エブリーでソフトウェアエンジニアをしている本丸です。 Go Conference 2024 もいよいよ明日開催ですね。 Goに関する話ということでDELISH KITCHENのユニットテストで使用されているライブラリを紹介したいと思います。 弊社ブログの過去の記事にテストの可読性についてのものがあるので興味があればぜひ読んでみてください! Go testにおける可読性を保つ方法を考える DELISH KITCHENのユニットテストで使用しているライブラリ DELISH KITCHENではユニットテストを行うときに主に以下の4つのライブラリを使用しています。 gomock testify/assert go-cmp httptest gomock 名前の通り、ユニットテストの際にモックを提供してくれます。 https://github.com/uber-go/mock gomockでは以下のようなinterfaceからmockを生成することができ、 type UserRepo interface { Insert(age int ) User BulkInsert(ages [] int ) []*User Change(u User) *User } テストコードの中で下記のように使います。 ctrl := gomock.NewController(t) repo := NewMockUserRepo(ctrl) repo.EXPECT().Add( 20 ).Return(User{}) これだけでも便利なのですが、社内のコードに個人的に便利な機能だと思うものがあったので、いくつか紹介します。 Do() ドキュメント からの引用ですが、下記のことを行ってくれます。 Doは、呼び出しがマッチしたときに実行するアクションを宣言します。後方互換性を保つため、関数の戻り値は無視されます。 社内で具体的にどのように使っているかというと repo.EXPECT().Change(gomock.Eq(u)). Do( func (user) { u.ID = 1 }). Return(&u) 構造体を受け取って、その構造体のフィールドを変更して返す関数のモックを含む時に使用しています。 InAnyOrder() こちらも ドキュメント からの引用ですが、下記のことを行ってくれます。 InAnyOrderは、順序を無視して同じ要素のコレクションに対して真を返すMatcherです。 社内で具体的にどのように使っているかというと idMap := map [ int ] struct {}{ 19 : struct {}{}, 20 : struct {}{}, } ids := make ([] int , 0 , len (idMap)) for id := range idMap { ids = append (ids, id) } repo.BulkInsert() のような処理があり、BulkInsert()のmockを作りたい時に repo.EXPECT().BulkInsert(gomock.InAnyOrder([] int64 { 20 , 19 })). Return() arrayの順番が保証されないためこちらを使用しています。 testify/assert ある値がこうなるはずだというアサーションのチェックを行ってくれます。 https://github.com/stretchr/testify testify/assertを使用してある関数のレスポンスが期待したものと一致するか確認したい場合は以下のようになります。 want := true got := doAnything() assert.Equal(t, want, got) 様々なアサーションが用意されているのですが、 Equal 以外では下記に示したものがDELISH KITCHENだとよく使用されていました。 Contains() Error() Len() go-cmp オブジェクトを比較してくれるライブラリで、ユニットテストでもオブジェクトの比較のために使用しています。 https://github.com/google/go-cmp if diff := cmp.Diff(want, got); len (diff) != 0 { t.Errorf( "got diff = %v" , diff) } go-cmpはオプションを使用することで様々なケースに対応することが可能です。 その中から社内で使われているものを一部紹介します。 IgnoreUnexported IgnoreUnexportedをオプションとして指定すると、構造体の中のprivateなフィールドなどunexportedなものを無視して比較してくれます。 type SearchRequest struct { Client http.Client url string } 例えば、上記のような構造体があった場合はClientだけ比較されて、urlは無視されるといった挙動になります。 IgnoreFields IgnoreFieldをオプションとして指定すると、構造体の中の指定したフィールドを無視して比較してくれます。 type User struct { ID int CreatedAt time.Time } opts := []cmp.Option{ cmpopts.IgnoreFields(User{}, "CreatedAt" ), } 上記のように指定すると、CreatedAtが無視されてIDだけ比較されるという挙動になります。 SortSlices SortSlicesをオプションとして指定すると、指定したarrayのフィールドをソートした後に比較してくれます。 func GetUserIDs() [] int { // 要素の順番がランダムなuserIDのarrayを返す処理 } got := GetUserIDs() want := [] int { 1 , 2 , 3 } opt := cmpopts.SortSlices( func (i, j int ) bool { return i < j }) if diff := cmp.Diff(got, want, opt); diff != "" { t.Errorf( "GetUserIDs() = %v, want %v" , got, tt.want) } 例えば、GetUserIDs()という関数のテストをしたい時に、実際のコードではランダムな順序で問題ない場合でもテストでは順序も含めて比較を行うため失敗してしまうということが起こり得ます。このような時に SortSlices をオプションとして指定すると任意の順番にソートした後に比較を行うため、配列の順番でテストが失敗するということは起こらなくなります。 httptest 標準ライブラリなので趣旨と少しズレるかもしれませんが、テスト用のモックサーバーとして利用しています。 func NewRequestHTTPRequestMock() (*httptest.Server, func () string ) { var body string return httptest.NewServer(http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) body = string (b) })), func () string { return body } } func Test_Request(t *testing.T) { requestMock, getRequestContent := NewRequestHTTPRequestMock() _, _ = s.Search(requestMock.URL) got := getRequestContent() if diff := cmp.Diff(tt.want, got); diff != "" { t.Errorf( "got diff (-want +got): \n %s" , diff) } } DELISH KITCHENでは http.Client を使用している箇所があり、利用箇所のテストを行うためにhttptestを利用しています。 上記のコードでは、NewRequestHTTPRequestMock()でモックサーバーを作成して、そこに対してリクエストを行うことでリクエストの中身が正しいのかのテストを行なっています。 まとめ 改めてまとめてみるとDELISH KITCHENではGoのテストではメジャーなライブラリが使われているといった印象でした。それと同時に、普段使用しているライブラリでも改めてドキュメントを読み直してみると、自分は使いこなせていない部分も多いと気付かされました。 Go Conference 2024 まで、あと1日! https://gocon.jp/2024/ 株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! https://gocon.jp/2024/sponsors/2/
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 6 日目の記事です。 目次 はじめに イントロダクション そもそもメールヘッダーとは net/mail パッケージ メールの解析 ヘッダーの取得 Body の取得 net/mail パッケージのメール解析で辛いところ MIME マルチパートメッセージの解析が不完全 MIME マルチパートメッセージとは デコード機能が不十分 メールプロトコルに沿わせた構成にするのが大変 メールプロトコルとは net/mail で解析したメールを送信可能なメールにするために jhillyerd/enmime パッケージ メールヘッダーの設定 net/mail と jhillyerd/enmime の比較 net/mail メリット デメリット jhillyerd/enmime メリット デメリット まとめ 最後に はじめに こんにちは!最近推しの配信が多くなってきて嬉しい @きょー です!DELISH KITCHEN 開発部のバックエンド中心で業務をしています。 業務でメール内容を解析、処理する機会があり、そこで経験した学びについて話していこうと思います。 イントロダクション 業務中に メールを解析 、 メールヘッダーのカスタマイズ 、 メールの送信 をするという場面に出くわしましたが、Go の標準パッケージである net/mail では解決が難しいことがわかり、苦労した経験があります。この記事では net/mail の基本的な使い方や遭遇した辛いところを紹介し、その辛さを解決してくれるパッケージ jhillyerd/enmime についてお話しようと思います。 そもそもメールヘッダーとは メールヘッダとは、メールの詳細情報が書かれている部分のことです。具体的には、メールが配送された経路や時間、経由したサーバーなどが記録されています。 以下は、一般的なメールヘッダーの例とその説明です。 From: 送信者のメールアドレスが記載されています。 To: 主な受信者のメールアドレスが記載されています。 Subject: メールの件名が記載されています。 Received: メールが経由したサーバーとその日時が記載されています。これはメールの配送経路を追跡するのに使われます。 Content-Type: メールの本文の形式(例:text/plain, text/html)が記載されています。 MIME-Version: メールが MIME(Multipurpose Internet Mail Extensions)規格を使用している場合、そのバージョンが記載されています。 メールヘッダーは、メールのトラブルシューティング、スパムの検出、セキュリティ分析などに使用されます。たとえば、 Received ヘッダーを調べることで、メールがどのサーバーを経由してきたかを追跡し、スパムやフィッシングメールの出所を特定することができます。 net/mail パッケージ pkg.go.dev net/mail パッケージは、メールメッセージを解析するための機能を提供します。このパッケージを使用すると、メールのヘッダー情報やアドレスの解析、メッセージの本文の取得などが行えます。 net/mail パッケージの基本的な使用方法について紹介していきます。 メールの解析 net/mail パッケージを使用してメールを解析するには、まず mail.ReadMessage 関数を使用してメールデータを読み込みます。 // メールのサンプルデータ rawEmail := `From: sender@example.com To: recipient@example.com Subject: This is a test email Content-Type: text/plain; charset="utf-8" This is the body of the email.` // ←がBody部分 // io.Readerの作成 reader := strings.NewReader(rawEmail) // ReadMessageを使用してメールを解析 msg, _ := mail.ReadMessage(reader) ヘッダーの取得 メールのヘッダーは Header 型で表され、これは下記のような map[string][]string の型定義です。 type Message struct { Header Header Body io.Reader } type Header map [ string ][] string ヘッダーの値は Header.Get(key) メソッドを使用して取得できます。このメソッドは指定されたキーに対応する最初の値を返します。 // ヘッダーの取得 header := msg.Header // Fromヘッダーの取得 from := header.Get( "From" ) fmt.Println( "From:" , from) // From: sender@example.com Body の取得 以下のように Message 構造体の中にある Body からメールの本文を取得できます。 // 本文の取得 bytes, _ := io.ReadAll(msg.Body) fmt.Printf( "Body: %s" , string (bytes)) // Body: This is the body of the email. net/mail パッケージのメール解析で辛いところ net/mail パッケージは、基本的なメールメッセージの解析機能を提供しますが、いくつかの辛みポイントがあります。以下にその主な点を挙げます。 MIME マルチパートメッセージの解析が不完全 net/mail パッケージは MIME マルチパートメッセージ の解析を直接サポートしていません。 Message 構造体の Body フィールドには、メールの本文が含まれますが、 MIME マルチパートメッセージ の場合、下記のコードのような boundary 文字列( --000000000000abcdefg12345 )や各パートのヘッダーなどがそのまま含まれてしまいます。これにより、メールの本文だけを簡単に取得することができない、という問題が生じます。 --000000000000abcdefg12345 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: base64 44GT44KT44Gr44Gh44Gv --000000000000abcdefg12345 Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: base64 PGRpdiBkaXI9ImF1dG8iPuOBk+OCk+OBq+OBoeOBrzwvZGl2Pg== --000000000000abcdefg12345-- MIME マルチパートメッセージとは MIME マルチパートメッセージ は テキスト や html 、 画像 などそれぞれ異なるパートに分け、それらを組み合わせ構成されたものです。この仕組みは複数のファイルを電子メールに添付するときなどに使用されます。 上記のメールの本文では、 text/plain や text/html の部分が組み合わされ一つのメッセージとなっています。画像や動画を送る場合は image/png 、 video/mp4 などのパートがメッセージに追加されます。 developer.mozilla.org デコード機能が不十分 net/mail パッケージにはほぼデコードの機能がありません。( ParseAddress 関数を除く) そのため、日本語で書かれたメールの 件名 や 本文 を エンコード 方式( base64 や quoted-printable など)に合わせ適切に デコード しなければ文字化けしてしまいます。 また、 net/mail パッケージでは Header ではなく Body の中に MIMEマルチパートメッセージ の エンコード 方式が書かれています。そのため デコード するために形式を取得したくとも簡単には取得できない、という問題があります。 メールプロトコルに沿わせた構成にするのが大変 メールプロトコルとは メールを送信する上で意識しなければいけないのが メールプロトコル です。メールプロトコルとは、電子メールの送受信に関する規則や手順を定めたもので、電子メール通信をする上でメールデータが正しくやり取りされるために必要です。 RFC2822 でメールプロトコルが規定されています。下記に内容の一部を紹介していきます。 ASCII コードで構成されること 一行は 78 文字以下が推奨 Header フィールドは、フィールド名の後にコロン(":")、フィールド本体が続き、CRLF で終了 Body の前は空行にする これらの規則や手順を守らないと、メール送信できなかったり送信できても文字化けしてしまうなどの問題に繋がります。 net/mail で解析したメールを送信可能なメールにするために net/mail パッケージでは Message 構造体の中に Header と Body フィールドがあります。メール送信するためにはこれらを組み合わせ []byte 型にしなければいけなく、具体的には以下のような処理が必要になります。 複数の Header のフィールド名と値をセットで取り出し、1 行に 1 セット設定する 一行が 78 文字以上にならないように適宜改行コードを入れる Header と Body を組み合わせて[]byte に変換 これを自分で対応しようとすると骨の折れる作業になります。実際に行った記事としても以下のような記事がよくまとまっています qiita.com 上記の記事のコードを手元で管理したくないという思いから、MIME のエンコードやデコードを気にせず、電子メールの生成や解析をしてくれるパッケージを探し始めました。 そこで見つけたのが以下で紹介するパッケージです。 jhillyerd/enmime パッケージ jhillyerd/enmime パッケージは MIME エンコードおよびデコードライブラリで、MIME エンコードされた電子メールの生成と解析に重点を置いています。 net/mail パッケージでは Message 構造体のフィールドの Header と Body がそれぞれ分かれていたため、 解析 → Header 修正 → MIME 対応 → Header をエンコード → Body と組み合わせる → メール送信可能な構造に修正 → メール送信 といった流れでした。 jhillyerd/enmime パッケージでは Header も Body も全て一緒に MIME に対応した解析と生成をするため 解析 → Header 修正 → MIME 対応したエンコード → メール送信 のように処理が簡易化されます。 実際に例を見てみましょう。 pkg.go.dev メールヘッダーの設定 // objはio.Reader型 // メールの内容を解析 envelope, err := enmime.ReadEnvelope(obj) // Fromヘッダーの上書き err = envelope.SetHeader( "From" , [] string {fmt.Sprintf( "%s <%s>" , senderName, senderEmail)}) // Toヘッダーの上書き err = envelope.SetHeader( "Subject" , [] string { "new subject" }) buf := & bytes.Buffer {} // MIMEに対応したエンコード err = envelope.Root.Encode(buf) _ := sendEmail(buf.Bytes()) 以上を踏まえ、簡単に net/mail と jhillyerd/enmime のメリット、デメリットについて以下にまとめてみました。 net/mail と jhillyerd/enmime の比較 net/mail メリット 標準パッケージのため、追加の依存関係を導入しなくて済む 公式が管理しているため、安定してメンテナンスされる API がシンプルで、処理が追いやすい デメリット MIME マルチパートメッセージやテキストエンコーディングの解析など、複雑なメール処理に必要な高度な機能が不足している jhillyerd/enmime メリット MIME マルチパートメッセージの解析、添付ファイルの処理、エンコーディングの変換など、複雑なメール処理に対応している デメリット 管理しているコミュニティが小さく、メンテナンスが継続されないリスクがある まとめ メールヘッダーを取得・設定するだけであれば net/mail パッケージだけで十分だと思いました。 MIME マルチパートメッセージ の解析・エンコーディングをする必要がある場合は、複雑な処理を管理しなくて済むので jhillyerd/enmime の利用を検討してみても良いかもしれません。 最後に Go Conference 2024 まで、あと 2 日! gocon.jp 株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 5 日目の記事です。 はじめに こんにちは、TIMELINE 開発部 Service Development をしている ほんだ です! 初の Go Conference オフライン参戦なので浮かれてる今日この頃です。 今回はスマホ向けネットスーパーアプリの API を Python から Go へ移行する際のデータベース操作の観点での課題と実際にどのような解決策を取ったのか実装をメインに紹介します。 ネットスーパーアプリのリプレイスを行うことにした背景やシステム全体の課題、解決策に関しては前回のブログに記述しているので是非ご一読ください。 tech.every.tv 技術スタック 以下は今回の記事に関係のあるリプレイス前後の技術スタックになります。 言語 DB ORM リプレイス前 Python MySQL PyMySQL リプレイス後 Go MySQL sqlboiler + sqlx 課題 リプレイスを行うにあたりデータベース操作の観点で以下の 4 点の課題がありました。 テストの不在 既存の実装にテストがないため、リプレイス後のコードが正しく機能するかを検証する手段が限られています。これにより、修正後のコードが期待通りの動作をするかの判断が困難です。 長大な SQL の扱い 200 行を超える長大な SQL クエリを sqlboiler で書き換えることは非常に困難です。これは、sqlboiler が主に CRUD 操作に最適化されており、複雑なクエリの扱いには向いていないためです。 名前付きプレースホルダーの問題 元のクエリでは以下の例のように名前付きプレースホルダー( %(format)s )が多用されていますが、sqlboiler はこの機能をサポートしていません。これにより、プレースホルダー(MySQL では ? )で実装されたクエリでは、クエリが長くなるほど可読性と保守性が損なわれます。 WHERE item.item_name like %(search_word)s OR item.item_area like %(search_word)s OR item.item_spec like %(search_word)s OR event_item.event_item_name like %(search_word)s OR event_item.event_item_area like %(search_word)s OR event_item.event_item_spec like %(search_word)s 型の厳格化 既存の Python 実装ではレスポンスが dict 型で返されるため、柔軟なデータ構造を扱うことができます。しかし、sqlboiler でデータベース操作を行うとレスポンスは tag を元に構造体にバインドされるため厳格な型定義が必要となり、これがリプレイスの際の追加の課題となります。 実装 先に挙げた課題点に対処するため、以下の実装方針を採用しました。 長大なクエリの移行 : 長大なクエリは、可能な限りそのまま Go に移行します。これにより、既存のクエリロジックを保持し、移行に伴うリスクを最小限に抑えることができます。 名前付きプレースホルダーの使用 : sqlx を使用して、名前付きプレースホルダーを実装します。これにより、クエリの可読性と保守性を向上させることができます。 汎用的な実行関数の作成 : 生の SQL クエリを実行し、結果を Go の構造体にバインドする汎用的な関数を作成します。このアプローチにより、異なるタイプのクエリに対しても柔軟に対応することが可能になります。 クエリの移行について 「長大なクエリは可能な限りそのまま Go に移行する」という方針に基づき、sqlboiler で移行可能なクエリと生クエリを明確に区別するために、次のようなディレクトリ構成を採用しました。 repository/ ├── models/ │ ├── item.go │ ├── favorite.go │ ├── menu.go │ └── user.go ├── rawquery/ │ ├── util.go │ ├── item_builder.go │ └── menu_builder.go ├── item.go ├── favorite.go ├── menu.go └── user.go repository ディレクトリ直下には、sqlboiler を用いて移行されたクエリの実装があります。一方で、repository/rawquery ディレクトリには、生クエリを直接扱う実装を配置しています。これらの生クエリは、sqlboiler の Raw 関数をラップしたユーティリティ関数を介して、repository 直下のファイルから呼び出されます。repository/models ディレクトリには、クエリ実行時に結果をバインドするための構造体が定義されています。 この構成により、クエリの種類ごとに責務を分離し、コードの整理と保守性の向上を図っています。 名前付きプレースホルダーを sqlx で実装 次に、名前付きプレースホルダーの実装について説明します。既存の Python 実装では pymysql を使用し、 %(format)s 形式で名前付きプレースホルダーを実装していました。しかし、sqlboiler にはこの機能がないため、sqlx を採用しました。 名前付きプレースホルダーを使用することで、長大なクエリにおける多数の引数や重複する引数の取り扱いが容易になります。ここでは、名前付きプレースホルダーを含む生クエリ、引数の実装、およびそれらをバインドする関数の実装について順を追って説明します。 以下は、repository/rawquery にある名前付きプレースホルダーに渡される引数をフィールドに持つ構造体、初期化関数、名前付きプレースホルダーを含む生クエリを返すメソッド、および引数を返すメソッドの実装例です。 // repository/rawquery/item_builder.go package rawquery type ItemBuilder struct { price int janCode string tax int } func NewItemBuilder(name string , price int , janCode string , tax int ) *ItemBuilder { return &ItemBuilder{ price: price, janCode: janCode, tax: tax, } } func (b *ItemBuilder) BuildQueryWithArgs() (ReBindedQueryArgs, error ) { return buildQueryWithArgsDefault(b.rawQuery(), b.args()) } func (b *ItemBuilder) rawQuery() string { q := ` SELECT name,item_code,price,jan_code,tax_rate FROM item WHERE price > :price AND jan_code = :jan_code` if b.tax != nil { q += " AND tax_rate = :tax_rate" } return q } func (b *ItemBuilder) args() map [ string ] interface {} { args := map [ string ] interface {}{ "price" : b.price, "jan_code" : b.janCode, } if b.tax != nil { args[ "tax_rate" ] = *b.tax } return args } ItemBuilder 構造体は、クエリに必要な引数を保持します。 BuildQueryWithArgs メソッドを呼び出すと、sqlx を使用して名前付きプレースホルダーが含まれる生クエリのプレースホルダーを適切な形式に置き換え、引数の順序に準拠した interface{} 型のスライスを返します。 以下は、 BuildQueryWithArgs メソッドの実行結果の例です。 // repository/rawquery/util.go type ReBindedQueryArgs struct { Query string Args [] interface {} } func buildQueryWithArgsDefault(rawQuery string , args map [ string ] interface {}) (ReBindedQueryArgs, error ) { namedQuery, namedArgs, err := sqlx.Named(rawQuery, args) if err != nil { return ReBindedQueryArgs{}, err } return ReBindedQueryArgs{Query: sqlx.Rebind(sqlx.QUESTION, namedQuery), Args: namedArgs}, nil } sqlx.Named(rawQuery, args) は、生クエリ(rawQuery)と引数(args)を受け取り、クエリ内の名前付きプレースホルダーを引数の値で置き換えます。置き換えられたクエリ(namedQuery)と引数(namedArgs)を返します。 sqlx.Rebind(sqlx.QUESTION, namedQuery) を使用して、名前付きプレースホルダーを ? に再バインドします。そして、再バインドされたクエリと引数を含む ReBindedQueryArgs を返します。 以下は buildQueryWithArgsDefault を実行した結果になります。 sql := ` SELECT name,item_code,price,jan_code,tax_rate FROM item WHERE price > :price AND jan_code = :jan_code` args := map [ string ] interface {}{ "jan_code" : 12345 , "price" : 200 , } queryArgs, _ := buildQueryWithArgsDefault(sql, args) fmt.Println(queryArgs) # 実行結果 { SELECT name,item_code,price,jan_code,tax_rate FROM item WHERE price > ? AND jan_code = ? [ 200 12345 ] } 名前付きプレースホルダー :price , :jan_code が ? に、引数が名前付きプレースホルダに対応した順序の slice になっていることがわかります。 sqlboiler を用いたクエリの実行関数 次に生クエリを実行し Go の構造体に bind する汎用的な関数について説明します。 以下が具体的な実装になります。 // repository/rawquery/util.go func Execute[T any](ctx context.Context, exec boil.ContextExecutor, queryArgs ReBindedQueryArgs) (*T, error ) { var result T if err := queries.Raw(queryArgs.Query, queryArgs.Args...).Bind(ctx, exec, &result); err != nil { return nil , err } return &result, nil } 型引数 T には response に期待する構造体を指定します。 引数に指定された ReBindedQueryArgs の Query と Args を用いて queries.Raw でクエリを生成、 Bind で result にクエリの結果をバインドます。 実行方法 最後に repository 直下のファイルの実装について説明します。 以下のように実装することで生クエリを意識することなくデータベース操作を行えるようにすること、生クエリを廃止し sqlboiler での実装に統一した時の影響が最小限になるようにしています。 // repository/item.go type ItemRepository struct {} func NewItemRepository() *ItemRepository { return &ItemRepository{} } func (r *ItemRepository) ListItem(ctx context.Context, exec boil.ContextExecutor, name string , price int , janCode string , tax int ) (*models.Items, error ) { queryArgs, err := rawquery.NewItemBuilder(name, price, janCode, tax).BuildQueryWithArgs() if err != nil { return nil , fmt.Errorf( "failed to build item query args: %w" , err) } res, err := rawquery.Execute[models.Items](ctx, exec, queryArgs) if err != nil { return nil , fmt.Errorf( "failed to get items: %w" , err) } return res, nil } まとめ この記事では、リプレイスプロジェクトにおけるデータベース操作の課題と、それに対する実装方針について詳しく紹介しました。理想的には、リプレイス前に既存コードにテストを追加し、最低限のリファクタリングを行うことが望ましいです。しかし、今回は迅速な移行と、Go への書き換え後にリファクタリングを進めるという方針のもと、生クエリをそのまま移行することにしました。 sqlboiler と sqlx という二つの異なる ORM を併用することには無理があるように思われるかもしれませんが、結果として責務が適切に分割され、より良いコードへと近づいたと感じています。 Go Conference 2024 まで、あと 3 日! gocon.jp 株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024(夏) 4 日目の記事です。 こんにちは!トモニテで開発を行っている吉田です。 今回はGo 言語の特徴的な機能である並行処理について書いていきます。並行処理を支えるゴルーチン (goroutine) とチャネル (channel) の仕組みと使い方を、サンプルコードとともに紹介します。 並行処理を実現するにあたり まずはゴルーチンとチャネルについて理解を進めます。 ゴルーチンとは ゴルーチンとは 他のコードに対して並行に実行している関数のことです。 前提として全ての Go のプログラムには最低 1 つのゴルーチンがあります。それがメインゴルーチンです。 下記のように関数の前に go キーワードを追加することでゴルーチンを起動することができます。 func PrintStr(str string ){ fmt.Println(str) } go PrintStr( "start goroutine!" ) // 即時関数で実装することも可能 go func () { fmt.Println( "start goroutine!" ) } チャネルとは チャネルは、同時実行中のゴルーチンをつなぐパイプです。あるゴルーチンからチャネルに値を送り、その値を別のゴルーチンで受け取ることができます。 チャネルはデータを順序よく受け渡すためのデータ構造(queue)になっており、バッファを持つことができます。 また Go のチャネルはブロックをします。キャパシティがいっぱいのチャネルに書き込もうとするゴルーチンはチャネルに空きが出るまで待機し、空のチャネルから読み込もうとするチャネルは少なくとも要素が 1 つ入るまで待機します。 下記のように make 関数を使ってチャネルを初期化します。 ch := make ( chan interface {}, 100 ) // 第2引数でバッファを指定 バッファのあるチャネルがブロックするのは、バッファが一杯になったときだけでバッファに空きが出たら値を受け取ります。 バッファ付きチャネルが空で、それに対する読み込みチャネルにも空きがある場合にはバッファはバイパスされ送信元から受信先へと直接値を渡すことができます。 その他の特徴 チャネル利用時は値を chan 型の変数に渡しプログラムのどこかの場所でそのチャネルから読み込む チャネル同士はお互いが何をしているのかは知らずチャネルが存在しているメモリの同じ場所を参照している ex.) package main import "fmt" func main() { send := make ( chan string ) // 双方向チャネルの初期化 // データの送信 go func () { send <- "hello!" // ゴルーチンでデータを送信 }() receive := <-send // メインゴルーチンでデータを受信 fmt.Println(receive) // "hello!" を出力 } 上記のように書くことでメインゴルーチンの処理とは別に並行で異なる処理を行うことができます。 ゴルーチンとチャネルを使うことで、複数のタスクを同時に実行することができますがどのような場面でその良さが出るのでしょうか。 ここでは運用しているサービスでユーザー全員にメッセージを送信する必要があるという場面を例にゴルーチンを使用した場合とそうでない場合の差を見てみます。 ※それぞれ Go のバージョンは 1.22.3 で実施しています ゴルーチンを使わない場合 package main import ( "fmt" "sync/atomic" "time" ) type ( MessageInfo struct { User string Message string } ) var messageCount int64 // GetUsers 対象ユーザーの抽出 func GetUsers() [] string { var names [] string for i := range 10000 { names = append (names, fmt.Sprintf( "Mr. %d" , i)) } return names } // Setting ユーザーごとにメッセージ作成 func Setting() ([]MessageInfo, error ) { users := GetUsers() target := make ([]MessageInfo, 0 ) // ユーザーごとにメッセージを作成 for _, user := range users { params := MessageInfo{ User: user, Message: fmt.Sprintf( "Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!" , user), } target = append (target, params) } return target, nil } // SendMessage メッセージを送信する func SendMessage(param MessageInfo) { time.Sleep( 10 * time.Millisecond) // 送信処理に時間がかかると仮定 // 送ったメッセージ数をカウント // 複数のゴルーチンが同時にmessageCountを更新することによる競合を防ぐためatomicパッケージを使用 atomic.AddInt64(&messageCount, 1 ) } // Send 全ユーザーに対してメッセージ送信 func Send(targets []MessageInfo) error { for _, target := range targets { SendMessage(target) } return nil } func main() { start := time.Now() targets, err := Setting() if err != nil { fmt.Println(err) return } Send(targets) fmt.Printf( "No Goroutine method took %s \n " , time.Since(start)) fmt.Printf( "Messages sent: %d \n " , atomic.LoadInt64(&messageCount)) } かかった時間 $ go run main.go No Goroutine method took 1m49.024703667s Messages sent: 10000 ゴルーチンを使う場合 package main import ( "fmt" "sync" "sync/atomic" "time" ) type MessageInfo struct { User string Message string } var messageCount int64 // GetUsers 対象ユーザーの抽出 func GetUsers() [] string { var names [] string for i := range 10000 { names = append (names, fmt.Sprintf( "Mr. %d" , i)) } return names } // Setting ユーザーごとにメッセージ作成 func Setting() (<- chan MessageInfo, error ) { users := GetUsers() targets := make ( chan MessageInfo, 100 ) // チャネルにバッファを設定 go func () { defer close (targets) for _, user := range users { targets <- MessageInfo{ User: user, Message: fmt.Sprintf( "Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!" , user), } } }() return targets, nil } // SendMessage メッセージを送信する func SendMessage(user, message string ) { time.Sleep( 10 * time.Millisecond) // 送信処理に時間がかかると仮定 atomic.AddInt64(&messageCount, 1 ) // 送ったメッセージ数をカウント } // Send 全ユーザーに対してメッセージ送信 func Send(targets <- chan MessageInfo) error { var wg sync.WaitGroup for taraget := range targets { // 各メッセージ送信は独立したgoroutineで処理 wg.Add( 1 ) go func (taraget MessageInfo) { defer wg.Done() SendMessage(taraget.User, taraget.Message) }(taraget) } wg.Wait() return nil } func main() { start := time.Now() targets, err := Setting() if err != nil { fmt.Println(err) return } Send(targets) fmt.Printf( "Goroutine method took %s \n " , time.Since(start)) fmt.Printf( "Messages sent: %d \n " , atomic.LoadInt64(&messageCount)) } かかった時間 $ go run main.go Goroutine method took 31 .348791ms Messages sent: 10000 並行処理を使わない場合は使う場合に比べ3倍ほどの時間がかかっており、使う場合と使わない場合の差を実感することができました。 続いては並行処理に用いた実装について説明します。 まずは対象者に向けてメッセージを作成する Setting メソッド内にある defer close(targets) についてです。 // Setting ユーザーごとにメッセージ作成 func Setting() (<- chan MessageInfo, error ) { users := GetUsers() targets := make ( chan MessageInfo, 100 ) go func () { defer close (targets) for _, user := range users { targets <- MessageInfo{ User: user, Message: fmt.Sprintf( "Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!" , user), } } }() return targets, nil } 冒頭説明したように go キーワードでゴルーチンが作成できます。 その直後、 defer close(targets) があります。 これはチャネルが閉じてこれ以上値が送信されることがないことを伝えるために用いられます。今回の場合だと targets チャネルにこれ以上値が送信されないということを伝えています。 // Send 全ユーザーに対してメッセージ送信 func Send(targets <- chan MessageInfo) error { var wg sync.WaitGroup for target := range targets { // 各メッセージ送信は独立したgoroutineで処理 wg.Add( 1 ) go func (target MessageInfo) { defer wg.Done() SendMessage(target.User, target.Message) }(target) } wg.Wait() return nil } なぜチャネルに値が送信されないかを伝える必要があるのかについてですが、これは targets チャネルを利用している Send メソッド内の for taraget := range targets が targets チャネルが閉じられるまで別のチャネルから値を受信し続ける(ループが永遠に終わらない)ためです。 試しに defer close をコメントアウトして実行すると fatal error: all goroutines are asleep - deadlock! というエラーが発生しました。これはゴルーチンが値を待ち続けて処理をブロックしてしまうためデッドロックが発生していたということです。 続いては上記 Send メソッド内の sync.WaitGroup についてです。sync パッケージは同期的な処理によく用いられますが WaitGroup はゴルーチンを終了を待つために使っています。 そもそもどうしてゴルーチンの終了を待つ必要があるのでしょうか?答えはメインスレッドはゴルーチンの終了を待ってくれないからです。 WaitGroup をコメントアウトして試してみます。 // 変更がないところは省略します。 // Send 全ユーザーに対してメッセージ送信 func Send(targets <- chan MessageInfo) error { // var wg sync.WaitGroup for taraget := range targets { // 各メッセージ送信は独立したgoroutineで処理 // wg.Add(1) go func (taraget MessageInfo) { // defer wg.Done() SendMessage(taraget.User, taraget.Message) }(taraget) } // wg.Wait() return nil } func main() { start := time.Now() targets, err := Setting() if err != nil { fmt.Println(err) return } Send(targets) fmt.Printf( "Goroutine method took %s \n " , time.Since(start)) fmt.Printf( "Messages sent: %d \n " , atomic.LoadInt64(&messageCount)) } $ go run main.go Goroutine method took 19 .215833ms Messages sent: 3356 送りたい数は 10000 ですが 3356 しか実行されておらず sync.WaitGroup の必要性を確認することができました。 コード内 wg が何をしているのか簡単に説明すると以下の通りです。 wg.Add(1) ... 待機したいゴルーチンの数(カウンタ)を設定。カウンタが 0 になると、後述 Wait でブロックされているすべてのゴルーチンが解放される。監視対象のゴルーチンの直前に書くのが慣習 wg.Done() ... カウンタを 1 減らす。defer キーワードを用いてゴルーチンのクロージャーが終了する前に WaitGroup に終了することを確実に伝えるために使用 wg.Wait() ... WaitGroup カウンターがゼロになるまでメインゴルーチンをブロックする 最後に 以上が Go における並行処理についてです。 ゴルーチンとチャネルを使うことで、複数のタスクを同時に実行することが可能になり、プログラムの効率を大幅に向上させることができます。 今回の記事を通じて、Go の並行処理についての理解が深まっていれば幸いです! ここまでお読みいただきありがとうございました! Go Conference 2024 まで、あと【4】日! gocon.jp 株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp 参考 www.oreilly.co.jp pkg.go.dev gobyexample.com www.spinute.org
アバター
この記事は every Tech Blog Advent Calendar 2024(夏) 3 日目の記事です。 はじめに こんにちは、トモニテでバックエンド周りの開発を行っている rymiyamoto です。 最近は学園アイドルのプロデューサー業に追われています。 今回は、Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介します。 選定理由 現状の課題 Go 言語だとスクリプト処理を実装する際、簡単なものであれば main.go にそのまま処理を書いていくことが多いですが、コマンドライン引数を取るような処理を書く場合、コードが複雑になりがちです。 実際トモニテ内のスクリプト処理も当時の実装メンバーに依存しており以下のような課題がありました。 コマンドのフォーマット サブコマンド指定だったり引数だったり設計者依存 hoge --param=1 or hoge -param 1 それぞれで無駄な共通引数定義 dry-run ログの出力 標準の logger だと使いにくい logrus apex/log 、 zap と割と自由にしがち 同じような pkg が多いとメンテナンスも辛い これらの課題から、コマンドライン引数を取る処理を簡単に実装できるパッケージとして cobra を、ログは go1.21 から標準パッケージで使えるようになった構造化ログが扱える slog を採用しました。 cobra について cobra は Go 言語で CLI ツールを開発する際に歴史があり、Kubernetes、Hugo、GitHub CLI などの多くの Go プロジェクトで使用されています。 コマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また、CLI ツールで cobra-cli が提供されており、コマンドからスクリプトファイルの作成ができます。 github.com github.com slog について go1.21 から導入された構造化ログを扱うことができる go の標準パッケージです。 構造化ログは JSON や key=value 形式でログを出力することができ、ログの解析や可視化が容易になります。 また、標準パッケージであるため、外部パッケージを追加することなく go の標準ライブラリでログを出力することができます。 pkg.go.dev イメージ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil )) logger.Info( "hello" , "count" , 3 ) { " time " : " 2024-06-03T15:28:26.000000000-05:00 " , " level " : " INFO " , " msg " : " hello " , " count " :3 } 環境作成 以下のようなディレクトリ構成で CLI ツールを作成していきます。 $ tree . ├── Dockerfile ├── Makefile ├── cobra.yml └── compose.yml 事前準備 Dockerfile cobra-cli を使いたいので、Go のイメージに cobra-cli をインストールします。 ARG GO_VERSION=1.22.3 FROM golang:${GO_VERSION} AS dev RUN go install github.com/spf13/cobra-cli@v1.3.0 compose.yml name: go-cli-management services: scripts: container_name: scripts build: context: . dockerfile: ./Dockerfile target: dev working_dir: /scripts volumes: - .:/scripts tty: true Makefile cobra-cli を使ったコマンドやコマンドの実行をやりやすくするために作成しています。 container = scripts .PHONY: dev dev: docker compose up -d .PHONY: init init: dev docker compose exec $(container) go mod init $(name) docker compose exec $(container) cobra-cli init .PHONY: add add: dev @$(eval script_file := ${name}.go) @$(if $(name),, $(error name is not defined)) @$(eval script_file_exists := $(shell ls . | grep ${script_file})) @$(if $(script_file_exists), $(error $(name) is already exists)) docker compose exec $(container) cobra-cli add $(name) --config ./cobra.yml .PHONY: run run: dev docker compose exec $(container) go run ./main.go $(line) cobra.yml cobra-cli でコマンドを追加する際の設定ファイルです。 このファイルを編集することでコマンドの中身を拡張できます。 github.com name: author_name useViper: true 初期設定 以下のコマンドから gomod の初期化と cobra-cli の初期化を行います。 $ make init name =go-cli 実行後は以下のようなディレクトリ構成になります。 $ tree . ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd │ └── root.go # cobra で生成されたファイルで、このファイルをベースにしてコマンドを追加していきます ├── cobra.yml ├── compose.yml ├── go.mod ├── go.sum └── main.go # CLI ツールのエントリーポイント 拡張 現状 cmd/root.go に処理をベタ書きしていけばそのままコマンドとして実行できますが、それだと拡張性が失われてしまうのでサブコマンドやデフォルトフラグを追加して取り回しを良くしていきます。 デフォルトフラグの追加 cmd/root.go に初期値を追加します。 今回は並列処理の管理と dry-run モードを追加します。 const ( // concurrencyDefault デフォルトの並列数 concurrencyDefault = 10 // waitTimeDefault デフォルトの処理チャンク単位の待機時間 waitTimeDefault = 1 ) // ... func init() { rootCmd.PersistentFlags().Bool( "dry-run" , false , "Dry run mode" ) rootCmd.PersistentFlags().Uint( "concurrency" , concurrencyDefault, "並列更新数(1以上)" ) rootCmd.PersistentFlags().Uint( "wait-time" , waitTimeDefault, "処理チャンク単位の待機時間(秒)" ) } サブコマンドの追加 cobra-cli を使ってサブコマンドを追加します。 $ make add name =hello 実行すると cmd 配下に hello.go が作成されます。(以下参照) /* Copyright © 2024 rymiyamoto */ package cmd import ( "fmt" "github.com/spf13/cobra" ) // helloCmd represents the hello command var helloCmd = &cobra.Command{ Use: "hello" , Short: "A brief description of your command" , Long: `A longer description that spans multiple lines and likely contains examples and usage of using your command. For example: Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application.` , Run: func (cmd *cobra.Command, args [] string ) { fmt.Println( "hello called" ) }, } func init() { rootCmd.AddCommand(helloCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // helloCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // helloCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } この状態で make run line=hello を実行すると hello called が出力されます。 $ make run line =hello hello called フラグの追加 フラグの追加は作成された cmd/hello.go に cmd/root.go のときと同様に行います。 // ... func init() { rootCmd.AddCommand(helloCmd) helloCmd.Flags().StringP( "target-at" , "t" , time.Now().In(time.FixedZone( "Asia/Tokyo" , 9 * 60 * 60 )).Format(time.DateOnly), "対象日(e.g 2023-10-05)" ) } 処理の整形 Run メソッドではコマンドの実行時の処理を記述しますが、エラーを返すことができないため、エラーハンドリングが限定的です。これに対し、 RunE メソッドを使用すると、エラーを呼び出し元に返すことができ、より柔軟なエラー処理が可能になります。 pkg.go.dev また、slog を使用してデフォルト引数やフラグの値をログに埋め込むことで、実行時の状況を明確に記録できます。slog の JSON ハンドラを標準出力に設定することで、レイヤードアーキテクチャにおいても、中間層を介さずに直接ログを出力することが可能です。これにより、ログの伝播に関するコードの複雑さが軽減されます。 // ... RunE: func (cmd *cobra.Command, args [] string ) error { // デフォルトフラグ dryRun, _ := rootCmd.Flags().GetBool( "dry-run" ) concurrency, _ := rootCmd.Flags().GetUint( "concurrency" ) waitTime, _ := rootCmd.Flags().GetUint( "wait-time" ) // サブコマンド固有フラグ targetAt, _ := cmd.Flags().GetString( "target-at" ) base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})) logger := base.With( "dry-run" , dryRun, "concurrency" , concurrency, "wait-time" , waitTime, "target-at" , targetAt) slog.SetDefault(logger) slog.Info( "hello world!" ) return nil }, 実行すると、以下のような構造化ログが出力されます。構造化ログは、ログデータをキーと値のペアで表現することで、自動化された解析や人間による読解を容易にします。これにより、ログの監視や分析が効率的に行えるようになります。 # サブコマンド実行 $ make run line = " hello " { " time " : " 2024-05-29T11:20:21.059692464Z " , " level " : " INFO " , " msg " : " hello world! " , " dry-run " :false, " concurrency " :10, " wait-time " :1, " target-at " : " 2024-05-29 " } # デフォルトフラグの書き換え $ make run line = " hello --dry-run " { " time " : " 2024-05-29T11:20:46.268378503Z " , " level " : " INFO " , " msg " : " hello world! " , " dry-run " :true, " concurrency " :10, " wait-time " :1, " target-at " : " 2024-05-29 " } # サブコマンド固有フラグの書き換え $ make run line = " hello --target-at=2024-06-02 " { " time " : " 2024-05-29T11:21:23.834180257Z " , " level " : " INFO " , " msg " : " hello world! " , " dry-run " :false, " concurrency " :10, " wait-time " :1, " target-at " : " 2024-06-02 " } あとはサブコマンドの中身を実装や追加をしていけば、CLI ツールの開発が進められます。 まとめ 今回は Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介しました。 cobra はコマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また slog を使うことで構造化ログを出力することができ、ログの解析や可視化が容易になります。 RunE の繰り返しは面倒な作業ですが、これを改善する方法を模索していく予定です。 今後は、このベースを使って実際の処理を実装していくことで、より実用的な CLI ツールを開発していきたいと思います。 Go Conference 2024 まで、あと 5 日! gocon.jp 株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp
アバター
目次 はじめに CPUが理解できる言葉 プログラミング言語が機械語として理解されるまで アセンブリ言語 プログラミング言語の解釈 コンパイラ リンカ インタープリタ まとめ 参考 はじめに こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司( ktanonymous )です。 every Tech Blog Advent Calendar 2024(夏) の2日目の記事執筆担当者として参加させていただいております! tech.every.tv 今回の記事では、普段書いているプログラムがCPUによってどのように理解されているのかについて、気になって勉強したのでまとめてみたいと思います。 (厳密には異なる表現があるかもしれませんが、概念的な理解を目指すものなので、ご容赦ください。) CPUが理解できる言葉 CPUが理解できる言葉は、機械語と呼ばれるものです。 我々エンジニアが普段から書いているプログラミング言語は、CPUから見れば 「意味のわからない、ただの文字列でしかない」と言えるでしょう。 機械語とは、例えば、以下のように表現することができます。(CPUの種類によって表現が異なっていたり、そもそもの解読が辛かったりするので正確な表現・値ではありません) 01 00 10 各数値は16進数で表されており、これで「アドレス 00 番地に値 10 を書き込む」というように、処理( 01 )と必要な対象を組み合わせて1つの命令を表現します。 では、プログラミング言語はどのように機械語としてCPUに解釈されるのでしょうか。 プログラミング言語が機械語として理解されるまで アセンブリ言語 現在一般的に使われているプログラミング言語の話をする前に、アセンブリ言語について触れたいと思います。 アセンブリ言語とは、人間が理解しやすいように機械語と1対1で対応させた言語です。 アセンブリ言語がアセンブラと呼ばれるプログラムによって機械語に変換されることで、CPUが言語を理解できるようになります。 アセンブリ言語に関しては、実際にシェル上で objdump コマンド 1 を利用することで確認することができます(これはオブジェクトファイルの情報を表示するコマンドですが、 -d フラグをつけることで機械語を逆アセンブルすることができます)。 例として、筆者のマシン上で echo 命令を逆アセンブルしてみます。 $ objdump -d /bin/ echo すると、以下のような出力が得られます。 (なお、出力結果の全体は非常に長いので、先頭の数行を抜粋しています) /bin/ echo ( architecture x86_64 ) : ( __TEXT,__text ) section 100000bbc: 55 pushq %rbp 100000bbd: 48 89 e5 movq %rsp, %rbp 100000bc0: 41 57 pushq %r15 100000bc2: 41 56 pushq %r14 100000bc4: 41 55 pushq %r13 100000bc6: 41 54 pushq %r12 100000bc8: 53 pushq %rbx 100000bc9: 48 83 ec 28 subq $4 0, %rsp ... 左から、ファイル(今回は /bin/echo )上でのオフセット、実際の機械語(16進数の値2~4つの組)、機械語に対応する命令を表すニーモニック(mnemonic)の順に並んでいます。 プログラミング言語の解釈 一般的に、我々が日々書いているソースコードは、アセンブリ言語への変換を目指して解釈が進められ、最終的にCPUが実行可能な機械語へと変換されます。 この解釈の過程を担っているのが、コンパイラやインタプリタと呼ばれるものになります。 コンパイル型言語やインタプリタ型言語というのは、この解釈の過程がどのように行われるかによって分類されます。 コンパイラ コンパイラ は以下のように定義できます 2 (説明のため一部表現を変えています)。 言語 のプログラムを言語 のプログラムに変換するプログラム 一般的なコンパイル型言語では、ソースコードをアセンブリ言語まで変換する役割をコンパイラが担っていることが多いでしょう(アセンブラはコンパイラに含まれている場合もあります)。 この定義から考えると、アセンブラもコンパイラの一種と言えると思いますが、 アセンブリ言語から機械語への変換は、変換元がアセンブリ言語であることを強調するためにアセンブラと呼ばれることがあります。 コンパイル型言語の1つとして、弊社でも利用されているGo言語が挙げられます。 Go言語では以下のステップを経てソースコードがコンパイルされます 3 。 Parsing Lexical analysis (tokenize) Syntax analysis (parse) AST construction Type checking IR construction Middle end (最適化) Walk (順序評価、構文の低級化) Generic SSA(Static Single Assignment 4 ) Generating machine code Goのコンパイラにはアセンブラも含まれているため、最終的には機械語に変換されていることがわかります。 Goの標準のコンパイラである gc はGoで実装されています。 これは、セルフホスティングと呼ばれる手法で、自身の言語で自身のコンパイラを書くというものです。 gcは元々C言語で書かれていましたが、この手法を用いることでGoで書かれたコンパイラが実現されています 5 。 自身の言語で実装されたコンパイラを実行するために、コンパイラのコード自身がコンパイルされている必要があります。 そのため、異なる言語で実装されたコンパイラ を用いて自身の言語で実装されたコンパイラ をコンパイルし、 最後に で 自身をコンパイルすることで、自身の言語で書かれたコンパイラが完成します( イメージ )。 また、コンパイラの実現方法の他に、コンパイルの手法にもJIT(Just-In-Time)コンパイラ 6 やAOT(Ahead-Of-Time)コンパイラ 7 などの種類があります。 TypeScriptからJavaScriptへの変換(トランスパイル)もコンパイルの一種です。 リンカ 一般的に、プログラムは複数のソースコードから構成されます。そのため、 コンパイラによって生成されたそれぞれのオブジェクトファイルは、そのままでは実行可能な1つのプログラムとはなりません。 これらのオブジェクトファイルをリンカと呼ばれるプログラムによって結合することで、実行可能な1つのプログラムが生成されます。 リンカは、関数のエントリーポイント情報を補完するなどして、複数のオブジェクトファイルを結合した1つの実行可能ファイルなどを生成します。 通常、ソースコードを機械語にコンパイルしてリンクするまでの過程を指して「ビルド」と呼びます。 なお、生成された実行可能ファイルは、ローダーによってストレージ(外部記憶装置)からメインメモリ(RAM)などに読み込まれます。 また、リンクには静的リンクと動的リンクの2種類があります。 静的リンクは、プログラムの実行に必要なライブラリなどを単一の実行ファイル内部にリンクする方法、 動的リンクは、呼び出される側のライブラリが実行時にリンクされる方法です。 リンク方法が静的か動的かどうかで実行ファイルのサイズや開発サイクルのスピードなどに違いが出てきます。 インタープリタ インタープリタ は以下のように定義できます 8 (説明のため一部表現を変えています)。 言語 を用いて実現した、言語 のプログラムが動作するプログラム インタープリタは、実行時にソースコードを逐次解釈して実行するものです。 インタープリタの解釈手法には、ソースコードをそのまま逐次解釈するものもあれば、 一度バイトコードなどに変換(コンパイル)してから逐次解釈するものもあります。 例えば、Pythonの標準的なインタープリタであるCPython 9 は、 ソースコードをバイトコード(中間表現)に変換してから逐次解釈され、 PVM(Python Virtual Machine)によって処理されます 10 。 ちなみに、Pythonのバイトコードは dis モジュール 11 を利用することで確認することもできます。 例えば、以下のようなコードを実行する場合を考えます(Google Colaboratoryでの実行を前提にしています)。 import dis def print_hello (): print ( "Hello!" ) dis.dis(print_hello) このコードを実行すると、以下のような出力が得られます。 4 0 LOAD_GLOBAL 0 ( print ) 2 LOAD_CONST 1 ( ' Hello! ' ) 4 CALL_FUNCTION 1 6 POP_TOP 8 LOAD_CONST 0 ( None ) 10 RETURN_VALUE 左から、ソースファイル内での行番号、命令のバイトコード(とその該当バイトインデックス)、 命令が取得する引数の参照インデックスと引数の順に並んでいます。 まとめ 今回の記事では、普段意識することのなかったプログラムの処理系について勉強したことをアウトプットしてみました。 これを知ったからといって普段のコーディングが劇的に変わるということはないと思いますが、 こういった基礎的な知識がシビアなシーンでは役に立つことも多いと思います。 この記事が、「なんかそれっぽいこと書いてるだけで勝手にPCが結果を出してくれる」を脱却したい人の一助になれば幸いです。 最後まで読んでいただき、ありがとうございました。 参考 大堀淳の計算機科学チャネル | コンパイラ ー原理と構造ー 筑波大学 | プログラミング言語処理 講義資料 | 言語処理系とは Rui Ueyama, 低レイヤを知りたい人のためのCコンパイラ作成入門, 2020/03/16 東京情報大学 | オペレーティング・システム | 第8回 プログラムの実行制御(その3) プログラムの実行 本当に初心者の人に捧げるコンピューター入門 | 1.4.3 まずは機械語 wikipedia | 機械語 Go コンパイラのコードを読んでみよう About the go command Introduction to the Go compiler GO | Frequently Asked Question logmi Tech | コンパイラが作ったバイナリをつなぎ合わせるプログラム「lld」の作者が語る、リンカの仕組み IT用語辞典 e-words | リンカ IT用語辞典 e-words | 静的リンク IT用語辞典 e-words | 動的リンク IT用語辞典 e-words | ローダー speakerdeck | Goコンパイラをゼロから作ってセルフホスト達成するまで / How I wrote a self hosted Go compiler from scratch what is a self-hosting compiler? wikipedia | インタープリタ CPython Python Glossary synopsys ブログ | Pythonバイトコードの知識 objdump ↩ コンパイラ ー原理と構造ー 第2回:計算機の模倣、プログラミング言語の構造と原理、プログラミング言語開発の枠組み(25:32くらい) ↩ Introduction to the Go compiler ↩ Static Single Assignment(静的単一代入) ↩ what compiler technology is used to build the compilers? ↩ JIT コンパイラー ↩ AOT コンパイラー ↩ コンパイラ ー原理と構造ー 第2回:計算機の模倣、プログラミング言語の構造と原理、プログラミング言語開発の枠組み(21:25くらい) ↩ cpython ↩ bytecode ↩ disモジュール ↩
アバター
はじめに この記事は、 every Tech Blog Advent Calendar 2024(夏) の1日目の記事です。 DELISH KITCHEN開発部の羽馬(@NaokiHaba)です。 この記事では、DELISH KITCHEN チラシ で使用している Vuex の Pinia への移行について紹介します。 chirashi.delishkitchen.tv 本記事では、これらの知識があることを前提に説明を進めます。 Vue.jsの基本的な知識 Nuxt.jsの基本的な知識 Vuexの基本的な知識 Piniaとは Pinia(ピーニャ)は、Vue.js用の新しい状態管理ライブラリです。Vuexの次のイテレーションとして開発が始まり、Vuex 5に組み込むことを想定していたアイデアを多く取り入れています。 pinia.vuejs.org Piniaは、Vuexと比較して以下のような特徴や利点があります。 シンプルなAPIを提供し、学習コストが低い TypeScriptとの連携が強化され、型の恩恵を受けやすい モジュール方式を採用せず、ストアを個別に定義できるため、コードの可読性や保守性が向上する Vue Devtoolsとの統合が進んでおり、開発体験が良い Piniaは、Vue.js v2とv3の両方に対応しており、Nuxt.jsにも対応しています。Nuxt v3からは、VuexからPiniaが公式に推奨されるようになりました。 なぜPiniaに移行するのか DELISH KITCHEN チラシ では、以下の理由からPiniaへの移行を決定しました。 Nuxt3への移行を見据えて、早めにPiniaを導入しておきたかった Vuex は現在メンテナンスモードであり、今後のアップデートが見込めないため Nuxt3以降もPiniaの公式サポートが続くと予想されるため Piniaへの移行によって、Nuxt3への移行をスムーズに進めることができると考えました。 移行の手順 1. Piniaの導入 まずは、Pinia を導入します。 pinia.vuejs.org $ yarn add pinia @pinia/nuxt # or with npm $ npm install pinia @pinia/nuxt 次に、nuxt.config.js に Pinia の設定を追加します。 移行時点では、Vuex と Pinia を併用することとなるため、disableVuex を false に設定します (disableVuex はデフォルトで true になっているため、Vuex が無効化されます) // nuxt.config.js export default defineNuxtConfig ({ buildModules : [ // set `disableVuex` to false if you need to use Vuex alongside Pinia [ '@pinia/nuxt' , { disableVuex : false } ] , ] , }) 以上で、Pinia の導入は完了です。 2. VuexストアのPiniaストアへの移行 次に、既存のVuexストアをPiniaストアに移行します。 Piniaでは、ストアをdefineStore関数を使って定義します。defineStore関数には、ストアの名前を表すidと、ストアの定義を表すoptionsの2つの引数を渡します。 pinia.vuejs.org 以下は、Vuexストアの例です。 // store/todo.js export default { state : { todos : [] , } , mutations : { setTodos ( state , todos ) { state . todos = todos } , } , actions : { async fetchTodo ({ commit } , id ) { try { const response = await this. $axios . get ( `https://jsonplaceholder.typicode.com/todos/ ${ id } ` ) commit ( 'setTodos' , [ response . data ]) } catch ( error ) { console . error ( error ) } } , } , getters : { allTodos : state => state . todos , } , } このVuexストアを、Piniaストアに移行すると以下のようになります。 // stores/todo.js import { defineStore } from 'pinia' export const useTodoStore = defineStore ( 'todos' , { state : () => ({ todos : [] , }) , actions : { async fetchTodo ( id ) { try { const response = await this. $nuxtAxios . get ( `https://jsonplaceholder.typicode.com/todos/ ${ id } ` ) this. todos = [ response . data ] } catch ( error ) { console . error ( error ) } } , } , getters : { allTodos : ( state ) => state . todos , } , }) Piniaストアでは、mutationsが削除され、actionsとgettersのみが残っています。これは、Piniaではmutationsの概念がなくなり、actionsで直接ステートを更新するためです。 また、actions内でのthisの扱いが変わっています。Piniaでは、thisがストアのインスタンスを指すため、this.todosのように直接ステートを更新できます。 ここで、 this.$axios が this.$nuxtAxios に変更されていることに注目してください。 Piniaでは、ストアの中で this がストアのインスタンスを指します。したがって、Vuexストアで使っていた this.$axios をそのまま使うことはできません。 代わりに、Nuxtのコンテキストからプラグインを介して $axios を取得し、 this.$nuxtAxios として使用しています。 このプラグインは、以下のように定義します。 // plugins/pinia-inject-axios.js export default defineNuxtPlugin (( nuxtApp ) => { nuxtApp . $pinia . use (() => ({ $nuxtAxios : markRaw ( nuxtApp . $axios ) , })) ; }) ; そして、nuxt.config.js でこのプラグインを登録します。 Nuxt3では、 $fetch を使うことが推奨されており、 @nuxtjs/axios は利用できないため、このプラグインは不要になります。 // nuxt.config.js export default defineNuxtConfig ({ plugins : [ '~/plugins/pinia-inject-axios.js' , ] , }) nuxtServerInit の扱い Vuexでは、 nuxtServerInit はサーバーサイドレンダリング(SSR)時に、サーバー側での初期化処理を行うための特別なアクションでした。Nuxt.jsでは、SSR時に store ディレクトリ内の各ストアの nuxtServerInit アクションが自動で呼び出される仕組みがあります。 一方、Piniaでは nuxtServerInit が自動で呼び出される仕組みがありません。代わりに、 plugins や middleware を利用して、 nuxtServerInit の処理を移行する必要があります。 例えば、 plugins/nuxt-server-init.js というファイルを作成し、以下のようなコードを記述します。 export default defineNuxtPlugin(nuxtApp => { if (process.server) { // サーバー側での初期化処理をここに記述 } }) 3. コンポーネント内でのストアの利用方法の変更 最後に、コンポーネント内でのストアの利用方法を変更します。 VuexではmapState、mapGetters、mapActionsなどのヘルパー関数を使ってストアにアクセスしていました。 Piniaでも同様のヘルパー関数が用意されていますが、mapGettersの代わりにmapStateを使うことが推奨されています。 pinia.vuejs.org <template> <div> <div v-for="todo in todos" :key="todo.id"> {{ todo.title }} </div> <button @click="fetchTodo(1)">Fetch Todo</button> </div> </template> <script> import { mapState, mapActions } from 'pinia' import { useTodosStore } from '~/stores/todosStore' export default { fetch({ app, error, $pinia }) { const todosStore = useTodosStore($pinia) todosStore.fetchTodo(1) }, computed: { ...mapState(useTodosStore, [ 'todos' ]), }, methods: { ...mapActions(useTodosStore, [ 'fetchTodo' ]), }, } </script> Composition APIを使う場合は、useStore関数を使ってストアのインスタンスを取得し、直接ストアの状態やアクションにアクセスできます。 <script setup> import { useTodosStore } from '~/stores/todosStore' const todosStore = useTodosStore() await todosStore.fetchTodo(1) </script> <template> <div> <div v-for="todo in todosStore.todos" :key="todo.id"> {{ todo.title }} </div> <button @click="todosStore.fetchTodo(1)">Fetch Todo</button> </div> </template> まとめ この記事では、DELISH KITCHEN チラシ におけるVuexからPiniaへの移行について紹介しました。 Piniaは、Vuexと比べてシンプルなAPIを提供し、TypeScriptとの連携が強化されているため、Nuxt3での開発をスムーズに進めることができます。 Nuxt3での開発を行う際には、ぜひPiniaの導入を検討してみてください。
アバター
はじめに DELISH KITCHEN開発部 兼 Dev Enableチームの羽馬(@NaokiHaba)です。 初夏の陽気が心地よい今日この頃、every Tech Blog ではもうすでに夏へのカウントダウンが始まっています。 そして今年は、その夏を少し先取りする形で、6月にアドベントカレンダーを開催します! every Tech Blog Advent Calendar とは every Tech Blog Advent Calendar は、2023年12月に始まった弊社のエンジニアによる技術ブログ企画です。 Advent Calendarにちなんで、12月は1日から25日まで日替わりで記事を公開してきました。 Webフロントエンドからバックエンド、インフラ、機械学習、データ分析など幅広い分野の記事が集まり、多くの方にご覧いただけました。 tech.every.tv every Tech Blog Advent Calendar 2024 (夏) の見どころ 今回のアドベントカレンダーでは、前回の知見を踏まえつつ、さらに多様で深い技術記事を 6月1日から28日までの28日間 毎日お届けします! 暑い夏を熱いテクノロジーで乗り切るべく、エンジニアたちが知恵を絞って記事を執筆中です。 ご期待ください! 注目の企画として、今回はエブリーがスポンサーを務める2つのカンファレンスを盛り上げるべく、カウントダウン企画を実施します! 6/3〜6/7 Go Conference 2024カウントダウンブログ Go Conference 2024が開催される6月8日まで、Go言語に関する記事を毎日公開します。 イベント詳細はこちらから! gocon.jp 6/19〜6/21 KotlinFest 2024 カウントダウンブログ KotlinFest 2024が開催される6月22日まで、Kotlinに関する記事を毎日公開します。 イベント詳細はこちらから! www.kotlinfest.dev 公開日 テーマ URL 2024/06/01 Vuex から Pinia への移行を行いました https://tech.every.tv/entry/2024/06/01/170000 2024/06/02 プログラムが CPU に理解されるまでのプロセスをまとめてみた https://tech.every.tv/entry/2024/06/02/103000 2024/06/03 go 言語で cobra と slog を使った CLI ツール開発 https://tech.every.tv/entry/2024/06/03/103933 2024/06/04 Go 言語の並行処理: ゴルーチンとチャネルの活用法について https://tech.every.tv/entry/2024/06/04/100307 2024/06/05 ネットスーパーリプレイス〜長大なクエリと向きあう編〜 https://tech.every.tv/entry/2024/06/05/150124 2024/06/06 Go 言語で行うメール解析 https://tech.every.tv/entry/2024/06/06/192547 2024/06/07 DELISH KITCHEN のユニットテストで使用しているライブラリ https://tech.every.tv/entry/2024/06/07/104820 2024/06/08 Go Conference 2024 に プラチナ Go ルドスポンサー として参加しました! https://tech.every.tv/entry/2024/06/08/200152 2024/06/09 レシピ動画からサムネイル画像を自動抽出する AI システムを作りました https://tech.every.tv/entry/2024/06/09 2024/06/10 社内ナレッジ活用のための RAG 基盤の PoC を行いました https://tech.every.tv/entry/2024/06/10/110918 2024/06/11 API Gateway から Amazon Data Firehose へ Lambda を使わずにデータを流す https://tech.every.tv/entry/20240611 2024/06/12 Xcode 15 の画像/色のシンボル自動生成機能を SPM マルチモジュール環境で使う https://tech.every.tv/entry/2024/06/12/111801 2024/06/13 Databricks Model Serving と AWS API Gateway で作る ML API https://tech.every.tv/entry/2024/06/13/170411 2024/06/14 mamadays.tv から tomonite.com へドメインを変更しました https://tech.every.tv/entry/2024/06/14/144222 2024/06/15 新規プロダクトのリポジトリ構成にモノレポを採用してみた https://tech.every.tv/entry/2024/06/15/0001 2024/06/16 N1 分析してみる https://tech.every.tv/entry/2024/06/16/000000 2024/06/17 ML のスモールスタート時に Databricks の Feature Store を導入するべきか否か https://tech.every.tv/entry/2024/06/17/140157 2024/06/18 Flutter エンジニアが年 150 万円のサーバー費用を削減する話 https://tech.every.tv/entry/2024/06/18/175820 2024/06/19 LiveData を Kotlin Coroutines Flow に移行した話 https://tech.every.tv/entry/2024/06/19/114100 2024/06/20 Android プロジェクトの KSP 化を検討するにあたって https://tech.every.tv/entry/2024/06/20/095753 2024/06/21 8 年前に Kotlin を採用してたくさん恩恵を受けた話 https://tech.every.tv/entry/2024/06/21/100756 2024/06/22 Kotlin Fest 2024 に ひよこスポンサー として参加してきました! https://tech.every.tv/entry/2024/06/22/185445 2024/06/23 AWS Summit Japan 2024 に参加しました https://tech.every.tv/entry/2024/06/23/144931 2024/06/24 RDS で EBS BurstBalance が枯渇した事例の紹介 https://tech.every.tv/entry/2024/06/24/195434 2024/06/25 Go 言語で multipart/form-data を使用して画像を受け取り外部に送信する https://tech.every.tv/entry/2024/06/25/110115 2024/06/26 Amazon QuickSight を使用してインタラクティブな可視化をしてみる https://tech.every.tv/entry/2024/06/26/114130 2024/06/27 リアーキテクチャを支えるテスト駆動開発:効果的なリファクタリングの方法 https://tech.every.tv/entry/2024/06/27/121736 2024/06/28 Golangでアプリ課金(iab/iap)を実装するときは awa/go-iap が便利って話 https://tech.every.tv/entry/2024/06/28/115518 投稿された記事は、 株式会社エブリー 開発部の公式X でポストするとともに、こちらの技術ブログにも順次掲載していきますので、ぜひブックマークやコメント・シェアをお願いします! それでは、6月1日からお楽しみに! 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター
TSKaigi 2024 に参加してきました! はじめに Dev Enableチームの羽馬( NaokiHaba ) と 庄司( ktanonymous )です。 2024年5月11日(水)に開催されたTSKaigi 2024に参加してきましたので、イベントの様子や印象に残ったセッションをいくつかご紹介します。 各セッションのアーカイブも公開予定とのことですので、ぜひ公式サイト・YouTubeチャンネルなどをチェックしてみてください。 tskaigi.org www.youtube.com イベントの様子 TSKaigi 2024は、今年から開催された新しいイベントです。TypeScriptを中心にしたカンファレンスで、TypeScriptの最新情報や活用事例などが紹介されました。 会場には、国内外から多くのエンジニアが集まり、盛況のうちに開催されました。会場内では、様々なブースが設けられ、最新のツールやサービスの紹介が行われていました。 特に印象に残ったブースは、アセンド株式会社 様のブースで開催された 「TypeScriptコンパイルチャレンジ」です。 このコンパイルチャレンジは、以下のような内容でした。 参加者は、青色の「型カード」を引く 机に並べられた赤色の「値カード」から一つをめくる 引いた型カードと値カードを使って、TypeScriptのコンパイルにチャレンジ コンパイルに成功すると、景品が贈呈される(高難易度コンパイルに成功した方には、HHKBなどの豪華景品も) 弊社メンバーも、あと一歩のところで当選を逃してしまいましたが、楽しい体験ができました。 参加レポート Keynote: What's New in TypeScript 発表者: Daniel Rosenwasser さん( https://twitter.com/drosenwasser ) レポート: 庄司 Microsoft / TypeScript Principal Product Manager の Daniel Rosenwasser さんによる Keynote では、TypeScript の最新情報が紹介されました。 TypeScript 5.4 および TypeScript 5.5 Beta の新機能について、 ライブコーディングを交えながら、各機能の使い方や利点が丁寧に解説されていました。 主な新機能は以下の通りです: TypeScript 5.4 The NoInfer Utility Type Preserved Narrowing in Closures Following Last Assignments TypeScript 5.5 Beta Type Imports in JSDoc Regular Expression Syntax Checking Inferred Type Predicates Isolated Declarations 特に NoInfer は型推論を制御する上で強力な機能だと感じました。 型の絞り込み (Narrowing) の改善や JSDoc での型インポートのサポートなど、日々の開発で嬉しい機能が多数含まれていました。 Regular Expression の構文チェックは地味ながら実用的な機能追加だと思います。 TypeScript は着実に進化を続けており、次のバージョンが今から楽しみです。 TypeScript の抽象構文木を用いた、数百を超える API の大規模リファクタリング戦略 発表者: やなえもん さん( https://twitter.com/yanaemon169 ) レポート: 庄司 speakerdeck.com こちらのセッションでは、数百のAPIを抱える Express コードを、AST(抽象構文木, Abstract Syntax Tree) を利用して Nest.js コードに大規模移行するという取り組みが紹介されました。 コードのリプレイスと言えば、正規表現を利用したスクリプトによる変換やIDEによる一括置換などが一般的ですが、こちらは AST を利用しているという点が新鮮でした。 AST を利用することで、微妙な表記揺れなどを気にせず、コードの構造に則したリプレイスが可能になるという話には説得力がありました。 TypeScriptのコンパイラでもASTが利用されているように、ASTとTypeScriptの相性の良さを感じました。また、近年の生成AI技術の発展によって、ASTの取り扱いもより容易になるのではないかと期待が持てます。 一方で、レビュアーの負担が大きいという課題にも触れられていましたが、全体としてとてもチャレンジングで興味深い取り組みだと感じました。 AST については、HireRoo さん( https://twitter.com/hirerooinc ) が発表された TypeScript ASTを利用したコードジェネレーターの実装入門 でも詳しく解説されていましたので、興味のある方はそちらもチェックしてみてください。 TypeScriptから始めるVR生活 発表者: TamaG さん( https://twitter.com/TAMAGOKAKE_G_ ) レポート:羽馬 speakerdeck.com Resonite 上でビジュアルプログラミング言語「ProtoFlux」を使った開発の様子が紹介されました。 ProtoFluxは「ノード」と「ノード」をつなぐことでプログラミングができる言語ですが、バージョン管理や関数化ができないという問題点がありました。 これらの問題を解決すべく生まれたのが「MirageX」です。MirageXは、TypeScriptとReactを使ってResoniteの開発ができるフレームワークです。 コードベースの開発になるため、バージョン管理やAIの力を借りることができるようになりました。また、ライブラリを使うこともできるため、本格的なシューティングゲームなども作成可能です。 VRプラットフォームでのTypeScriptを使った開発事例が紹介され、その可能性と課題について理解を深めることができました。VRという新しい領域でのTypeScriptの活用法について学べる貴重な機会でした。 興味を持った方は、ぜひ MirageXのGitHubリポジトリ をチェックしてみてください! サービス開発におけるVue3とTypeScriptの親和性について 発表者: からころ / karacoro さん( https://twitter.com/karan_corons ) レポート:羽馬 speakerdeck.com Vue3ではComposition APIの登場により、コンポーネントのロジックを外部ファイルに切り出しやすくなり、型付けも改善されました。 また、コンポーネントランタイムの型付け強化により、Props、Emit、Provide/Injectなどでも型の恩恵を受けられるようになっています。 さらに、Volar.jsとvuejs/language-toolsの貢献により、テンプレートへの型の反映などエディタ連携の問題も解決されました。 講演の丁寧な解説と豊富なコード例は、Vue3とTypeScriptを活用したサービス開発のベストプラクティスを学ぶ上で非常に参考になる内容だと感じました。 まとめ TSKaigi 2024は、TypeScriptを中心にしたカンファレンスとして、多くのエンジニアにとって有益な情報が得られるイベントでした。 TypeScriptの最新情報や活用事例を学ぶことができ、新しい技術やアイデアに触れることができました。 今後も、TypeScriptコミュニティの発展と、エンジニアのスキルアップに貢献するイベントとして、TSKaigiが続けられていくことを期待しています。 また、今回の参加レポートが、TypeScriptを学びたい方や、TypeScriptを活用したい方の参考になれば幸いです。 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター
はじめに DelishKitchen や ヘルシカ でインフラをやったりバックエンドをやったりしているyoshikenです。 今回は、Treasure Dataにログを送信しようとfluentdとfluent-bitを使っていたときにハマった話を書きます。 fluentdからfluent-bitへ もともと弊社では歴史的背景でfluentdを使っていました。が、 大々的なlogの加工が必要なものはTreasure Dataなど別サービスで行う リソースの消費がやや気になる FireLensはじめ、Fargateでfluentbitのほうが相性が良い などの理由より、新規サービスはすべてfluent-bitを使用することになりました。 移行から数ヶ月~数年経っていますが、flunetdに比べ軽量であるため期待した通りのパフォーマンスを発揮してくれています。 fluent-bitでTreasure Dataにログを送信できない現象 ヘルシカではアクセスログをTreasure Dataに送信する要件がありましたので、fluent-bitでTreasure Dataにログを送信する設定を行いました。 confを 公式ドキュメント通り に記述しましたが、ログが送信されず、logを漁ってみると以下のようなエラーが出ていました。 [202x/xx/xx xx:xx:xx] [ warn] [output:td:td.0] HTTP status 404 {"status_code":404,"message":"Resource not found","severity":"error","error":"Resource not found","text":"Resource not found"} 同じような設定でflunetdを動かしてみると問題なく送信/挿入できたので、fluentbit固有の問題と考えdebugしていきます。 fluent-bitとfluentdではTreasure Dataプラグインの挙動が違う件 結論からいうと、fluent-bitのTreasure Dataプラグインはfluentdの同名のプラグインと挙動が微妙に異なります。 fluentdではテーブルが存在しない場合、正確に記すと「upload時に 404 not found httpステータスコードが帰ってきた場合」はテーブルを作成する処理を行います https://github.com/treasure-data/fluent-plugin-td/blob/master/lib/fluent/plugin/out_tdlog.rb#L209-L224 begin begin @client.import(database, table, UPLOAD_EXT, io, size, unique_str) rescue TreasureData::NotFoundError unless @auto_create_table raise end ensure_database_and_table(database, table) io.pos = 0 retry end 対してfluent-bitでは、テーブルが存在しない場合でも特に追加処理などせずにそのままエラーを返却する形になっています https://github.com/fluent/fluent-bit/blob/master/plugins/out_td/td.c#L188-L207 /* Validate HTTP status */ if (ret == 0) { /* We expect a HTTP 200 OK */ if (c->resp.status != 200) { if (c->resp.payload_size > 0) { flb_plg_warn(ctx->ins, "HTTP status %i\n%s", c->resp.status, c->resp.payload); } else { flb_plg_warn(ctx->ins, "HTTP status %i", c->resp.status); } goto retry; } else { flb_plg_info(ctx->ins, "HTTP status 200 OK"); } } else { flb_plg_error(ctx->ins, "http_do=%i", ret); goto retry; } 理由ついてはissueなどを漁ってみましたが、特に言及はなかったです。 一応歴史的にはfluentdも昔はflunet-bit同様にエラーをそのままエラーで返していたましたが、途中でリトライ処理が追加された形になります。 Prevent retrying unretriable errors by cyberdelia · Pull Request #35 · treasure-data/fluent-plugin-td まとめ まとめると以下の表になります。 fluentd fluent-bit テーブルが存在する 送信可能 送信可能 テーブルが存在しない 自動生成 404 not found 弊チームではデータチームと話し合い、"エラーが出続けるのは健全ではない"・"fluentdと同じ仕様と勘違いし、Treasure Data側のテーブル作成を忘れてしまう"などの懸念が生じ、即座のリアルタイム性が必要なログではないため、"一度S3にoutput。その後、Treasure Dataのbatch importで挿入する。"という形で対応することとなりました。 同じようなプラグインでも挙動が異なるというレアケースを引いてしまったため、後世に同じような人がハマらないように記事に残しておきます。
アバター
はじめに Dev Enableチームの羽馬( @NaokiHaba )です。 この度、エブリーは2024年6月8日(土)に開催される『Go Conference 2024』に、プラチナGoルドスポンサーとして協賛することになりました! gocon.jp エブリーでは、Go言語を積極的に採用し、様々なプロジェクトでその力を発揮しています。今回の協賛を通して、さらなるGo言語コミュニティの発展に貢献できればと考えております。 今年のGo Conference 2024のテーマは「一期一会」です。Go言語に関する情報交換や交流を通じて、新たな出会いや気づきを得ることができるでしょう。 ぜひ、タイムテーブルをご覧いただき、気になるセッションに参加してみてください。 https://sessionize.com/api/v2/7zlcfd7c/view/GridSmart 弊社も、17時50分からのスポンサーセッションでGo言語を活用したプロダクトやサービスの開発事例をご紹介いたします。ぜひご期待ください! また、私たちのブースでは、Go言語の最新技術情報や活用事例をご紹介する予定です。エブリーのエンジニアが直接皆様からのご質問にお答えしますので、ぜひお立ち寄りください。 エブリーにおけるGo言語の活用 ここでは、これまでのエブリーのテックブログで公開してきたGo言語関連の記事をいくつかご紹介します。 Go testにおける可読性を保つ方法を考える tech.every.tv テストコードの複雑化や保守性の低下といった問題に直面した際に、テストコードの可読性を維持するための方法について紹介しています。 ネットスーパーアプリ GraphQL から REST へ移行始めました tech.every.tv ネットスーパーアプリでGraphQLからRESTへ移行した経緯と、その過程で得られた知見について紹介しています。 PythonからGoへのリプレイスを行っている事例として参考になる内容となっています。 sqlboilerとoapi-codegenの活用事例 tech.every.tv APIサーバー開発にsqlboilerとoapi-codegenを導入した事例を紹介しています。 これらのツールを活用することで開発の生産性を向上させた経験について説明しています。 WebSocket APIを用いたリアルタイム通知の実装 tech.every.tv Next.jsとGoを組み合わせ、AWS API GatewayのWebSocket APIを用いてAPIサーバーからフロントエンドにリアルタイム通知を送る方法について解説しています。 WebSocketを用いたリアルタイム性の高いアプリケーション開発の参考になる内容です。 その他にも、Go言語を活用したプロダクトやサービスの開発に関する情報を今後も随時公開していきますので、ぜひご確認ください。 tech.every.tv 皆様とお会いできることを楽しみにしています! Go Conference 2024では、当社がどのようにGo言語を活用しているのか、具体的な事例を交えながらご紹介できることを楽しみにしています。また、皆様と直接お話しできる機会を大切にしたいと思っておりますので、ご質問やご意見があればお気軽にお寄せください。 みなさまとお会いできることを心より楽しみにしております。6月8日、Go Conference 2024でお会いしましょう! 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター