TECH PLAY

株式会社エブリー

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

385

はじめに エブリーでソフトウェアエンジニアをしている本丸です。この記事は 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の取り扱いなど慎重にならざるを得ないケースが多々ありますが、その問題も解決できるのが一番のメリットかなと思いました。 以上、何かの参考になれば幸いです。
アバター
はじめに こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 この記事は every Tech Blog Advent Calendar の13日目の記事になります。 今回は、社内ChatApp向けに作成した、RAG(Retrieval-Augmented Generation)と呼ばれる手法を用いてコードを解説する機能について紹介します。 社内向けChatApp作成の取り組みは こちらの記事 で紹介されています。 LLMを利用したコード解説 最近、古くから利用されているロジックの詳細を調査する機会がありました。 しかし、初めて利用するサービスのコードであり、また、初見の言語であることからLLMを利用してコードを解説してもらいながら調査を進めていました。 都度コードをコピペしつつChatAppに解説を依頼していましたが、手間がかかるため、より効率的に利用したいと考えました。 ChatAppによるコード解説例 RAGについて RAGは、ユーザの質問に関連する情報をPromptに入れ込むことで、prompt内の情報を用いて回答を生成する手法です。 LLMに事前に用意した「カンニングペーパー」を渡してあげることで、より正確な回答を生成できます。 RAGは以下のような手順で動作します。 ユーザが質問を入力する 類似する情報を取得する 取得した情報をPrompt中に入れ込み、LLMに入力する LLMが回答を生成する RAGのイメージ 一般的に、事前に情報をベクトル変換したものをVector DBに保存しておき、類似度検索をかけることで質問に関連する情報を取得します。 ベクトル変換と類似検索 LLMに対して特定のデータを参照させる場合、 LLMをFine-tuningさせる方法 プロンプト中にデータを埋め込む方法 などが挙げられますが、LLMのFine-tuningは大量の計算リソースと時間が必要となるため、コストがかかります。 特に頻繁に更新されるデータを参照させる場合、Fine-tuningを行うたびにコストがかかるため、大きな問題となります。 今回扱うコードは頻繁に更新されるため、Fine-tuningの利用は避けたいと考え、プロンプト中にデータを埋め込む方法であるRAGを採用しました。 コード解説機能構成 今回作成する機能は、利用に必要な労力をなるべく減らすことを目的としてます。 そのため、ユーザが質問を投げかけるだけで、コード解説が実行されるようにします。 構成図 構成として、 コードのベクトル化部分 (pre.1,2) ユーザの質問から関連するコードを取得する部分 (1 ~ 3) 取得したコードを元に回答を生成する部分 (4,5) があり、それぞれ GitHubからコードを取得し前処理を行ってからEmbeddingを行いベクトル化し、メタデータとともにVector DBに保存する ユーザの質問を元に、Vector DBのメタデータフィルタを生成し、類似するコードを取得する 取得したコードとユーザの質問を元に、回答を生成する といった処理を行います。 今回Vector DBとして Pinecone を利用しました。 また、GitHubからのコード取得、前処理などはそれぞれ LlamaIndexの Github Repository Loader LangChainの CodeTextSplitter を利用しました。 Vector DBのMetadata Filteringを利用した検索 Vector DBにはコードのベクトルとともに、 リポジトリ名 ブランチ名 ファイル名 ファイルパス といった情報をメタデータとして保存しています。 これは単純にコードのみをEmbeddingした場合、コード中に存在しない情報を検索に利用できないため、ユーザの期待とは異なるコードが返却される可能性があるためです。 期待と異なる検索結果 そこで、Vector DBのMetadata Filtering機能を利用することで、より正確な検索結果を返却できるようにします。 https://docs.pinecone.io/docs/metadata-filtering ただし、ユーザがメタデータを意識することなく利用できるようにしたいため、メタデータフィルタリングの利用を自動化する必要があります。 そこで、メタデータとメタデータの説明がセットになったメタデータカタログを用意しLLMに入力することで、ユーザの質問から利用するメタデータと値を生成させます。 メタデータフィルタリングを利用した検索 生成された値を元にメタデータフィルタリングを利用することで、ユーザの質問に対するコードをより正確に取得できます。 これにより、 hogeレポジトリのfuga.pyのpiyo関数の処理を教えて といった質問を投げるだけで、対象のコードを正確に取得し回答を生成できます。 今回、LangChainの self-querying retriever を利用することで簡単に実装できました。 実験 テストとして簡単なコードを用意し、適切なフィルタリングが行われているかを確認します。 from langchain.embeddings.openai import OpenAIEmbeddings from langchain.chat_models import ChatOpenAI from langchain.chains import ConversationalRetrievalChain from langchain.retrievers.self_query.base import SelfQueryRetriever from langchain.chains.query_constructor.base import AttributeInfo from langchain.schema import Document from langchain.vectorstores import Chroma # ベクターストアの構築 def vectorstore (): # テスト用のドキュメント docs = [ Document( page_content= """ def func(a: int, b: int) -> int: return a + b """ , metadata={ "repository" : "AAA" , "filename" : "hoge.py" } ), Document( page_content= """ def func(a: int, b: int) -> int: return a - b """ , metadata={ "repository" : "BBB" , "filename" : "hoge.py" } ), Document( page_content= """ def func(a: int, b: int) -> int: return a * b """ , metadata={ "repository" : "BBB" , "filename" : "huga.py" } ), ] return Chroma.from_documents(docs, OpenAIEmbeddings()) # メタデータフィルタリングを利用するself-query retrieverの構築 def self_query_retriever (vectorstore): # メタデータカタログ metadata_field_info = [ AttributeInfo( name= "filename" , description= "The filename of the source code" , type = "string" , ), AttributeInfo( name= "repository" , description= "Repository name where source code exists" , type = "string" , ), ] document_content_description = "Source code files collected from multiple repositories" llm = ChatOpenAI(temperature= 0 , model_name= "gpt-4-1106-preview" ) return SelfQueryRetriever.from_llm( llm, vectorstore, document_content_description, metadata_field_info, verbose= True ) # メタデータフィルタリングを利用しないretrieverの構築 def retriever (vectorstore): return vectorstore.as_retriever() def get_retrieval_chain (retriever): return ConversationalRetrievalChain.from_llm(ChatOpenAI(temperature= 0 , model_name= "gpt-4-1106-preview" ), retriever=retriever, return_source_documents= True , verbose= True ) vectorstore = vectorstore() retriever = self_query_retriever(vectorstore) # retriever() or self_query_retriever() qa = get_retrieval_chain(retriever) result = qa({ "question" : "repository BBBのhoge.pyの関数funcについて解説してください" }) メタデータフィルタリングを利用しない場合 まずは、メタデータフィルタリングを利用せずに、 repository BBBのhoge.pyの関数funcについて解説してください という質問を投げてみます。 メタデータフィルタリングを利用しない場合 LLMが参照にしたコードの項目を見ると、 repository BBBのコード以外にもAAAのコードも参照している hoge.pyではなくhuga.pyのコードも参照している といったように、ユーザの質問に対して不適切なコードを参照していることがわかります。 その結果、ユーザが期待する回答を得られませんでした。 メタデータフィルタリングを利用した場合 次に、メタデータフィルタリングを利用して、同様の質問を投げてみます。 メタデータフィルタリングを利用した場合 今度は、repository BBBのhoge.pyのみを参照しており、ユーザが期待する回答を得ることができました。 メタデータフィルタリングを適切に利用できない場合 self-query retrieverはLLMがユーザの質問とメタデータカタログを元に利用するメタデータフィルタを自動生成します。 そのため質問の仕方や、メタデータカタログの内容によっては、適切なメタデータフィルタが生成されない可能性があります。 その場合、誤ったドキュメントの参照や、ドキュメントの参照が行われず、適切な回答が得られない可能性があります。 メタデータフィルタの生成精度を向上させるには、self-query retrieverで使用するpromptや、メタデータカタログの内容を調節する必要があります。 終わりに 今回作成した、コード解説機能は社内向けChatAppにβ版としてリリース予定です。 self-query retrieverを利用してメタデータフィルタを自動生成することで、RAGで適切なデータを参照することができるようになり、ユーザが質問を投げるだけでコード解説が行えるようになりました。 今後の取り組みとしては、コードEmbeddingの効率化や最適化、prompt改善による回答精度の向上などを行っていきたいと考えています。
アバター
こんにちは。開発本部のデータ&AIチームでマネージャー兼データサイエンティストをしている伊藤です。 この記事は every Tech Blog Advent Calendar 12日目の記事です。 今回は、先日発表されたOpenAI Assistants APIを使って分析用のSQLの生成を試してみた取り組みについて紹介します。 分析用SQLの生成 最近1年ほどでLLMが注目されるようになり、ChatGPTをはじめLLMを使ってより効率的に処理できる作業が増えてきています。 自分自身も、社内のChatAppにコーディングを手伝ってもらったり、ちょっとした文章の整形や知識の深掘りに活用しています。 中でも、データサイエンティストとしてLLMで効率化したい作業の一つにデータ分析、特にSQLの作成があると思います。 LLMによってSQLが生成できるようになると、データサイエンティストをはじめ分析者がより効率的に分析できるようになるのはもちろん、 普段SQLを書かない社員が分析する際の助けにもなります。 SQLの生成自体は現在のLLMでも可能ではありますが、世の中にリリースされているLLMは自社独自のデータ構造等のドメイン知識は学習していないため、 社内での分析利用を目指すのであれば、そういったドメイン知識をいかに扱うかが重要となります。 直近 Amazon Q Generative SQLのプレビュー版が発表されるなど、ドメイン知識を活用したSQL生成はLLM活用の中でも関心の高い領域となっていそうです。 今回紹介する取り組みは、11月の中旬に開催された挑戦weekで実施したもので、 当時リリースされたばかりだった OpenAI Assistants API を使った内容を紹介できればと思います。 OpenAI Assistants APIについて OpenAI Assistants APIは、チャットボットのようなアプリケーションにCode InterpreterやRetrieval、Function callingといった"Tool"を 簡単に組み込める機能を提供しています。 https://platform.openai.com/docs/assistants/overview 詳しい使い方は公式ドキュメントに委ねますが、主要な構成要素として ドメイン知識などのデータが記録されているFile 入力に対してFileを使った処理を行うTool 入力に対するレスポンスとして生成されるMessage 過去やり取りされたMessage群を一連の会話として保持しているThread FileやToolを利用し、入力に対してMessageを作成するAssistant があり、それらは AssistantにFile、Toolを紐づける(それぞれ複数指定可能) Threadを立ててユーザーからMessageを入力する ThreadをAssistantに渡すと、LLMからのレスポンスがMessageとしてThreadに追加される といった関係性で動作します。 Assistants API 概念図 開発時には、Fileの作成やToolの選定を適切に実施するだけで、目的に応じたAssistantの作成が可能です。 方針 今回作成するSQL生成アプリケーション(以下SQLジェネレーターと呼びます)は、社員が誰でも利用できるようなツールを目指しています。 現状エブリーのデータ基盤のうち、社員全員が利用できる部分ではRedashからTreasureData (Presto) を参照しているため、 TreasureData上で実行可能なSQLの生成を目標とします。 要件を整理すると、 ユーザーから知りたい内容の質問を受け取り 質問内容を集計できるTreasureData (Presto) で動作するSQLを返す ような方針で設計を行いました。 SQLジェネレーター構成 まずToolの選定ですが、入力に基づいてFileを参照し、必要なドメイン知識を抽出する役割が必要なため、Retrievalを採用しました。 本来、外部のドメイン知識を扱う際には、それらを適切な長さのチャンクに分割し、ベクトルデータベース等に格納しておく必要がありますが、 Retrieval Toolではその辺りをOpenAI側で処理しているため、深く考慮せずとも実装が可能です。 ドメイン知識に関しては、テーブル情報を記載した表や特殊な関数の使い方をまとめたものをPDFファイルとしてアップロードしました。 ユーザーの質問をAssistantに渡す部分では、LLMがタスク内容を解釈しやすいように、入力するプロンプト内で ユーザーから要求されている分析内容を集計できるSQLを作成する 利用するデータソースやカラム、独自関数を使った期間指定の方法等はファイルを参考にする TreasureDataでそのまま実行できるSQLを出力する といった内容を記述しています。 実験 まずは、何もドメイン知識を与えない状態で、社内ChatApp (GPT-4-turbo) にSQLを生成させてみます。 SQL生成例1 当然ですが、ドメイン知識を何も渡していないため、このままではTreasureData上で実行できません *1 。 続いて、 SQLジェネレーターに同じ質問を投げかけてみます。 SQL生成例2 今度は、検索データのevent_searchテーブルを参照した上で、適切なカラムのselect、search_typeやtag_idなどの条件指定、TreasureDataの独自関数 (TD_TIME_FORMAT、TD_TIME_RANGE) の使用が適切で、 実際に使用できるSQLとなっています *2 。 実行例 実際の分析でよく利用されるデモグラフィックデータとの突合も試してみます。 SQL生成例3 検索データのevent_searchに加えて年代情報のあるuser_masterテーブルとカラムを正しく指定できており、join操作含めて正しいSQLになっています。 他にも、ウインドウ関数を使った移動平均の集計なども対応でき、ドメイン知識を取り込んだSQLの生成が簡単に実装できました。 ドメイン知識を適切に扱えないケース 前節ではうまく生成できた結果をピックアップして紹介しましたが、もちろん間違ったSQLが出てくることも多くあります。 間違え方としては、SQLの文法自体が誤っているケースはほとんどなく、主に独自関数の使い方に集中していました。 特に、期間指定に用いる独自関数の中でも、TD_TIME_RANGEよりもTD_INTERVALという関数の扱い方は間違っている割合が高く、 ファイルに記載しているにも関わらず、入力パラメータや出力値の型を間違って認識していました。 試しに、ドメイン知識を与えない状態でTD_TIME_RANGE、TD_INTERVALそれぞれの意味を質問したところ、以下のような回答が返ってきました。 TreasureData独自関数に関する回答 回答では、TD_TIME_FORMATについては正しい内容が書かれてますが、TD_INTERVALは間違った回答(Hallucination)になっています *3 。 SQLジェネレーターにおいて、TD_INTERVALの使い方は必ず間違えるわけではなく、正解する場面も見られたため、 プロンプトや確率的な変数など何かしらの要素が起因してファイル中の知識よりもGPTが持っている知識体系にバイアスを受けている可能性がありそうでした。 このようなLLM自体が持つバイアスへの対処は、ファイル中のドメイン知識の書き方やプロンプトエンジニアリングなどで解消できる可能性はあるため、今後の課題と考えています。 今後の展望 今回作ったSQLジェネレーターは、改善点は多く残されてはいますが、社内ChatAppの拡張機能としてベータ版を展開予定です。 今後の取り組みとしては、レスポンス速度や精度の改善、SQLが間違っている場合に修正しやすい仕組みの提供といった機能的な改善はもちろん、 社員が普段見ているドキュメントをAssistantsのドメイン知識にどのように転換させるかといった運用的な改善も考えられます。 今後もLLMを中心とした業務効率の改善に、チーム全体として取り組んでいければと思います。 *1 : 入力プロンプト内にドメイン知識を記述して生成させる手段はありますが、今回はユーザーから直接ドメイン知識を与えない状況を想定しているため考えないものとします。 *2 : 具体的な値にはぼかしを入れています *3 : 公式ドキュメント https://docs.treasuredata.com/display/public/PD/Supported+Presto+and+TD+Functions#SupportedPrestoandTDFunctions-TD_INTERVAL
アバター
この記事は every Tech Blog Advent Calendar 2023 の11日目です。 こんにちは。トモニテ開発部iOS担当兼、開発組織活性化委員会リーダーを勤めている國吉です。 今回はエブリーで初の試みとなる開発部全体イベント”挑戦week”を開催/運営してみての所感を書こうと思います。 前段 開発部では各事業部毎にバックエンドチームやクライアントチームが存在しています(一部横断するチームもあります)。 このような組織体系だと、時間経過と共に課題として挙がってくるのがチーム横断でのコミュニケーションの取りづらさです。 この課題があり続ける限り、ナレッジ共有ができず知らず知らずの間に同じような技術検証をしていることなどが生じてしまいます。 それらを含めた問題を解決するために”組織活性化委員会”が発足しました。 施策 ”組織活性化委員会”は名前の通り、組織を活性化させチーム間で生じているコミュニケーション障壁をなくす施策や他チームが取り組んでいる内容を把握することができる施策に取り組んでいます。 具体的に実施している施策は下記です。 TechTalk 挑戦week アドベントカレンダー 簡単に挑戦week以外の活性化施策にも触れようと思います。 TechTalk TechTalk毎月1回実施しており、内容としては”AllHands”と”LT”です。 ”AllHands”は前月に各チームが取り組んだ内容やその中でのトピックの共有を行い、”LT”は毎回2~3人ほどが技術について発表してくれています。 TechTalk終了後は、雑談する時間を設けておりチームを横断したコミュニケーションを促進しております。 アドベントカレンダー これは絶賛取り組んでいるイベントです! ブログを執筆し社外へアウトプットする目標は開発部側の目標となりますが、組織活性化委員会と協業してイベント事として盛り上げていこうとしています。 是非、こちらから他のブログも見ていただけたらと思います! tech.every.tv 挑戦week さて、本題の挑戦weekのお話に戻ります。 ロードマップ まず、挑戦week含め各施策の組織活性化委員会のロードマップをざっくり共有します。 図を見て分かる通り、挑戦weekを実施するだけで1年間みっちり活動しており、その中でTechTalkやアドベントカレンダーを実施しているので結構大変です。 組織活性化委員会のメンバーは異なるチームからメンバー構成がされているため、MTGは週に1時間〜2時間しかとれず各パート長めに期間を取らざる終えません。 実施内容 続きまして、内容に少し触れていきます。 これまでで計3回開催していますが、共通していることがあります。それは”1週間事業部の施策やMTGには参加せず、集中して挑戦weekに取り組む”ことです。 もちろんこれは各事業部側の皆さんにも協力してもらい成り立っていることです。1週間施策を止めることはすごく大きなことなので、改めて感謝しないといけませんね。 開発部のメンバーが実際に取り組む内容については、開発部に所属している人からやりたいことを募り内容を決めたり、部長陣から今後に活かせる内容を募り内容を決めたりと試行錯誤しながら開催しています。 取り組んだ内容は、ブログとして執筆している人がいるので気になる方は是非ご一読ください!おそらくアドベントカレンダーの中にも挑戦weekで取り組んだことについて執筆する人いると思います。 tech.every.tv tech.every.tv tech.every.tv 開催/運営してみた所感 今回は挑戦weekの内容について深ぼるのではなく、開発部全体を巻き込むイベントを実装に運用してみて僕個人が難しいと感じたとこや楽しいと感じたところをアウトプットできたらと思います。 難しいポイント まず、エブリーとして全体イベントを実施するのが初めてでノウハウもない中での実施となるので、最初は一つ一つ全てが難しかったです。 その中でも継続して運営していく上で特に難しいと感じるポイントは下記です。 取り組むお題(内容)の選定 アンケート結果から改善内容の精査 取り組むお題(内容)の選定 漠然とやってみたい形式はあるが、詳細に落とし込む段階で”今後事業部に活かせるのか””その粒度で皆をまとめることができるのか”に納得できず、時間だけが経過してしまうことが多々あります。 第1回目は実施実績がないので、まずは無難なとこから始めてみようということで開発部のみんなが日々挑戦したいと思っていることや改善したいと感じているとこから取り組むことにしました。 結果、日々の改善系が多くなりましたが第1回目としては成功したと感じています。 ただ、イベント名に”挑戦”という単語が含まれているため一定の挑戦要素を盛り込みたいなと考えており、お題を決めるとこに関しては引き続き頭を抱えながらアンケート結果を振り返りつつ検討しています。 アンケート結果から改善内容の精査 毎回挑戦week実施後に、参加者に対しアンケートを実施しています。アンケート内容は満足度や不満点、改善点などです。 参加者が多ければ多いほど、様々な意見がありどこまで吸収し、次に活かしていくかを毎回議論するのですが、それが難しいです。 極端なことを言うと、「挑戦week自体やる意味ありますか?」といった意見もあります。少しでもやる意義を提示できてないことは”組織活性化委員会”の課題であり改善点です。 まずは、皆が協力して参加してくれるようなイベントにできるよう改善を行うのが重要なのかなと考えています。 他にも時間がない中で、様々な部分での意思決定が必要となるため難しいと感じるポイントが多々あります。 楽しいポイント 逆に楽しいと感じるポイントも少なからずあります。楽しいと言うよりは”嬉しい”や”組織活性化委員会やって良かった”と感じるポイントと言った方が適切かもしれません。 一番嬉しいことは、挑戦week実施後のアンケートで「新しい技術への挑戦ができ楽しかった!」や「話したことない人とコミュニケーションが取れ、会話の幅が広がった」「いつも運営ありがとうございます」等の意見を言ってもらえることです。 まだまだ会社の文化として全社イベントは根付いてないので、試行錯誤のフェーズだと思いますがその中でポジティブな意見がもらえるのはとてもありがたいことです。 他には少なからず意思決定する場面が多く、イベント内容の仕様が決まっていく様子や実際にイベントを開催することで達成感があり、同時にやりがいを感じます。 最後に ここまで所感などを書いてきましたが、総括すると開催実績がない状態で全体イベントを開催/運営に携われたのは良かったです。 意思決定や大勢を巻き込むイベント実施の難しさを体感できたのは、今後に活かせる経験であることは間違いなく、開催に協力してくれた組織活性化委員会のメンバーはもちろんのこと、開発部に所属している方や事業部側にもとても感謝です。 まだまだ文化として定着させるには時間がかかるかもしれませんが、将来的には他職種を巻き込み影響範囲を広げていけたら、会社全体としても盛り上がっていくんじゃないかなと期待しています。 最後に挑戦week最終日に取り組んだ内容を共有する場を設けているのですが、その時の写真を載せておきます。
アバター
概要 この記事は every Tech Blog Advent Calendar 2023 の10日目です。 TIMELINE開発部の内原です。 本日はTIMELINE開発部で利用しているAWS RDSへの踏み台サーバの構成を、ECS Fargate+PortForward+Adhocな機構に変更した話を書きます。 似たような記事はいたるところで見かけるので何番煎じになるか分からない状況ですが、以前からやってみたいと考えていたものだったので個人的にはよかったです。 変更前の構成 変更前の踏み台サーバの構成は以下のようなものでした。 EC2インスタンス (Amazon Linux, t2.micro) SSHポート転送を利用してRDSに接続 SSH用アカウントはgithubの公開鍵を手動で登録 上記構成を利用してSSHポート転送を行うには、以下のようなコマンドを実行します。 $ ssh -L 3306:database.xxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306 $user @ $db_proxy_host その後MySQLクライアントなどで 127.0.0.1:3306 に接続することで、RDSに接続できます。 問題点 この構成には以下のような問題点がありました。 EC2インスタンス固定費が発生 t2.micro の料金は USD 0.0116/hour で、1ヶ月で 24 * 30 = 720 時間稼働すると USD 8.35 かかります 微々たるものですが、使っていないにも関わらず費用が発生するのはもったいないです EC2インスタンスメンテナンスが必要 AMIの更新やセキュリティ脆弱性など、必要に応じて定期的にメンテナンスが必要になります アカウントの管理が面倒 開発者が増える度に、手動でUnixユーザ追加と公開鍵の登録を行う必要があります また、退職者のアカウントの削除も手動で行う必要があり、忘れるとセキュリティ上問題があるため注意が必要です 対応策 以下のようなことを実現したいと考えました。 EC2インスタンスを廃止 ECS Fargateを利用して、必要に応じてコンテナを起動する方針とします。 これによりインスタンスの管理が不要となります。 SSHポート転送を廃止 ポート転送にはAWS SSM Session Managerを利用することにします。 これによりSSHの管理も不要となります。 アカウントの個別管理を廃止 エブリーではAWSのアカウント管理にAWS IAM Identity Center(旧AWS SSO)を利用しており、認証にはGoogle Workspaceを用いています。 退職者はGoogle Workspaceにアクセスできなくなるため、自動的にAWSへのアクセスも不可能となり、アカウントの管理が不要となります。 自動的に停止する機能 せっかくECS Fargateに切り替えても、常時コンテナが起動していたのでは却って費用が割高になってしまいます。 このため、RDSへの接続が不要になったタイミングでコンテナが自動的に終了するようにしたいと考えました。 また安全のため、なんらかの理由でコンテナの自動終了ができなかった場合でも、一定時間経過したら強制的に終了するようにしておきたいです。 なるべく軽くかつ安くしたい ECS Fargateのコンテナは、最低でもvCPU 0.25、メモリ 512MBのリソースを必要とします。やりたいことはポート転送だけなので、最低限のスペックにしておきます。 また2024/02/01より、AWSではPublic IP Addressに対する課金が発生することになっています。このため、Public IP Addressを割り当てないように設定します。 RDSはVPC内にあるため、private subnetにコンテナを配置しても問題なく接続できます。 というわけでできました ECSタスク定義 あらかじめ以下のようなタスク定義を作成しておきます。 スペックは最低限、かつ一定時間が経過したらコンテナが自動的に終了する構成にしておきます。 { " taskDefinitionArn ": " arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:task-definition/db-proxy:1 ", " containerDefinitions ": [ { " name ": " db-proxy ", " image ": " alpine:latest ", " cpu ": 0 , " portMappings ": [] , " essential ": true , " entryPoint ": [ " sh ", " -c " ] , " command ": [ " sleep $TIMEOUT_SEC " ] , " environment ": [ { " name ": " TIMEOUT_SEC ", " value ": " 3600 " } ] , " mountPoints ": [] , " volumesFrom ": [] } ] , " family ": " db-proxy ", " taskRoleArn ": " arn:aws:iam::xxxxxxxxxxxx:role/ecs-task-role ", " executionRoleArn ": " arn:aws:iam::xxxxxxxxxxxx:role/ecsTaskExecutionRole ", " networkMode ": " awsvpc ", " volumes ": [] , " requiresAttributes ": [ { " name ": " com.amazonaws.ecs.capability.task-iam-role " } , { " name ": " com.amazonaws.ecs.capability.docker-remote-api.1.18 " } , { " name ": " ecs.capability.task-eni " } ] , " placementConstraints ": [] , " compatibilities ": [ " EC2 ", " FARGATE " ] , " requiresCompatibilities ": [ " FARGATE " ] , " cpu ": " 256 ", " memory ": " 512 " } 起動スクリプト そして、RDSに接続をする際には以下のようなスクリプトを用います。 スクリプトではFargateコンテナの起動とポート転送を行い、スクリプト終了時にはコンテナを停止します。 スクリプトの終了はCtrl-Cで行うことができます。 #!/bin/bash set -e # タスクが終了するまで待機する場合は wait_stopped=1 を指定する wait_stopped = cluster = " YOUR_CLUSTER_NAME " remote_db_host = " YOUR_DB_HOST " remote_db_port = " 3306 " local_db_port = " 3306 " profile = " ${AWS_PROFILE :- default } " task_definition = " db-proxy:1 " subnets = " subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx " # private subnetsを指定 security_groups = " sg-xxxxxxxxxxxxxxxxx " running_task_arn = "" # Ctrl-C で終了した場合にコンテナを停止する function shutdown() { if [[ -z $running_task_arn ]] ; then exit 0 fi aws \ --profile $profile \ ecs stop-task \ --cluster $cluster \ --task $running_task_arn > /dev/null if [[ -z $wait_stopped ]] ; then exit 0 fi aws \ --profile $profile \ ecs wait tasks-stopped \ --cluster $cluster \ --tasks $running_task_arn > /dev/null } trap shutdown 2 # タスク起動 running_task_arn = $( aws \ --profile $profile \ ecs run-task \ --cluster $cluster \ --enable-execute-command \ --task-definition $task_definition \ --launch-type FARGATE \ --network-configuration " awsvpcConfiguration={subnets=[ $subnets ],securityGroups=[ $security_groups ],assignPublicIp=DISABLED} " | jq -r ' .tasks[0].containers[0].taskArn ' ) # タスクが起動するまで待機 aws \ --profile $profile \ ecs wait tasks-running \ --cluster $cluster \ --tasks $running_task_arn # runtimeId を取得 runtime_id = $( aws \ --profile $profile \ ecs describe-tasks \ --cluster $cluster \ --tasks $running_task_arn | jq -r ' .tasks[0].containers[0].runtimeId ' ) task_id = $( echo " $running_task_arn " | cut -d ' / ' -f 3 ) target = " ecs: ${cluster} _ ${task_id} _ ${runtime_id} " # コンテナに対しポート転送を行う aws \ --profile $profile \ ssm start-session \ --target $target \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ --parameters ' {"host":[" ' $remote_db_host ' "],"portNumber":[" ' $remote_db_port ' "], "localPortNumber":[" ' $local_db_port ' "]} ' 以下のような出力が得られたら、MySQLクライアントなどで 127.0.0.1:3306 に接続することでRDSに接続できます。 Starting session with SessionId: xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69 Port 3306 opened for sessionId xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69. Waiting for connections... その後 Ctrl-C を押すと、以下のような出力が得られ、コンテナが終了します。 Terminate signal received, exiting. Exiting session with sessionId: xxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69. 仮にスクリプトを長時間放置したままにしていた場合や、なんらか不測の事態によりコンテナを正しく終了できなかった場合でも、1時間経過すると自動的に終了します。 まとめ 以上で、ECS Fargate+PortForward+Adhocな機構による踏み台サーバの構成変更を行うことができました。 これにより、EC2インスタンスの管理やSSHの管理、アカウントの管理が不要となり、かつコンテナの起動は必要最低限に抑えられるため、費用も削減できるようになりました。 セキュリティ向上やメンテナンスコストがなくなったのが個人的には一番のメリットでした。 以上、何番煎じか分からないRDS踏み台サーバを作る話でした。
アバター
目次 はじめに range over int range over intとは range over intをGo Playgroundで触ってみる range over intを用いる利点 よくあるfor文が簡潔になる リーディングコストが軽減される range over func range over funcとは range over funcをGo Playgroundで触ってみる func(func()bool) func(func(V)bool) func(func(K, V)bool) range over funcを用いる利点 シンプルで標準的なイテレータを実装できる おわりに Appendix: range over funcを理解するコツ はじめに 株式会社エブリーで DELISH KITCHEN 事業のバックエンドエンジニアをしている、GopherのYuki( @YukiBobier )です。主に 広告サービス を担当しています。 前回の記事 では、Goにおけるヒープ使用量改善手法についてご紹介しました。今回は、 every Tech Blog Advent Calendar 2023 の9日目の記事として、変わりゆくGoの range について取り上げます。 なお、ここで取り上げる内容は開発中のものとなりますので、将来的に変更される可能性があることを申し添えておきます。 range over int range over intとは 早い話、これが for i := 0 ; i < 5 ; i++ { fmt.Println(i) } こう書けるようになるということです。 for i := range 5 { fmt.Println(i) } 2022年10月25日にRuss Coxによって後述するrange over funcと合わせて ディスカッション が開始され、2023年7月18日に プロポーザル が出されたのち、同年10月27日に Accepted となりました。 また、そこで合意された内容として、range over intはGo 1.22に追加されることとなりました(後述しますが、range over funcはGo 1.22ではGOEXPERIMENT入りにとどまります)。 range over intをGo Playgroundで触ってみる Goの開発ブランチにはrange over intがすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。 [Go Playgroundで実行する] package main import "fmt" func main() { for i := range 5 { fmt.Println(i) } } 次のような結果が得られるはずです。 0 1 2 3 4 Program exited. range over intを用いる利点 よくあるfor文が簡潔になる 私たちがrange over intを用いる1つ目の利点として、 for i := 0; i < N; i++ { のような、0からN-1までカウントアップするよくあるfor文が簡潔になります。 特に、カウンタに関心がない場合は前述の例よりもさらに簡潔になります。例えば、ベンチマークテストは下のようになります。 for range b.N { doSomething() } リーディングコストが軽減される 2つ目の利点として、リーディングコストが軽減されます。 通常の3節からなるfor文は、それが0からN-1までカウントアップするよくあるfor文なのか、それとも例えば1からNまでカウントアップするunusualなfor文なのか、その区別はちゃんと読まないとつきません。逆に、どうせよくあるfor文だと思って読み飛ばしていると、実はそうではなくてミスリードが生じるということもあるでしょう。 for i := 0 ; i < 5 ; i++ { fmt.Println(i) } // 👆よくあるfor文とunusualなfor文はちゃんと読まないと区別できない👇 for i := 1 ; i <= 5 ; i++ { fmt.Println(i) } この点、よくあるfor文をrange over intに置き換えることで、そうではないunusualなfor文が通常の3節からなるfor文として際立ちます。つまり、通常のfor文に出会った時だけちゃんと読めばいいので、for文を読む負担が減ります。 for i := range 5 { fmt.Println(i) } // 👆range over intとunusualなfor文はちゃんと読まなくても区別がつく👇 for i := 1 ; i <= 5 ; i++ { fmt.Println(i) } range over func range over funcとは range がイテレータの標準として機能するようになるということです。 ディスカッションでRuss Coxは次のように述べています。 When you want to iterate over something, you first have to learn how the specific code you are calling handles iteration. This lack of uniformity hinders Go’s goal of making it easy to easy to move around in a large code base. People often mention as a strength that all Go code looks about the same. That’s simply not true for code with custom iteration. 現在のGoにはイテレータの標準がなく、それぞれがそれぞれのアプローチをしています。標準ライブラリ内でさえ、それぞれの方法でイテレーションをハンドリングしています。 例えば、 database/sql.Rows のイテレータは次のようになっています。 for rows.Next() { var name string if err := rows.Scan(&name); err != nil { log.Fatal(err) } fmt.Println(name) } 一方で、 archive/tar.Reader.Next では次のようになっています。 for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { log.Fatal(err) } fmt.Println(hdr.Name) } だいぶ違いますね。 このような状況なので、コードを書くにしろ読むにしろ、イテレータについては個々のハンドリング方法を知らなければなりません。同じような目的は同じようなコードで達成されるというGoの長所が、イテレータに関しては発揮されていないということになります。 このような状況を解決するべく、 range を拡張する形でイテレータの標準化が図られることとなりました。それがどのようなものであるかの詳細については、コードをみるのが一番分かりやすいので次項に譲ります。 なお、range over intとともにAcceptedとなりましたが、こちらはGo 1.22ではGOEXPERIMENT入りにとどまり、引き続き詳細が検討されることになりました。環境変数 GOEXPERIMENT に rangefunc を設定することで実験的な使用が可能になります。 range over funcをGo Playgroundで触ってみる こちらもGoの開発ブランチにすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。 なお、range over funcには次の3タイプがあるため、ひとつひとつ取り上げます。 func(func()bool) func(func(V)bool) func(func(K, V)bool) ちなみに、プロポーザルの冒頭ではそれぞれ bool を返すとされていますが、検討を経た結果としてこの戻り値は取り除かれると Discussion Summary / FAQ では述べられており、実際に開発ブランチではそのように実装されています。 func(func()bool) まずは、ループ変数に値を渡さないタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func () bool ) { if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } } func main() { for range rangeFive { fmt.Println( "Hello" ) } } 次のような結果が得られるはずです。おそらく開発中のバグでGo vetが転けていますが、実行には成功しています。 # [play] vet: ./prog.go:26:12: cannot range over rangeFive (value of type func(yield func() bool)) Go vet failed. Hello Hello Hello Hello Hello Program exited. func(func(V)bool) 次に、1つのループ変数に値を渡すタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func ( string ) bool ) { if !yield( "H" ) { return } if !yield( "e" ) { return } if !yield( "l" ) { return } if !yield( "l" ) { return } if !yield( "o" ) { return } } func main() { for s := range rangeFive { fmt.Println(s) } } 次のような結果が得られるはずです。 # [play] vet: ./prog.go:26:17: cannot range over rangeFive (value of type func(yield func(string) bool)) Go vet failed. H e l l o Program exited. func(func(K, V)bool) 最後に、2つのループ変数に値を渡すタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func ( string , int ) bool ) { if !yield( "H" , 4 ) { return } if !yield( "e" , 3 ) { return } if !yield( "l" , 2 ) { return } if !yield( "l" , 1 ) { return } if !yield( "o" , 0 ) { return } } func main() { for s, i := range rangeFive { fmt.Println(s, i) } } 次のような結果が得られるはずです。 # [play] vet: ./prog.go:26:20: cannot range over rangeFive (value of type func(yield func(string, int) bool)) Go vet failed. H 4 e 3 l 2 l 1 o 0 Program exited. range over funcを用いる利点 シンプルで標準的なイテレータを実装できる 私たちがrange over funcを用いる利点としてはやはり、 range を介したシンプルで標準的なイテレータを実装できることです。 例えば、二分木のイテレータを実装したいとします。現在のGoでは次のようになるでしょう。 [Go Playgroundで実行する] package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) getIterator() iterator { return iterator{ stack: []*binaryTreeNode{btn}, } } type iterator struct { stack []*binaryTreeNode } func (i *iterator) hasNext() bool { return len (i.stack) > 0 } func (i *iterator) getNext() binaryTreeNode { var btn *binaryTreeNode btn, i.stack = i.stack[ len (i.stack)- 1 ], i.stack[: len (i.stack)- 1 ] if btn.right != nil { i.stack = append (i.stack, btn.right) } if btn.left != nil { i.stack = append (i.stack, btn.left) } return *btn } func main() { bt := binaryTreeNode{ v: 1 , left: &binaryTreeNode{ v: 2 , left: &binaryTreeNode{ v: 3 , left: &binaryTreeNode{ v: 4 , left: nil , right: nil , }, right: nil , }, right: nil , }, right: &binaryTreeNode{ v: 5 , left: &binaryTreeNode{ v: 6 , left: nil , right: nil , }, right: &binaryTreeNode{ v: 7 , left: nil , right: nil , }, }, } iter := bt.getIterator() for iter.hasNext() { btn := iter.getNext() fmt.Println(btn.v) } } 気になるのは次の点です。 イテレーションの状態を管理する必要があるので、イテレータが別途必要であり実装が複雑である デザインパターン(Iterator)に則ったインターフェースにしているとはいえ、将来の読み手に使い方や意図が伝わるか心配がある これをrange over funcで書き直すと、次のようになります。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) all(yield func (binaryTreeNode) bool ) { if btn == nil { return } if !yield(*btn) { return } btn.left.all(yield) btn.right.all(yield) } func main() { bt := binaryTreeNode{ v: 1 , left: &binaryTreeNode{ v: 2 , left: &binaryTreeNode{ v: 3 , left: &binaryTreeNode{ v: 4 , left: nil , right: nil , }, right: nil , }, right: nil , }, right: &binaryTreeNode{ v: 5 , left: &binaryTreeNode{ v: 6 , left: nil , right: nil , }, right: &binaryTreeNode{ v: 7 , left: nil , right: nil , }, }, } for btn := range bt.all { fmt.Println(btn.v) } } このバージョンには次のような利点があります。 yield func(binaryTreeNode) bool を介してforループとイテレータをシーケンシャルに行き来することから状態管理が不要であるため、イテレータを別途設ける必要がなく実装がシンプルである range を介したシンプルで標準的なインターフェースにより、使い方や意図が明らかである 将来的に、各種ライブラリが range を介したシンプルで標準的なイテレータを実装するようになれば、利用者としてもその恩恵を受けることができるでしょう。 おわりに Go 1.22がリリースされrange over intが使用できるようになったら、既存のforループをゴリゴリ書き換えて可読性を高めていきたいと思っています。 また、最終的にどのような形に落ち着くかはまだ確定していないものの、もしrange over funcが正式に追加されたら、既存の各種ライブラリに大きな変化を促すGenerics以来の機能追加になると思います。こういった大きな変更にリリース前から触れて慣れておくことができるのは、オープンに開発されているメリットだと感じます。 いやあ、やっぱりGoっていいですね。 Appendix: range over funcを理解するコツ この記事を書くにあたって、range over funcについてきっちり調べたのですが、実は、range over funcに触れたのは今回が初めてではありませんでした。以前とある勉強会で登壇者が紹介しているのを聞いたのがファーストコンタクトだったのですが、その時は仕組みを理解できませんでした。 きっとrange over funcを理解するのが難しいというのは私に限ったことではないと思うので、最後におまけとしてこれを理解するコツを紹介したいと思います。 range over funcを理解するコツは、 これは被訪問者側に主導権があるVisitorパターンだと思ってコードを読んでみること です。 デザインパターンのVisitorは、被訪問者クラスがVisitorクラスを受け入れ、それによりVisitorクラスが被訪問者クラスの構造を巡りながら処理を行うパターンです。 range over funcにおけるVisitorは、上の二分木の例における yield です。この yield というVisitorが引数という玄関から all メソッドというイテレータに訪問し、値を受け取ってはループに送り込んでいます。 ただし、本家のVisitorパターンと異なるのが、range over funcにおけるVisitorは構造を巡る主導権を持たないということです。次の値、次の値と構造を巡る実装が書かれているのは被訪問者側である all メソッド側です。言うなれば、訪問者 yield は引数という玄関から入ったのち、主の all に手を引かれて屋敷の中を案内され、次から次へとお土産を持たされるイメージです。そして、 all が渡すべきお土産が尽きるか、 yield がこれ以上はいらないと言うかして( break )訪問が終了するというわけです。 なんとなくお分かりいただけたでしょうか? 理解の助けになったならば幸いです。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2023 の 8 日目です。 トモニテのiOSアプリは今年、トモニテ妊娠アプリの開発を期にSPMを用いたマルチモジュール構成に移行しました。 これらのアプリにはアカウント管理やデザインシステムなど共通部分が多くあります。また一部機能は重複しているため、コード共通化をしやすくするのが主目的でした。 この記事ではマルチモジュール構成への移行をどのように進めたかと結果について書きたいと思います。 コード共通化の方針 以下の選択肢がありました。 A: コード共通化をしない B: 共通部分を別リポジトリに切り出し、アプリもそれぞれ別リポジトリで運用する C: 1プロジェクト(1リポジトリ)で複数アプリを開発する コード共通化にはデメリットもあります。片方のアプリを修正した時もう一方のアプリにも影響する可能性があり、本来は必要のなかった調査や動作確認が必要になるかもしれません。しかし今回は共通部分が多く共通化のメリットの方が大きいと判断したためAは選びませんでした。 Bは、共通部分がアプリ本体と疎で、変更頻度が低ければ良い選択肢だったと思いますが、今回は選びませんでした。 モジュール構成を決める モジュール間の依存が循環しないように関係を整理しつつ、分割方法を決めます。 まず、共通部分を以下のように、レイヤーに沿って分割しました。 Utilities: 便利クラス、Extensionなど Network: 外部通信 Model: モデル Core: Feature間の画面遷移の仕組みなどを提供する 一方、機能ごとに Feature モジュール群を作成しました。 Home: ホーム Media: 記事や記事検索機能など Childcare: 育児記録機能 Babyfood: 離乳食機能 … これらの Feature モジュールは共通モジュール群に依存しますが、各Feature が相互に依存することは禁止しています。 Featureモジュール間の参照をせずに、Feature間の画面遷移を可能にする実装についてはこちらの記事を参考にさせていただきました。 メルペイのスケーラビリティを支えるマルチモジュール開発 この仕組みによってFeatureモジュールの独立性が保たれ、必要なモジュールだけをターゲットに組み込むことができるようになりました。 モジュールを切り出す 方針に沿ってモジュールを作成していきました。他への依存が少ない部分から順番に進める必要があります。Utilities -> Network -> Model -> Core -> 各Feature のような順序です。 Xcodeでプロジェクト内にパッケージを作成し、 Package.swift ファイルにパッケージの定義と依存関係を記述します。 import PackageDescription let package = Package( name : "Network" , platforms : [ .iOS ( .v14 ) ] , products : [ .library ( name: "Network" , targets: [ "Network" ]) ] , dependencies : [ .package ( url: "https://github.com/Moya/Moya.git" , .upToNextMajor ( from: "15.0.0" )) , .package ( path: "Utilities" ) ] , targets : [ .target ( name: "Network" , dependencies: [ "Moya", "Utilities" ] , resources: [ .copy ( "Stab" )] ) ] ) あとはファイルをパッケージの中に移動し、外部から参照される宣言をpublicにするなど、アクセス修飾子を修正します。 移動したコードに本来あるべきでない依存があった場合は、依存関係をなくすための修正が必要になる場合も多々あります。 作業量がかなり多くなりますが、一度に終わらせる必要はなくパッケージ単位でリリースが可能です。通常の開発と並行して少しずつ進め、3ヶ月程度かかりました。 開発用の minimumTarget マルチモジュールの利点を活かし、新規機能や新規画面を作る場合の開発効率を上げるために minimumTarget というものを作りました。 開発対象の Feature と最低限の共通部分だけをターゲットに組み込んで開発でき、以下のような利点があります。 ビルド時間短縮 トモニテ本体と比較して、クリーンビルド時間 141 秒 → 38 秒 シミュレータでのデバッグ開始が早い SwiftUIのプレビューを利用可能 本体ではプレビューが表示されるまでの時間が非常に長く実質利用できなかったのが利用可能になります Xcode Cloud デバッグ用の Firebase App Distribution 配布と App Store Connect へのサブミットを Xcode Cloud で自動化していて、リポジトリに変更を加えると二つのアプリが配布/サブミットされます。 結果 これまで書いてきたとおり開発効率を向上できました。 複数のアプリに同様の変更を加える場合、重複した開発をせずに済む モジュール間の依存関係が整理され、コード変更の影響範囲を把握しやすい パッケージ内のファイルは xcodeproj ファイルで管理されなくなるので、 xcodeproj のコンフリクトがほぼ無くなる minimumTarget で時間短縮 一方よくない点もありました。 複数のアプリへの影響を考慮しながら開発する必要がある 本来開発対象ではないアプリへの影響を調査したり、動作確認が必要になる可能性がある しかし全体としては利点が大きく、移行した価値があったと判断しています。 この記事がどなたかの参考になれば幸いです。
アバター