TECH PLAY

株式会社エブリー

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

411

はじめに はじめまして。DELISH KITCHEN開発部の羽馬( @naokihaba )と申します。 私が所属するDELISH KITCHEN開発部では、現在Nuxt3へのアップグレードに向けた取り組みを進めています。 この記事では、私たちが行っているNuxt3へのアップグレードに向けた取り組みについて紹介いたします。 移行背景 私が所属するDELISH KITCHEN開発部では、いくつかのプロダクトでNuxt.jsバージョン2.xを利用しています。 しかしながら、Nuxt.js@2.xのサポートは2024年6月30日に予定されており、Nuxt3にアップグレードする必要が出てきました。 そこで、私たちはNuxt3へのアップグレードに向けて「Nuxt3に移行するべきか、それともNext.jsに移行するべきか」という議論を続けてきました。 Nuxt3への移行を選択した理由 近年、Next.jsは人気を集めており、弊社でもNext.jsを採用するプロダクトがあるため、Next.jsへの移行も選択肢の一つとして考慮しました。 しかしながら、Next.js@13.x で新しく導入された App Router がまだ発展途上であること、そして頻繁に更新されていることを理由に、Next.jsは一定の安定性が求められるシチュエーションにおいてはリスクを伴うかもしれないと考えました。 それに対して、Nuxt3への移行を選択した要因はいくつかあります。 Nuxt3やNuxt Bridgeのマイグレーションガイドが整備されている Nuxt3への移行についての情報が共有されている STORES 株式会社様の登壇資料 など、参考にできる資料が豊富に存在します。 Nuxt3のドキュメントが整備されている これらを総合的に考慮した結果、Next.jsへの移行と比べて、Nuxt3への移行コストが全体的に低いと結論付けました。 移行課題 Nuxt3への移行には、以下のような課題があります。 Nuxt.js v2 から Nuxt3 へのアップグレード Vue.js v2 から Vue3 へのアップグレード @Nuxt/axiosからfetch APIへの移行 VuexからPiniaへの移行とする or Vuex4へのアップグレード Webpack v4からv5へのアップグレード 関係しているパッケージ・ライブラリのアップグレードに伴う修正作業(PostCSS etc...) これらの課題に対して、私たちは次のようなアプローチを取ることにしました。 移行計画 Nuxt3への移行には、主に2つのアプローチが考えられます。 Nuxt Bridgeを利用したアップグレード Nuxt.js v2 から Nuxt3 への直接アップグレード Nuxt2からNuxt3への直接アップグレードも不可能ではありません。 しかしながら、関連するパッケージのアップグレードとそれに伴う修正作業と付随して、移行作業が複雑化することが予想されます。 また、大規模なリリースとなることが予見されるため、Nuxt Bridgeを利用した段階的なアップグレードを選択しました。 移行作業 ここでは、Nuxt3への移行作業の一部をご紹介します。 ただし、移行作業はまだ進行中であり、完了した部分のみをご紹介します。 移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。 NuxtBridgeへの移行 まず、Nuxt Bridgeへの移行作業について説明します。 Nuxt Bridgeへの移行に際して解決すべき課題は以下の通りです。 Nuxt.jsをバージョン2.15.8から2.17.2へアップグレード Vue.jsをバージョン2.6.0から2.7.0へアップグレード Nuxt 2.17.2 へのアップグレード Nuxt 2.17.2・Vue 2.7.0へのアップグレードについては概ねスムーズに進行しました。 しかし、Nuxt 2.17.2 へのアップグレードを行う際に、バージョン間のリリースノートを確認したところ、 Nuxt 2.16.0 から postcss が v8 にアップグレードされた点に注意が必要です。 注視すべきは、 postcss.preset.importFrom と postcss.preset.exportTo が廃止されていることです。 これにより、特定のソースから変数やミックスインをインポートする機能が失われています。 この問題を解決するため、 @csstools/postcss-global-data を利用することにしました。 PostCSS v8へのアップグレードに関する詳細な作業は、PostCSSの公式リポジトリのWikiにまとめられています。こちらを参照してください。 PostCSS Preset Env 8 PostCSS 8 for end users 具体的な設定変更については以下を参照してください。 // nuxt.config.js export default { build : { postcss : { plugins : { '@csstools/postcss-global-data' : { files : [ 'assets/styles/_variables.css' , ] , } , } , } , } } まとめ この記事では、Nuxt3へのアップグレードに向けた取り組みについて紹介しました。 Nuxt3への移行には、いくつかの課題がありますが、Nuxt Bridgeを利用した段階的なアップグレードを進めることで、移行作業を進めています。 移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。 エブリーで一緒に働くエンジニアを募集しています 最後になりますが、エブリーでは、一緒に働くエンジニアを積極的に募集しています。 この記事に興味を持っていただけた方や、エブリーに興味を持っていただけた方は、 エブリーの採用情報 をご覧ください。
アバター
はじめに こんにちは。DELISH KITCHENでデータサイエンティストをやっている山西です。 今回は、挑戦WEEKにて実践した DELISH KITCHENユーザーのクラスター分析事例 についてご紹介いたします。 挑戦WEEKとは 「弊社開発メンバーが通常業務から離れ、技術的に何かに集中して挑戦する」という位置付けの1週間です。 詳細はCTOの今井が下記記事にて説明しておりますので、よろしければ併せてご覧ください。 tech.every.tv 本記事の想定読者 Webサービスの分析従事者(PdM, データサイエンティスト, データアナリスト) クラスター分析の実務例に興味がある人 レシピ提供サービスの分析事例に興味がある人 サービスが手がけるドメイン知識を活用した分析事例に興味がある人 説明しないこと クラスタリングアルゴリズムの統計/機械学習的性質の詳細 扱ったデータの具体値に触れるような説明 やったこと 「ユーザーが普段見ているレシピの栄養情報を用いたクラスター分析」を行いました。 DELISH KITCHEN各レシピが持つ栄養情報 出来たもの 普段DELISH KITCHENにアクセスするユーザーを、3つの栄養クラスタ(レシピ嗜好別に分けたグループ)に分類しました。 各栄養クラスタの特徴をまとめたものが下表になります。 栄養クラスタ名 特徴 仮説 主食も取り入れ層 ・他クラスタに比べて、見ているレシピのカロリーが高い傾向 ・量としてのボリュームが多いレシピを良く見ている? ・DELISH KITCHENで主食主菜系を積極的に作っている層? 材料メイン層 ・脂質比率が相対的に高い ・糖質の相対的割合が低いカロリーが特筆して高いわけではない ・ご飯や麺物はあまり探してないが、付け合わせの主菜とかはよく探している? ・材料ベースで検索する傾向? ・糖質を控えている人も混ざっているかも スイーツトレンド層 ・他クラスターに比べて、見ているレシピひとつひとつのカロリー低め ・糖質高め, たんぱく質低め ・お菓子など, 全体量は少ないものの栄養構成としては糖質高めなものが好き? ここから、この分析をするに至った動機、および具体の分析の流れを順に紹介していきます。 動機 「DELISH KITCHENという料理動画メディアならでは活かせるデータの価値にあまり向き合えていないな」という課題感が、今回の”挑戦”につながりました。 普段は、機能利用率や継続率等のアプリ内行動指標、プレミアム課金機能の事業成果を測るKPI等、主にWebサービスとしての文脈で分析を行うことが多いです。 その一方で「料理」というコンテンツの肝となる体験に焦点を当て、より解像度高くユーザーニーズに迫るような分析もしてみたいという思いもありました。 そこで今回は「ユーザーが視聴しているレシピの栄養素情報」に着目し、これを探索的に分析して新たな示唆が得られないか実験してみました。 その結果行き着いたのがクラスター分析です。 クラスター分析とは ここでいうクラスター分析は、データをクラスタリングすることにより、データ内の隠れたパターンや構造に関する示唆を得ることを目的とした分析です。 クラスタリングとは、データを似た特徴を持つグループにまとめる作業です。こうしてまとめられたグループをクラスタと称します。 今回は計6種類の栄養項目(カロリー、たんぱく質、脂質..等)を用いて、ユーザーを「よく似た栄養構成のレシピを視聴する集団」にクラスタリングします。 こうして出来たクラスタを分析により観察することで、新しい知見を得られないか試してみます。 機械学習手法による自動クラスタリングの恩恵 しかし、判断軸が多い&正解が定められていないこの作業を仮に人力で行おうとすると多大な労力が必要となります。 そこで、 与えられたデータの中から機械的に法則を見出す(教師無し機械学習をする)ようなクラスタリングアルゴリズム を用いれば、この作業を自動化することが出来ます。 世には様々なアルゴリズムがありますが、今回はその中でも特に広く使用されている K-means法 を採用しています。 参考: ITエンジニアのための機械学習理論入門 第6章 k 平均法:教師なし学習のモデルの基礎 | クラウドエース株式会社 分析の流れ 今回の分析の流れの全体像を、図と共に解説します。 概観としては 「ユーザーが直近で見ているレシピの平均的な栄養素情報」を集計 このデータを使って、K-Meansアルゴリズムを用いたクラスタリング(ユーザーのグループ分け)を実施 出来たクラスタを観察し、ユーザーのレシピ嗜好に関する示唆を得られないか分析 という流れになります。 クラスター分析におけるデータの流れ こうして作成したクラスタを以後、 栄養クラスタ と呼んでいくことにします。 クラスタの観察 ここからが クラスター分析 の集大成です。 出来上がった3つのクラスタを定量、定性の観点を組み合わせつつ多角的に観察してみます。 どのように分類されたか まずは3つの栄養クラスタがどのように分類されたかを、「各クラスタのユーザーが視聴するレシピの平均栄養値」で可視化します。 ※ この時点ではまだクラスタに名前が無いため、0,1,2の番号で識別します。 上: 視聴レシピの平均カロリー(Kcal) 下:視聴レシピの平均たんぱく質対エネルギー比率(青)/平均脂質対エネルギー比率(橙)/平均糖質対エネルギー比率(緑) 図の注釈にあるように、 クラスタ0はカロリーが高め クラスタ1は脂質が高め クラスタ2は糖質が高め などの傾向が相対比較で見て取れます。 「どんなレシピに興味を持っていそうか」目視で把握 次に、各栄養クラスターが視聴しているレシピ名をもとにワードクラウドを作成してみます。 栄養クラスタ別のワードクラウド これも図内の注釈に書いての通りですが、どんなレシピやキーワードに興味がもたれやすいかがなんとなく見えてきました。 観察結果をもとに、栄養クラスタを命名する これらの観察結果を統合し、それぞれ以下の仮説をもとに名前を付けました。 クラスタ0: 平均カロリーが高め & 主食系のレシピも視聴されやすい → 主食も取り入れ層 クラスタ1: 脂質高め(≒糖質は相対的に低め)& 材料系の頻出語が多い → 材料メイン層 クラスタ2: 糖質高め & お菓子系の頻出語が多い → スイーツトレンド層 こうして、本記事冒頭 出来たもの のセクションにて紹介した栄養クラスタの概観を把握することができました。 (この分類がどこまで妥当かの判断は後続分析に委ねるものの)やってみた側としては「それっぽいな」という所感を得ました。 活用事例 前述の通り、これらの 栄養クラスタ は「ユーザーのレシピへの嗜好」をある程度反映出来ているようにも見えます。 その可能性を確かめるべく、「DELISH KITCHENアプリ内でのレシピ検索用推薦キーワードの出し分け」のロジックに栄養クラスタを利用する実験を行ってみました。 そして、これがユーザーの検索行動にどのような変化が生じたか、A/Bテストによる効果検証を行いました。 参考: tech.every.tv 対象面 DELISH KITCHENアプリ 検索サジェストの「注目のキーワード」 アプリで検索をする際に提示される10件のキーワード ※ 直近のDELISH KITCHENユーザー全体の検索行動を踏まえ、その流行が反映されるような集計ロジックで選定している 新規ロジックの実装とA/Bテスト 「注目のキーワード」の集計ロジック自体は現行と同じものを用いる 集計対象の集団を、ユーザー全体(control群) vs 栄養クラスターに絞り込んだもの(test群) という問題設定に落とし込み、A/Bテストを実施しました。 栄養クラスタ別に注目のキーワードを出し分けるA/Bテストの実施 そして、結果の良し悪しを観察するゴール指標として 注目のキーワードタップ率 ※を設定しました。 ※ A/Bテスト期間中に一度でも注目のキーワード欄をタップするユーザーの割合 結果 露出されたキーワード controlおよび各栄養クラスタ(test群)の露出キーワード例 クラスタリングの結果解釈時、ワードクラウドで見たような傾向が反映されています。 キーワードタップ率の変化 主食も取り入れ層(test1) , スイーツトレンド層(test3) で、キーワードタップ率が有意に変化しました。 一方、 材料メイン層(test2) では、タップ率の変化はほとんど認められませんでした。 A/Bテストやってみての所感 箇条書きですがまとめます。 栄養クラスタを用いることで、 露出される注目のキーワードの雰囲気 がガラリと変わりました。 今まで埋もれていた、 ユーザー嗜好別の注目 に今までよりも肉薄できた可能性があります。 3つのうち2つのクラスタでタップ率が大幅に向上したため、従来よりも ユーザーが求めるキーワードを先回りして提示できた 可能性があります。 ついでに、 こういう変化に反応しやすいクラスタとそうでないクラスタ が浮き彫りになりました。ここも深掘り分析しても面白そうな所感です。 まとめ この記事では、ユーザーのレシピに対する栄養嗜好を活用したクラスタリングの事例と、それをアプリのロジックに組み込むことでユーザーの行動変化を促した事例を紹介しました。 シンプルな栄養項目を用いただけでこのような成果を得られたことから、DELISH KITCHENが保有するデータの潜在的な可能性を改めて感じました。 今回開発した「栄養クラスタ」は、アプリのロジックだけでなく、ユーザーセグメント分析など様々な分野での応用が考えられます。 今後もレシピメディアとしてのドメイン知識にも目を向けつつ、データの眠る価値を引き出すための取り組みを継続していく予定です。
アバター
はじめに こんにちは トモニテ でバックエンド周りの開発を行っている rymiyamoto です。 今回はトモニテの新規事業として、2023 年 11 月 30 日にローンチした家族・家庭や恋愛に対する悩みをプロのカウンセラーと相談出来る新サービス トモニテ相談室 の開発に関わる中で API サーバーの開発環境での工夫について紹介していきます。 トモニテ相談室の通話周りの基盤の話は別の記事で紹介されていますので、こちらも合わせてご覧いただけると幸いです。 tech.every.tv 技術選定 新規でサービスを立ち上げるに当たっての技術選定は 2 点を意識して選びました できるだけ既存の技術をを使う 面倒なところは楽できるようにする できるだけ既存の技術をを使う こちらに関しては基本的にはトモニテの技術スタックを踏襲することにしました。 使用言語: Go フレームワーク: echo インフラ: AWS ECS どちらも現在のトモニテだけに関わらず社内での技術スタックとして定着しているものを選びました。 面倒なところは楽できるようにする トモニテ本体だとリリースしてから大きく内部のツールの変更はしていないままで、以下のようなアジリティが悪いところがありました。 テーブル定義後のモデル定義が手動で、 dbr だと eager loading が面倒 エンドポイントのドキュメント定義がソースからの定義で乖離しやすく扱いづらい( echo-swagger ) そのため、今回はトモニテ本体にも後々展開もしやすくある程度ある活発なパッケージを選んでいます。 ORM として スキーマファーストな sqlboiler oapi-codegen でドキュメント駆動開発 開発(ローカル)環境 ローカル環境は docker に固めて開発を行っており、go のイメージに対して、以下のようなツールを入れています。 ツール 用途 air ホットリロード sql-migrate マイグレーション sqlboiler ORM oapi-codegen ドキュメント駆動開発 mockgen モック生成 gofumpt フォーマット golangci-lint 静的解析 ARG GO_VERSION=1.21.4 FROM golang:${GO_VERSION} AS dev RUN go install github.com/cosmtrek/air@latest RUN go install github.com/rubenv/sql-migrate/...@latest RUN go install github.com/volatiletech/sqlboiler/v4@v4.15.0 RUN go install github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-mysql@v4.15.0 RUN go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.13.4 RUN go install go.uber.org/mock/mockgen@v0.2.0 RUN go install mvdan.cc/gofumpt@latest RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 COPY .air.toml / CMD [ "air" , "-c" , ".air.toml" ] モデル生成(sqlboiler)の活用 sqlboiler はスキーマファーストなので、一般的な ORM とは異なりソースからモデルを生成するのではなく、テーブルからモデルを生成を行うことができます。 そのため前もって DB にマイグレーションをかけておくことでモデルを生成することができます。 (今回は トモニテでも運用している sql-migrate でマイグレーションを管理することにしました) sqlboiler を使用するにあたって DB 情報や出力先を config.toml に記載しておきます # 生成コマンド実行時に既存のディレクトリを削除するかどうか wipe = true output = "internal/models" no-tests = true templates = [ "/go/pkg/mod/github.com/volatiletech/sqlboiler/v4@v4.15.0/templates/main" ] [mysql] dbname = "example" host = "db" port = 3306 user = "root" pass = "password" sslmode = "false" blacklist = ["gorp_migrations"] ここまで準備できてコマンドを実行するとモデルが生成されます。 (そのままコマンドを実行すると毎回 docker compose exec app XXX と打たないといけないので make コマンドを用意しています。) # model生成 .PHONY: model model: model docker compose exec app sqlboiler mysql --config ./asset/sqlboiler/config.toml --add-soft-deletes $ make model こうすることで、 internal/models 配下に DB の制約に応じたリレーションを考慮したモデルが生成されて利用ができるようになります。 ORM としての使い方は 公式ドキュメント を参照してください。 ドキュメント駆動開発(oapi-codegen)の活用 oapi-codegen は OpenAPI からコードを生成するツールです。 OpenAPI の定義を api/app.yaml に記載しておきます。 (記述は一般的な OpenAPI の構文が使えます) openapi : 3.1.0 info : title : Example API version : 1.0.0 servers : - url : http://localhost:1323/1.0/app paths : /notifications : get : tags : - notifications summary : お知らせ一覧を取得 operationId : getNotifications responses : "200" : description : OK content : application/json : schema : type : array items : $ref : "#/components/schemas/Notification" components : schemas : Notification : type : object required : - id - title - body properties : id : type : integer title : type : string body : type : string ここまで準備できてコマンドを実行するとコード (エンドポイントの定義) が生成されます。 # openapiからのコード生成 .PHONY: openapi openapi: openapi docker compose exec app oapi-codegen -generate server -package api -o ./internal/app/api/openapi_api.gen.go api/app.yml docker compose exec app oapi-codegen -generate types -package api -o ./internal/app/api/openapi_types.gen.go api/app.yml $ make openapi こうすることで、 internal/app/api 配下に OpenAPI に従ったエンドポイントの定義が生成されます。 その後 RegisterHandlersWithBaseURL (自動作成される関数)を main.go で呼び出してエンドポイントの登録を行うことで、エンドポイントの定義が完了するので、実際の処理を handler に書くだけで実装が完了します。 エンドポイントへの登録 internal/app/server.go package app import ( "github.com/rymiyamoto/server/internal/app/api" "github.com/rymiyamoto/server/internal/app/handler" "github.com/labstack/echo/v4" ) type server struct { *handler.Handler } func newServer() *server { return &server{ handler.NewHandler(), // 内部での依存解決は handler に任せる } } func RegisterHandlers(e *echo.Echo, basePath string ) { server := newServer() api.RegisterHandlersWithBaseURL(e, server, basePath) } main.go package main import ( "github.com/rymiyamoto/server/internal/app" "github.com/labstack/echo/v4" ) func main() { e := echo.New() // ... app.RegisterHandlers(e, "/1.0/app" ) e.Start( ":1323" ) } まとめ 基本的な大枠(言語やフレームワーク)はトモニテの技術スタックを踏襲しつつ、面倒なところは楽できるようにすることで開発効率を上げることができました。 導入当初はいくらツールの歴史があるといえどキャッチアップに時間がかかることもありますが、今後の開発効率向上には必要な投資だと考えています。 また、今回はローカル環境での開発を docker に固めて行っていますが、これにより開発環境の差異をなくすことができ、開発者の環境構築にかかる時間を削減することができました。 今後もトモニテ相談室の開発においては、技術選定や開発環境の構築についても工夫を重ねていきたいと考えています。
アバター
ドウデュース、武豊さん、さすがの競馬でしたね。 CTOの @imakei_ です。株式会社エブリーの2023年アドベントカレンダーの締めとなる25日目のブログです。 他のブログも力作が揃っておりますでの、まだの方はぜひ こちら をご覧ください! 本ブログでは、2023年の振り返りと、2024年に向けた思いを書きたいと思います。 2023年は、開発本部としても「挑戦WEEK」を始めたり、 7月から会社としても「挑戦」をテーマに掲げるなど、 「挑戦」をたくさん行った1年となりました。 2023年のハイライト 開発本部としての挑戦をいくつか振り返ります。 コンパウンドなプロダクトへの挑戦 弊社はDELISH KITCHEN、トモニテ、TEMELINEと3つのカンパニーに分かれ、 ドメインの異なる複数のプロダクトを運営しています。 これだけでもコンパウンドといえばコンパウンドなのですが、 さらに各ドメインでの価値提供を最大化すべく、 それぞれのドメインごとにコンパウンドなプロダクトを展開することに挑戦しています。 DELISH KITCHENでは数年前から展開している小売り向けのソリューションをRetailHubというサブブランドとし、 ネットスーパー事業の事業継承を受けるなど、事業を拡大、 より包括的な小売様向け・小売ユーザー向けのソリューションを提供し始めました。 また、これから更なるサービス展開を見越して基盤周りの刷新を行うなど、 コンパウンドなプロダクトで勝つべく、着実に下準備を進めています。 トモニテでは、より時代に即したサービスに向けてMAMADAYSから名称を変更したほか、 子育てをはじめとする家族・家庭の悩みを相談できるトモニテ相談室という事業を新たにリリース。 TIMELINEでは、昨年始まったクラウドファンディングのアップデートに加え、 クラファンに連携するメディアの立ち上げを行なっています。 それぞれのカンパニーで、既存のサービスをより伸ばしながらも、 複数の新たなサービスが立ち上がるというかなりチャレンジングな1年となりました。 開発組織の活性化 昨年(2022年)の個人的な反省から、今年は組織の活性化に向けていくつかの施策が動いた1年でもありました。 昨年の反省というのは、自分がCTOになってから、 「エンジニアリングは手段であり、事業を通じて価値提供することが大切である」 と至る所で口酸っぱく話してきたのもあり、 目の前の事業課題の解決に向けた動きがとても良くなったと感じる昨年になった一方で、 同時に技術面の遊びのようなものが減り、窮屈に感じることも多くなってしまっていたことです。 個人的に、非連続な事業成長を考えた時には技術での変革が不可欠だと考えており、 この状態は中長期的には致命的なのではないかと、「組織の活性化」は密かに今年の自分のテーマとしていた部分でもあります。 挑戦WEEKの開始 今年の3月から導入した「挑戦WEEK」ですが、この1週間は、事業部の通常業務から離れ、技術的な挑戦に集中する時間です​​​​​​​​。 昨年の11月にDELISHKITCHEN開発本部にて行われた技術課題の棚卸し会を発端に、技術に向き合うことが必要だと感じ、導入を進めました。 弊社の開発本部には組織活性化委員会という、より良い開発組織していくために動く組織があり、 上記を相談したところぜひやりましょう!ということで年明けからすぐに動いてくれました。 ビジネス側ともスケジュールを調整が必要であったにもかかわらず、すごいスピード感で進めてくれました。 そんな開催自体も「挑戦」だった「挑戦WEEK」ですが見事にやり遂げ、参加者の満足度も高く、 まだ3回の実施ですが早くも弊社の良い文化になろうとしています。 詳しくは 組織活性化委員長の國吉のブログ や 自分のブログ でも言及しておりますので、ぜひ一読ください。 勉強会の開催とTechBlogの再開 この半期ではありますが、カンパニー間の技術交流および開発本部全体のスキルアップを目的とし、 勉強会の開催とTechblogの更新をOKRに入れ、取り組むことを意思決定しました。 勉強会はこれまで有志で開催されてきましたが、それを開発本部全体に広げた形です。 こちらは毎月1人1回は発表をしようということで進めました。 また、今までブログには何度かチャレンジしてきましたが、月数回の更新で中途半端な形となってしまっていたのですが、 半年で1人2本以上の更新をしよう(毎週2本以上リリース!)ということを打ち出し、組織一体となって取り組みました。 正直メンバーにはかなり負荷をかけた部分ではありましたが、みんな協力的で、 勉強会は48回開催(毎週2回以上!!?)、ブログは60本と成果を上げることができました。 目標を掲げておいて、正直かなり厳しいと思っておりましたが、 見事にそれ以上の動きをしてくれたメンバーは本当に誇りです。 2024年に向けて 色々な「挑戦」をしてきた2023年ですが、その反面で出来なかったことも浮き彫りになった1年でした。 特に「今はどちらかしか出来ない」と選択を迫られ、片方を諦めないといけない場面も増えた一年だったと思います。 「もっと仲間がいれば。。。」と思った回数は数え切れないです。 そこで来年、2024年は、 『採用』 をテーマに動きたいと思ってます。 コンパウンドなプロダクト開発への挑戦 社内外に誇れる開発組織への挑戦 はまだまだ道半ばです。 これから一緒に最高な組織を作っていってくれる仲間を募集しています! ちょっとでも面白そうじゃんって思ってくれた方は、 X(旧Twitter)とかでも気軽に声かけてください! 美味しいご飯でも食べながら開発組織を語りましょう! https://twitter.com/imakei_
アバター
タイトル この記事は every Tech Blog Advent Calendar 2023 の24日目です。 DELISH KITCHEN 開発部で小売向き合いでFlutterのアプリ開発をしている野口です。 本記事では、弊社の開発しているFlutterアプリでユーザーがどの画面を表示したかのログを取るために、AndroidにおけるonResumeのタイミングでログを送る必要が出たのでその際に得られた知見について紹介します。 やりたいこと 要件としては、以下のタイミングでアプリが表示している画面情報を送る必要があります。 画面遷移時(Navigatorのpush、pop、replaceでの画面遷移) アプリの復帰時 これを行うにはAndroidで言うonResumeのような挙動が必要でこの挙動をFlutterで再現するのがやりたいことです。 AndroidのonResumeはどのタイミングで発火するものなのか 公式だとライフサイクルは以下の画像のようになっています。 Activityとは表示されている画面で、画面が表示されると画像のようなライフサイクルで動きます。 アクティビティのライフサイクルに関する簡略な図 https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ja onResumeのタイミングに絞ると以下の図のようになります。 AndroidのonResumeの挙動 onResumeの挙動をまとめると以下の2点になります。 画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する バックグラウンドからフォアグラウンドに復帰した際に発火する Flutterだとどうなるか 前提としてFlutterでは画面はwidget(部品のようなもの)で構成されていて、widgetを切り替えることによって画面遷移しています。なのでそもそもAndroidのActivityは画面のような概念がないため、ルーティングを設定をすることで、切り替えています。 1.画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する Flutterで再現するために NavigatorObserver を使用しました。 MaterialApp()のroutesにルーティングを指定します。 後述しますが、navigatorObserversにMyNavigatorObserver(NavigatorObserverを継承したクラス)を指定することで、ルーティングが検知できるようになります。ちなみにルーティングの指定をしないと、 didPush や didPop などで受け取れる route でルーティングが取得できなくなリます。 Widget build ( BuildContext context) { ... return MaterialApp ( title : 'sample' , home : Page1 (), routes : { '/page1' : (_) => Page1 (), '/page2' : (_) => Page2 (), '/page3' : (_) => Page3 (), }, navigatorObservers : [ MyNavigatorObserver (), ], ); } NavigatorObserverを継承したクラスでは、以下のタイミングで画面遷移を検知できるようになります。 didPushは Navigator.of(context).push() され、画面遷移した時に発火します。 didPopは Navigator.of(context).pop() され、画面遷移先から戻る時に発火します。 didReplaceは Navigator.of(context).pushReplacement() され、画面遷移した時に発火します。 class MyNavigatorObserver extends NavigatorObserver { @override void didPush ( Route route, Route previousRoute) { super . didPush (route, previousRoute); } @override void didPop ( Route route, Route previousRoute) { super . didPop (route, previousRoute); } @override void didReplace ({ Route < dynamic >? newRoute, Route < dynamic >? oldRoute}) { super . didReplace (newRoute : newRoute, oldRoute : oldRoute); } } NavigatorObserverのやり方はこちらが参考になりました! 参考: https://qiita.com/kurun_pan/items/b725e02051ab90759df4 ちなみに、routesを設定しなくても以下のようにpush時にルーティングを設定することもできます。 Navigator . of (context). push ( MaterialPageRoute ( settings : RouteSettings (name : '/page1' ), builder : (context) { return const Page1 (); }, ), ); 2. バックグラウンドからフォアグラウンドに復帰した際に発火する Flutterでバックグラウンドからフォアグラウンドの検知は didChangeAppLifecycleState でできます。 didChangeAppLifecycleState を使用するためにはStatefulWidgetでWidgetsBindingObserverを継承する必要があります。(厳密にはMixinsです、わかりやすいのでこちらの記事をご参照ください。) https://qiita.com/trm11tkr/items/b0c1c50b81d5c40d8bbf didChangeAppLifecycleStateの AppLifecycleState.resumed を使用することによってバックグラウンドからフォアグラウンドに復帰したことを検知することができます。 class App extends StatefulWidget { const App ({ super .key}); @override State < App > createState () => _AppState (); } class _AppState extends State < App > with WidgetsBindingObserver { @override void didChangeAppLifecycleState ( AppLifecycleState state) { if (state == AppLifecycleState .paused) { // アプリがバックグラウンドに移行した時 } else if (state == AppLifecycleState .resumed) { // アプリがフォアグラウンドに戻った時 } else if (state == AppLifecycleState .inactive) {   // アプリが一時的に非アクティブになる時 } else if (state == AppLifecycleState .detached) { // アプリが終了する時 } } } didChangeAppLifecycleStateについてはこちらが参考になりました! 参考: https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/v2-app-lifecycle また、onResumeのタイミングで画面情報のログを送りたいですが、 didChangeAppLifecycleState からは今どの画面にいるかわからないため、ルーティングのパスの情報を状態で保持する必要があります。 これは、グローバルで状態を保持できれば方法はなんでもいいと思いますが、以下のようにRiverpodで保持することにしました。 final routeNameProvider = StateProvider < String ?> ((_) => null ); 実装まとめ 上記の内容を実装としてまとめると以下のようになりました。 sendViewLog で表示している画面名をログに送るようにしているのですが、それを AppLifecycleState.resumed と NavigatorObserver で発火するようにすることで画面遷移した時とバックグラウンドからフォアグラウンドに復帰した時にログが送れるようにしています。そのタイミングで ref.read(routeNameProvider.notifier).state = routeName; で遷移した画面名を状態に保持しています。 また、 AppLifecycleState.resumed では表示している画面名が受け取れないので final routeName = ref.read(routeNameProvider.notifier); で保持していた画面名の状態を取得してログで送るようにしています。 import 'package:flutter/material.dart' ; import 'package:flutter_riverpod/flutter_riverpod.dart' ; final routeNameProvider = StateProvider < String ?> ((_) => null ); class App extends ConsumerStatefulWidget { const App ({ super .key}); @override ConsumerState < App > createState () => _AppState (); } class _AppState extends ConsumerState < App > with WidgetsBindingObserver { Future < void > sendViewLog ( String routeName) { // ログを送る処理を行う ref. read (routeNameProvider.notifier).state = routeName; // 受け取ったルート名を保持する }   @override   void didChangeAppLifecycleState ( AppLifecycleState state) { if (state == AppLifecycleState .resumed) {    // アプリがフォアグラウンドに戻った時 final routeName = ref. read (routeNameProvider.notifier); sendViewLog (routeName); }  }   return MaterialApp ( title : 'sample' , home : Page1 (), routes : { '/page1' : (_) => Page1 (), '/page2' : (_) => Page2 (), '/page3' : (_) => Page3 (), }, navigatorObservers : [ MyNavigatorObserver ((routeName) => sendViewLog (routeName)) ], ); } class MyNavigatorObserver extends NavigatorObserver { final Future < void > Function ( String ? ) sendViewLog; MyNavigatorObserver ( this .sendViewLog); @override void didPush ( Route route, Route previousRoute) { super . didPush (route, previousRoute); final routeName = route.settings.name; sendViewLog (routeName); } @override void didPop ( Route route, Route previousRoute) { super . didPop (route, previousRoute); final routeName = route.settings.name; sendViewLog (routeName); } @override void didReplace ({ Route < dynamic >? newRoute, Route < dynamic >? oldRoute}) { super . didReplace (newRoute : newRoute, oldRoute : oldRoute); final routeName = newRoute ? .settings.name; sendViewLog (routeName); } } 感想 AndroidではonResumeだけで行える処理がFlutterだとかなり手間がかかる処理になってしまいました。 ただ、今回の実装で画面遷移を検知した時、その画面の情報(パスや画面名など)を取得するためにルーティングの設定が必要ということがわかったのは大きな収穫でした。 Flutterはルーティングを気にしなくても作ろうと思えば作れてしまうので、ルーティングの重要性があまり分かっていませんでした。遷移先が一元管理できるからいいよね!くらいに思っていましたが、ルーティングがないとそもそも表示している画面がわからないのでルーティングは必須だということが理解できました。 go_routerとかだと 今回、ルーティングをNavigatorで行なっているため、このような実装になってますが、 go_router とか使えばもう少し楽に実装できるのかなと思いました。 例えば、go_routerはredirectというものがあり、画面遷移すると発火し、遷移先のパスが取得できるのでこれを使用した方が簡単になるかなと。 GoRouter ( redirect : ( BuildContext context, GoRouterState state) { sendViewLog (state.location); } ); https://zenn.dev/joo_hashi/books/fa5c73ffcbf71a/viewer/aae5cf とはいえフォアグラウンドの復帰時の処理は必要なのであまり変わらないですかね。 ちなみに本アプリでもgo_router使いたかったですが、開発当初に以下の問題でボトムナビゲーションバーを表示しながら画面遷移できなかったため使用を断念しました(涙) https://zenn.dev/flutteruniv_dev/articles/20230427-095829-flutter-auto-route ただ、現在は解決しているらしいのでgo_routerに書き換えるのもありかなーと思っています。 https://zenn.dev/flutteruniv_dev/articles/stateful_shell_route 終わりに FlutterでのonResumeの再現方法について紹介しました! 明日はAdvent Calendarの最終日です。CTOからのありがたいお話があると思うのでぜひご覧ください!
アバター
はじめに every Tech Blog Advent Calendar 2023 24日目の記事になります。 DELISH KITCHENでデータサイエンティストをしている山西です。 普段はDELISH KITCHENの企画/改善に向けた分析をPdMと連携しながら行っています。 今回はその経験談をもとに、分析用ログ策定の流れを改善&仕組み化した事例をノウハウとして紹介したいと思います。 経緯: ログがカオス DELISH KITCHENサービスの改善に向けた分析では、ユーザーのイベントログ(ユーザーがいつ、どこで、何を見たかの情報が含まれるもの)をデータとして利用しています。 これをSQL等の手段で集計し、分析を通して意思決定に向けた示唆が得られるような利活用をしています。 ところが、ログをいざ集計しようとなると困難に直面することが多々ありました。 これは、歴史的経緯から行き当たりばったりで設計されたものであったり、(データ利活用文化が発展途上だった頃)必ずしも分析観点で整理、設計がなされていなかったりしたことに起因します。 理想とする状態とのギャップをAs-Is/To-Beとして以下に整理しています。 As-Is To-Be 必要なログが存在しない 分析したい観点に必要なログが存在している ログはあっても集計したいカラム、項目が足りない 分析要件に応じて、必要な情報が発火されるようになっている 使われていない&用途も不明なログが散見される 必要の無いログは減らし、開発工数やコスト面での最適化を図る 企画書&設計書を読んでも、どのイベントとログが対応しているかよくわからない 関係者が齟齬なく解釈できるようなドキュメントとして整理されている ログはあるものの、集計難易度が高い 楽に集計できる(SQL習熟度が必ずしも高くないPdM、ビジネス層でも書けるくらいの難度感) ログ設計フローの見直し このギャップを踏まえ、目指すべき状態(To-Be)に向けて分析ログ設計フローを整理し、PdMとともにこれに則って進行していくようにしました。 以下がその全体図です。 全体の流れ 仮説を立てたうえで分析方針を決定し、その後に手段(ログ)を選定するという流れを意識しています。 ※ログの活用用途は他にも 探索的分析(EDA) 、 機械学習の特徴量 等々がありますが、本記事は仮説検証型の事例ベースで紹介します(思想自体はその他用途にも応用できると思います)。 ここから、各段階でやることを順に整理していきます。 1. 分析観点を整理する ログの具体に入る前に、まずは「何を分析したいか」を言語化し整理します。 施策(機能やデザインの改善/機能追加)によって、何が期待されるかを仮説として言語化する その仮説を踏まえ、どういう分析をしたいか方針を決める(指標の定点観察なのか、SQLや可視化を通じて深掘りするのか) KPI, 指標として何を観察すべきかを明文化する 分析観点の例 種別 例 1. 事業への影響を測るKPI 課金CVR, 広告影響指標 2. 汎用的に使用されるKPI 画面imp率, クリック率 3. 機能エンゲージメントを測る指標 あるコンテンツに接触した後の機能継続率 4. ファネル分析 特定の画面遷移の離脱率 1はビジネスへの貢献を示すために優先的に設定されがちです。 また、2は「とりあえず分母分子を置いて観察する」ように定義がしやすく”わかりやすい”ため、企画時に見逃されることは少ないです。 一方、3や4は事前の仮説立て、導線や機能の観察を怠るとおざなりになりがちです。 結果、「分析したいけどログがない」に陥ることが多々ありました。 上記のフォーマットは、この反省を踏まえて整理したものになります。 こうして、まず「何を分析したいか」が明確になりました。 2. 分析する面のイベント, ユースケースを洗い出す ここでは分析対象の面(ページや機能など)でどんなイベントが発生するか、そこでどんなユーザー行動が発生し得るかを整理します。 具体例とともに見ていきます。 まず、「画面を見た」「ボタンがクリックされた」等のユーザイベントを自然言語で表現し、一覧化します。 イベント一覧の抜粋 次に、ユーザー行動を想定し、その際発生し得るイベントを時系列整理したものを作ります。 ユースケース整理の例 このように、遷移の順番の多様性、およびユーザーのステータスの差異(課金有無など)がある場合、例え同じページを閲覧&機能を利用していたとしても、そこから発生していくイベントの順番や有無は異なります。 そのため、代表的なペルソナのユースケース別に、想定し得るイベントの発生順を整理しておきます。 これが、次工程での整理に役立ちます。 ※ 全ての組み合わせを網羅する必要は無いです。 なお、ここで採用している手法は イベントストーミング手法 に着想を得ています(以下記事に詳しいです)。 www.eventstorming.com blog.kinto-technologies.com 3. ログを仕込むべきイベントを見定める ユーザー行動をイベントの粒度で整理出来たら、「どのイベントにログを対応付けるか」を見定めていきます。 その判断軸となるのが1.で整理した分析観点です。観測ポイントをどこに置けば欲しい指標、やりたい分析の集計ができるかを想像しつつ行います。 以下が完成系のイメージです。 イベントとログの対応関係の整理 分析対象によってその検討内容は千差万別ですが、例えば 分子/分母にどのイベントを置けば、指標が表現できるか イベントが起きた、という事実以外に取得すべきものは何か(例: どんな料理レシピを見たかを知りたい場合は、その情報も必要) 離脱状況をファネル分析したい場合、どのイベントをチェックポイントとすべきか 等々に考えを巡らせます。 この際、先の工程で実施したイベント&ユースケースの整理が役立ちます。 仮に全てのイベントに機械的にログを仕込もうとすると大変ですが、こうして目的に応じて整理をすることで、設計&実装をより本質的なタスクに集中することが出来ます。 4. 設計叩き台を作る ログとして仕込むべきイベントが見定まったら、PdMとデータサイエンティスト間で要件をすり合わせしつつ、設計書の叩き台を作ります。 そのログがどのイベントに対応するものか どんな項目(カラム)が欲しいか どういう形式で情報を持つと、後の集計が楽になるか の視点をもとに、具体として埋めていきます。 ログ設計書の雰囲気 なお、この際、ログ実装としての優先度を決めておくと後に役に立ちます(どうしても分析に必要なものは「高」、後々役立ちそうなものは「中」など) 。 ここまでで、 「何」を分析したいか そのために「どのイベント」が集計対象となるか ログとして「どんな項目」を集計すべきか 分析要件として整理できました。 5. 開発側と議論しながら設計を完成させる ここまで準備できたら、PdM & データサイエンティスト & 開発エンジニアでログの設計を具体として話し合う場を設けます。 ログを実装してもらうことになるサーバーサイドおよびクライアントサイドのエンジニアに設計叩き台を共有し、 そもそも実装として実現可能か(技術的に可能か) 工数感はどんなものか(現実的に可能か) 等々の観点でレビューしてもらい、このまま実装に進むか、はたまた軌道修正すべきかを擦り合わせていきます。 「何がやりたいか」「そのために何が必要か」が優先度と共に事前整理&説明されていることで、納得感が生まれたり、技術的には難しい場合には代替手段を検討しやすかったりと、建設的な議論が可能になります。 ここを通過したら、あとは開発側の実装にバトンタッチです。 実装後、開発環境で意図通りにログが発火されるかをテストします。 それを見届けた後は、サービスがリリースされてそのログを使って分析を待つのみです。 やってみた所感 ログの抜け漏れが減った 何を分析したいか、そのためにはどういう項目がログとして実現されるべきか、という流れで整理するようにしたことで「必要なものがないから分析できない」「必要が無いログを過剰に仕込む」問題は自ずと解決されていきました。 分析計画の視野が広がった 企画の初期段階で「ログ」という具体に走らず「何を分析したいか」と抽象度を上げておくことが振り返りや意思決定の質に寄与すると実感いたしました。 最初からログの具体を設計しようとすると、自ずと視野が狭くなりがちです。 「難しさ」の事前対策がしやすくなった 当初想定したログ設計や集計が難しいことが見えてきた場合も、事前に対策しやすくなりました。 例えば、 出したいイベントログのloggingが技術的に難しい場合は、近い導線のイベントログで代替し、近似値として集計する イベントログだけだとSQLの集計が複雑する場合は、データチーム側で中間テーブルを用意しておき、PdMにはそれでクエリを書いてもらう 等の判断を、分析観点やユースケースに立ち戻りながら円滑に行えます。 挙動がイメージしやすくなった イベントとユースケースを一通り整理しておくことによって、どのタイミングでどのログが発火されるかを想像しやすくなりました。 これまで、後から企画書や設計書を読んでもログがどこの導線&イベントに対応しているかがわからなかったり、開発端末やMockで画面遷移を見ながら目視で動きを確認したりする場面が多々ありました。 それらが込み入ると、だんだん何を見ていっているのかがわからなくなってきたり、関係者間で知らないうちに齟齬が起きたりに様々な辛みにつながります。イベントとユースケースの整理は、その対処に一役買ったといえます。 機能追加に向けた現状整理がしやすくなった 既存イベント&想定ユースケースがログと紐づくように整理され、挙動がイメージしやすくなったことで、機能の改修や追加がある際に現状との差分を確認しやすく、影響範囲を見定めやすくなりました。 終わりに 「ただ機能に対応するログを仕込む」ではなく、「分析者の目線に立ってログを設計する」という意識変革とその実践事例を紹介しました。 一連の取り組みを通じて、ログ設計の最適化はもちろん、継続的に分析&改善サイクルの質も向上することが出来ました。 本記事がデータ分析の取り組みにおける一助となれば幸いです。
アバター
はじめに エブリーでソフトウェアエンジニアをしている本丸です。この記事は every Tech Blog Advent Calendar 2023 の 23 日目の記事となります。 DELISH KITCHENでは、2023年12月12日(火)にアプリ内での初めてのライブ配信を行いました。アプリ内のライブ配信では、AWSのIVS(Interactive Video Service)の低レイテンシーストリーミングを利用しています。 今回はライブ配信にIVSを使う上で、どのようなことをおこなったのかなどについてお話しできればと思います。 対象読者 IVSを用いたライブ配信に興味があるエンジニア 本記事の目的 IVSの概要を知ってもらうこと IVSでライブ配信を行う上で何をしたかを知ってもらうこと 本記事の対象外 IVSでのライブ配信のフロントエンドの実装について IVSでのリアルタイムストリーミングについて IVSの具体的な設定 IVSとは Amazon Interactive Video Service (Amazon IVS) は、低レイテンシーやリアルタイムでライブ配信を行うことができるAWSのマネージドなライブストリーミングサービスです。 また、IVSの低レイテンシーストリーミングの特徴として、 AWSのドキュメント からの引用ですが、次のようなことが挙げられています。 チャネルを作成して数分以内にストリーミングを開始する。 魅力的でインタラクティブな体験を、超低レイテンシーのライブビデオと併せて構築できる。 さまざまなデバイスやプラットフォーム向けに大規模に動画を配信する。 ウェブサイトやアプリに簡単に統合できる。 IVSがやってくれること ライブ配信の映像に関わるところはIVSが全てやってくれると言っても過言ではないです。 従来のAWS Elemental Media Serviceでは変換・保存・配信などそれぞれのサービスが用意されていて、それを組み合わせる必要があったようですが、IVSは変換から配信まで全て行ってくれます。 今回はIVSが条件に合っていたので選択しましたが、従来のAWS Elemental Media Serviceを使うとIVSを使うよりもより細かい制御ができるなどのメリットがあるため、ユースケースによって使い分ける必要がありそうです。 また、DELISH KITCHENのライブ配信にはチャット機能が必要だったのですが、IVSにはIVS ChatというIVSに付随するマネージド型のチャット機能もありました。 自分で用意が必要なこと どのような配信を行いたいかの仕様によって異なる部分があると思いますが、弊社の場合には下記のようなことを準備する必要がありました。 チャットトークンを発行するためのサーバの用意 リアクション機能 同時接続数の表示 同時接続数やリアクション機能はIVSの機能である時間指定メタデータを使用しています。 IVSでは専用のIVS Player SDKが用意されていて、メタデータをイベントとして受け取ることができます。 フロントエンドでこのイベントと連携するアクションを実装することで、任意のタイミングで同時視聴者数の更新などを可能にしています。   https://docs.aws.amazon.com/ja_jp/ivs/latest/LowLatencyUserGuide/player.html ブログに載せるために簡略化したものになりますが、同時接続数を取得して時間指定メタデータを送信する場合、下記のようなコードになります。 これを一定間隔で実行し、フロントエンドでそのデータを使用することで同時接続数を表示できるようになります。 func sendViewerCount() error { // ivsのリソースを操作するクラアント client := ivs.NewFromConfig(config) streamInput := ivs.GetStreamInput{ ChannelArn: aws.String(in.ChannelARN), } // 配信しているチャンネルのARNからストリム情報を取得 stream, err := client.GetStream(ctx, &streamInput) if err != nil { return err } // 送信するメタデータを作成する metadata := map [ string ] interface {}{} metadata[ "viewer_count" ] = stream.ViewerCount jsonData, err := json.Marshal(metadata) if err != nil { return err } metadataInput := ivs.PutMetadataInput{ ChannelArn: aws.String(channelARN), Metadata: aws.String(jsonData), } // metadataを送信する err = client.PutMetadata(ctx, &metadataInput) if err != nil { return err } return nil } IVSを使って良かったこと 他のライブ配信サービスに関わったことがないため比較はできないのですが、実装のコストは低いように感じました。動画の配信はもちろんですが、同時接続数のようなライブ配信に使用する他の機能に関しても時間指定メタデータなどを用いることで比較的簡単に実装することができたと思います。 フロントエンドでIVS Player SDKを使わなければならないという制約はあるようなのですが、3秒程度の遅延で配信ができているようでした。ライブ配信中のコメントへの反応も少ない遅延で行えているようでした。 まとめ ライブ配信にIVSを使用してみて、実装のコストも低く、実際の配信の面でも遅延が少なく便利だと感じました。 IVSを使ったライブ配信を考えている人の参考になれば幸いです。 ここまで読んでいただきありがとうございました。 参考資料 https://docs.aws.amazon.com/ja_jp/ivs/latest/LowLatencyUserGuide/what-is.html
アバター
目次 はじめに Wear OSとは 環境 今回実装するアプリについて 実装の流れ 1. プロジェクトの作成 2. 受信側が機能をアドバタイズする 3. 送信側でノードを取得する 4. 送信側でメッセージを送信する 5. 受信側でメッセージを受信する まとめと感想 終わりに 参考 はじめに every Tech Blog Advent Calendar 23日目の記事になります! こんにちは トモニテ でAndroidアプリの開発を行っている岡田です。 今回は、挑戦WEEK中にAndroidスマホで表示している動画をスマートウォッチで操作するアプリを作成したので、その内容についてご紹介させていただきます。 弊社の挑戦WEEKの取り組みについてはこちらをご覧ください! https://tech.every.tv/entry/2023/10/13/172151 Wear OSとは 正式名称は Wear OS by Google。 Googleが開発したウェアラブルデバイス向けのOSです。 2023年12月現在、一般的に普及しているウェアラブルデバイスといえばスマートウォッチくらいだと思いますので、スマートウォッチに搭載されるのが主な用途だと思います。 環境 IDE: Android Studio Iguana | 2023.2.1 Canary 11 言語: Kotlin 今回実装するアプリについて Android Appで再生している動画をWear Appで操作するアプリを作成します。 ここではWear Appで再生ボタンが押された時に、それをAndroid Appに通知する処理を実装します。 したがってAndroid Appが受信側、Wear Appが送信側になります。 実装の流れ 以下の流れで実装しました。 1. プロジェクトの作成 2. 受信側が機能をアドバタイズする 3. 送信側でノードを取得する 4. 送信側でメッセージを送信する 5. 受信側でメッセージを受信する これらは 公式のガイド を参考に作成しました。 1. プロジェクトの作成 AndroidStudioのNew Projectに空のWear APPとAndroid APPを作成するテンプレートがあるので、これを利用しました。 2. 受信側が機能をアドバタイズする 受信側の res/values/ にXML形式で機能を文字列で記述し、機能をアドバタイズします。 アドバタイズとは、Bluetooth Low Energy(BLE)などを使用して、デバイスの情報を周囲の他のデバイスに知らせる行為のことを指します。 Androidスマホは複数のスマートウォッチを接続できるため、Wear Appは使用する接続ノードが特定の機能を備えているのか判断する必要があります。 したがって、Android Appでは実行されているノードで特定の機能を提供していることをアドバタイズする必要があります。 例えば今回作成するアプリでは、ノードがAVPlayerを操作する機能を備えているかを判別する必要があります。 機能を識別するために player_operation とし、以下のように記述しました。 <resources xmlns : tools = "http://schemas.android.com/tools" tools : keep = "@array/android_wear_capabilities" > <string-array name = "android_wear_capabilities" > <item> player_operation </item> </string-array> </resources> 3. 送信側でノードを取得する 送信側で受信先と通信するためのノードを取得します。 CapabilityClient クラスの getCapability() を呼び出すことで、必要な機能を備えたノードを検出できます。 取得したノードのidは idupdateOperationCapability() にて operationNodeId で保持します。 companion object { // 受信側でアドバタイズしたものと同じ必要がある private const val PLAYER_OPERATION_NAME = "player_operation" } private var operationNodeId: String ? = null fun setupOperation() { viewModelScope.launch(Dispatchers.IO) { val capabilityInfo: CapabilityInfo = Tasks.await( Wearable.getCapabilityClient(context) .getCapability( PLAYER_OPERATION_NAME, CapabilityClient.FILTER_REACHABLE ) ) updateOperationCapability(capabilityInfo) } } private fun updateOperationCapability(capabilityInfo: CapabilityInfo) { operationNodeId = pickBestNodeId(capabilityInfo.nodes) } private fun pickBestNodeId(nodes: Set <Node>): String ? { // デバイスに直接接続されているノードがあるかit.isNearbyで識別する return nodes.firstOrNull { it.isNearby }?.id ?: nodes.firstOrNull()?.id } 4. 送信側でメッセージを送信する いよいよメッセージを送信します。 CapabilityClient クラスの sendMessage() を呼び出すことで、指定したノードにメッセージを送信できます。 3 で取得したノードidと、任意のPath、そして送信するテキストを ByteArray 型で指定します。 const val PLAYER_OPERATION_MESSAGE_PATH = "/player_operation" private fun requestOperationn(textData: ByteArray ) { val context = getApplication<Application>() operationNodeId?.also { nodeId -> Wearable.getMessageClient(context).sendMessage( nodeId, ClientPath.MESSAGE.path, textData ).apply { addOnSuccessListener { // 成功時の処理 } addOnFailureListener { // 失敗時の処理 } } } } // ClickEventで呼び出す fun onPlayButtonClick() { val dataText = "play" val data = dataText.toByteArray( Charsets .UTF_8) requestOperation(data) } ... 今回は送信側の再生ボタンに onPlayButtonClick() のようなメソッドを用意して呼び出しました。 他にもボタンを用意し、対応するメソッドを作成・呼び出してメッセージを送信します。 5. 受信側でメッセージを受信する MessageClient.OnMessageReceivedListener インターフェースを実装します。 addListener() を使用してリスナーを登録することで、メッセージを受け取ることができます。 onMessageReceived() では、受信した MessageEvent を用いて処理を記述していきます。 例えば「送信されたMessageが"play"だったら、動画の再生・一時停止処理を行う」といった処理を記述しています。 class MainActivity : AppCompatActivity(), MessageClient.OnMessageReceivedListener { ... const val PLAYER_OPERATION_MESSAGE_PATH = "/player_operation" override fun onMessageReceived(messageEvent: MessageEvent) { when (messageEvent.path) { PLAYER_OPERATION_MESSAGE_PATH -> { val message = messageEvent.data.toString( Charsets .UTF_8) //文字列に変換 when (message){ "play" -> { if (binding.videoView.player?.isPlaying == true ){ binding.videoView.player?.pause() } else { binding.videoView.player?.play() } } ... } } } } override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) setContentView(binding.root) Wearable.getMessageClient( this ).addListener( this ) } ... } まとめと感想 まとめ Androidスマホで表示している動画をスマートウォッチで操作するアプリを作成 受信側では機能のアドバタイズと受信処理を記述 機能のアドバタイズは res/values/ にXMLで記述 受信は MessageClient.OnMessageReceivedListener インターフェースを実装 送信側ではノードの取得とメッセージ送信処理を記述 ノードを検出は CapabilityClient クラスの getCapability() で処理 メッセージ送信は CapabilityClient クラスの sendMessage() で処理 感想 アドバタイズする機能やPathなど共通で扱うものは、共通で使用するModuleで管理するのが良さそう 送信するデータは ByteArray 型であることに注意しないといけない 終わりに 今回はWear OS端末を触ってみました! 最近はスマートウォッチと連携しているアプリも多く、新世代ウェアラブルデバイスの普及次第ではもっと盛り上がる技術だと思いますので、今後も注目して追っていきたいと思います! 参考 Wear でメッセージを送受信する Wear OS アプリの品質
アバター
はじめに こんにちは。DELISH KITCHEN 開発部の村上です。 この記事は every Tech Blog Advent Calendar 2023 の 22 日目です。いよいよ長く続いたアドカレも終盤になりました。これまで投稿された他の記事もリスト化されているのでぜひ見てみてください!! さて今回はエブリーで運営しているキャンペーンの LP 開発においてヘッドレス CMS の microCMS と Next.js を用いた効率化に取り組んでいるのでその取り組みや知見を紹介させていただきます。 ヘッドレス CMS 導入の背景 エブリーではクライアントが協賛する形でいくつかのプレゼントキャンペーンが実施されており、それぞれのキャンペーンは独自の LP ページを持ちます。元々は Google フォームで行っていたものを LP ページ作成での CV 向上を計るという方針にあたり、取り組み当初はうまくいくか不透明な中で仕組みを作りすぎず、最速で PoC を行うためにエンジニアが自らマークアップを行い、Next.js で SSG したコンテンツを S3 + CloudFront で配信する最小構成を選択しました。実際にこの構成である程度のスピード感を持って施策の検証をすることができたのですが、PoC を終えて拡大フェーズに事業が入っていく中で管理するキャンペーンも増え、以下のような課題も浮き彫りになってきました。 同時並行で複数キャンペーンが開始したい時にエンジニアの工数がボトルネックになる 開催中のキャンペーン情報の細かい変更や改善でもエンジニアへの依頼が必要 キャンペーン担当者との実装確認依頼もありコミュニケーションコストが高く、ケアレスミスも発生しやすい このようにその当時は最適と思われた意思決定も事業の成長スピードに対して開発体制やシステムが次第に追いつかなくなり、開発がボトルネックになることが目立ってきていたような状況でした。そこでこうした変化にシステム側も対応するためにエンジニアが作業をすることなく、キャンペーンの作成や更新をできるような状態を目指し、ヘッドレス CMS の導入を検討しました。既存の配信構成をほぼ変える事なく、コンテンツ管理も内製せずに改善ができるのはヘッドレス CMS の大きなメリットでした。 ヘッドレス CMS で有名なところだと Contentful などもありますが、実際に使う運用者を考えるとわかりやすい UI や日本語サポートが充実していて、日本での採用事例の多くある microCMS という日本製の CMS を最終的には採用しました。エンジニアとしては 開発ロードマップ が公開されて日々アップデートがされていたり、Next.js での新しい機能での microCMS の利用 をいち早く公式として発信したりとその開発体制にも好感を持てました。 microCMS を用いたシステムの全体像 microCMS の導入後のシステム全体像は以下のようになりました。 キャンペーンページの配信までは大きく以下のような流れをたどります。 microCMS 内でコンテンツの入稿または更新 変更を検知して、Github Actions が発火 Next.js で SSG してビルド、 S3 にデプロイ Cloudfront 経由でキャンペーンページを配信 ここからはこの仕組みを導入していくまでの工程の詳細を説明していきます。 microCMS の API 作成 まずはコンテンツ入稿やシステム連携の土台となる API の作成から行っていきます。API スキーマはフィールドという単位で要素を追加しながら定義していきます。選択できるフィールドの種類はたくさんあるのでこちらの 公式ドキュメント を参考にしていただきたいのですが、その中でも私たちが LP のコンテンツ作成において一番活用しているカスタムフィールドと繰り返しフィールドを紹介します。 カスタムフィールド カスタムフィールドは microCMS 内において複数のフィールドを組み合わせて固有のフィールドを作ることができる機能です。例えば、LP においてボタンを表示したいとしても自由にカスタマイズできるようにしようと思うと、ボタンのリンクだけではなく、表示するテキストや独自スタイルごとのボタン種別、分析用のイベント識別子などを追加したくなります。そういった場合はカスタムフィールドを用いて以下のようなフィールドを作成することで実現可能です。 実際の API はレスポンス内でネストされたオブジェクトの形をとります。 { " id ": " test-id ", " createdAt ": " 2023-12-20T08:30:38.460Z ", " updatedAt ": " 2023-12-20T08:30:38.460Z ", " publishedAt ": " 2023-12-20T08:30:38.460Z ", " revisedAt ": " 2023-12-20T08:30:38.460Z ", " button ": { " fieldId ": " itemButton ", " text ": " ボタン ", " type ": [ " form " ] , " link ": " https://example.com ", " eventLabel ": " test " } } 繰り返しフィールド カスタムフィールドと活用することで API スキーマの柔軟性が格段に向上するのが繰り返しフィールドです。繰り返しフィールドはカスタムフィールドを複数選択し、選択したカスタムフィールドを好きな順序で繰り返し入れることができるものです。例えば、先ほどのボタンに加えて、シンプルにタイトルを表すカスタムフィールドを追加して繰り返しフィールドを追加すると以下のような形で入稿することができます。 実際の API レスポンスは配列の中に各カスタムフィールドのオブジェクトが入っているので識別子でそのオブジェクトの型を判別して実装します。 { " id ": " test-id ", " createdAt ": " 2023-12-20T08:30:38.460Z ", " updatedAt ": " 2023-12-20T08:33:39.574Z ", " publishedAt ": " 2023-12-20T08:30:38.460Z ", " revisedAt ": " 2023-12-20T08:33:39.574Z ", " items ": [ { " fieldId ": " itemTitle ", " title ": " タイトルA " } , { " fieldId ": " itemButton ", " text ": " ボタンA ", " type ": [ " form " ] , " link ": " https://example.com ", " eventLabel ": " test_a " } , { " fieldId ": " itemTitle ", " title ": " タイトルB " } ] } 今回 CMS 導入に至った LP のコンテンツ制作においてはテキストや画像、ボタンなど各要素の配置をそれぞれのキャンペーンに最適化して作るため、自由にコンテンツを入れ替えて構築することができることは必須要件でそれを実現できるこういった機能はとても便利でした。 Next.js の build 時に microCMS の API を利用してページを生成 API が作成できたら次はその API を利用して実際にページを生成する部分を作っていきます。既存のシステムでは Next.js の SSG 機能を使って build し S3 から CloudFront 経由で配信をしているので build 時に microCMS の API を呼び出す形で連携していきます。microCMS では Javascript SDK を npm で配布しており、 microcms-js-sdk を使うことで簡単に連携をすることができます。 実際にこれらを利用することで以下のような実装をすることができます。 // libs/client.js import { createClient } from "microcms-js-sdk" ; export const client = createClient( { serviceDomain: "domain" , apiKey: process.env.API_KEY, } ); // pages/campaign/[id].jsx import { client } from "libs/client" ; export default function Campaign( { campaign } ) { return ( <main> <h1> { campaign.title } </h1> <p> { campaign.content } </p> </main> ); } export const getStaticPaths = async () => { const data = await client.get( { endpoint: "campaigns" , } ); const paths = data.contents.map((c) => `/campaign/ ${c.id} ` ); return { paths, fallback: false } ; } ; export const getStaticProps = async ( { params } ) => { const data = await client.get( { endpoint: "campaigns" , contentId: params.id, } ); return { props: { campaign: data, } , } ; } ; getStaticPaths と getStaticProps で microCMS から返却されたデータを使ってページ生成することができました。これでデプロイ時に自動で CMS のコンテンツを取得してサイトに反映することが可能になりました。 変更を検知して Github Actions を起動 デプロイ時に自動でコンテンツ反映ができるようになりましたが、CMS での運用を考えると管理画面内の変更が即時に反映できる形が望ましいです。これを実現するために microCMS 内での変更を検知して Github Actions を起動するようにします。microCMS では Github Actions への webhook 通知ができるようになっており、変更を検知すると dispatch イベントが発火し、起動するようになります。 API 設定 > Webhook からトリガーイベントや通知タイミングの設定を行えます。 Github Actions は既存の CI/CD ですでに組み込んでいたので一部のビルド、デプロイ処理を切り出すことで特別このためにやることなく設定することが可能です。 本番運用する中での工夫 以上で最低限、microCMS と連携して自動でコンテンツ反映ができるようになると思います。ただ、実際に本番運用するとなるといくつか注意したいポイントがあったのでそちらについても触れていきたいと思います。 1. CMS で入稿された画像を S3 に同期して別で配信を行う microCMS では画像管理、配信も行える基盤が整備されており、裏側は imgix と連携しています。したがって、imgixAPI で行えることが microCMS でも行うことができ、動的なサイズやフォーマット変更など画像を配信する上で強力な機能をたくさん備えています。ただ、この画像配信は無料で制限なく使えるものではなく、実際には 各料金プラン で確保された月のデータ転送量外で増えた転送量はその分従量課金されていく形になります。今回の場合では LP として画像配信が多く、トラフィックを試算したところ自前で画像配信基盤を構築した方が低コストに運用ができそうだったので移行に踏み出しました。 先ほどのコンテンツ管理での Github Actions 連携同様に microCMS でのメディア管理では Webhook の機能があります。今回はこの機能を利用して、API Gateway + lambda で S3 に画像を同期するような設定を行っています。 body には以下の内容が入ってきます { " service ": " test ", " type ": " edit ", // new または update または delete " old ": { " url ": " https://image.microcms-assets.io/xxxxxx ", " width ": 100 , " height ": 100 } , " new ": { " url ": " https://image.microcms-assets.io/xxxxxx ", " width ": 100 , " height ": 100 } } 今回はドメインの置換のみで参照先を切り替えられるようにしたいので、lambda 側では microCMS でのパスを引き継いだ状態で S3 に格納します。弊社で動いている python のコードは以下のような実装になっています。 from http import HTTPStatus from urllib.request import urlopen import os import boto3 s3 = boto3.resource( 's3' ) bucket = s3.Bucket(os.environ.get( 'S3_BUCKET' , 'test.bucket' )) replace_url = os.environ.get( 'REPLACE_URL' , 'https://images.microcms-assets.io/' ) def sync_media_cms_to_s3 (event, _): try : if event[ 'type' ] == 'new' : uploadS3(event[ 'new' ][ 'url' ]) elif event[ 'type' ] == 'edit' : deleteS3(event[ 'old' ][ 'url' ]) uploadS3(event[ 'new' ][ 'url' ]) elif event[ 'type' ] == 'delete' : deleteS3(event[ 'old' ][ 'url' ]) except Exception as e: body = f "failed to sync media. err: {e}" print (f "ERROR: {body}" ) return { "statusCode" : HTTPStatus.INTERNAL_SERVER_ERROR.value, "body" : body, } return { "statusCode" : 200 , } def uploadS3 (url): upload_s3_path = url.replace(replace_url, '' ) bucket.upload_fileobj(urlopen(url), upload_s3_path) def deleteS3 (url): delete_s3_path = url.replace(replace_url, '' ) bucket.Object(delete_s3_path).delete() 次に独自の画像ドメインに参照を変える必要もあります。こちらはシンプルな対応になりますが、API のレスポンス内にある microCMS の画像ドメイン(images.microcms-assets.io)を独自ドメインに置換して build することで参照を変えることができます。 あくまで今回上げさせてもらったこの取り組みは弊社の基盤での最適解であり、確保された月のデータ転送量の範囲内で運用できる場合や超えたとしても自前で基盤を作るコストと天秤にかけて問題がない場合には積極的に画像配信の機能も使った方がいいと思うので、参考程度に見ていただけると嬉しいです。 2. microCMS の API 制限の回避 先ほど Next.js を用いた microCMS との連携とページ生成について、その手法を紹介させていただきましたが、こちらには一つ問題があります。それはある程度コンテンツ数が増えてくるとその数だけ getStaticProps 内での API 呼び出しが行われ、GET API のレートリミットである 60 回/秒を超えてしまう可能性が考えられることです。 これは microCMS に限らず、Next.js 内の SSG での課題として 議論 されていたり、レスポンスを再利用するような手法も 紹介 されていたりします。実際に私たちも紹介された手法を用いて、getStaticPaths で fetch した API のレスポンスをファイルとしてキャッシュして getStaticProps で再利用するようなやり方で API の呼び出し回数を抑えています。 詳細な実装は紹介されているサイトを見ていただきたいですが、ページ生成部分の実装は以下のように変わります。 import { client } from "libs/client" ; export default function Campaign( { campaign } ) { return ( <main> <h1> { campaign.title } </h1> <p> { campaign.content } </p> </main> ); } export const getStaticPaths = async () => { const data = await client.get( { endpoint: "campaigns" , } ); // データをキャッシュする await client.cache.set(data.contents); const paths = data.contents.map((c) => `/campaign/ ${c.id} ` ); return { paths, fallback: false } ; } ; export const getStaticProps = async ( { params } ) => { // キャッシュからデータを取得 const data = await client.cache.get(params.id); return { props: { campaign: data, } , } ; } ; 3. プレビュー機能を活用する 実際に運用していくとおそらく変更前にその見た目の確認をできれば本番と同じような環境で行いたくなってくると思います。microCMS ではプレビュー画面を表示する機能が備わっており、設定する URL の中に {CONTENT_ID} と {DRAFT_KEY} という文字列を埋めることによって、プレビュー画面利用時に自動で置換され、URL が構築されるようになっています。 この機能を使って、特定のパスに preview ページを作って、受け取った CONTENT_ID と DRAFT_KEY から動的に画面を生成することができ、自前で実装せずとも簡単にプレビュー画面を作れるようになっています。 おわりに 現在では徐々に microCMS でのコンテンツ連携に各キャンペーン LP が移行している状態で、すでにエンジニア側、担当者双方で工数が減り、改善スピードが上がったことを実感しています。元々こういった改善案もエンジニア側で事業の成長スピードに合わせて、議論されて挑戦してみた結果生まれており、エンジニアがその時々の事業状況からその都度最適なシステムを考えるオーナシップがあるからこそ実現できたと思います。エブリーではこれからも技術的挑戦を行いながら、エンジニア側から技術で事業の成長を牽引していきたいと思っているので、ぜひ興味を持った方はカジュアルにお話させていただきたいです! https://corp.every.tv/recruits/engineer
アバター
新卒1年目Web系エンジニアが社内ChatAppのテンプレート機能の実装に挑戦した話 新卒1年目Web系エンジニアが社内ChatAppのテンプレート機能の実装に挑戦した話 目次 新卒1年目Web系エンジニアが社内ChatAppのテンプレート機能の実装に挑戦した話 はじめに 現在の社内ChatAppについて 実装したテンプレート機能について Template Generator 今後について&まとめ はじめに こんにちは。 トモニテ開発部でバックエンドやフロントエンドの設計・開発に携わっている 新卒1年目エンジニアの庄司( ktanonymous )です。 every Tech Blog Advent Calendar 2023 の22日目の記事執筆担当者として参加させていただきました! いよいよ最終日が近づいてきていますが、是非最後までチェックしていってください! 先日、エブリーでは開発部全体のイベントである挑戦week 1 が開催されました。 挑戦weekの運営についての記事も出していますので是非ご覧ください。 こちらの記事では、 挑戦weekで実装した社内ChatApp 2 のテンプレート機能についてご紹介していきたいと思います。 現在の社内ChatAppについて ChatGPTが使えるようになって以降、作業の効率化やクリエイティブな活動など非常に様々な場面で利活用されるようになっています。 弊社でも例に漏れず、ChatGPTを利用した社内ChatAppが利用されています。 ChatAppの仕様はシンプルで、プロンプトを入力して送信することで回答を得られます。 現在のChatAppの画面 また、上記画像には写っていませんが、会話の履歴をcsvファイルとしてダウンロードすることもできます。 今回自分が挑戦するテーマを考えるにあたり、ChatGPTに対して常々思っていたことがありました。 それは、「プロンプト考えるの面倒だし、何が『良い』プロンプトか分からない!」ということです。 これは共感していただける方が多いと思っているのですが、プロンプトを考えるのは面倒だし、 かといって適当に書いたプロンプトで中途半端に間違えられると修正の方がゼロから作るより大変になることもあるのが辛みポイントだと感じています。 そこで、プロンプト自体をChatGPTに生成してもらい、それをテンプレートとして利用することで 手軽に「良い」プロンプトを使えるようにしたいと考え、テンプレート機能の実装に挑戦することにしました。 実装したテンプレート機能について プロンプトのテンプレート機能を実装するにあたり、どのように使えると便利になるかを考え以下の要件を設定しました。 簡単な用件を伝えるだけで「良い」プロンプトのテンプレートを生成することができる 生成したプロンプトのテンプレートを保存しておくことで再利用・共有できる 保存されているプロンプトのテンプレートを書き換えるだけで類似の用件のプロンプトをすぐに作成できる 以下のようなイメージです。 テンプレート機能のイメージ 最終的に実装したものは以下のような手順で利用できるようになりました。 テンプレートを生成する(Template Generator) テンプレート生成ページにアクセスして、用件のジャンルを選択する ページトップに表示されるジャンルから希望のものを選択します。 用件のジャンルを選択する 用件を送信する 先ほど選択したジャンルに応じた用件を入力して送信します。 用件を送信する 生成されたプロンプトのテンプレートを確認・保存する プロンプトが生成されて画面に表示されます。 左下の「Save templates」ボタンを押すと、テンプレートを保存することができます。 生成されたプロンプトのテンプレートを確認・保存する テンプレートを利用する(Use Template) テンプレート利用ページにアクセスして、セレクトボックスから使用したいテンプレートを選択する テンプレートはGoogleスプレッドシートに保存されているため、社内全体で共有することもできますし、Template Generator以外で作成したテンプレートを各自で保存することもできます。 使用したいテンプレートを選択する 必要に応じてテキストを編集して送信する 選択したテンプレートがテキストエリアに書き出されるので、必要に応じて編集します。 必要に応じてテキストを編集して送信する おまけ: 用件「テックブログの書き方を教えて」から生成されたプロンプトへの回答 テックブログの書き方についてのガイドラインは以下の通りです。 テックブログを書く目的とその重要性 : テックブログを書く目的は、技術的な知識や経験を共有し、読者に価値を提供することです。これにより、著者は自身の専門知識を示し、業界内での信頼性を築くことができます。また、コミュニティとの関わりを深め、新しい技術トレンドやアイデアを広めるプラットフォームとしても機能します。重要性は、教育的なリソースを提供し、同時にSEOを通じてオンラインでの可視性を高めることにあります。 ターゲットオーディエンスの特定方法 : オーディエンス分析 : 読者がどのような技術的背景を持っているか、どのような情報を求めているかを理解します。 ニーズの特定 : 読者が直面している問題や興味を持っているトピックを特定します。 フィードバック : コメントやソーシャルメディアを通じて読者からのフィードバックを収集し、それに基づいてコンテンツを調整します。 効果的なタイトルの作り方 : 明確さ : タイトルは具体的で、読者が記事の内容をすぐに理解できるようにします。 キーワードの使用 : 検索エンジンで見つけやすいように、関連するキーワードを含めます。 興味を引く : 読者の好奇心を刺激するような言葉遣いを使い、クリックを促します。 テックブログの構成 : 導入 : 読者に記事のトピックとその重要性を紹介し、興味を引きます。 本文 : 論点を明確にし、段落ごとに一つのアイデアに焦点を当てます。サブヘッダー、リスト、画像などを使用して情報を整理し、読みやすくします。 結論 : 主要なポイントをまとめ、読者に次のステップを促すような結びを提供します。 SEOの基本とテクニック : キーワードリサーチ : 記事に関連するキーワードを見つけ、それらをタイトル、見出し、本文に適切に組み込みます。 メタデータ : メタタイトルとメタディスクリプションにもキーワードを含め、検索結果でのクリック率を高めます。 内部リンク : 関連する他のブログ記事へのリンクを含めることで、サイト内での滞在時間を延ばし、SEOを強化します。 関心を持ち続けるコンテンツの書き方 : 教育的価値 : 読者が新しい知識を得られるようにします。 事例の使用 : 実際の例やケーススタディを取り入れ、理論を実践に結びつけます。 ビジュアル : 画像、図表、動画を使用して、複雑な情報を視覚的に表現します。 定期的な更新の重要性とスケジュール管理 : 一貫性 : 定期的な更新は読者に信頼感を与え、検索エンジンによる評価も高まります。 スケジュール : コンテンツカレンダーを作成し、投稿の頻度とトピックを計画的に管理します。 プロモーション戦略とソーシャルメディアの活用: ソーシャルメディア : Twitter、LinkedIn、Facebookなどで記事を共有し、関連するコミュニティやフォーラムでディスカッションを促進します。 メーリングリスト : ニュースレターを通じて定期的にコンテンツを配信し、読者との関係を築きます。 ゲストブログ : 他のブログやメディアにゲスト投稿をすることで、新しいオーディエンスにリーチします。 これらのガイドラインに従うことで、テックブログの品質を高め、より多くの読者に価値を提供することができます。 以上の手順で、簡単な要件から「良い」プロンプトを利用して回答を得ることができます。 ここからは、機能の核を担う Template Generator について詳しく説明していきます。 Template Generator Template Generatorは、与えられた用件をChatAppに尋ねるための「良い」プロンプトを生成する機能です。 ChatGPT自身にプロンプトを考えてもらうことで、利用者の負担の軽減を実現することができます。 「良い」プロンプトを生成してもらうために、アプリ内部でユーザーの入力を利用して、 先ほどの用件を入力して送信している画像 のように肉付けしたプロンプトをメッセージとして送信します。 プロンプトの工夫はプロンプトエンジニアリングと呼ばれますが、筆者はプロンプトの肉付けをする上で以下のようなことを意識していました。 プロンプト生成の過程でユーザーにアクションを求めさせない 指示が具体的になるようにする テンプレートして使いまわしやすいようにシステムメッセージが極力入り込まず、かつ、整形されている プロンプトを受け取るChatGPTに役割を自覚させるような文言を盛り込ませる プロンプトを受け取るChatGPTに発破をかけるような文言を盛り込ませる 上記の点はテンプレートを受け取るChatGPT向けの視点で書かれていますが、 テンプレートを生成するためのプロンプト自身にも反映されるように気をつけました。 また、用件・条件・それ以外のメッセージが明確に区別されるようにもしました。 これらの中で特に工夫した点として、4, 5が挙げられます。 4. に挙げているのは、モデルに役割設定を与える手法です 3 。 また、 5. に挙げているのは、 Emotion Prompts と呼ばれる手法 4 で、モデルに 感情的な言葉を投げかけることでパフォーマンスが向上するそうです。 プロンプトエンジニアリングの世界も奥深く、筆者自身知らないことが多いです。 興味のある方は是非調べてみてください。 また、生成したテンプレートの保存先はGoogleスプレッドシートとしました。 テンプレートの保存先をGoogleスプレッドシートにすることで社内全体で共有することができ、 さらに、直接編集することでテンプレートの微調整や自分で作成したプロンプトをテンプレートとして保存することもできます。 今後について&まとめ 今回の挑戦では社内ChatAppの本番環境へのリリースまでは間に合わなかったので、なるべく早くリリースして社内のみんなに使っていただきたいと思っています。 また、やりきれなかったことも多いと思っているので、隙を見て改善を進めていけたら良いなと思っていたりもします。 以下の点は特に改善したいと思っている点です。 実はジャンルがハードコーディングされているので、ジャンルもスプレッドシートで管理したい 「テンプレート」とはいうものの、いわゆる穴埋めして利用できるテンプレートにはなっていない AIの力を借りて穴埋め形式にしてみたり、テンプレートの中身を自動で臨機応変に書き換えてもらうのはアリかもしれない 本記事では、先日開催された挑戦weekで取り組んだ社内ChatAppのテンプレート機能の実装についてご紹介させていただきました。 OpenAIのChatGPT-3の発表以降、急速に勢いを増しているLLMを利用した開発に携わることができたのは非常に貴重な経験でした。 AIに限らず技術の変化は日々進んでいるので、これからも様々な技術に触れてキャッチアップしていきたいと思っています。 エブリーでは今年、1週間普段の業務から離れて開発事業の推進のために技術的な挑戦に集中する期間の設定を試みていました。 ↩ エブリーではChatGPTを利用した社員向けのチャットアプリが利用されています。 ↩ SkillUp AI | ChatGPTのプロンプトエンジニアリングとは|7つのプロンプト例や記述のコツを紹介 ↩ GigaziNE | AIに「それがファイナルアンサーなの?」「全力を尽くして」といった感情的な命令文を伝えるとパフォーマンスが向上する ↩
アバター
はじめに この記事は、 every Tech Blog Advent Calendar 2023 の21日目の記事です! 男梅シート、あのクセになるしょっぱさと噛めば噛むほど溢れ出てくる旨さは悪魔的ですよね。僕の推しです。 初めまして!エブリーで内定者インターンをしている @きょー です! インターンでは業務でサーバーやフロントをタスクベースで開発しています。 現在フロントのコードをリプレイスしていて、Eslintの設定ファイルを見直す機会をもらったのでその際に得た知見を共有していきたいと思います! 導入に至った背景 We expect the first alpha release of ESLint v9.0.0 to be released in December or January, depending on the progress we make on our tasks. ESLint v9.0.0の最初のアルファリリースは、タスクの進捗にもよりますが、12月か1月にリリースされる予定です。 Eslint v9.0.0 からFlat Configという新しい設定ファイルがデフォルトになります。それに伴いこれまで使っていたeslintrc形式は非推奨になり、v10.0.0(公式:予定では2024年末〜2025年初頭) では完全に削除されてしまいます。現在のプロジェクトはeslintrc形式で記述してあったので対応する必要がありました。 https://eslint.org/blog/2023/11/whats-coming-in-eslint-9.0.0/ https://eslint.org/blog/2023/10/flat-config-rollout-plans/ いざ導入! ゴール 「monorepoの各projectでflat configを導入すること」をゴールとします!現プロジェクトではmonorepoで開発しているためです。monorepoの説明は省きますが、詳しく知りたい方は circleci blog を見てみてください。 書いていく! root配下に一つだけ eslint.config.js を置くところから移行は始めました。これからその過程を書いていこうと思います。また、 turborepo を導入しているのでcacheをうまく使えるようにするのを意識しながら書いていきました。 最初は公式と uhyoさんの記事 を参考にさせていただきました。uhyoさん、丁寧でわかりやすい記事をありがとうございます!!! - https://eslint.org/docs/latest/use/configure/migration-guide - https://eslint.org/docs/latest/use/configure/configuration-files-new Step 1: 一番最初は↓のような構成でした。root配下の設定ファイルの中で各projectのパスと設定したいrulesやその他 tsconfig.json などの設定ファイルをprojectごとに書いていきました。 |---/projectA |---/projectB |---/projectC |---eslint.config.js eslint.config.js module.exports = [ { files: [ '/projectA/**/*.ts' ] , // その他設定 } , { files: [ '/projectB/**/*.ts' ] , // その他設定 } , { files: [ '/projectC/**/*.ts' ] , // その他設定 } , ] ; しかしこの構成だとあるプロジェクト固有のlint設定を追加した時や、一部のlintを修正しただけで全てのlintのcacheが効かなくなってしまいます。 Step 2: そこでその課題を解決するために各project配下に設定ファイルを置き、それぞれの設定ファイルから共通でlintさせたいrulesを読み込み適用させる必要がありました。最終的に↓のような構造になります。この状態だとそれぞれのprojectごとでcacheが残るようになります。 |---/projectA |---eslint.config.js |---/projectB |---eslint.config.js |---/projectC |---eslint.config.js |---/eslint-config-custom |---index.js eslint-config-custom/index.js 全projectで共通にしたいlintのruleやparserの設定が書かれています。 const globals = require( 'globals' ); const tsEsLintParser = require( '@typescript-eslint/parser' ); const { FlatCompat } = require( '@eslint/eslintrc' ); const compat = new FlatCompat(); const jsRules = { // jsに適用するルール } ; const tsRules = { // js, tsに適用するルール } ; module.exports = [ ...compat. extends ( 'eslint-config-airbnb-base' ), { rules: { ...jsRules, } , } , { files: [ '/**/*.ts' , '/**/*.tsx' ] , languageOptions: { parser: tsEsLintParser, parserOptions: { globals: { ...globals.browser, } , sourceType: 'module' , project: './tsconfig.json' , } , } , rules: { ...jsRules, ...tsRules, } , } , ] ; project~/eslint.config.js index.js の設定を引き継いだproject~/の設定ファイルです。ここでは index.js でexportsされた設定を展開させ、projectごとで適応させたい設定(例えば tsconfig.json など)を設定しています。 const custom = require( '../eslint-config-custom/index.js' ); module.exports = [ ...custom, { // projectで設定したいsetting } , ] ; 導入で躓いたところ エディター上でlintが効かなくなる 設定ファイルを編集している時にエディター上でlintが効かなくなることがありました。原因としては適切にeslintのrulesの設定がされていなかったり、exportsされたeslintの設定ファイルが配列ではなくオブジェクトになっていたためでした。 import / exportsしているlintの設定ファイルをconsole.logで出力して確認したり、cliでlintをチェックして確認しましょう。 exportsされたeslintの設定は↓のようになっていれば適用されるはずです。 [ { files: [ ' /**/*.ts ' , ' /**/*.tsx ' ] , rules: { ' no-unused-vars ' : ' off ' } } , { files: [ ' /**/*.js ' ] , rules: { ' no-undef ' : ' off ' } } ] ワークスペースが認識されない 今回のような複数のprojectで作業するときにエディター上でeslintが効いていないように見えることがあります。cliでeslintをチェックしてみて期待しているエラーが出ている場合は vscode/settings.json に明示的にワークスペースを登録する必要があるかも知れません。自分はこれで解決しました! 例) " eslint.workingDirectories ": [ " ./projectA ", " ./projectB " ] monorepo環境でflat configを導入してみての感想 monorepo環境下でも無事に導入できました!基本的にproject配下の設定ファイルでは index.js をimportしてくるだけで共通の設定を適用できるので、projectが増えても簡単に拡張しやすい構成になっていると思っています!また依存性の管理も /eslint-config-custom の中を見れば大体書いてあるので把握しやすくなっています。 eslint自体の知識が浅かったのですが、どのようなルールでlintingされていてコードの可読性が保たれているのかを知れる機会になったので勉強になりました。綺麗なコードはlintから、、、!! 終わりに monorepo環境下でのeslint flat config導入で得た知見を紹介しました!書いた記事が皆さんに役に立てたら嬉しいです。 もし何かありましたら twitter などで聞きにきてください! 明日も記事が出ると思います!お楽しみに!! ps. ホロパレード楽しい、、!!
アバター
はじめに こんにちは!トモニテにて開発を行なっている吉田です。 この記事は every Tech Blog Advent Calendar 2023 の 21 日目の記事となります。 今回は、私が実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うことについて取り上げます。 経緯 私は今年の 2 月にエブリー入社し、エンジニアとしてのキャリアも同じタイミングでスタートしました。 入社してもうすぐ1年経つのですが、日々の業務に取り組む中でさまざまなサービスや技術にふれてきました。 出会うもの全てが未知との遭遇でエブリー入社当初、知らないことだらけでまずい!と思ったことを覚えています。 そこで今回はタイトルの通り「実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うこと」、その中でもアークテクチャとテストについて取り上げます。 エンジニアとしての初心者の立場からの視点で、同じような境遇の方や興味を持っている方の参考になれば幸いです。 アーキテクチャについて アーキテクチャは英語で「構造」という意味、開発における意味合いでも同様で「システムやソフトウェアの構造」という意味になります。 実際に開発に参加しサービスのコードを見ていると処理がいくつかの層に分かれていることに気づきました。そこで思ったことはそれぞれの層は何のために分かれていて、それぞれはどんな役割を持っているんだろうということです。 調べてみるとこの実装方法がレイヤードアーキテクチャであることが分かりました。その他にも以下のようなアーキテクチャがあります。 ヘキサゴナルアーキテクチャ オニオンアーキテクチャ クリーンアーキテクチャ etc... それぞれについて調べてみましたが何を言っているのかよく分かりません… そもそも上記で述べた層とはレイヤーのことです。では各レイヤーはどんな役割を持つのでしょうか。 アーキテクチャについての考え方を理解する上では、私は上記のアーキテクチャの元になっている3層アーキテクチャがシンプルで一番理解しやすいと思ったのでこちらを例に説明します。 3 層の各名称と役割は以下の通りです。 プレゼンテーション層…クライアント(アプリやブラウザ)からリクエストを受け付ける ファンクション層…受け取ったデータに加工・処理を実行 データアクセス層…データベースにアクセスする wikipedia より これを見ると何となく各層の役割が分かりそうです。 ではなぜ、このように分けるのでしょう。 私もエンジニア勉強期間中はこのようなことは全く気にしていませんでした。あくまで自分の作りたいサービスが完成できればいいという考えです。 しかし、世の中に出ているサービスはそうもいきません。むしろサービスをリリースしてからがスタートでその後保守や新機能追加と様々な開発を続けていく必要があります。 そのような場面で 1 つのメソッドにたくさんの処理がまとまっていると新機能追加時には処理全体を1から追ってどこに新しい機能を加えるべきなのが適切で、その処理を加えたことによってどこに影響が出るのかの洗い出し+それに伴う修正が必要なります。 他にも実装したコードによってバグが発生し改修が必要になった時にも同様の理由で原因特定に多くの時間が必要になることもあります。 これがアーキテクチャを採用することで各処理がレイヤーに分かれ、レイヤー内での変更は他のレイヤーに影響を与えない変更や拡張に強いコードになります。 さらにアーキテクチャを実現することで各レイヤーが疎結合になり実装がシンプルでテストも書きやすくソースレビューしやすいというメリットも生まれます。 ※疎結合...システム構造間の結びつきや依存度が弱く独立性が高い状態のこと では具体例を交えて説明します。ここではユーザー ID を元にユーザー情報を取得する GetUserInfo メソッドを例とします。 このメソッドで必要な処理は以下になります。 クライアントからリクエストを受け付ける クライアントから受け取るパラメータが適切なものか確認 受け取ったパラメーターを用いて DB へ接続しデータを取得 受け取ったデータを加工 クライアントへレスポンスを返す <アーキテクチャ採用前> package XXX import ( "database/sql" "fmt" "net/http" _ "github.com/go-sql-driver/mysql" "github.com/go-playground/validator/v10" "github.com/labstack/echo" ) type User struct { ID int64 } type UserParams struct { ID string `json:"id" validate:"required"` } func GetUserInfo(c echo.Context) error { // 1. クライアントからリクエストを取得 userID := c.Param( "id" ) userParams := UserParams{ ID: userID, } // 2. クライアントから受け取るパラメータが適切なものか確認 if err := validate.Struct(userParams); err != nil { return c.JSON(http.StatusBadRequest, map [ string ] string { "error" : fmt.Sprintf( "Invalid parameters: %s" , err.Error())}) } // 3-1. 受け取ったパラメーターを用いて データベースへの接続 db, err := sql.Open( "mysql" , "user:password@tcp(host:portNo)/dbname" ) if err != nil { return err } defer db.Close() query := "SELECT * FROM users WHERE id = ?" row := db.QueryRow(query, userID) var user User // 3-2. ユーザー情報を取得 err = row.Scan(&user.ID) if err != nil { return nil , err } // 4. 受け取ったデータを加工(firstNameとlastNameを結合) name := user.FirstName + user.LastName // 5. クライアントにレスポンスを返す return c.JSON(http.StatusOK, &userInfo{ ID: user.ID, Email: user.Email, name: name, }) } ※コードは必要な箇所を抜粋したものになります 比較的単純な処理ではありますが、長くなっていて読みやすいかといえばそうではないですよね... 次いでアーキテクチャを採用した例です。プレゼンテーション層、ファンクション層、データアクセス層の 3 層に分けます。 <アーキテクチャ採用> /* ファイル名:presentation/userInfo.go 役割: クライアントからリクエストを受け取りその結果を返します */ package XXX import ( "fmt" "net/http" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/hogehoge_server/function" ) type User interface { Get (c echo.Context) error } //依存するメソッドを呼び出すため定義 type UserInfoImpl struct { UserInfo function.UserInfo } func (p *UserInfoImpl) Get(c echo.Context) error { // 1. クライアントからリクエストを取得 userID := c.Param( "id" ) userParams := UserParams{ ID: userID, } // 2. クライアントから受け取るパラメータが適切なものか確認 if err := validate.Struct(userParams); err != nil { return c.JSON(http.StatusBadRequest, map [ string ] string { "error" : fmt.Sprintf( "Invalid parameters: %s" , err.Error())}) } res, err := p.UserInfo.GetUserInfo(userParams.ID) // ファンクション層のメソッド呼び出し // 5. クライアントにレスポンスを返す return c.JSON(http.StatusOK, res) } /* ファイル名:function/userInfo.go 役割: データアクセス層からの返ってきたデータをを加工してプレゼンテーション層へ返します */ package XXX import "github.com/hogehoge_server/db" type User interface { GetUserInfo(userID int64 ) (*userInfo, error ) } //依存するメソッドを呼び出すため定義 type UserInfoImpl struct { User db.User } func (s *UserImpl) GetUserInfo(userID int64 ) (*userInfo, error ) { userInfo, err := s.User.GetByID(userID) // データアクセス層のメソッド呼び出し if err != nil { return nil , err } // 4. 受け取ったデータを加工(firstNameとlastNameを結合) name := userInfo.FirstName + userInfo.LastName return &userInfo{ ID: userInfo.ID, Email: userInfo.Email, name: name, }, nil } /* ファイル名:db/userInfo.go 役割: データベースから取得した値をファンクション層へ返します */ package XXX import ( "database/sql" _ "github.com/go-sql-driver/mysql" ) type User struct { ID int Email string FirstName string LastName string } func GetByID(userID int64 ) (*User, error ) { // 3-1. 受け取ったパラメーターを用いて データベースへの接続 db, err := sql.Open( "mysql" , "user:password@tcp(host:portNo)/dbname" ) if err != nil { return nil , err } defer db.Close() query := "SELECT * FROM users WHERE id = ?" row := db.QueryRow(query, userID) var user User // 3-2. ユーザー情報を取得 err = row.Scan(&user.ID) if err != nil { return nil , err } return &user, nil } ※コードは必要な箇所を抜粋したものになります。 階層構造は以下のようになっています。 hogehoge_server ├ presentation └ userInfo.go ├ function └ userInfo.go └ db └ userInfo.go ファイルこそ増えましたが アーキテクチャ採用前の 1 つのメソッドに複数の処理がまとまっていた時よりも以下のメリットが挙げられます。 可読性の向上 各レイヤーが特定の責務を担当することになったので変更が発生した場合でも関連する部分だけを修正できます。例えば、データベース接続情報の変更はデータアクセス層で対応し、プレゼンテーション層には影響を与えません。 各レイヤーが独立することにより他の機能でも同じくユーザー情報が必要な場合、GetByID メソッドを再利用することで、重複したデータベースアクセスのコードを避けることができます。 アーキテクチャは、コードの構造をシンプルにし、開発者がコードを理解しやすくするための強力なツールです。レイヤードアーキテクチャなど他のアーキテクチャについても理解が進めば実装を進める上でとても強い味方になってくれるはずです! テストについて 続いてテストについてです。ここでは Go 言語での単体テストについて取り上げます。 かくいう私もエンジニア勉強期間中はテストをコードで管理するようなことはしておらず、実際の画面で操作を行なってスプレッドシートにまとめたチェック項目でテストを行なっていました^^; 入社後は自分でテストコードを書く必要がありましたがここで私がつまずいたのがモックです。ちゃんと理解せず既存のテストコードをコピペしそれを修正して使っていたら痛い目に遭いました... モックを使ったテストとは モックとはテストの際に実際のオブジェクトや機能を模倣したものです。テスト対象のコードが期待どおりに動作するかどうかを確認するために使います。 メリットとしては実際のデータベースや外部 API などとの通信を避けることができます。テストのために追加したデータが実際のデータにも追加されるなど意図しない変更が起きてしまうと大変です。 モックを利用するために以下のライブラリを使います。 https://github.com/uber-go/mock ここでは ID でユーザー情報を取得する GetUserInfo を例に話します。 type User interface { GetUserInfo(userID int64 ) (*userInfo, error ) } type UserImpl struct { User db.User } // テストしたいメソッド:userIDでユーザーの基本情報を取得する func (s *UserImpl) GetUserInfo(userID int64 ) (*userInfo, error ) { userInfo, err := s.User.GetByID(userID) // DBにuserIDでユーザー情報を問い合わせる※ if err != nil { return err } return userInfo } 上記のメソッドの場合だと、 userInfoが取得できる 場合と DBが情報を取得できずにエラーを返す 2パターンの挙動をテストする必要があります。 この実装では※の DB に userID でユーザー情報を問い合わせるところをモックで差し替えます。 手順は以下になります。 モック生成( 参考 ) 1-1. mockegen をインストール go install go.uber.org/mock/mockgen@latest 1-2. 対象ファイル指定  mockgen -source=<モックを作成したいファイル> [other options] (出来上がるファイルは下記<手順 1-2 によって出来上がるファイル>で記載) モック準備 テストケース作成 テストで呼ばれるべき関数と返り値を設定 テストをかく テストコード func Test_GetUserInfo(t *testing.T) { type fields struct { UserImpl func (ctrl *gomock.Controller) User // 2. モック準備 } tests := [] struct { name string fields fields userID int64 want *userInfo wantErr bool }{ // 3. 以下テストケース作成 { name: "ユーザー情報の取得に成功" , userID: 1 , fields: fields{ UserImpl: func (ctrl *gomock.Controller) User { m := NewMockUser(ctrl) // ※₁ m.EXPECT().GetByID( int64 ( 1 )).Return(&userInfo{ /* 期待されるデータを記入 */ }, nil ) // 4. テストで呼ばれるべき関数と返り値を設定 ※₂ return m }, }, want: &userInfo{ /* 期待されるデータを記入 */ }, wantErr: false , }, { name: "ユーザー情報の取得に失敗" , userID: 2 , fields: fields{ UserImpl: func (ctrl *gomock.Controller) User { m := NewMockUser(ctrl) m.EXPECT().GetByID( int64 ( 2 )).Return( nil , errors.New( "error" )) // 4. テストで呼ばれるべき関数と返り値を設定 return m }, }, want: nil , wantErr: true , }, } for _, tt := range tests { // 5. テストをかく t.Run(tt.name, func (t *testing.T) { s := &UserImpl{ User: tt.fields.UserImpl, } gotUserInfo, err := s.GetUserInfo(tt.userID) if (err != nil ) != tt.wantErr { t.Errorf( "UserImpl.GetUserInfo() error = %v, wantErr %v" , err, tt.wantErr) return } if !reflect.DeepEqual(gotUserInfo, tt.want) { t.Errorf( "UserImpl.GetUserInfo() = %v, want %v" , gotUserInfo, tt.want) } }) } } <手順 1-2 によって出来上がるファイル> // Code generated by MockGen. DO NOT EDIT. // Source: <モックを作成したいファイル> // Package service is a generated GoMock package. package service import ( reflect "reflect" gomock "github.com/golang/mock/gomock" ) // MockUser is a mock of User interface. type MockUser struct { ctrl *gomock.Controller recorder *MockUserMockRecorder } // MockUserMockRecorder is the mock recorder for MockUser. type MockUserMockRecorder struct { mock *MockUser } // NewMockUser creates a new mock instance. ※₁ func NewMockUser(ctrl *gomock.Controller) *MockUser { mock := &MockUser{ctrl: ctrl} mock.recorder = &MockUserMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. ※₂ func (m *MockUser) EXPECT() *MockUserMockRecorder { return m.recorder } // GetUserInfo mocks base method. func (m *MockUser) GetUserInfo(userID int64 ) (*userInfo, error ) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserInfo" , userID) ret0, _ := ret[ 0 ].(*userInfo) ret1, _ := ret[ 1 ].( error ) return ret0, ret1 } // GetUserInfo indicates an expected call of GetUserInfo. func (mr *MockUserMockRecorder) GetUserInfo(userID interface {}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo" , reflect.TypeOf((*MockUser)( nil ).GetUserInfo), userID) } 上記のテストコードのように書くことで GetUserInfo メソッド のモックを利用したテストが作成できます。 また、テストでは繰り返し処理によってテストケースを網羅し実行していますがこの方法を テーブル駆動テスト といいます。 メリットとしては以下が挙げられます。 入力と出力への期待値が容易に理解できる テストケースの追加が簡単 ちなみに vscode の 拡張機能 をインストールした上で gotests をインストールしておけば、テストしたいファイルを開いて ctrl+shift+P または右クリックから Go: Generate Unit Tests for File を選択するとテストファイルを生成することができます。 終わりに 本記事では私が実務に入る前に理解していたら開発速度を上げられたかなと思うアークテクチャとテストついて紹介しました! 明日以降の Advent Calendar 投稿もぜひチェックしてみてください!
アバター
はじめに Hygenとは? Hygen を用いて解決したいこと 導入方法 install 初期化 対話型コードジェネレーターの作成 新規ジェネレーターの作成 プロンプトの作成 テンプレートファイルの作成 実際に使ってみる 感想 参考 はじめに こんにちは、 retail HUB で Software Engineer をしているほんだです。 この記事は、every Tech Blog Advent Calendar 2023 tech.every.tv 20 日目の記事です。他にもたくさんの記事が掲載されているのでぜひ確認してみてください。 今回は私が入社後初めて技術選定から参加した retail HUB 小売りアプリのフロントエンド開発で効率化またプロジェクト参加のハードルを下げるために導入した Hygen について紹介しようと思います。 この記事では Next.js App Router, Atomic Design, 複数サービスが統合されたプロダクトで生まれた課題点を Hygen の対話型コード生成機能でどのように解決したかまたどのように導入したかについて紹介していこうと思います! Hygenとは? Hygen は、開発プロセスを加速するシンプルかつ高速なコードジェネレーターです。プロジェクト固有のニーズに合わせて、またはグローバルな設定で利用することができ、繰り返し行う作業を自動化します。 generator を使って、質問文とテンプレートファイルを活用して、新しいファイルを生成します。Hygen には一般的なコマンドライン引数を用いた方法と対話的な入力方法の二種類でコードジェネレートする方法が存在し用途によって使い分けることが可能です。 Hygen を用いて解決したいこと 私が開発を進める上で直面した課題と、それを解決するために Hygen を導入することを考えた理由について説明します。Hygen を導入することで解決したい課題は主に二つあります。 一つ目は、React Server Component (RSC)の運用に関する課題です。Next.js App Router では、デフォルトのコンポーネントが RSC となっています。RSC では、ブラウザ専用の API や hooks が使用できないなど、Pages Router を用いたコンポーネント開発よりもより多くのことを意識する必要があります。私たちの会社では、App Router を用いたプロダクトが他にはなく、初めて App Router に触れる人が開発を始める際のハードルを下げること、そして必要な考慮点を減らすことが重要だと考えました。 二つ目の課題は、ディレクトリ構成に関するものです。今回のプロジェクトでは、異なるユースケースを持つ複数のサービスが同じ場所に存在する形となっており、さらに Atomic Design を採用しているため、コンポーネントの適切なディレクトリ構成を決定することが難しくなっていました。 これらの課題を解決するために、Hygen の導入を検討しました。 導入方法 install 初めにプロジェクトに Hygen を導入し npm run で実行できるように scripts に追加します。 $ npm i -D hygen { " scripts ": { " hygen ": " hygen " } } 初期化 次に Hygen の初期化を行います。 $ npm run hygen init self 初期化を行うことで generator, init という 2 種の generator と generator に help, new, with-prompt という 3 種の action,init に repo という action が作成されます。 generator は action の集合となっています。generator の action を指定することで実際に自分で作成した処理または初期化で作成された generator を作成するための処理を実行することが可能です。 初期化で作成された generator new または with-prompt action が新たな generator を作成するための選択肢となっていて new はコマンドライン引数を用いた generator, with-prompt は対話を用いた generator を作成するために利用します。 _template/ |-- generator/ | |-- help/ | | |-- index.ejs.t | |-- new/ | | |-- hello.ejs.t | |-- with-prompt/ | | |-- hello.ejs.t | | |-- prompt.ejs.t |-- init/ | |-- repo/ | | |-- new-repo.ejs.t generator を用いるには下記のように generator_name, action_name のペアを指定する必要があります。 $ npm run hygen [ generator_name ] [ action_name ] 対話型コードジェネレーターの作成 新規ジェネレーターの作成 次にコンポーネントを開発するための generator と action を作成します。今回は対話型コード生成の機能を用いるため generator の with-prompt を選択し generator を作成します。 $ npm run hygen generator with-prompt --name component プロンプトの作成 次に作成された component generator の with-prompt action の対話的な処理を行うためのファイル(prompt.js)を修正します。 _template/ |-- component/ | |-- with-prompt/ | | |-- hello.ejs.t | | |-- prompt.js <-- fix prompt.js には複数の prompt を設定することができます。prompt の形式、利用可能な type については enquirer を確認してください。 { // required type: string | function , name: string | function , message: string | function | async function , // optional skip: boolean | function | async function , initial: string | function | async function , format: function | async function , result: function | async function , validate: function | async function , } 今回は選択式の質問を 2 種、自由入力の質問を 2 種、確認式の質問を 1 種の合計 5 つの質問を準備します。 また以降のテンプレートファイルで利用する対話的な質問の回答と回答をもとに作成した変数を作成します。 module.exports = { prompt : ( { prompter } ) => { return prompter . prompt ( [ // 対象のサービスについて { type: "select" , name: "service_name" , message: "Please select the service name." , choices: [ "common" , "mart" , "users" ] , } , // Atomic Designのステージを選択 { type: "select" , name: "stage" , message: "Please select the stage of Atomic Design." , choices: [ "atoms" , "molecules" , "organisms" , "templates" ] , } , // ステージ以下の詳細なディレクトリ名を入力 { type: "input" , name: "dir" , message: "Please enter the detailed directory name." , } , // コンポーネント名を入力 { type: "input" , name: "component_name" , message: "What is the name of component?" , } , // クライアントコンポーネントとして作成するか確認する { type: "confirm" , name: "component_type" , message: "Is it client component?" , } , ] ) .then((answers) => { const { service_name, stage, component_name, dir, component_type } = answers; // 入力したサービス名、Atomic Designのステージ、詳細なディレクトリ名をもとにパスの作成を行います。 const path = ` ${service_name} / ${stage}${dir ? `/ ${dir} ` : `` } ` ; const abs_path = `src/components/ ${path} ` ; return { ...answers, path, abs_path } ; } ); } , } ; テンプレートファイルの作成 最後に対話によって得られた回答をもとに出力するために用いるテンプレートファイル(hello.ejs.t)を修正します。 出力ファイルを tsx にするために拡張し tsx を追加します。 Hygen では change-case ライブラリを使うことで文字列のケースを容易に変更することができます。今回の例では h.changeCase.pascalCase(component_name) を用いることで component_name をパスカルケースに変換しています。コンポーネント名はパスカルケースにしたいがファイル名はスネークケースにしたいといったケースにも対応できるため一度目を通しておくことを推奨します。 今回は説明のために hello.tsx.ejs.t にコメントを追加していますが実際のテンプレートファイルにコメントを記述しておくとそのコメントも出力されてしまうのでコメントを削除して利用してください。 _template/ |-- component/ | |-- hello.tsx.ejs.t <-- fix | |-- index.js --- # ファイルの出力先を設定します。 to: <%= abs_path %>/<%= h.changeCase.pascalCase(component_name) %>.tsx --- # クライアントコンポーネントが選択された場合'use client';を設定します。 <% if (component_type) { -%> 'use client'; <% } -%> import React from "react"; # 入力されたコンポーネント名をパスカルケースに変換して設定します。 const <%= h.changeCase.pascalCase(component_name) %> = () => { return ( ); } export { <%= h.changeCase.pascalCase(component_name) %> } generator と action の作成はここまでで終了です。Hygen の説明にある通りシンプルかつ高速に対話型コードジェネレーターが作成できたのではないでしょうか。次の章から実際に作成したものの使い方を説明していきます。 実際に使ってみる 作成した generator と action を実際に使ってみましょう! 下記のコマンドを入力することで component generator の with-prompt action が実行されます。 $ npm run hygen component with-prompt 一問目は対象サービスを選択する質問が表示されます。 > test@0.1.0 hygen > hygen component with-prompt ? Please select the service name. … ❯ common mart users 二問目は Atomic Design のステージを選択する質問が出力されます。 ✔ Please select the service name. · common ? Please select the stage of Atomic Design. … ❯ atoms molecules organisms templates 三問目は詳細なディレクトリ名を入力する質問が出力されます。 ✔ Please select the stage of Atomic Design. · atoms ? Please enter the detailed directory name. › test 四問目はコンポーネント名を入力する質問が出力されます。 ✔ Please enter the detailed directory name. · test ? What is the name of component? › test-test 最後にクライアントコンポーネントを用いるかの選択をする質問が出力されます。デフォルトは false になっています。 ✔ What is the name of component? · test ? Is it client component? (y/N) › true 以上の対話に答えていくことで対象のコンポーネントを適切なサービスの適切なステージに一致するディレクトリ内に生成することができます。 Loaded templates: _templates added: src/components/common/atoms/test/testTest.tsx 作成されたファイルは以下のようになります。 クライアントコンポーネントに関する回答通りファイル先頭に 'use client'; が入力されコンポーネント名で入力した test-test がパスカルケースの testTest というコンポーネント名になっていることがわかります。 'use client' ; import React from "react" ; const TestTest = () => { return ( ); } export { testTest } 感想 Next.js App Router を使ったプロジェクトでは、Page Router に慣れた人ほど 'use client'; を忘れたり、サーバーコンポーネントで web-only API を使用して意図しない動作につながることがあります。実際に後から App Router を導入し始めたチームメンバーではまっている人がいたので Hygen を活用することで開発速度を向上させることができると思います。 また今後 storybook を用いた VRT や単体テストを行う時にそれらのファイルを自動生成することでテスト忘れを無くすことにも寄与できるのではないかと感じています。 今回のプロジェクトでは、Hygen の対話型コード生成機能を使用しました。自身のプロジェクトへの理解が浅い段階でも、提供される選択肢によるサポートは対話型の長所であると感じました。この対話形式を通じて、提供される選択肢を使いながらコード生成を行うことで、プロジェクトの理解を深める手助けになると考えています。また今回は実装例として提供していませんが慣れてきたらコマンドライン引数として対話の回答を渡す action を生成することがより効果的だと感じました。 簡単なハンズオン形式のブログでしたが読んでいただき、ありがとうございます。25 日まで毎日 tech blog が更新されるので他の記事もぜひチェックしてみてください! 参考 Hygen enquirer change-case
アバター
この記事は every Tech Blog Advent Calendar 2023 の 20 日目です。 こんにちは。 開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。 今回は、最近私が取り組んでいるDELISH KITCHENのレコメンドの立ち上げとこれからに向けてのお話をしようと思います。 はじめに DELISH KITCHENでは、プロの管理栄養士が作成したレシピコンテンツを提供しています。 DELISH KITCHENをローンチした初期はコンテンツ数が多くなかったこともあり、サーバー側の簡易な集計ロジックをもとにユーザーにレシピを提供していました。 しかし、ローンチから8年経過した現在、レシピ数は5万レシピを超え、現状のロジックに任せるだけではユーザー自らが好みのレシピを見つけることも難しくなってきました。 そのような背景から、ユーザーの嗜好に寄り添ったアプリのパーソナライズが課題となってきています。 データ&AIチームとして、パーソナライズに向けたロジック(ルールベース、機械学習等)の開発を推進すべく動き始めています。 レコメンドはじめました アプリのパーソナライズ手段の一つとして、レシピのレコメンド開発に着手しています。 既存のレコメンドの仕組みがなかったわけではないのですが、数年間ロジックの更新がされておらず、また過去にどのような経緯で企画及び開発されていたのか不明瞭な状態となっていました。 レコメンド開発における社内のノウハウも多くはなく、ほとんど0→1フェーズの立ち上げに近い状態で始まりました。 既存のアプリケーションにレコメンドのような新たな仕組みを導入する際、まずは理想の状態を整理することが重要だと考えています。 特に整理が必要だったのは、ロジックをDELISH KITCHENのサーバーから分離するという点です。 理想としては、データ&AIチームが継続的にロジックの改善に集中でき、サーバーエンジニアがロジック改善に伴う修正を対応せずとも運用できる状態を目指しています。 それに加えて、A/Bテストの設定や機械学習に伴う学習と推論の実装などもやや密結合となっており、コードの管理や開発体験面でも分離の必要性を感じています。 そこで、レコメンドの立ち上げにおいて、以下のようなことを意識して取り組みました。 PDCAの結果をドキュメントとして残し、社内のノウハウをためる レコメンドの立ち上げから継続的に改善される状態を目指している現状において、仕組みづくりが重要だと考えています。 その手段としてドキュメント化は必須だと考えており、社内のノウハウをためるという意味でも大事なことだと思います。 立ち上げ始めの今から、どのような仕組みを入れるかの企画を立て、レコメンドのためのアルゴリズムを実装し、A/Bテストで評価、分析をするPDCAを回しています。 この結果をドキュメントとして誰でも見れる形でまとめ、過去どんな改善に取り組んだかのノウハウを残し、いつでも振り返りができる状態にしています。 開発スピードを優先し、事例を作る 企画からレコメンド開発、A/Bテストの評価と事後分析を全て一人のデータサイエンティストが担うにはAgility観点で懸念があります。 レコメンド開発は将来的に企画職、そして会社を巻き込んで取り組んでいく必要があると考えており、そのためにはまず事例作りから始めようと考えました。 まず、データ&AIチームでDELISH KITCHEN内のどの部分にパーソナライズを導入できそうか議論し、工数や導入のしやすさなどを見積もりました。 そこから、既存の仕組みを再利用でき、かつルールベースの簡単なロジックから始められる枠でレコメンド開発を進めました。 レコメンドの開発では、ph1はルールベースによるレコメンド、ph2は行列分解など機械学習によるレコメンドといったように、段階的に改善と効果検証を進め、仮説ベースに試行錯誤を繰り返しています。 ポジティブな結果を出す 前節とも重複する部分はありますが、パーソナライズのような仕組みを普及させていくためにも、初速で結果を出すことは大事だと考えています。 以下の画像はレコメンドのA/Bテストの結果です。 旧ロジック(control)と新ロジック(test)で評価指標を比較し、新ロジックでポジティブな結果を出すことができました。 もちろん、すべてのA/Bテストでポジティブな結果を出せたわけではありませんが、レコメンド開発を推進する走り出しとしては良い結果になったかなと思います。 これからに向けて レシピレコメンドはあくまで一つの事例であり、今後もパーソナライズの仕組みをDELISH KITCHENに導入していく予定です。 そこで、「理想の状態を整理する」節でも述べた部分と関連して、導入を進めていく上で以下のような整理をしています。 ロジック開発に関して As-Is ロジック開発は弊社のデータ基盤であるDatabricksを用いて実装できます。 DatabricksにはMLに必要なモデル、特徴量、実験の管理などが MLflow や Feature Store の機能を用いて実現可能です。 対して、ロジックを開発する上でのコードの管理やオフライン評価の検証などが、各データサイエンティスト依存となっており、体系的な整理がされていません。 To-Be 今後は、我々が実現したいML基盤の実現に向けたコーディングルールを決め、共通のオフライン評価基盤を作るなどの改善を進めていきます。 効果検証に関して As-Is 弊社にはA/Bテスト基盤があり、アプリ内に新しく導入したレコメンドなどの仕組みの効果検証が可能になっています。 旧ロジック(control)と新ロジック(test)として評価指標を比較し、新ロジックの介入効果を計測することで、新ロジックの性能を評価できます。 対して、ロジックの実装とA/Bテスト基盤としての実装が一部癒着しており、人力の設定が必要となっています。 そのため、効果検証自体は可能ですが、ロジック開発に工数がかかったり、実装者のヒューマンエラーが発生するリスクを孕んでいます。 To-Be 今後は、A/Bテストの設定の自動化やロジックの実装とA/Bテストの機能の分離することで、効果検証の効率化を図っていきます。 サービングに関して As-Is Databricks内でバッチ推論し、推論結果をデータストアであるRedis(ElastiCache for Redis)にデプロイすることでサーバー側と連携できます。 サーバー側はクライアント側のリクエストに対して、Redisを読み込んでレシピを返すことができます。 対して、サービング方式がRedisを用いたバッチ推論であるため、ロジックの変更でデータ形式が変化した場合には、サーバー側の改修が必要となってしまいます。 また、リアルタイム推論を実現したいビジネス要件が出た場合に対応できません。 To-Be 今後は、サーバー側との連携をRedisを介してのデータの受け渡しだけではなく、リクエストに対して推論結果を返すようなML APIを加えていきたいと考えています。 これにより、新たなロジックの追加や改修もAPIレベルで行えるため、サーバーとロジックを繋ぐ柔軟性と開発効率の向上を期待しています。 また、リアルタイム推論の要件にも対応可能となり、より多くのビジネス要件に対応できるようになると考えています。 その開発を進めるMLエンジニアのポジションを、社内のデータサイエンティストとデータエンジニア共同で開発予定です。 まとめ 本ブログでは、DELISH KITCHENにおけるのレシピレコメンドの立ち上げとこれからに向けての取り組みを紹介しました。 ご紹介したとおり、理想を実現するための課題は多くありますが、データ&AIチームとしてパーソナライズの仕組みをDELISH KITCHENに導入していくことで、ユーザーの嗜好に寄り添ったアプリを目指していきたいと考えています。 社内でもAI/MLプロダクトや生成AIの活用の兆しが出てきているなと感じており、既存のアプリのさらなる成長を目指し、挑戦的な取り組みへのスタートが切りだせてるのではないかと思います。 データ&AIチームでは一緒に働く仲間を募集しています! 動画メディアでAI/MLプロダクトの推進にご興味のある方はぜひ、以下のURLからご応募ください。 https://corp.every.tv/recruits#position-list
アバター
はじめに DELISH KITCHEN 開発部で小売向き合いの開発をしている池です。 この記事は every Tech Blog Advent Calendar 2023 の 19 日目です。 本記事では、弊社が提供しているネットスーパーアプリにおける、 GraphQL Mesh を利用した GraphQL Gateway Server について、紹介したいと思います。 構成について ネットスーパーアプリでは複数サーバーからデータを連携して取得することを想定し、Gateway 構成を採用しています。 例えば、ネットスーパーで保持している商品情報をもとに、関連する情報を他サーバーから取得し、Gateway で集約してアプリに返却するイメージです。 バックエンド API として、GraphQL 形式のネットスーパーAPIと、接続予定の REST 形式の他サーバーAPIがあり、Flutter ネットスーパーアプリはそれらを統合する Gateway Server を経由してデータを取得しています。 この構成において、Gateway Server として GraphQL Mesh というライブラリを利用しており、ここからは GraphQL Mesh の選定理由から、普段の開発時における活用事例について紹介します。 GraphQL Mesh 選定理由 上記構成の特徴として、バックエンド API の形式が異なるということがあげられます。ネットスーパー API は 株式会社ベクトルワン様からの事業譲渡により引き継いだシステムで、GraphQL 形式の API として作成されていました。それに対し、他サーバー API は REST 形式となっています。そのため、アプリはそれら異なる形式の API から組み合わせてデータを取得する必要があります。 そのような特徴を踏まえて、技術選定する上での重要な要件をまとめると次のとおりです。 様々な形式( REST , GraphQL など)のバックエンド API をサポートしていて、それらをスキーマ統合できる スキーマ統合を Gateway 側で完結できる(バックエンド側の変更不要) 2 点目は開発しやすさを重視した要件として設定しています。 その他にも、ドキュメントや事例が豊富にあるか、ライブラリのメンテナンスが頻繁に行われているか、Github のスター数なども考慮しつつ選定を行いました。 比較検討したライブラリは次の 4 つです。 Apollo Federation GraphQL Mesh branble nautilus この中で唯一すべての要件にマッチしたのが GraphQL Mesh だったので、採用に至りました。 Apollo Federation は最も活用事例が豊富でドキュメントも整っていましたが、当時 Apollo Federation に準拠した GraphQL のみがバックエンド API として接続可能であったことと、スキーマ統合する場合にバックエンド側で設定が必要であったため、要件に合致せず見送りました。 branble と nautilus はどちらも Go 製の GraphQL federation gateway ということもあり、比較検討対象のライブラリとしましたが、これらも GraphQL のみ対応でした。さらに、実例も少なかったため、採用にはなりませんでした。 GraphQL Mesh 活用事例 ここからは GraphQL Mesh の導入手順と、普段 GraphQL Mesh をどのように活用して開発を行っているか、紹介します。 GraphQL Mesh 導入 Envelop ライブラリを用いたプラグイン構築 GraphQL Mesh 導入 まずは、導入手順です。スキーマ統合を考慮せずにバックエンドに接続するのみであれば簡単に導入することができます。 ライブラリのインストール GraphQL Mesh のライブラリをインストールします。 npm i @graphql-mesh/cli @graphql-mesh/graphql graphql 設定ファイルに接続情報を記載 バックエンドサーバーの接続情報を yaml 形式で記述します。この例は GraphQL 形式のバックエンドに接続する記述になります。 実際には、API KEY などの機密情報は Github Actions で AWS Secret Manager から取得して環境変数に設定するようにしています。 sources: - name: Fresh API handler: graphql: endpoint: ${FRESH_API_ENDPOINT} introspection: ./fresh-schema.graphql schemaHeaders: Content-Type: application/graphql x-api-key: ${SERVER_API_KEY} operationHeaders: Content-Type: application/graphql x-api-key: ${SERVER_API_KEY} method: POST 起動 次のコマンドで GraphQL Mesh サーバーを起動できます。これらコマンドを npm scripts に設定して実行しています。 mesh build mesh start サーバーの起動に成功すると、サーバーのドメインにブラウザからアクセスすることで playground を利用できます。 Envelop を用いたプラグイン構築 Envelop とは GraphQL 実行時のレイヤーをカスタマイズするためのライブラリです。Envelop を用いることで GraphQL サーバーを強化するプラグインを簡単に構築、構成することができます。 具体的には、GraphQL の実行フロー parse、validate、execute、subscribe などの前後のフェーズにフックして処理を実行することが可能になります。 弊社 Gateway Server では Envelop を利用して以下のような独自のプラグインを作成して、ログの取得や、ハンドリング等を行っています。 アクセスログ エラーログ エラーハンドラー Sentry へのログ送出 アプリ強制アップデート判定用のハンドラー サーバーメンテナンス用のハンドラー Envelop 利用する手順と、エラーログを取得する一例を説明します。 導入 Envelop ライブラリをインストールします。 npm i @envelop/core graphql 処理の記述 続いて、エラーログ取得の処理です。 GraphQL の execute フェーズの前後にフックしてエラーログをターミナルに出力する処理を記述します。 export function useErrorLogger () : Plugin < HttpRequestContext > { return { onExecute ( { args: { contextValue: { req } , operationName , } , } ) { const startNs = process .hrtime.bigint (); return { onExecuteDone: ( { result } ) => { // NOTE: AsyncIterable in case of stream response. if ( isAsyncIterable ( result )) return; if ( result.errors === undefined ) return; for ( const e of result.errors ) { const errorlog = { log_type: "error" , message: e.message , locations: e.locations ?.map (( l ) => `{ line: ${ l.line } , column: ${ l.column } }` ) .join ( "" ) ?? "" , path: e.path?.join ( "," ) ?? "" , } ; logger ( errorlog ); } } , } ; } , } ; } Envelop に追加 最後、作成した処理を Envelop に追加し、yaml に読み込むための設定を記述します。 type EnvelopPlugins = Parameters <typeof envelop > [ 0 ][ "plugins" ] ; export default function () : EnvelopPlugins { return [ useErrorLogger () ] ; } additionalEnvelopPlugins: ./plugins 以上により、Gateway Server に API リクエストを投げると、GraphQL 実行時の前後でエラーが作成した処理が動作するようになります。 エラーログ出力例 { " log_type " : " error " , " timestamp " : 1647921567193 , " message " : " Cannot return null for non-nullable field XXXXXXX.xxxxxxxx. " , " locations " : " { line: 7, column: 7 } " , " path " : " xxxxxxxxxxxxxxxx,xx,xxxxxxxxxxx " } 所感 良かった点 / 使いづらい点 まだ当初想定していた異なる形式の複数バックエンド API をスキーマ統合することについて、まだテスト的な動作検証しかしていないため、本来の所感はそれ以降かと思っていますが、現状運用してきた中でも以下の点を良かったと感じています。 カスタマイズ性の高さ 機能の豊富さ コミュニティが活発 紹介した Envelop だけでなく、スキーマ開発用モック追加、カスタムリゾルバを使用したスキーマ統合、など多くの機能を有しており、現状問題なく開発できています。 コミュニティが活発なので、困った場合は Github の Issue などを検索すると、大体同じ内容の課題を調べることができました。 反対に以下については使いづらさを感じました。 カスタマイズの複雑さ デバッグの難しさ カスタマイズ性の高さや機能の豊富さの反面、それぞれが GraphQL Mesh 独自の記載方法になるため、理解して適用するまでには一定の時間を要します。 また、GraphQL Mesh に限ったことではないですが、GraphQL Mesh 内で特定の GraphQL 実行レイヤーにおける特定のエラーを出したい場合に再現が難しく、デバッグに困ることがありました。 今後の課題 今後の課題はたくさんありますが、一例です。 スキーマファイルのバックエンド/フロント間における管理 キャッシュ戦略 現状、アプリ / Gateway / バックエンド 間でスキーマファイル自体を受け渡して追従しているため、二重三重管理となっています。本来は一つの共通のスキーマファイルをそれぞれのレイヤーで扱うことが理想だと思います。 また、現状 GraphQL Mesh のキャッシュ機能を利用しておらず、ネットスーパーのバックエンド API のパフォーマンスも低いため、適切なキャッシュ戦略を検討して適用することも課題です。バックエンド API にも関連しますが、Persisted Query なども検討できれば、よりパフォーマンス改善に繋がると考えています。 おわりに GraphQL Mesh を利用した Gateway Server について選定理由と活用事例、所感を記載させていただきました。 GraphQL Mesh は紹介した機能の他にも多くの機能があり、ドキュメントも豊富であるため、様々なカスタマイズを簡単に実現することが出来ます。 現状はネットスーパーのバックエンドにのみ接続していますが、今後の取り組みとして他のサーバーと接続して、ネットスーパーの商品情報と関連した情報と連携させていく予定です。 その際にスキーマ統合の実装が加わるので、また次回その内容も紹介できればと思います。 以上、どなたかの参考になれば幸いです。
アバター
お久しぶりです ,トモニテ開発部で Software Engineer(SE) をしている鈴木です. every Tech Blog Advent Calendar 2023 の18日目を担当する事になりましたので,鈴木が関わっているトモニテの新規事業についてお話させていただきます. はじめに トモニテ相談室のロゴ トモニテは2023年11月30日に家族・家庭や恋愛に対する悩みをプロのカウンセラーと相談出来る新サービス トモニテ相談室 をローンチしました! 有り難いことに,新卒2年目にも関わらずトモニテ相談室のローンチメンバーの1人に選任していただき,webサイトとそれに必要なAPI全般,及びサービスの基盤である相談の仕組みを任せていただきました. 開発当初,通話形式での相談を提供することだけが決まっており,通話に関する技術に対して弊社に知見があまり無かったことやコスト面,そしてリリースまでの工数感を踏まえ,相談基盤にはTwilioを利用する運びになりました. トモニテ相談室にはどのような開発要件があり,その要件を満たすためにTwilioのどの機能をどのように利用したのかを紹介していこうと思います. Twilioとは? Twilioは,人間が機械を通して行うコミュニーケーションに対してアクセス出来る様々なAPI群を提供しているサービスです. 多種多様なコミュニーケーションに対して対応しており,電話やSMSはもちろんのこと,Eメールやビデオ配信,そしてそれらを用いた認証等にも対応しております. Twilioを利用することで電話によるお問い合わせ対応の一部を自動化出来たり,電話/SMS認証やメール認証の仕組みを一任することが出来ます. 公式ドキュメント を眺めるとTwilioで実現出来る事とその豊富さが実感出来ると思いますので,是非一読いただきたいです. 満たすべき要件 トモニテ相談室の相談基盤が満たすべき主要な要件として以下が挙げられます. カウンセラー様固有の連絡先情報無しで相談が可能 通話の状態遷移をトリガーにアクションが出来る 通話内容を録音可能 カウンセラー様固有の連絡先情報無しで相談が可能 カウンセラー様固有の連絡先情報(電話番号など)を用いて相談可能にしてしまうと,トモニテ相談室が関与しないところでカウンセラー様にご迷惑をおかけしてしまう可能性があり,カウンセラー様に不利益を与えてしまいます. このような可能性を排除するため,カウンセラー様固有の連絡先情報無しでカウンセラー様と相談可能にする必要がありました. 通話の状態遷移をトリガーにアクションが出来る トモニテ相談室ではユーザー様が相談したいカウンセラー様を選ぶようになっているため,カウンセラー様が現在どのような状態なのかをユーザー様にお知らせすることが重要になります. 具体的には,カウンセラー様は退席中なのか,相談受付中なのか,それとも他のユーザー様と現在通話中なのかをお知らせすることで,ユーザー様が相談したいカウンセラー様を選ぶ際の判断の一助になると考えています. また,トモニテ相談室は通話した時間でユーザー様に課金するため,ユーザー様とカウンセラー様の間で通話が正常に開始されたのかどうか,開始された場合にはいつ開始されたのかといった情報が必要になります. これらを実現するためには通話の状態遷移をトリガーにアクションが出来る事が必須でした. 通話内容を録音可能 トモニテ相談室では,「相談」が持つべきプライベートな性質を考慮し,相談中はユーザー様とカウンセラー様の2人だけが接続された状態になり,トモニテ相談室は関与しないようになっております. このようにすることでユーザー様は安心して悩みを打ち明けることが出来ると考えております.一方で,プライベート故にカウンセラー様に対してより良いカウンセリングをするためのフィードバックをする事や,相談中に何らかのトラブルが発生した際の対応が難しくなってしまいます. これらの課題を解決するため,必要に応じて相談後に関与することが出来る録音機能が必要でした. 要件を満たすために利用したTwilioの機能 上記要件は,Twilioの Voice Webhooks を利用しTwilioに指示を出すことで大部分を満たす事が出来ます(一部 録音データを外部のストレージに保存するための設定 などが別途必要となります). Voice Webhooksとは Voice Webhooksとは,特定の電話番号(今回はトモニテ相談室の電話番号)に対して電話があったことをトリガーに指定されたAPIにリクエストを送る機能のことです. Voice Webhooksを利用する開発者は, TwiML というXMLベースのマークアップ方式で記述されたレスポンスを返すAPIを用意し,事前にエンドポイントを設定しておく必要があります. TwiML TwiMLはXMLに対して独自のタグを追加したものです. 独自タグはVerb(動詞)とNoun(名詞)から構成されており,これらを組み合わせることでTwilioに対して多種多様な処理を指示します. 例えば,以下のTwiMLは動詞 Say と動詞 Dial 及びそれに付随する名詞 Number から構成されており,Twilioに対して次のことを指示しています. 日本語で「お電話ありがとうございます」と読み上げる 受けた電話を+810123456789の電話番号に繋ぎ直す このTwiMLを利用するだけでカウンセラー様の連絡先情報を公開すること無くユーザー様とカウンセラー様を接続することが出来ます. また, Dial 動詞は record プロパティ を指定出来,このプロパティを指定することでTwilioに録音の指示が出来ます(保存先の指定には別途設定が必要になります). 更に, Number 名詞は statusCallback プロパティ を持っており,このプロパティを利用することで通話の状態遷移が発生した際に指定のエンドポイントにリクエストを送ることが出来ます. つまり,以下のシンプルなTwiML(を一部拡張したもの)を動的に生成し返すAPIを用意するだけで,トモニテ相談室における要件の全てを満たすことが出来るのです! <? xml version = "1.0" encoding = "UTF-8" ?> <Response> <Say language = "ja-JP" > お電話ありがとうございます </Say> <Dial> <Number> +810123456789 </Number> </Dial> </Response> なお,トモニテ相談室ではカウンセラー様が相談中にユーザー様の事前アンケートの内容を確認したり,メモを取る際などの利便性を考慮し,実際には Number 名詞ではなく Client 名詞 によるブラウザフォンを採用しています.両者の間には電話かブラウザフォンか以外に大きな違いはないため,ここでは Number を用いて説明いたしました. トモニテ相談室の電話の仕組み カウンセラー様の行動 カウンセラー様が相談可能な時にはカウンセラー様専用のwebにログインしていただき,待機開始処理をしていただいています. 待機が開始された際にAPIサーバーがトークンを発行し,そのトークンを利用してTwilioとWebSocketを利用した双方向通信を開始し,ブラウザフォンを有効にしています. カウンセラー様の行動 ユーザー様の行動 ユーザー様には相談受付中のカウンセラー様から相談したいカウンセラー様を予約していただいています. 予約が成功したらAPIサーバーからトモニテ相談室の電話番号が返され,カウンセラー様の連絡先情報は返されません. ユーザー様の行動 電話の流れ ユーザー様からの発信をトリガーにTwilioからAPIサーバーへリクエストが届き,カウンセラー様のトークンと紐付けられた情報をもとにTwiMLを生成してレスポンスを返します. TwilioはTwiMLの内容を元にリダイレクト先のブラウザフォンを特定し,リダイレクトします. 通話の状態が遷移する度にTwilioはTwiMLで指定されたエンドポイント(今回はトモニテ相談室のAPIサーバー)へリクエストを送ります. また,通話が終了した段階で弊社のS3バケットに確定した録音データが保存されます. 電話の流れ 全体像 上記を踏まえ,トモニテ相談室における電話の仕組みの全体像は以下の図のようになります. Twilioを利用することで,シンプルな構成にも関わらず満たしたい要件全てを満たすことが出来ています. 全体像 まとめ トモニテの新規事業サービス「トモニテ相談室」における電話の仕組みを紹介させていただきました. トモニテ相談室の電話の仕組みの多くはTwilioというサービスのVoice Webhooksを利用することで実現されており,Voice WebhookではTwiMLというマークアップ言語を用いてTwilioに対して様々な指示をすることが出来ます. TwiMLで実現出来ることは非常に多岐に渡るため,TwiMLをカスタマイズすることで満たしたい要件のほとんどを満たすことが出来ました. この記事が類似の要件に対してどのようにアプローチすべきか検討されている開発者の方々のお役に立てたら大変嬉しいです. ここまでお読みいただきありがとうございました!
アバター
はじめに この記事は every Tech Blog Advent Calendar 2023 の17日目の記事です。 先日 DELISH KITCHEN アプリにライブ配信機能が追加されました。 開発をはじめてから2ヶ月弱を経て、ついにリリースです。 今回はライブ配信画面の UI でソフトウェアキーボードの動きに追従する View の実装を簡単なサンプルを用いて紹介したいと思います。 ゴール コメントにフォーカスを当てるとソフトウェアキーボードが表示され、コメントはキーボードの上部に追従する。この動きを作っていきます。 仕様 構成要素は次の3つです。 動画プレイヤーの View コメントの EditText いいねの ImageView 動作仕様は次の通りです。 動画プレイヤーは 16:9 の表示 いいねボタンは固定 コメントはソフトウェアキーボードに追従し、ソフトウェアキーボードが表示されると幅いっぱいに広がる また、端末によって画面サイズが異なるため、コメントといいねボタンの位置は固定表示ではなく調整する必要があります。そこで画面サイズに応じた配置は次のように決めます。 画面の高さに余裕がある 動画プレイヤーの下に配置 動画プレイヤー下に余白があるが、コメントやいいねボタンを配置できるほどの高さがない 動画プレイヤーの内側で下に配置 動画プレイヤー下に一切余白がない ルートコンテナの下に配置 レイアウトの定義 AndroidManifest.xml はじめに Activity に android:windowSoftInputMode を指定します。 adjustResize はソフトウェアキーボードが表示されると Activity のメインウインドウがリサイズされる指定です。 <activity android : name = ".MainActivity" android : exported = "true" android : windowSoftInputMode = "adjustResize" activity_main.xml <? xml version = "1.0" encoding = "utf-8" ?> <androidx . constraintlayout . widget . ConstraintLayout ... > <View android : id = "@+id/player" android : layout_width = "0dp" android : layout_height = "0dp" app : layout_constraintDimensionRatio = "w,16:9" app : layout_constraintEnd_toEndOf = "parent" app : layout_constraintStart_toStartOf = "parent" app : layout_constraintTop_toTopOf = "parent" /> <!-- button_size: 48dp --> <androidx . appcompat . widget . AppCompatImageView android : id = "@+id/reaction" android : layout_width = "@dimen/button_size" android : layout_height = "@dimen/button_size" android : layout_marginVertical = "8dp" android : layout_marginEnd = "8dp" android : src = "@drawable/baseline_favorite_24" app : layout_constraintBottom_toBottomOf = "@id/player" app : layout_constraintEnd_toEndOf = "parent" /> <!-- いいねボタンと高さを揃える --> <androidx . appcompat . widget . AppCompatEditText android : id = "@+id/comment" android : layout_width = "0dp" android : layout_height = "@dimen/button_size" android : layout_margin = "8dp" android : paddingHorizontal = "8dp" app : layout_constraintBottom_toBottomOf = "@id/player" app : layout_constraintEnd_toStartOf = "@id/reaction" app : layout_constraintStart_toStartOf = "parent" /> </androidx . constraintlayout . widget . ConstraintLayout> レイアウトは動画プレイヤーとコメント、いいねボタンをシンプルに並べています。 コメントといいねボタンは動画プレイヤーの内側に表示されるよう制約をつけています。 ちなみに、動画プレイヤーの下には制約をつけていません。 これは動画を画面幅いっぱいに表示させるためです。 そのため画面サイズによっては動画プレイヤーの下が見切れることになります。 以下、レイアウトのプレビューです。 画面サイズに合わせて制約をつける レイアウトに定義した各 View の高さを取得して制約をどこにつけるか計算します。 private fun setupController() { val root = binding.root.height val content = binding.player.height val button = binding.reaction.height + margin * 2 when { // 動画プレイヤーが画面に収まらない root < content -> ConstraintTo.ON_ROOT // 動画プレイヤー下に予約が足りずコントローラーが収まらない root < (content + button) -> ConstraintTo.ON_SCREEN // 動画プレイヤーとボタンを含めた高さが画面に収まる root >= (content + button) -> ConstraintTo.UNDER_SCREEN else -> null }?.let { constraintTo -> // TODO : 各 View に制約をつける } ConstraintTo... は定義した enum です。 enum class ConstraintTo { ON_ROOT, ON_SCREEN, UNDER_SCREEN, } この処理は onWindowFocusChanged で実行します。 onWindowFocusChanged はフォーカスがメインウインドウに当たる・外れるタイミングで実行されるのでパラメータの hasFocus でフォーカスが当たったタイミングを判別します。 override fun onWindowFocusChanged(hasFocus: Boolean ) { if (hasFocus) setupController() 計算した ConstraintTo でコメントといいねボタンの制約を変更します。各 View にはレイアウトで既に制約がついているので、はじめに制約をクリアしてから ConstraintTo に応じた制約を追加します。 この処理は、コメントといいねボタンの両方で使えるよう拡張関数として定義しています。 fun View.constraintTo( root: ConstraintLayout, player: View, margin: Int , constraintTo: ConstraintTo ) { ConstraintSet().apply { clone(root) clear(id, ConstraintSet.BOTTOM) when (constraintTo) { ConstraintTo.ON_SCREEN -> { connect( id, ConstraintSet.BOTTOM, player.id, ConstraintSet.BOTTOM, margin ) } ConstraintTo.UNDER_SCREEN -> { connect( id, ConstraintSet.TOP, player.id, ConstraintSet.BOTTOM, margin ) } ConstraintTo.ON_ROOT -> { connect( id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, margin ) } } applyTo(root) } } 計算した ConstraintTo を使い、コメントといいねボタンに制約をつけます。 これでコメントといいねボタンの配置はできました。 when { // 動画プレイヤーが画面に収まらない root < content -> ConstraintTo.ON_ROOT // 動画プレイヤー下に予約が足りずコントローラーが収まらない root < (content + button) -> ConstraintTo.ON_SCREEN // 動画プレイヤーとボタンを含めた高さが画面に収まる root >= (content + button) -> ConstraintTo.UNDER_SCREEN else -> null }?.let { constraintTo -> binding.reaction.constraintTo( root = binding.root, player = binding.player, margin = margin, constraintTo = constraintTo ) binding.comment.constraintTo( root = binding.root, player = binding.player, margin = margin, constraintTo = constraintTo ) 実際に異なる画面サイズのエミュレータで動かしてみると、次のようになります。 ルートView プレイヤー内 プレイヤー下 キーボードの動きを検知する キーボードの動きは ViewTreeObserver.OnGlobalLayoutListener を用いて検知しました。 (分かりやすくするために一部コードを省いています) private var screenHeight = 0 fun start( activity: Activity, onShow: () -> Unit , onHide: () -> Unit , ) { val contentView = activity.findViewById<View>(android.R.id.content) contentView.viewTreeObserver.addOnGlobalLayoutListener( object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { if (contentView.height == 0 ) { return } if (screenHeight == 0 ) { screenHeight = contentView.height } when { screenHeight == contentView.height -> { onHide() } else -> { onShow() } } } }) } まず、関数を実行したタイミングでメインウインドウの高さを取得します。 onGlobalLayout() が呼ばれる毎にメインウインドウの高さをチェックして、はじめの高さと同じならキーボードは表示していないものとみなします。 この処理はリスナーが登録されている間は動き続けるので、リスナーを解除する処理が別途必要です。 また、 onShow/onHide も何度も呼ばれるのでフラグなどで回避する必要があります。 キーボードの動きに合わせて制約をつける キーボードの動きが検知できるようになったので、動きに合わせて適切な箇所にコメントの制約をつけます。 キーボードに制約をつける キーボードと表現していますが、実際にはレイアウトのルートに制約をつけています。 fun View.constraintToKeyboard( root: ConstraintLayout, margin: Int , constraintTo: ConstraintTo ) { ConstraintSet().apply { clone(root) when (constraintTo) { ConstraintTo.ON_ROOT -> { // nothing } ConstraintTo.ON_SCREEN -> { // nothing } ConstraintTo.UNDER_SCREEN -> { clear( this @constraintToKeyboard.id, ConstraintSet.TOP) } } connect( this @constraintToKeyboard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, margin ) connect( this @constraintToKeyboard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, margin ) applyTo(root) } } コメントの下部と右側の制約をコントロールします。 コメントの上部に制約がついていると意図した挙動にならないため、コメントがプレイヤー下の場合のみ上部の制約をクリアします。また、コメントの右側の制約はいいねボタンにつけられているので、レイアウトのルートに制約をつけかえます。 スクリーンに制約をつける fun View.constraintToScreen( root: ConstraintLayout, screen: View, button: View, margin: Int , constraintTo: ConstraintTo ) { ConstraintSet().apply { clone(root) when (constraintTo) { ConstraintTo.ON_ROOT -> { connect( this @constraintToScreen.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, margin ) } ConstraintTo.ON_SCREEN -> { connect( this @constraintToScreen.id, ConstraintSet.BOTTOM, screen.id, ConstraintSet.BOTTOM, margin ) } ConstraintTo.UNDER_SCREEN -> { clear( this @constraintToScreen.id, ConstraintSet.BOTTOM) connect( this @constraintToScreen.id, ConstraintSet.TOP, screen.id, ConstraintSet.BOTTOM, margin ) } } connect( this @constraintToScreen.id, ConstraintSet.END, button.id, ConstraintSet.START, margin ) applyTo(root) } } 前のコードと同様にコメントの下部と右側の制約をコントロールします。 コメントが動画プレイヤー下の場合は、下部の制約をクリアし上部に制約をつけます。コメントの右側の制約もルートからいいねボタンの左側につけかえます。 これで次のような挙動を実装することができました。 通常 キーボード表示 サジェスト表示 最後に 特定の View をキーボードや他の View に制約をつけかえる方法を紹介しました。 実際の挙動は、ぜひ DELISH KITCHEN のライブ配信で確認してもらえたらと思います。 この記事がどなたかのお役に立てれば幸いです。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2023 の 16 日目です。 12 月もいよいよ後半となりました。 今回は Android で簡単にバーコードリーダーを実装する方法を紹介したいと思います。 一昔前ですと、バーコードをスキャンするライブラリといえばほぼ ZXing 一強状態でしたが、当初は少しでも暗所に行くと読み取れなくなる、完全に静止しないと読み取れなくなるなどまだまだ精度が低く、システム面ではライトを付ける、オペレーション面ではなるべく静止するなど、創意工夫が必要となっていました。 私自身、先方から何度ももっと読み取り精度を上げてくれと要望を頂いて、何度も苦労しながら改善に改善を加えた苦い記憶があります。 ただ、Google Play services も機能を充実させていき、バージョン 7.8 では Google Mobile Vision という簡単にバーコードをスキャンする機能が提供されるなど、ここ近年ではサードパーティ製のライブラリをわざわざ組み込まなくても簡単にバーコードをスキャンする機能を実装出来る環境が整ってきました。 そして何と今年、UI すら実装が不要という Google Code Scanner API というものまで公開されたため、今回そちらについて情報をまとめてみたいと思います。 Google Code Scanner API とは https://developers.google.com/ml-kit/vision/barcode-scanning/code-scanner?hl=ja まず初めに先ほど紹介した Google Mobile Vision についてですが、こちらは既に非推奨となっており、代わりに ML Kit というものが提供されています。 公式では ML Kit の方がよりパフォーマンスや安定性に優れているとの情報が公開されており、 Google Code Scanner API はその ML Kit をベースに作られています。 その Google Code Scanner API を使うことでどんなメリットがあるかと言うと、 UI を一切実装する必要がない カメラへのアクセス権限のリクエストを実装する必要がない という、カメラ機能周りを実装する上で一番のハードルとなる部分を全て省いて実装することができます。 具体的な実装方法は次の章から説明していきたいと思います。 実装手順 ここからは実装手順についてまとめていきます。 開発環境 Android Studio Giraffe 2022.3.1 開発言語 : Kotlin リポジトリを追加 ルートの settings.gradle に以下を追加 dependencyResolutionManagement { repositories { google() mavenCentral() } } ライブラリの依存関係を追加 app レベルの build.gradle に以下を追加 dependencies { implementation( "com.google.android.gms:play-services-code-scanner:16.1.0" ) } スキャナモジュールをデバイスに自動的にダウンロードする設定を追加 AndroidManifest.xml に以下を追加 <application ... <meta - data android:name= "com.google.mlkit.vision.DEPENDENCIES" android:value= "barcode_ui" / > < / application> 実装追加 import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning ... class MainActivity : AppCompatActivity() { ... private fun startReader() { val options = GmsBarcodeScannerOptions.Builder() .setBarcodeFormats( // 読み取るバーコードの種別を設定 Barcode.FORMAT_QR_CODE // 今回は QR コードを読み取るよう設定 ) .enableAutoZoom() // 自動ズーム有効 .build() val scanner = GmsBarcodeScanning.getClient( this , options) // ここを実行するとバーコードスキャンの画面が起動する scanner.startScan() // 読み取りが成功した時のリスナー .addOnSuccessListener { barcode -> val rawValue: String ? = barcode.rawValue Log.d( "BarcodeTest" , "成功しました : $rawValue " ) rawValue?.let { Toast.makeText( this , rawValue, Toast.LENGTH_SHORT).show() } } // 読み取りキャンセルした時のリスナー .addOnCanceledListener { Log.d( "BarcodeTest" , "キャンセルしました" ) } // 読み取り失敗した時のリスナー .addOnFailureListener { exception -> Log.d( "BarcodeTest" , "失敗しました : ${ exception.message } " ) } } 上記までで依存関係の設定や実装は完了です。かなり手軽に実装ができました。 実行画面 画面上部にある「コードのスキャン」という文字やバツボタン、画面中央にあるスキャン位置を示す枠などは全て提供された UI が表示されます。 先述した公式サイトのリンクを見ると分かりやすいのですが、バーコードを認識するとスキャン位置を示す枠がバーコードに合わせて移動するアニメーションをするなど、シンプルですが雑な印象にならない UI が組み込まれていました。 また読み取り速度は高速のため、使い勝手の悪さは特に感じませんでした。 読み取り可能なバーコードのフォーマットについて 一般的なバーコードのフォーマットにはほぼ対応しています。対応フォーマットは以下の通りです。 Codabar Code 39 Code 93 Code 128 EAN-8 EAN-13 ITF UPC-A UPC-E Aztec Data Matrix PDF417 QR コード 注意点 Google Code Scanner API では UI のカスタマイズが一切できません。UI を自由にカスタマイズしたい場合は ML Kit で実装する必要がありますが、その場合は UI を作成、権限チェックの実装を独自で行う必要があるため、トレードオフで最適な手法を検討する必要があります。 また、バージョン 16.1.0 時点では日本語の QR コードの読み取りに対応しておらず、読み取り時に null になるという仕様になっています。(英数字であれば正常に読み取れます) おわりに バーコードリーダーを 1 から実装しようとすると、カメラのチューニング、レイアウトの調整、権限周りの実装など、考慮する点が多く意外と工数がかかるものではありますが、その問題を全て解消できる意味では非常に効果的な手法だと感じました。 唯一レイアウトが調整できないという問題はありますが、バーコードリーダーを実装したいけど実装方法が難しい、レイアウトを組むのが難しい、といった課題がある方は是非 Google Code Scanner API を検討してみてはいかがでしょうか? 少しでもこちらの記事の内容が実装の参考になれば幸いです。
アバター
はじめに 子育てメディア「トモニテ」でバックエンドやフロントエンドの設計・開発を担当している桝村です。 この記事は「every Tech Blog Advent Calendar 2023」 の 15 日目の記事です。 tech.every.tv 私たちは 2023 年 6 月にシステムメンテナンスを実施し、トモニテのアプリやWebサイト等で利用している本番環境のデータベース Amazon Aurora の MySQL バージョンを 5.7 から 8.0 へアップグレードしました。 tomonite.com 本記事では、メンテナンスを伴う MySQL 8.0 へのアップグレードについて、実施内容やそれによって得られた知見について紹介します。 背景 前提として、Amazon Aurora は 1 〜 3 の メジャーバージョンが存在し、それぞれのバージョンに対してサポート終了日が設定されています。 Auroraバージョン MySQLバージョン Aurora 標準サポート終了日 1 5.6 2023 年 2 月 28 日 2 5.7 2024 年 10 月 31 日 3 8.0 未定 docs.aws.amazon.com トモニテでは、2019 年にサービスを開始して以来、Aurora バージョン 2 (MySQL 5.7 互換) を利用してきました。 しかし、Aurora バージョン 2 (MySQL 5.7 互換) のサポート終了日 (2024 年 10 月 31 日) が近づいていることから、Aurora バージョン 3 (MySQL 8.0 互換) へのアップグレードを実施することにしました。 また、Aurora MySQL は コミュニティ版 MySQL をベースに開発されているため、コミュニティ版 MySQL 8.0 の機能を利用できることもアップグレードの実施理由の一つになりました。 docs.aws.amazon.com 調査 データベースの現状把握 アップグレードにおける最初のステップとして、データベースの現状を正確に把握することが重要です。 そこで、データベースのエンジンバージョンや、アップグレード可能なエンジンバージョンを確認しました。 # データベースのエンジンバージョンを出力 # AWS CLI の aws rds describe-db-clusters コマンドを利用 $ aws rds describe-db-clusters --db-cluster-id tomonite-prod-rds-cluster \ --query ' *[].EngineVersion ' \ --output text \ 5 . 7 . 12 # エンジンバージョンから、アップグレード可能なエンジンバージョンのリストを出力 # AWS CLI の aws rds describe-db-engine-versions コマンドを利用 $ aws rds describe-db-engine-versions --engine aurora-mysql \ --engine-version 5 . 7 . 12 \ --query ' DBEngineVersions[].ValidUpgradeTarget[].EngineVersion ' \ [] # データベースのインスタンスへ接続の上、 Aurora のバージョンを出力 # Aurora MySQL関数 AURORA_VERSION() を利用 mysql > select AURORA_VERSION () ; +------------------+ | AURORA_VERSION () | +------------------+ | 2 . 02 . 5 | +------------------+ 1 row in set ( 0 . 00 sec ) アップグレード可能なエンジンバージョンが存在しないことがわかり、Aurora のバージョンを確認したところ、Aurora バージョン 2.02.5 で、これは新規作成には使用できなく、非推奨となっているバージョンであることがわかりました。 この前提を踏まえて、次のステップとしてアップグレードの手法の検討を行いました。 アップグレードの手法の検討 アップグレードの手法の検討に際して、重要な考慮事項として以下を挙げました。 切り戻しができ、かつ容易であること 移行作業が簡潔であること データベースの停止時間が短いこと データベースは Web や App, RSS 等のサービスへの影響範囲が大きい点で、不具合発生の可能性を考慮すると、切り戻しができ、かつ容易であることが特に重要でした。 結果としては、 スナップショット復元 を利用したアップグレード手法を採用しました。 理由としては、切り戻しの必要がある場合、サーバーのデータベースへの向き先を古いデータベースへ切り替えるだけで済むため、切り戻しが容易であることです。 他の候補として インプレースアップグレード 、 ブルー/グリーンデプロイ があり、移行作業の簡潔さやデータベースの停止時間の短さという点では、スナップショット復元よりも優れている可能性があると考えていました。 docs.aws.amazon.com docs.aws.amazon.com しかしながら、前述の通り Aurora バージョン 2.02.5 はアップグレード可能なエンジンバージョンが存在しなく、また新規作成にも使用できない点で、利用できないと思われることから、採用を見送りました。 定期的に運用・管理することが大切だと感じる事例であり、今後の教訓にしたいと思います。 MySQL 8.0 の変更点の影響確認 ここでは、MySQL 8.0 における変更の影響を確認しました。 照合順序 照合順序とは、データベースでも文字列を比較する際のルールや順序を定義するものです。 MySQL 8.0 より、 utf8mb4 のデフォルトの collation が utf8mb4_general_ci から utf8mb4_0900_ai_ci になるという変更があります。 つまり、明示的に collation を設定していない場合は utf8mb4_0900_ai_ci になってしまうという問題ですが、トモニテでは、明示的に utf8mb4_general_ci を指定していたため、影響はありませんでした。 各文字セットにはデフォルト照合があります。 たとえば、utf8mb4 および latin1 のデフォルトの照合は、それぞれ utf8mb4_0900_ai_ci および latin1_swedish_ci です。 MySQL :: MySQL 8.0 リファレンスマニュアル :: 10.2 MySQL での文字セットと照合順序 予約語の rank MySQL 8.0 より rank という単語が予約語に追加されました。トモニテでは、既存のテーブルに rank というカラムが存在しており、マイグレーション時にエラーが発生しましたが、バッククォートでエスケープすることで回避しました。 MySQL :: MySQL 8.0 リファレンスマニュアル :: 9.3 キーワードと予約語 暗黙的ソート MySQL 8.0 より、 GROUP BY 句での暗黙的ソートがされなくなったので、 ORDER BY 句を使う必要が発生しました。ただし、トモニテでは、暗黙のソートに依存している箇所はなかったため、影響はありませんでした。 以前は (MySQL 5.7 以下)、GROUP BY は特定の条件下で暗黙的にソートされていました。 MySQL 8.0 では発生しなくなったため、暗黙的ソートを抑制するために最後に ORDER BY NULL を指定する必要はなくなりました (前述のとおり)。 ただし、クエリー結果は以前の MySQL バージョンとは異なる場合があります。 特定のソート順序を生成するには、ORDER BY 句を指定します。 MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.2.1.16 ORDER BY の最適化 計画・検証 メンテナンスの手順 まずデータベースのアップグレードを含むメンテナンスの手順を策定するにあたって、現行のシステムなど前提条件を整理すると、以下の通りになりました。 アップグレードの手法は、スナップショット復元を利用 新旧のデータベースにおいて、できる限り整合性の担保が必要 データベースへのリクエストは必ずサーバーを経由 AWS EventBridge 経由で定期実行されるバッチ処理や死活監視ツール Datadog のアラート通知が存在 関係箇所を構成図にまとめると、以下のようになります。 トモニテのアーキテクチャ図 これらの前提条件を踏まえた結果、メンテナンスの手順を概ね以下で策定しました。 メンテナンスの手順 # 1. 事前作業 (ex. バッチ処理実行・死活監視ツールのアラート通知を一時停止) # 2. サーバーが一律で503を返却するように ALB を設定 # 3. 古いクラスター (Aurora バージョン 2) からスナップショットで復元 # 4. スナップショットからクラスター・インスタンス (Aurora バージョン 3) を生成 # 5. サーバーのDBの向き先を新しいクラスター (Aurora バージョン 3) のエンドポイントへ変更 # 6. [切り戻しの必要がでた場合] 古いクラスター (Aurora バージョン 2) のエンドポイントへ変更 # 7. サーバーがリクエストを受け付けるように ALB を設定 # 8. アプリ・Webサイト等の動作確認 # 9. [切り戻しの必要が出た場合] 古いクラスター (Aurora バージョン 2) のエンドポイントへ変更 # 10. 事後作業 (ex. バッチ処理実行・死活監視ツールのアラート通知を再開) 手順の 2 ~ 6 が、サーバーがリクエストを受け付けていない、つまり各サービスが停止している時間になります。また、手順 3 ~ 5 が、データベースのアップグレード ( スナップショット復元 ) に関する手順になります。 開発環境での検証 本番環境にて、可能な限りアップグレードを計画通りに完了させるためにも、開発環境での検証は非常に重要です。 特に以下の点について、念頭におきながら開発環境での検証を行いました。 定義した手順でのアップグレードの実現性 各手順の所要時間の概算見積もり 作業効率化など改善ポイントの洗い出し 結果としては、大きな遅延や問題なくアップグレードを完了させることができました。 また、作業に含まれていた AWS CLI コマンドのパラメータの指定ミスや、Web サイトがメンテナンス画面の表示できない等が発覚し、本番環境への実施に向けて、開発環境での検証の重要性を再認識することができました。 本番環境での実施 ここまで長い道のりでしたが、ついに本番環境でのアップグレードを実施しました。 結果としては、予定のメンテナンスの時間内かつサービスへの大きな影響なく、アップグレードを完了させることができました。1回のメンテナンスで完了させることができたことは、非常に良かったと思います。 ただし、アップグレードに際して、一部意図しない事象が発生したので、簡単ですが紹介します。 データベースインスタンスのオペレーティングシステムアップデート 手順 4 のスナップショットからクラスター・インスタンス (Aurora バージョン 3) を生成した直後、データベースインスタンスの オペレーティングシステムアップデート が自動的に実施されました。 原因としては、RDS エンジンをアップグレードしたことに伴い、新しい RDS エンジンに対応する OS アップデートが必須だったためかと思われます。 DB インスタンスのメンテナンス - Amazon Relational Database Service このオペレーティングシステムアップデート自体は、15 分程度かかりましたが、幸いにも、他の手順がスムーズに進んでいたため、全体としてはメンテナンスの時間内にアップグレードを完了させることができました。 クエリによるCPU負荷の上昇 データベースのアップグレードが完了した翌日、クエリによる CPU 負荷が上昇していることが Datadog によるアラート通知で発覚しました。 RDS データベースのパフォーマンスを分析しチューニングできる RDS Performance Insights を有効化し、どのクエリが CPU 負荷の上昇に関与しているかを調査しました。 docs.aws.amazon.com その結果、社内向けサービスで利用している API の SELECT COUNT(*) クエリが CPU 負荷の上昇に関与していることがわかりました。 もちろん、本番環境における元々利用していたテーブルのレコード数が多く、ワークロードが大きいことも原因です。 SELECT COUNT (*) FROM `user_tokens`; RDS Performance Insights 調査したところ、MySQL バージョン 8.0 の特定のバージョンにおいて、 SELECT COUNT(*) のパフォーマンスに関係するバグが報告されていることを確認しました。 mysql Bug #97709 幸いにも、このクエリは無くても業務上大きな影響はないことが判明したため、クエリを利用しない設計へ変更することで、CPU 負荷の上昇を抑えることができました。 反省点としては、MySQL バージョン 8.0 における仕様変更やバグについて調査したり、開発環境と本番環境で異なるデータ量への依存を考慮しておくべきだったと思います。 全体を通して振り返り メンテナンスを実施し、本番環境のデータベースを Aurora バージョン 2 から 3 へアップグレードすることができました。 最後に、メンテナンス全体を通して、やってよかったことを振り返ります。 AWS リソースの設定変更を Terraform および AWS CLI でコード化 手順 2 の ALB の設定変更をはじめ、可能な限り AWS リソースの設定変更を AWS コンソール上での操作でなく、 Terraform を使ったコード化を行いました。 例えば、以下のように ALB の Listener Rule を追加する PR を作成しておき、メンテナンス中に terraform apply で適用します。 resource "aws_alb_listener_rule" "tomonite_server_rule_maintenance" { listener_arn = aws_alb_listener.tomonite_ecs_lb_https.arn priority = 1 action { type = "fixed-response" fixed_response { status_code = "503" content_type = "application/json" message_body = jsonencode ( { message = "メンテナンス中です" } ) } } condition { host_header { values = [ "server.tomonite.com" ] } } } 同様に、 手順 3, 4 でのデータベースのスナップショット復元やクラスター・インスタンスの生成などのAWS リソースの設定変更も、AWS CLI を利用しました。 これにより、AWSコンソールでの操作ミスの未然防止やチーム内でのレビューが容易になり、メンテナンスの安全性を高めることができました。また、メンテナンス中はチーム内でレビュー済みのコマンドを実行するのみになり、作業の効率化にもつながりました。 切り戻し時の作業のマニュアル化 手順 6 の切り戻しの必要があった場合の作業をマニュアル化しました。 本番環境でのメンテナンス中は緊張感が高まるので、意図しないトラブルが発生した場合、チームメンバーが普段通りの作業・判断ができない可能性があります。そのため、切り戻し時の作業をマニュアル化することで、メンテナンス中に切り戻しを実施する場合でも、作業の手順を確認しながら実施できるようにしました。 今回は切り戻しの必要はありませんでしたが、データベースインスタンスの オペレーティングシステムアップデート の件で、メンテナンス中にて時間内に完了できるかどうか焦りや不安を感じたので、切り戻し時の作業のマニュアル化は非常に有効だと感じました。 終わりに 今回は、トモニテの本番環境のデータベース Amazon Aurora の MySQL バージョンを 5.7 (Aurora バージョン 2) から 8.0 (Aurora バージョン 3) へアップグレードしたことについて、実施内容やそれによって得られた知見について紹介しました。 これから MySQL 8.0 化を検討されている方、データベースのアップデートに伴うメンテナンスを実施される方々の参考になれば幸いです。
アバター
title この記事は every Tech Blog Advent Calendar 2023 の 14 日目です。 DELISH KITCHEN iOSアプリの開発を担当しています久保です。 開発中のアプリでGraphQLを利用する機会があったので、導入と利用方法についてご紹介します。 なお、GraphQLについての紹介は、今更感があるので割愛させていただきます。 ライブラリの選定 GraphQLはcurlなどで実行してもらうとわかるのですが、単なるPOSTリクエストなので、そちらで書く方法もあります。その場合ライブラリの導入は不要になりますが、レスポンスに対応するオブジェクトの自動生成などのメリットを享受できないので見送りました。 今回Android側も同様に実装する必要があり、Androidにも同じようにライブラリを提供しているという理由から、 apollo-ios を採用しました。 前提条件 Xcode 15.0.1 Apollo 1.7.1 ローカルに作成したパッケージから apollo-ios を利用してデータを取得します。 データ取得先としては star-wars-swapi を使用させていただきました。 データ取得までの流れ 今回は以下の順で作業しました。 ローカルパッケージの作成 apollo-iosの導入 apollo-ios-cliを用いて必要なファイルを生成する データを取得する部分の実装 ローカルパッケージの作成 GraphQLSample という名称でプロジェクトを作成後、ルート直下に Packages というGroupを作成し、その下に API というパッケージを作成しました。 package apollo-iosの導入 先ほど作成したPackage.swiftに依存関係を追記します。 import PackageDescription let package = Package( name : "API" , products : [ // Products define the executables and libraries a package produces, making them visible to other packages. .library ( name: "API" , targets: [ "API" ]) , ] , dependencies : [ .package ( url: "https://github.com/apollographql/apollo-ios.git" , .upToNextMajor ( from: "1.0.0" ) ) , ] , targets : [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target ( name: "API" , dependencies: [ .product ( name: "Apollo" , package: "apollo-ios" ) , ] ) , .testTarget ( name: "APITests" , dependencies: [ "API" ]) , ] ) apollo-ios-cliを用いて必要なファイルを生成する appollo-ios-cli コマンドを利用可能な状態にします Sources配下にbinフォルダを作成し、そこに以下コマンドで生成します $ swift package --allow-writing-to-package-directory apollo-cli-install なお、実行可能なファイルを含んだままApp Store Connectにアップロードしようとするとエラーになるため、適宜シンボリックリンクに変更するか、ビルド時に除外するなりの対応が必要です。 設定ファイルを作成する ここが個人的に一番面倒でした...まずコマンドを利用して apollo-codegen-config.json を生成します $ bin/apollo-ios-cli init --schema-namespace SW --module-type embeddedInTarget --target-name API schemaをダウンロードするための設定を追加&取得 生成された設定ファイルに追記します " schemaDownloadConfiguration ": { // ここから丸っと追加。取得先の情報を設定。 " downloadMethod ": { " introspection ": { " endpointURL ": " https://swapi-graphql.netlify.app/.netlify/functions/index ", " includeDeprecatedInputValues ": false , " httpMethod ": { " POST ": {} } , " outputFormat ": " JSON " } } , " outputPath ": " ./schema.json " } この状態で下記コマンドを実行すると、 schema.json が取得できます $ bin/apollo-ios-cli fetch-schema Queryを定義したgraphqlファイルの追加&設定変更 タイトルのリストを返却する簡単なQueryを AllTitles.graphql に定義します。 このファイルはどこにおいても大丈夫なのですが、 Query というフォルダを作成しそこに格納しました。 query AllTitles { allFilms { films { title } } } 続いて、設定( apollo-codegen-config.json )を変更します。 自動生成されるファイルは Generated フォルダに格納されるようにします。 { " schemaNamespace " : " SW ", " input " : { " operationSearchPaths " : [ " **/*.graphql " ] , " schemaSearchPaths " : [ "./ schema . json " // ← 変更:今回はDLしてきたjsonファイルを指定 ] } , " output " : { " testMocks " : { " none " : { } } , " schemaTypes " : { " path " : " ./API/Generated ", // ← 変更:自動生成されるファイルの置き場 " moduleType " : { " embeddedInTarget " : { " name " : " API " } } } , " operations " : { " inSchemaModule " : { } } } , " schemaDownloadConfiguration ": { ... } } swiftファイルの生成 generateコマンドを実行することにより、swiftファイルが生成されます ここまでで一旦下準備は完了です タイトルのリストを返却するサンプル ApolloClient を直接利用しても良いのですが、簡易的にラップしたクラスを作成しました import Apollo import Foundation public final class APIClient { private let apollo : ApolloClient public init (endpointURL : String ) { // setup apollo client let cache = InMemoryNormalizedCache() let store = ApolloStore(cache : cache ) let client = URLSessionClient() let provider = DefaultInterceptorProvider(client : client , store : store ) let transport = RequestChainNetworkTransport( interceptorProvider : provider , endpointURL : URL (string : endpointURL ) ! ) apollo = ApolloClient(networkTransport : transport , store : store ) } public func allTitles () async throws -> [ String ] { try await withCheckedThrowingContinuation({ continution in apollo.fetch(query : SW.AllTitlesQuery ()) { result in switch result { case .success( let val ) : let titles = val.data?.allFilms?.films?.compactMap({ film in film?.title }) continution.resume(returning : titles ?? []) case .failure( let error ) : continution.resume (throwing : error ) } } }) } } これを用いて表示するViewのサンプルです。あらかじめAPIパッケージを利用できるようにXcodeの TARGETS > Frameworks, Libraries... で設定しておきます。 import SwiftUI import API struct ContentView : View { private var client = APIClient(endpointURL : "https://swapi-graphql.netlify.app/.netlify/functions/index" ) @State private var titles : [ String ] = [] var body : some View { List(titles, id : \. self ) { title in Text(title) } .onAppear(perform : { Task { titles = try await client.allTitles() } }) } } 実行結果 まとめ プロジェクトに応じて設定ファイルをこねくり回す必要がありますが、一回作成してしまえば以降は必要なgraphqlファイルを追加するだけでほぼ作業が完結するので、開発体験は良かったです。 また、レスポンスに対応する型を手で書くと、Optionalの取り扱いなど慎重にならざるを得ないケースが多々ありますが、その問題も解決できるのが一番のメリットかなと思いました。 以上、何かの参考になれば幸いです。
アバター