TECH PLAY

株式会社エブリー

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

385

初めまして,トモニテ開発部でSoftware Engineer(SE)をしている鈴木です. SEチームはAPI開発からそのAPIを利用したweb開発まで幅広い領域を担当しており,トモニテ開発部のweb開発には Next.js を採用しています. また,エブリーの開発部では定期的に挑戦week(※)なるものを開催し,技術的観点から事業貢献を行う1週間を設けており,その中でNext.jsの Pages Router を App Router に移行する機会を頂けたので紹介させていただきます. ※ 挑戦weekについては以下のオウンドメディアで記事にしています.今回の挑戦weekは事業部の垣根を超えたチーム編成で行い,大変面白いものでした! エンジニアが楽しみながら開発体験を向上させる「挑戦week」を実施しました! 背景 Next.jsでルーティングを行う際, v13以前は Pages Router を利用する必要がありましたが,v13以降は App Router という仕組みが選択肢に加わりました. App Routerは React の最新機能( React Server Components や Suspense など)に追従したルーティングシステムであることから,Next.js公式には 新しいアプリケーションを作成する際にはApp Routerの利用が推奨 されており,既にApp Routerでしか利用出来ない機能の開発も見受けられます. これらのことから,幾つかのメジャーアップデートを経てApp RouterがNext.jsのルーティングシステムの主流になることが予想されるため,長期にわたりNext.jsの最新機能に追従していくためにはPages RouterからApp Routerへの移行が必要だと判断し挑戦する運びになりました. 移行対象 影響の大きさを鑑みて,移行対象にはトモニテで利用しているdashboardを採用しました. 移行の流れ 1. Pages Routerの各ページをApp Routerのページに変換するスクリプトの作成 当然ですが,App Routerへ移行するためにはPages Routerのページを移行することが必須です. Pages RouterとApp Routerは 共存が可能 なため,1ページずつ移行していくことが出来るのですが,一つずつ置換していくと移行を終えるまでにかなりの時間を要してしまうことや,dashboard以外にもApp Routerに移行したい対象があるため,一括で置換するスクリプトを作成することになりました. 公式からは Codemods の置換スクリプトは提供されていないため,Pages Routerの各ページに対し以下のルールに基づいた変換をするスクリプトを自作しました(※). 手間は要しますが実装に困難な点はそれほど無いかと思います. use client の付与 [xxxx].tsx を [xxxx]/page.ts に変換 xxxx/index.tsx を xxxx/page.tsx に変換 xxxx.tsx を xxxx/page.tsx に変換 ※参考程度になりますが Gist で公開しています 2. ログイン前後のレイアウトの作成 トモニテのdashboardはログイン前後でレイアウトが異なるため,それぞれのレイアウトを作成しました. dashboardはログイン前にしかアクセス出来ないページとログイン後にしかアクセス出来ないページに二分されていたため, Route Groups という機能を用いることで比較的簡単にレイアウトの出し分けを実現出来ました. 以下のように括弧で囲まれたディレクトリ名をつけることでRoute Groupsを利用出来ます. app ├── ( loggined ) │ ├── layout.tsx │ ... └── ( unloggined ) ├── layout.tsx ... 3. _app.tsx 及び _document.tsx と等価な処理の再現 App Routerでは _app.tsx と _document.tsx が廃止されたため,それらが行っていたことと等価なことを file conventions や middleware を用いて再現する必要があります. トモニテのdashboardでは _app.tsx でsessionの管理やレイアウトの出し分けなどを, _document.tsx ではヘッダーの管理などを行っていたため,middlewareと layout.tsx を用いて再現しました. 注意点として,App Routerでは Header コンポーネントが廃止 されており,代わりに layout.tsx から Metadata オブジェクト をexportするようになっています.このMetadataオブジェクトですが,Headerコンポーネントを用いて実現していたこと全てが再現出来る訳でなく,例えば CDNからstylesheetを読み込むことが出来なく なっています.この点については,closeはされていますが issue にもなっており解決するのには時間がかかりそうであることと,幸いなことにファイルのimportで解決できるもの以外は影響が小さかったため後回しにすることになりました. 4. ルーティングに関するhooksの移行 App Routerではルーティングに関するhooksを next/router ではなく next/navigation からimportするように変更 されており, useRouter hookが持っていた役割も useRouter と usePathname , useSearchParams の3つのhooksに分解されています.従って,画像のように,next/routerを用いて router.push と記述していたコードはnext/navigationのuseRouterを, router.query と記述していたコードはuseSearchParamsを用いて置換する必要があります. useRouterの利用箇所自体は多岐に渡っていたため,修正にはだいぶ手間が必要となりました. また,この際,従来のuseRouterが持っていた events 等の一部の機能がnext/navigationでは実装されていないことに注意が必要です.eventsが実装されていないことから,Pages Routerで簡単に実現出来ていたページ遷移中の処理をApp Routerでは簡単には実現出来なくなりました. 公式からは usePathnameとuseSearchParamsを利用したコンポーネントを作成することによる再現方法 が提示されており,これを用いれば再現可能かと思われますが,トモニテのdashboardではページ遷移の進捗を表示するためのプログレスバーにしか用いておらず,進捗を確認するニーズの有る程重いページが存在しなかったため,再現せずに先に進むことになりました. ルーティングに関するhookの遷移 5. その他 React Server ComponentsではRuntime Configが利用出来ない ため,環境変数で置き換えるなどの移行作業をしました. 移行作業を通して感じたポイント 移行作業を通して, _app.tsx 及び _document.tsx の大きさが移行コストの大部分を決定するように感じました. これらのファイルで行っている事が大きいほどApp Routerのfile conventionsやmiddlewareに対する知識と実装経験が求められるためです. また出来なくなることや,再現に一手間が必要になることを整理した上で移行に望むと見通しがつきやすいように思います. まとめ 「Next.jsのPages RouterからApp Routerへの移行に挑戦してみた」と題し,実際に挑戦week中に行った移行作業について紹介させていただきました. 後回しにしたものなど一部やり残しはありつつも,主要部の移行自体は出来たのではないかなと思っております. 何より,移行作業を通してfile conventionsやRoute GroupsなどのApp Routerでのみ利用出来る機能を学び,利用することが出来,知的好奇心が刺激された一週間でした. アプリケーションの規模や利用している機能に依存しますが,移行する際にはおおよそ同様の流れを取るのではないかと感じており,この記事がPages RouterからApp Routerへの移行を検討されている開発者の方々のお役に立てたら大変嬉しいです. ここまでお読みいただきありがとうございました!
アバター
DELISH KITCHEN はレシピを動画でわかりやすく基本的な料理からアレンジまで様々なレシピを公開しています。 実はレシピ動画以外にも、季節にそったおすすめレシピ、素材についての解説、料理に役立つ情報などが記事にまとめられ公開されています。ご存知でしたか? 例えばこういった記事です。 食欲の秋にピッタリ!秋が旬の野菜を使ったレシピ6選 なす?なすび?呼び方の由来について解説します! 坊ちゃんかぼちゃを詳しく解説・特徴やレシピもご紹介♪ だし汁とは?簡単なだしの作り方もご紹介! 今回は、この記事を Android アプリで閲覧できるようにした話をしたいと思います。 記事とは まずは一例を見ていただければと思います。 目次 本文 記事の概要と見出し おいしそうな出汁… レシピ動画は料理の手順を簡単に把握しやすいのですが、一方で素材の魅力にふれるにはあまり適していない表現方法かと思います。記事では、そういった素材の魅力であったり、より詳細な解説や料理に役立つ情報などを紹介しています。 粉末だしとはその名の通り、鰹節や昆布などの素材を乾燥させて粉末状に加工したものです。製造過程で熱を加えていないため、より素材の自然な風味を感じられます。 なるほど…ふだんは顆粒だしを使っているのですが、今度は粉末だしを使ってみようかな? 記事のデータ 早速ですが記事について技術面の話をしたいと思います。 まずは記事を構成するデータの構造についてです。 ※ 以降の内容は具体的な処理については割愛し、掲載するデータもイメージとなります 先に紹介した記事の内容は記事 API を介して JSON 形式でデータを取得します。 { " article_id ": 123456 , " title ": " だし汁とは?簡単なだしの作り方もご紹介! ", " description ": " 和食におけるだし汁の役割はとても大きく… ", " thumbnail ": " https://tech.blog.com/images/header.png ", " created_at ": " 2022-06-02T16:00:00Z ", " contents ": [ { " type ": " image ", " image_url ": " https://tech.blog.com/images/main.jpeg " , } , { " type ": " header1 ", " text ": " 和食に欠かせないだし汁とは? " } , { " type ": " paragraph ", " text ": " だしにはさまざまな種類がありますが、中でも一般的によく使われている素材が鰹節や昆布… " } , ... } 記事は title や description など記事共通の要素の他に、本文などが含まれる contents で構成されています。 contents は記事の内容を柔軟に表現できるよう可変長になっています。その中身は、要素を区別する type と内容(テキストや URL など)の組み合わせで構成されたオブジェクトです。 これらのデータはアプリ側で扱いやすいように type で区別して各要素に変換します。 sealed class Content { // 見出しの属性として sealed class Headline : Content() { abstract val headline: String abstract val subHeadline: String ? } // 見出し1 data class Headline1( override val headline: String , override val subHeadline: String ?, ) : Headline() // 見出し2 data class Headline2( override val headline: String , override val subHeadline: String ?, ) : Headline() // 段落 data class Paragraph( val text: String , ) : Content() // 画像 data class Image( val imageUrl: String , ) : Content() ... } パースされた要素はリスト(RecyclerView)に追加します。 リストへの追加 各要素を RycyclerView に add するタイミングで type に応じた ViewHolder に変換して追加します。 以下、Groupie を使って作成した Adapter と ViewHolder の一部です。 ※ Groupie に関する詳細は github を参照してください class ArticleAdapter( private val listener: Listener ) : GroupAdapter<GroupieViewHolder>() { ... interface ContentItem interface SectionItem : ContentItem interface TitleItem : ContentItem interface SubTitleItem : ContentItem interface DescriptionItem : ContentItem interface OtherItem : ContentItem private class ContentHeadline1Item( private val item: Content.Headline1 ) : BindableItem<ItemContentHeadline1Binding>(), TitleItem { override fun getLayout() = R.layout.item_content_headline1 override fun initializeViewBinding(view: View) = ItemContentHeadline1Binding.bind(view) override fun bind(viewBinding: ItemContentHeadline1Binding, position: Int ) { viewBinding.title.text = item.headline } } ... } ContentHeadline1Item は ViewHolder です。このように、要素に合わせた ViewHolder を用意して各要素を変換します。 ここで ContentItem や SectionItem などの interface が気になった方がいらっしゃるかもしれません。この interface については後ほど触れたいと思います。 要素間のマージン ViewHolder に変換した要素は ViewHolder 内で指定したレイアウトが適用されます。下図のようにレイアウト内であれば目次のタイトルやリストなどマージンの調整は容易ですね。では、要素間のマージンはどうでしょうか? 目次は通常の layout.xml 内で完結できるので簡単です 見出しなら上部に 24 dp、画像なら上下に 16 dp のように要素ごとに固定のマージンなら単純だったのですが、記事の場合は前の要素によってマージンを変更する必要がありました。ひと手間かける必要がありそうです。 結論から言えば、今回の要素間のマージンは RecyclerView の ItemDecoration を使って解決しました。ItemDecoration はリストに追加されるアイテムにオフセットを追加する便利な仕組みを持っています。 この ItemDecoration の実装で先ほど触れた ContentItem などの interface が役に立ちます。 準備として、各 ViewHolder に適した interface を関連付けておきます。 以下、実装の一部です。 class MarginDecoration( resources: Resources, @IdRes private val space: Int ) : RecyclerView.ItemDecoration() { // space を元に必要な marginXX を定義 ... override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val itemCount = parent.adapter?.itemCount?.let { it - 1 } ?: 0 val position = parent.getChildAdapterPosition(view) // 最後の要素は下にもマージンを設ける val bottom = if (itemCount == position) margin40 else 0 // 最初の要素は上のマージンを固定 val top = if (position == 0 ) margin24 else null // 上のマージンは前の要素との関係によって決定するため、一つ前の ViewHolder を取得する val previousHolder = if (position > 0 ) (parent.adapter as? ArticleAdapter)?.findContentItemBy(position - 1 ) else null val currentHolder = parent.getChildViewHolder(view) as? GroupieViewHolder // 前の要素との関係でマージンを変える判定をここで行う when ( val item = currentHolder?.item) { is TitleItem -> outRect. set ( 0 , top ?: margin40, 0 , bottom) is SubTitleItem -> when (previousHolder) { is SectionItem -> margin40 else -> margin24 }.let { outRect. set ( 0 , top ?: it, 0 , bottom) } ... is OtherItem -> outRect. set ( 0 , top ?: margin24, 0 , bottom) else -> { // nothing } ... } getItemOffsets() はリストに要素が追加されるタイミングで要素にオフセットを追加することが可能です。パラメータの outRect に set(left, top, right, bottom) することで要素にマージンを適用します。 outRect.set() する際に現在の要素が SubTitleItem で前の要素が SectionItem なら 40 dp …と考えられる組み合わせを条件で定義して適切なマージンを設定しました。 以下、記事を実装した結果です。 目次 本文 アプリ版の目次部分 アプリ版の本文 ContentItem などの interface を用意したことで5種類の interface といくつかの要素を考慮するだけで済みました。新しく要素を追加する場合も適した interface を関連付けるだけで済みます。記事の要素として用意された type は10種類以上あるので、個別に対応することを考えると…脳が震えます。 最後に こうして DELISH KITCHEN の Android アプリでも記事が閲覧できるようになりました。一生懸命作った機能ですし、読み物としても面白いと思うので、ぜひ色々な記事を読んでみてほしいです。
アバター
はじめに iOSにはデフォルトで「ヘルスケア」というアプリが存在することをご存知でしょうか。 弊社のDELISH KITCHENアプリでは昨年ヘルスケアという新機能をリリースしましたが、日々改修を重ねていく中でヘルスケアアプリにも着目し、色々と調査を行いました。 今回はその調査内容について纏めていきたいと思います。 ヘルスケアアプリとは 赤丸で囲ったものがヘルスケアアプリです。 こちらのアプリでは、歩数などのアクティビティ情報や、血圧などのバイタル情報といった健康・医療情報を一つのアプリで管理できるものとなっており、簡単に情報を閲覧・編集することができます。 私の場合、Apple Watchで歩数を記録し、ヘルスケアアプリで一週間の平均歩数を確認する、といった使い方をしています。 まだ使用されたことがない方は一度触れてみてはいかがでしょうか。 HealthKitについて https://developer.apple.com/jp/health-fitness/ プログラムからヘルスケアアプリの情報にアクセスする場合、 HealthKit を使用します。 HealthKitを使用することで容易にヘルスケアアプリの情報にアクセスすることができます。 https://www.proofpoint.com/jp/threat-reference/hipaa-compliance 余談にはなりますが、医療分野で業務を行う立場の場合は HIPAA という法律に従うことになります。 今回はHIPAAについて深くは触れませんが、健康情報を記録するアプリ(非HIPAAアプリ)と医療行為に関わるアプリ(HIPAA対象アプリ)で作成するプライバシーポリシーやアプリ申請内容が異なりますので、よく内容を理解してから実装することをお勧めします。 ヘルスケア連携手順 ここからは実際にプログラムからヘルスケアに連携する方法を纏めていきたいと思います。 今回はHealthKitを使って歩数の情報を取得する方法を纏めます。 開発環境 Xcode Version 14.3.1 (14E300c) 開発言語 : swift Capability追加 TARGETS > Signing & Capabilities にて、HealthKitを追加 今回、臨床記録のデータにアクセス、およびバックグラウンド配信は行わないため、以下のチェックは不要です。 info.plist更新 info.plistにて、以下2項目を追加 - Privacy - Health Update Usage Description … ヘルスケアのデータを更新するための許可を求める時に表示される文言 - Privacy - Health Share Usage Description … ヘルスケアのデータを取得するための許可を求める時に表示される文言 Valueに書かれた文字列が、後述する権限確認ダイアログに表示されます。 認証処理実装 ここからは実装に入ります。 let healthStore: HKHealthStore init() { healthStore = HKHealthStore() } HKHealthStore のインスタンスを介して認証、取得、更新を行いますので、まずはインスタンスの生成をしておきます。 func auth() { // 更新したいデータ let shareTypes = Set([ // 歩数 HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)! ]) // 取得したいデータ let readTypes = Set([ // 歩数 HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)! ]) // 認証実施 healthStore.requestAuthorization( toShare: shareTypes, read: readTypes, completion: { success, error in if success { print("許可されました") } else { print("却下されました") } } ) } 次に認証処理となります。 requestAuthorization でユーザに対してアクセスする情報の権限確認ダイアログを表示します。 toShare には更新したいデータを設定、 read には取得したいデータを設定します。 ※plistで設定する情報とshareの意味が異なるので注意が必要です。 実行すると上記のような権限確認ダイアログが表示されます。 このダイアログには前述したplistに設定した文言が表示されます。 取得処理実装 ヘルスケアアプリからデータを取得する処理を実装します。 func getStepCount(fromDate: Date, endDate: Date) { let query = HKSampleQuery( // 取得したいデータの種別 sampleType: HKSampleType.quantityType(forIdentifier: .stepCount)!, // データの期間 predicate: HKQuery.predicateForSamples(withStart: fromDate, end: endDate), // 取得件数の上限 limit: HKObjectQueryNoLimit, // ソート sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)], resultsHandler: { (query, results, error) in guard error == nil else { return } // 取得した結果をサンプリングデータの型に変換 if let itemList = results as? [HKQuantitySample] { for item in itemList { print("記録日時 : \(item.endDate)") print("歩数 : \(String(item.quantity.doubleValue(for: .count())))") } } } ) healthStore.execute(query) } HKSampleQuery でクエリの情報を作成し、 execute メソッドに流すという実装となります。 更新処理実装 ヘルスケアアプリからデータを更新する処理を実装します。 func updateStepCount(targetData: Date, stepCount: Double) { // 更新したいデータの種別 let type = HKObjectType.quantityType(forIdentifier: .stepCount)! // 設定するデータ let quantity = HKQuantity(unit: .count(), doubleValue: stepCount) // サンプリングデータ作成 let sample = HKQuantitySample(type: type, quantity: quantity, start: targetData, end: targetData) healthStore.save(sample, withCompletion: { (success, error) in if success { print("成功") } else { print("失敗") } }) } HKQuantitySample でサンプリングデータを作成し、 save メソッドに流すという実装となります。 取得・更新時の注意点 取得・更新処理を前述しましたが、データを取り扱う際の注意点があります。 // 設定するデータ let quantity = HKQuantity(unit: .count(), doubleValue: stepCount) 更新処理のこちらの部分ですが、インスタンスを生成する際に第1引数に HKUnit を設定しています。 https://developer.apple.com/documentation/healthkit/hkunit HKUnitは「単位」のクラスになりますが、実装する際は設定するデータと単位が一致しないとExceptionが発生してしまいます。 今回は歩数のため、カウントの単位を返す.count()を設定していますが、 let quantity = HKQuantity(unit: .kilocalorie(), doubleValue: stepCount) のように、歩数にキロカロリーの単位を返す.kilocalorie()を設定してもビルド時にはエラーを検知できず、実行できてしまいます。 ヘルスケアアプリでは複数の情報を管理する都合上、単位の種類も多いのですが、適切な単位を設定する必要がある点に注意してください。 おわりに HealthKitを使用したヘルスケアアプリとの連携方法を纏めましたが、前述の設定、実装のみでアクセスできるため、敷居は低く、簡単に実装ができました。 近年、UIUXを向上させるためには簡単にデータを取得・編集ができる、別アプリと連携できることが必須だと感じていますが、健康志向の方も多くなり、またスマートウォッチが普及したこともあり、ヘルスケアアプリでデータを記録しているユーザも増えている印象なので、健康情報などヘルスケアアプリでも管理している情報を扱う際はHealthKitを導入することで十分な効果が見込めると思います。 今回はHealthKitの触り部分の紹介となりますが、ヘルスケア連携の実装を考えている方にとって少しでも参考になれば幸いです。
アバター
はじめに 子育てメディア「トモニテ」でバックエンドやフロントエンドの設計・開発を担当している桝村です。 2023年8月1日、MAMADAYSはトモニテに生まれかわりました。 tomonite.com アプリのメイン機能である「育児記録」「妊娠週数管理」「食材リスト」を軸として、家族やパートナー、家族以外の人や社会との接点を作るためのシェア機能やコミュニティ機能などの拡充をめざしていきます。 今回は、Continuous Profiling を実施することができる Pyroscope を使用して、トモニテで運用している Go サーバーのメモリリークを調査・改善した話をしたいと思います。 以前、 トモニテでEKSからECSに移行した話 という記事において、EKS on EC2 から ECS Fargate への移行に伴い、厳密なリソース管理の必要性が生じ、メモリリークを検出した件の対応になります。 tech.every.tv Pyroscope について Pyroscope とは Continuous Profiling を実施することができるオープンソースのプラットフォームです。 Continuous Profiling とは、ソフトウェアやアプリケーションが実行されている間、リアルタイムでパフォーマンスデータや実行情報を収集し、CPUやメモリ等のリソースをどこで多く消費しているか分析・チューニングする手法です。 2023年3月にデータ可視化ツール Grafana などを開発している Grafana Labs が Pyroscope を買収し、 オープンソース Grafana Pyroscope として統合されました。 本記事では Grafana Pyroscope の情報にも触れつつ、Pyroscope で Go サーバーのメモリリークを調査・改善した話となります。 Pyroscope でできること コード内のパフォーマンスに関連する問題やボトルネックを見つけることができます。例えば、CPU使用率の上昇やメモリリークの発生等です。 pyroscope の Single View 画面 タグ機能 Tags や時間指定により、UI上のプロファイリングデータを絞り込みできます。 pyroscope の Tags でのフィルタリング画面 ビュー機能 Comparison View により、2つの時間区間を並べて比較分析できます。 pyroscope の Comparison View 画面 ビュー機能 Diff View により、2つの時間区間の差分を取得してヒートマップのように色付けして比較分析できます。 pyroscope の Diff View 画面 参考: demo.pyroscope.io Pyroscope の仕組み Pyroscope Agent という言語ごとに用意されているプロセスが、アプリケーションの動作を定期的に記録・集計し、そのデータを Pyroscope Server に送信します。 Pyroscope Server がそのデータを処理・集約することで、ユーザーがプロファイリングの結果を WEB UI から Flame Graph (フレームグラフ)を閲覧することが可能になります。 なので、Pyroscope で Continuous Profiling を実施するには、 Pyroscope Agent と Pyroscope Server の設定が必要になります。 pyroscope の全体像 参考: pyroscope.io Grafana Pyroscope について 2023年3月にデータ可視化ツール Grafana などを開発している Grafana Labs が Pyroscope を買収し、 オープンソース Grafana Phlare と統合され、 Grafana Pyroscope になりました。 参考: grafana.com また、2023年8月末に Grafana Pyroscope として version 1.0 が公開されました。 このバージョンでは、以下をはじめとした改善が行われました。 Grafana と完全に統合され、Grafana ダッシュボードでメトリクスやログ、トレースなど他の可観測性の指標と一緒に Profiling のデータを表示可能になりました。 様々なオブジェクトストレージサービス (ex. AWS S3, Google Cloud Storage) との統合がサポートされ、プロファイルデータをストレージサービスに保存可能になりました。 水平方向のスケールアウトがサポートされ、あらゆる規模のプロジェクトで最適なパフォーマンスを出すことが可能になりました。 参考: github.com Pyroscope を活用したトラブルシューティング 問題だったこと トモニテで利用している Go サーバーの基盤である ECS のメモリ使用率が時間の経過とともに上昇し続けるという問題がありました。 もしサーバーのメモリ解放せず長時間経過した場合、サーバーの一時停止といったリスクがあったため、調査・改善が求められていました。 ECS メモリ使用率 改善前 Pyroscope の導入 ① Pyroscope Server の起動 Pyroscope Server をサーバー上で Docker 経由で起動しました。 Pyroscope によるリソース消費の最適化のため、データの保持期間 retention 、 exemplars-retention を調整し、それ以外の設定値は、デフォルトの値を利用しました。 ② Pyroscope Server と Go サーバーのネットワーク設定 それぞれでAWSアカウントが異なる構成だったのもあり、ネットワークの疎通のため、VPCピアリング接続やルーティングテーブルへのルートの設定をしました。 ③ Pyroscope Agent の起動 Profiling したいサーバーは Go 製なので、同様に Go の Pyroscope Agent を利用しました。 Pyroscope Server 側のリソース消費の最適化のため、 SampleRate を調整しました。 また、アプリケーションサーバー側の基盤が ECS なのもあり、バージョン単位でより詳細に分析できるように、 Tags にタスク定義のバージョンを設定しました。 // Init initialize func Init() { address := conf.PyroscopeAddress() if address == "" { return } pyroscopeConfig := pyroscope.Config{ ApplicationName: "tomonite-server" , ServerAddress: address, SampleRate: conf.PyroscopeSampleRate(), Tags: map [ string ] string { "taskArn" : ecs.TaskARN(), "taskDefinitionVersion" : ecs.TaskDefinitionVersion(), }, } if _, err := pyroscope.Start(pyroscopeConfig); err != nil { log.Alert(fmt.Errorf( "failed to start profiling for pyroscope. err: %w" , err)) } } Profiling の結果 時間の経過とともに Go サーバー全体に対してメモリの使用率が著しく上昇している処理を特定することができ、その処理は、 grpc-go というモジュールであることが分かりました。 ある時点A grpc-go のメモリ使用率はサーバー全体の15%ほど ある時点Aから12時間後 grpc-go のメモリ使用率はサーバー全体の30%ほど さらなる調査・改修対応 grpc-go は Go サーバー側で明示的に利用されている実装箇所を発見できなかったため、メインのモジュールとの依存関係を調査しました。 すると、Cloud Firestore データベースの読み取りと書き込みのためのクライアントを提供する cloud.google.com/go/firestore が依存していたことが判明しました。 $ go mod why -m google.golang.org/grpc github.com/everytv/tomonite-server/db cloud.google.com/go/firestore google.golang.org/grpc 実際に firestore 周りのコネクションが保持されてそのままになっている処理があったので、コネクションを解放するため、 (*firestore.Client).Close() を追記してリリースしました。 firestore のコネクションを解放する修正 その結果、Go サーバー全体に対するメモリの使用率の増加が顕著に緩やかになり、無事にメモリリークの問題を解決することができました。 ECS メモリ使用率 改善後 おわりに 今回は Pyroscope と Continuous Profiling 、Pyroscopeを活用したトラブルシューティングの事例について紹介しました。 Pyroscope を利用するメリットとして、WEB UIで直感的に操作できたり、ビュー機能である Comparison View や Diff View でより効率的な分析が可能なところだと考えてます。 Continuous Profilingをしておくことで、問題発生後に Profiling 結果を得て、即座に調査が可能になります。今後も Pyroscope をうまく活用してサービスの安定稼働を実現していきます。 また、Grafana Labs による買収・統合に伴い、さらなる機能開発が見込まれるため、今後の開発の動向を注視しつつ最新バージョンへのアップグレードも検討できればと考えています。
アバター
はじめに DELISH KITCHENで主にiOSの開発やマネジメントを担当している久保です。 以前、 DELISH KITCHEN iOSアプリ開発のCI環境について という記事でCI環境を紹介しました。 今回は、Xcode Cloudの導入経緯とCI/CD環境の変化についてご紹介します。 Xcode Cloudへ移行した理由 Xcode Cloud の発表以降、さまざまな試行を行ってきましたが、特に以下の理由から全面的な導入を決定しました。 他のサービスと比較して機能が少ないため、学習コストが低い TestFlightやApp Store Connectへのアップロード時に証明書の管理が不要 XcodeやmacOSのアップデートへの追従が速い ビルド番号の管理が不要 Firebase App DistributionからTestFlightに移行した理由 アプリ配布に使うサービスも、 Firebase App Distribution からTestFlightに移行しました。App Distributionを用いてAdHoc配信をしていた際には、以下の作業が発生していました。 インストールしたい端末のUUIDを登録 Provisioning profileの更新 これらの作業はfastlaneを使って半自動化していたのでそこまで手間ではなかったのですが、TestFlightを利用することによってこれらの雑務から解放されました。 Xcode Cloud以外を利用している場合は、App Store Connect APIを利用するようなスクリプトを組むことになると思うのですが、Xcode Cloudはここもよしなに処理してくれるというメリットがあります。 構成 Xcode Cloud導入後のアプリの社内向け配布およびApp Store Connectへ提出を行う全体のフロー図を以下に示します。 前回の記事ではfastlaneが色々な役割を果たしていたのですが、今回の用途に限るとfastlaneは不要になりました。 Release版のワークフロー App Store Connectへの提出と最終的な動作確認のための配布を行います。 特定のプレフィックスを含むタグがGitHubにプッシュされると、次のワークフローが実行されます。 TestFlightで内部テスターに配布 App Store Connectへの提出 成功または失敗をSlackチャンネルに通知 Develop版のワークフロー 主に社内関係者への配布を行います。別アプリとして扱いたいため、Release版とは別のバンドルIDを設定しています。 こちらも同様に、特定のプレフィックスを含むタグがGitHubにプッシュされると、次のワークフローが実行されます。 TestFlightで内部・外部テスターに配布する 成功または失敗をSlackチャンネルに通知 内部・外部テスターの使い分け 内部テスターと外部テスターの使い分けに関して、以下に簡単な違いをまとめました。 内部テスター (Internal Testers) 外部テスター (External Testers) Apple ID App Store Connectで管理されたApple IDが必要 特に制限なし 配布までの時間 アップロード後即時 審査通過後 デザイナーやプロダクトマネージャーなど、すぐに確認してもらいたい場合は内部テスターを、手軽に確認したい利用者向けには外部テスターを利用してもらっています。 まとめ Xcode Cloudの導入により、特にデプロイに関連する作業がシンプルになりました。 あえて気になる点を挙げると 設定がGUI App Store Connectに存在しないアプリに対してワークフローが設定できない 任意のスクリプトを実行する機構 はあるが、実行タイミングが限定的 キャッシュの設定などが不明瞭 などが考えられます。 今回はデプロイに焦点を当てた内容になりましたが、iOS開発の取り組みに関して少しでも知っていただければ幸いです。
アバター
Google CloudのData Analytics Workshopに参加してきました! こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 今回は、先日参加したGoogle CloudのData Analytics Workshopについて紹介します。 はじめに エブリーでは、各サービスからのログをデータ基盤に集約し、これをデータ分析や機械学習のために活用しています。 データ基盤の構成は、Databricks、TreasureData、そしてRedashを組み合わせたものです。 具体的には、DatabricksでETL処理を行い、そのデータをTreasureDataに保存します。 そして、Redashを用いてデータの可視化を行っています。 現在のデータ基盤には、歴史的な経緯から生じたデータ処理フローの複雑さや、全体的なコスト最適化といった課題が存在しています。 このような状況でデータ基盤の構成を見直している中、Google Cloudの方からData Analytics Workshopの案内を頂き、参加させていただきました。 Data Analytics Workshopとは Data Analytics Workshopは、Google Cloudのスペシャリストに現状のデータ基盤/データ活用の課題をヒアリングしていただき、Google Cloudの関連サービスを用いたソリューション・アーキテクチャを提案していただくワークショップです。 ワークショップの流れ 今回のワークショップでは、Google CloudからData Analytics Specialistを中心に、Customer EngineerやSalesの方々も含む3名の方が担当してくださいました。 エブリーからは、CTOとDAIメンバー、トモニテのバックエンドエンジニアが参加しました。 ワークショップは全2日あり、以下のような流れで進められました。 Day 1 エブリーの現状のデータ基盤と課題の認識合わせ 主要プロダクトのご紹介 事前ヒアリングを元に、いくつかのGoogle Cloudのサービスを紹介していただきました。 Day2 アーキテクチャ案の検討 我々の課題を解決するためのアーキテクチャ案を提案いただき、それについてのディスカッションを行いました。 ハンズオン いくつかのGoogle Cloudのサービスを用いたハンズオンを行いました。 ワークショップでの学び 以下にワークショップで学びや気付きを記載します。 データ基盤の課題とアーキテクチャディスカッション Day1では、エブリーの現状のデータ基盤と課題の認識合わせを行いました。 現行のアーキテクチャの共有だけでなく、そのアーキテクチャがなぜそのような形になったのか、AI活用に向けた取り組みの進捗、BIツールやデータガバナンスに関する課題など、データに関連する問題点を幅広くヒアリングしていただけました。 エブリーのデータ基盤には、以下のような課題が存在しています。 DatabricksとTreasureDataの2つの基盤にそれぞれデータ処理フローが存在するため、フローが複雑化している データ活用を促進するためのデータカタログの整備とデータガバナンスの強化が必要である 機械学習をプロダクトに適用するためのMLOpsの整備が求められている トモニテのデータ基盤であるBigQueryのコストが増加傾向にある これらの課題を考慮した上で、Day2ではGoogle Cloudのサービスを活用したアーキテクチャ案を提案いただきました。 提案いただいたアーキテクチャ案では、基本的にBigQueryを中心とした構成となっています。 これにより、以下のようなメリットが得られると考えられます。 データ処理フローを単純化できる Google Cloudの他のサービスとの連携が容易になり、Google Cloudの各サービスを最大限活用できる MLOps基盤としてBigQueryと連携が容易なVertexAIを利用できる データカタログとしてBigQueryから自動的にメタデータを収集可能なDataplexを利用できる BigQueryの強力な計算資源を活用できる 提案されたプランでは、Plan3からPlan1に向けてGoogle Cloudのサービス利用率を高めていく想定をしています。 Plan3では現行のDWHであるTreasureDataをBigQueryに移行し、Plan2ではDatabricksをGoogle CloudのSpark基盤であるDataProcに移行します。 そして、Plan1ではすべての処理をBigQueryに集約します。 Google Cloud上にデータ基盤を構築すると、BigQueryの計算資源を活用しつつ現行アーキテクチャに対してコストを抑えることが可能となります。 また、BigQuery MLなどの機能を使用が可能となります。 さらに、Google Cloudの他のサービスとの連携が容易になり、フルマネージドのデータカタログやML基盤、BIツールなどを導入しやすくなります。 提案されたアーキテクチャ案を検討した結果、現行のアーキテクチャと同等の性能を維持しつつコストを抑えることが可能だと判断しました。 しかし、移行コストの大きさやアプリ基盤との連携など、解決すべき課題も多く存在します。 今回のワークショップでは時間の制約から、アーキテクチャ案の詳細なディスカッションを行えませんでした。 しかし、ワークショップ終了後もオフィスアワーという形で、Google Cloudのスペシャリストの方々とオンラインでディスカッションする時間をいただいています。 主要プロダクトのご紹介 主要プロダクトのご紹介では、事前ヒアリングの際に伝えたLLMとデータガバナンスを中心にGoogle Cloudのサービスをご紹介いただいたので一部を以下で紹介します。 VertexAI ( https://cloud.google.com/vertex-ai?hl=ja ) 訓練用データの準備、モデルのトレーニング、デプロイメント、予測の監視、モデルの改善まで、すべてのステップを実行できる機械学習プラットフォーム Generative AI Studio ( https://cloud.google.com/generative-ai-studio?hl=ja ) 生成AIモデルのファインチューニングやプロンプト設計などを行えるツール Dataplex ( https://cloud.google.com/dataplex?hl=ja ) 分散したデータに対して一元化された管理とデータガバナンスを提供するためのプラットフォーム VertexAIの利点として、モデルのトレーニングからデプロイまでの一貫した操作が可能であるという点です。 これにより、MLOpsの実行が効率化されます。 さらに、Generative AI Studioの使用により、自前でファインチューニングを行うコストを削減できると考えられます。 Dataplexは、データガバナンスの運用をサポートするために、複数のサービスに分散して存在するデータに対して、データの管理単位を作成し、管理単位での権限管理を行なえます。 さらに、フルマネージドであり、Google Cloudサービスとシームレスに連携できるデータカタログとして、データカタログの管理・運用コストを削減できると考えられます。 一方、エブリーではデータカタログとしてOpenMetadataをセルフホストで使用しています。 OpenMetadataは、GCP/GCP以外も含めた様々なデータソースからの横断的なメタデータ収集が容易にできる点が魅力的ではありますが、セルフホスティング由来の運用コストなどの課題もいくつか存在しています。 そのため、使いやすさや運用コストを含め、Dataplexも選択肢の一つとして検討していきたいと考えています。 Google Cloudサービスのハンズオン ハンズオンでは、2グループに別れて以下のサービスを用いたハンズオンを行いました。 - BigQuery ML ( https://cloud.google.com/bigquery/docs/bqml-introduction?hl=ja ) - BigQuery Interactive SQL Translator ( https://cloud.google.com/bigquery/docs/interactive-sql-translator?hl=ja ) BigQuery MLでは、BigQuery上にあるDELISH KITCHENのイベントログを対象に、モデルのトレーニングと予測を行いました。 BigQuery SQL変換は、他のSQL言語をGoogle SQLに変換が可能なサービスです。 エブリーでは、RedashからTreasureDataへのクエリにPresto SQLを使用しているため、BigQuery基盤への移行を考えると、Presto SQLからGoogle SQLへの変換が必要となります。 現在、Redash上には約17,000件のクエリが存在しており、これらをGoogle SQLに変換する移行コストは大きな課題となります。 この問題を解決するために、BigQuery SQL変換を紹介いただき、実際に変換を試みました。 手順としてWebブラウザ上で変換前の言語を選択した後、SQLを入力してボタンを押すだけです。 その結果、標準的な関数はエラーなく変換でき、移行コストの大幅な削減が期待できると感じました。 しかし、TreasureDataの独自関数については変換が行えないため、移行コストを完全にゼロにすることは難しいと感じました。 移行を行う際は独自関数の変換にのみ注力し、標準的な関数については自動変換を行うことで、コストを抑えつつ移行を進められると考えられます。 今後の取り組み 今回提案いただいたアーキテクチャ案を参考に、エブリーのデータ基盤の改善を進めていく予定です。 データ基盤の大規模な刷新は、エンジニアリングリソースの確保や既存のデータ処理フローの移行など、多くの課題を伴います。 そのため、小規模なPoCを実施するなどして、移行の見積もりや移行後のアーキテクチャの選定を行っていく予定です。 また、ML周りのサービスなど、部分的に導入可能なサービスが存在するため、それらのサービスを活用したPoCも実施していく予定です。 おわりに 今回のワークショップでは、Google Cloudのスペシャリストの方々にエブリーのデータ基盤の課題をヒアリングしていただき、その上でGoogle Cloudのサービスを活用したアーキテクチャ案を提案いただきました。 さらに、様々なサービスの紹介も行っていただき、これらを利用してどのようなデータ活用基盤を構築するかについて様々なアイデアが湧きました。 ワークショップの開催にあたり、多くのリソースを提供していただいたGoogle Cloudの皆様に心から感謝申し上げます。
アバター
はじめまして。株式会社エブリーの開発本部のデータ&AIチームでマネージャー兼データサイエンティストをしている伊藤です。 今回は、エブリーのデータ組織が普段どういった取り組みを行なっているかを、簡単にご紹介したいと思います。 エブリーについて 株式会社エブリーは、「DELISH KITCHEN」「トモニテ」「TIMELINE」という3つのメディアを運営しています。 各メディアはそれぞれ主力となるサービスがあり、それらを起点に多岐に渡る事業を展開しています。 どのメディアも戦略上「データ」が不可欠となっています。 サービスのグロースのためのKGI・KPIのモニタリングはもちろんですが、 広告事業でのクライアント様向けの媒体資料や、OMO事業におけるオフラインデータの活用など、 データを起点とした取り組みは数多く実施されています。 チームObjective データ&AIチームでは、「データ資産を最大限活用した事業運営の実現」をObjectiveに掲げています。 このObjectiveが達成された状態は、2つに分けられます。 データ資産が適切に使える状態になっている データ資産の活用が事業改善に貢献している 前者は、サービスの動きやサービス内外で発生したユーザーの行動が正しく収集され、利用したい人が誰でも利用できるようになっている状態で、 データ基盤やBIツールの提供・データガバナンスなどが関連します。 後者は、データによって適切な意思決定がなされたり、データを使って事業価値が創出されている状態で、 データ分析や効果検証・統計モデル・機械学習などが関連します。 これら2つの状態は、どちらか片方だけでは不十分で、両方が機能して初めて data-driven / data-informed な意思決定につながるため、 それらを両立させるのがデータ&AIチームとしてのミッションだと考えています。 開発体制 2023年9月時点で、データ&AIチームは「データエンジニア」1名、「データサイエンティスト」3名、「データストラテジスト」1名が在籍しています。 それぞれの定義や担当領域は諸説ありますが、エブリーでは主に以下のようになっています。 データエンジニア: データ収集・分析基盤の構築と改善、データガバナンスの強化 データサイエンティスト: グロース施策の分析・効果検証、ロジック改善 データストラテジスト: マーケティングソリューションにおける営業支援 これらは大きな括りとしては分割されていますが、他の領域にまたがった取り組みを行う場面も多くあります。 例えば、データサイエンティスト・データストラテジストが、モニタリングや分析のために新しいデータが必要になったら、 基本的にはデータ取得処理の実装まで含めて担当します。 分業体制にできるほどの人数がいないという側面もありますが、 スキルの属人性を緩和しつつ、 大元のデータを活用できる状態に加工する経験を通してデータに対する解像度を上げられる、といったメリットもあると考えています。 技術スタックは、主要なものを挙げるとおおよそ以下のようになっています。 インフラ系: AWS、GCP、terraform データ基盤系: Databricks、TreasureData、Fivetran データ分析系: Redash、streamlit プログラミング言語: Python、Scala、SQL (SparkSQL、Prestoなど) ML系: MLflow、FastAPI (ML API) AIツール系: Github Copilot、OpenAI API 主な業務内容 ここでは、データ&AIチームが日頃取り組んでいる業務のうち、直近のものをいくつかをピックアップして紹介します。 データカタログ構築 現在データ基盤は様々な利用者に使っていただいていますが、 「欲しいデータがどこにあるか分かりづらい」「テーブルのカラムの定義が分からない」といった課題がたびたび挙げられていました。 そこで、データエンジニア中心にデータカタログの構築を進めました。 いくつかの実現方法に関してPoCを行い、結果的にいくつかのツールを跨いでメタデータを収集できるOpenMetadataを採用しました。 インフラはAWS上に構築しており、現在はデータカタログの普及に向けた取り組みを実施しています。 効果検証 主にデータサイエンティストが、PdMやエンジニアと連携し、日々の施策の効果検証を担当しています。 効果検証はA/Bテストを選択することが多く、実験設計の部分から最後の意思決定の部分まで、データサイエンティストが並走するケースが多々あります。 直近では、細かいクリエイティブの改善に割く工数を削減するため、バンディットアルゴリズムの導入も進めています。 PR強化プロジェクト これまで、マーケティングソリューションの課題の1つに、トレンド予測などのプレスリリースの少なさがありました。 プレスリリースは公開までに少なくない労力が必要ですが、営業メンバーの方が直近のトレンドを正しく把握するのに有効な側面に加え、 メディアとしてデータ資産を日頃利用いただいているユーザー様に還元できるという社会貢献としての側面もあります。 このプロジェクトはデータストラテジストと広報・管理栄養士が連携して進めており、 データストラテジストが探索的な分析ができるダッシュボードを作成 広報・管理栄養士を交えて議論しつつ、トレンドの探索どダッシュボードの調整を進める 発信の方向性が決まるとデータの整形と発信内容の調整を実施 といった流れで、データストラテジストを起点に各自の持つドメイン知識をうまく集約させたプロジェクトになっています。 直近はチョコミントに関するプレスリリースが発信され、いくつかのメディアでも取り上げていただいております。 データ組織としての直近の取り組み また、データ組織として、直近は以下のような取り組みを行っています。 勉強会の実施 データ分析や機械学習などのスキルを身につける上では、実践的な経験に加えて、数学などの基礎知識の継続的な学習も重要になります。 そこでデータ&AIチームでは、数式に向き合う時間を作ろうという名目で、有志のメンバーで輪読会を開催しています。 過去何度か社内で勉強会を開催したり参加したりしてきましたが、予習と発表資料の準備はそこそこの負担になっていたため、 この輪読会は担当者がその場でホワイトボードに書きながら読み進める、という形式をとっています (そのため、内容が難しい場合には参加者全員が頭を抱えてほとんど進まないまま終わる回もあります)。 現在は「統計的機械学習の数理100問 with Python」という書籍を扱っています。 週に1時間のペースで、2023年9月時点で開始から1年半以上(最近ようやく最終章に入りました)続いており、今では比較的古参の取り組みになっています。 社内ChatApp作成 最近ChatGPTのような生成AIを組み込んだサービスやツールが日々増加している中で、それらを日常的に触れて肌感を掴み、 より良い活用の仕方を考え業務改善に活かしていくスキルが求められつつあります。 そのような動きを社内で高めていくため、データ&AIチームとして社内ChatAppを作成・提供する取り組みを行いました。 リリース時のSlackメッセージ ChatAppはOpenAI APIをベースに動いており、入力した情報が学習に使われないよう設定できるため、ChatGPTよりも業務利用の敷居が下がっています。 また、GPT-4も社員は誰でも無料で使用できるようになっています。 ChatAppの作成は、チームで作ってみようという話題が出た翌週に、1日時間をとってデータエンジニア・データサイエンティストの2名で一気に作り上げた、 という点は地味なアピールポイントになっています。 また、作成においては以下の記事を参考にさせていただきました。 このような、メンバーの経験値になり、かつ業務にも役立つような取り組みを熱量を持って遂行するような動きは、今後もチームとして増やしていきたいと考えています。 今後の取り組み 最後に、データ組織としての今後に向けた取り組みを紹介します。 データ基盤の継続的な改善 現在のデータ基盤は幅広い事業部にデータを提供し利用いただいている状態ですが、 コストをはじめ、品質や提供速度など、より改善できる部分は多々存在しています。 また、利用者が使いやすいBIツールの設計や、見られなくなってしまうダッシュボードの管理方法なども、取り組むべき課題として挙がっています。 直近ではOpenMetadataを使ったデータカタログを作成しましたが、それをいかに社内普及させていくかもホットな課題です。 これらは一気に解決できるものではないですが、 チームとしては継続的に選択肢を検討し続ける状態を作り、少しでも良い状態を目指したいと考えています。 データによる技術更新の推進 現状DELISH KITCHENを初め、サービス内部には様々なロジック(検索やレコメンドなど)が埋め込まれています。 それらのほとんどは、サービス初期の頃に作成されたルールベースのアルゴリズムや、精度が継続的に評価されていないモデルになっており、 改善の余地が多く残されている状態です。 ユーザー様の利用体験を少しでも向上させるために、現在はサービスとロジックが適切に分離された状態を導入する取り組みを始めています。 サービスとロジックの分離がなされることで、データサイエンティストがロジックの改善に集中できる環境になりつつ、 必要であればロジックを前提としたUXの設計にも取り組めるようになると考えています。 今は少しずつ導入事例を増やしていく段階ですが、将来的にはインフラも整備しつつよりスケールする基盤を構築していけるよう取り組んでいきたいです。 おわりに データ&AIチームの直近・将来に向けた取り組みを、簡単にご紹介しました。 エブリーのデータ組織に興味を持っていただいた方の解像度が少しでも上がるような内容になっていると幸いです。 より詳しい話を聞きたい方は、カジュアル面談をお申し込みいただけると嬉しいです! corp.every.tv
アバター
こんにちは。トモニテでiOSアプリを開発している國吉です。  トモニテではサテライトを含め、複数のアプリをリリースしています。それぞれアプリのリリースが終えてからグロースするために改善/運用を行っていますが、N1インタビュー等でユーザーの声を聞いてみると「そんな機能があったんですね!知らなかったです」という意見がちらほら見受けられます。我々機能を提供している側としては、できるだけ機能認知してもらえるようにデザインを工夫して提供していても一定数は機能にたどり着く前に離脱してしまう可能性があります。  そこで今回はiOS17で追加されたTipKitを用いて、機能誘導できないか検証がてら触れていきたいと思います!  TipKitとは まずTipKitの概要をお話しします。 TipKitはアプリ上でヒントを表示しユーザーに機能/操作方法を知ってもらうための手段として提供されています。  これにより、アップデートで追加した機能を知ってもらったり、オンボーディングでアプリのコアとなる機能を知ってもらうことができます。 ただし、ユーザーアクションを促す上で、”ヒントをどのタイミングで表示するか””どのようなメッセージでアプローチするか”を考えるのが難しく、設計がすごく大事になると思います。  実装  早速実装に取り掛かります。今回はUIKitで表示するとこまでを紹介しますが、SwiftUIでも実装はすごく簡単です。  まずはAppDelegateでTipKitを使う設定だけしておきます。  import TipKit func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { Task { try? await Tips.configure() } /* ..... */ } 次にヒントを表示したいViewControllerで処理を記述していきます。 下記ではヒントで表示するTipの中身を定義しています。 import TipKit struct StartButtonFeatureTip: Tip { var title: Text { Text("陣痛が始まったと思ったらタップ!!") } var message: Text? { Text("陣痛がおさまってきたら「おさまったかも」ボタンをタップして計測を終了しましょう") } var image: Image? { Image(systemName: "hand.tap.fill") } } 動作検証では画面表示タイミングでヒントを表示したかったので、viewDidAppearで表示登録をしています。 画面から離れる際に、インスタンスを解放できるようにnilを代入しています。 // クラスプロパティ private var startButtonFeatureTip = StartButtonFeatureTip() private var tipObservationTask: Task<Void, Never>? private weak var tipPopoverController: TipUIPopoverViewController? override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) /* ... */ tipObservationTask = tipObservationTask ?? Task { @MainActor in for await shouldDisplay in startButtonFeatureTip.shouldDisplayUpdates { if shouldDisplay { let popoverController = TipUIPopoverViewController(startButtonFeatureTip, sourceItem: contractStartButton) present(popoverController, animated: animated) tipPopoverController = popoverController } else { if presentedViewController is TipUIPopoverViewController { dismiss(animated: animated) tipPopoverController = nil } } } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) tipObservationTask?.cancel() tipObservationTask = nil } ここまでの記述でシミュレーターを起動するとヒントが表示されるようになります。 すごく簡単ですね。次はテキストなどカスタマイズできるとこは調整しアプリに馴染むようにしていきたいと思います! テキストカラーやフォントなどはTipの中身を定義しているとこで記述し、アイコンの色についてはTipUIPopoverViewControllerの中にあるViewのtintColorを変更することで指定できます。 struct StartButtonFeatureTip: Tip { var title: Text { Text("陣痛が始まったと思ったらタップ!!") .foregroundStyle(Color(uiColor: .defaultTint)) } var message: Text? { Text("陣痛がおさまってきたら「おさまったかも」ボタンをタップして計測を終了しましょう") .foregroundStyle(Color(uiColor: .defaultTint)) } var image: Image? { Image(systemName: "hand.tap.fill") } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) /* ... */ tipObservationTask = tipObservationTask ?? Task { @MainActor in for await shouldDisplay in startButtonFeatureTip.shouldDisplayUpdates { if shouldDisplay { let popoverController = TipUIPopoverViewController(startButtonFeatureTip, sourceItem: contractStartButton) popoverController.view.tintColor = .defaultTint present(popoverController, animated: animated) tipPopoverController = popoverController } else { if presentedViewController is TipUIPopoverViewController { dismiss(animated: animated) tipPopoverController = nil } } } } } ヒントのbackgroundColorを指定することもできるのですが、閉じるアイコンの色の指定は現状不明なので、注意が必要そうでした(iOS17が正式リリースされたらUIKitからでも閉じるアイコンの色も指定できるようになることを願います。。) ヒントの中にアクションリンクを付与することも可能です。 Tipの中身の定義にActionを追加します。TipUIPopoverViewControllerのイニシャライザにactionHandlerがあるので、ハンドラー内でAction処理を記述していきます。 struct StartButtonFeatureTip: Tip { var title: Text { Text("陣痛が始まったと思ったらタップ!!") .foregroundStyle(Color(uiColor: .defaultTint)) } var message: Text? { Text("陣痛がおさまってきたら「おさまったかも」ボタンをタップして計測を終了しましょう") .foregroundStyle(Color(uiColor: .defaultTint)) } var image: Image? { Image(systemName: "hand.tap.fill") } var actions: [Action] { [Action(id: "start_button", title: "詳細はこちら")] } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) Tracker.track(event: .counterScreen) AdjustTracker.track(event: .counterScreen) let statusBarHeight = self.view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 headerViewHeightConstraint?.constant = 44 + statusBarHeight tipObservationTask = tipObservationTask ?? Task { @MainActor in for await shouldDisplay in startButtonFeatureTip.shouldDisplayUpdates { if shouldDisplay { let popoverController = TipUIPopoverViewController(startButtonFeatureTip, sourceItem: contractStartButton, actionHandler: { action in guard action.id == "start_button" else { return } /* 「詳細はこちら」をタップ後の動作 */ }) popoverController.view.tintColor = .defaultTint present(popoverController, animated: animated) tipPopoverController = popoverController } else { if presentedViewController is TipUIPopoverViewController { dismiss(animated: animated) tipPopoverController = nil } } } } } ルールをつける ヒントを表示したい時に「ユーザーがXXXXをしたら」「ユーザーがログインしたら」等々条件を満たした時に表示したい場合があると思います。そこで使用するのがルールです。 ルールは2種類あります。 パラメータベース 感覚的には”ログインしているか”等Boolで管理できるものだと思います。 イベントベース 「XXXをしたら」等のイベントトリガーを指定できます。 今回はお試しでイベントベースのルールを使用し、「きたかも」Buttonを2回タップしたら「おさまったかも」Buttonにヒントを表示する処理を書いていきます。 まずは、Tipの中身の定義にEventとRuleと記述します。 viewDidAppear内は特に変わりありません。 「きたかも」Buttonをタップされたタイミングで、startButtonTappedCountをインクリメントする必要があります。 struct StopButtonFeatureTip: Tip { static let startButtonTappedCount = Event(id: "start_button_tapped_count") var title: Text { Text("陣痛がおさまったらタップ!!") .foregroundStyle(Color(uiColor: .defaultTint)) } var message: Text? { nil } var image: Image? { Image(systemName: "hand.tap.fill") } var rules: [Rule] { #Rule(Self.startButtonTappedCount) { $0.donations.count >= 2 } } } private lazy var contractStartButton: ContractStartButton = { let button = ContractStartButton() button.configure(tapped: { [weak self] in guard let self = self else { return } Task { try? await StopButtonFeatureTip.startButtonTappedCount.donate() } /* ... */ }) return button }() その他 他にも1日1個のヒントしか表示しない。同じヒントが出続けないように出現回数を制限する。等のオプションもあります。ぜひ調べてみてください。 また、見た目の確認したいなど常にヒントを出したい時もあると思いますが、その時はこのように記述することで全てのヒントを表示することができます。 Tips.showAllTipsForTesting() 最後に 今回はiOS17で追加されたTipKitを触ってみました。感想としてはオンボーディングに組み込むことで初日の機能利用を促すことができ、細かいとこまで機能認知に繋がりそうだと感じました。 また大きめな機能を追加しアップデートを実施した際にも同様に価値を感じそうです。 ただ、ベースのレイアウトはOSが提供したものに則る必要があるため、すごくリッチな見た目で表示したい要望がある場合には使えなさそうです。 トモニテではサテライトアプリで検証し、ユーザーの反応が良ければ随時、他のアプリには展開していこうかなと考えています。 参考 https://developer.apple.com/videos/play/wwdc2023/10229/ https://developer.apple.com/documentation/tipkit/highlightingappfeatureswithtipkit
アバター
背景 DELISH KITCHEN 開発部で小売向き合いの開発に携わっている大村 ( @kosukeohmura ) といいます。 エブリーでは リテールメディアの構築・提供を通して小売様を支援していく 構想を掲げており、 retail HUB という枠組みでいくつかの SaaS プロダクトを開発・提供しています。開発のフェーズとしてはまだまだ未成熟な部分も多いのですが、今回はその中でもマルチテナントな SaaS に想定される要求に着目して、それを満たすために考えてきたことをお話しします。 マルチテナントなシステムとは エブリーでは各小売(主にスーパーマーケット)様がそれぞれテナントとなりシステムを共用する、いわゆるマルチテナントなプロダクトを作っています。マルチテナントなシステムは、テナントという概念自体が存在しないシステムや、テナントごとに独自のシステムが存在する場合に比べてどのような時に向いているものなのでしょうか。それぞれの場合について、私なりの理解を書いてみます。 テナントという概念が無いシステム ここで挙げるなかで最もシンプルなケースとして、テナントという概念がなく、各使用者が独立しているシステムがあります。弊社が運営するレシピ動画メディア DELISH KITCHEN もこのモデルです。 (当たり前な話ですが)組織で使用されないようなシステムでは、テナントを使ってリソースを束ねたい動機も生まれません。 下記のような要求が生まれうるシステムでは、テナントという単位で各種リソースを束ねることを検討する必要が出てきます。 自組織のメンバーを確認したい 自組織に属するリソースの変更を行いたい 特定の組織に属するユーザー群の、プロダクトの使用状況を確認したい 特定のテナント向けのシステム 次にやや極端な例として、テナントそれぞれに要求を細かく聴取し、その都合にあわせてそれぞれ制作・構築・提供されるシステムを考えてみます。コストを考えない場合には最もテナントの要求を満たしうる手法であり、テナントごとにシステムに対する要求が異なる場合、最も柔軟に対応できる手法です。 一方で、同じような課題を多数のテナントが抱える中でこの手法を取った場合には、システムごとに作り替えやリリース作業、仕様策定などのコストがかかり、そのコストは必要以上に多くなります。複数のシステムに同じ変更を適用し続けることも、テナントが増えるに連れて難しくなります。 マルチテナントなシステム マルチテナントなシステムでは、ソースコードやその他サーバー等のリソースをテナント間で共用します。テナントの増加に対して開発や運用に掛かるコストが(直接は)比例しないので、スケールしやすいシステムとなります。何らかの組織を表すようなデータ構造が必要で、同じような課題を持ったテナントが多く存在する場合には有力な選択肢となります。私達のチームでも、多くの小売様の抱える課題を共通のプロダクトで解決していきたいと考え、この手法をとっています。 留意しておくべきこととして、テナントごとの課題が大きく異なる場合や、各テナントからの要求を安直に受け取り機能拡張を続けたような場合には、特定のテナント向けの特別な実装が乱立したり、システムの必要以上な多機能化が起こり得ます。そうなるとプロダクトは複雑性を増していき、開発速度の低下やバグの頻出を招くでしょう。 テナントごとの要求は多少なりとも異なるものであり、それらの要求を時には切り捨てつつ、どうテナント共通の課題を解決できるプロダクトに落とし込んでいくか、という難しさに向き合うことが必要となります。マルチテナントなシステムは多くの場合 to B であり、お客様との関係が to C と比べて近く、システム使用者の生の声が間近で聞けることもあるかと思います。その中で、未来を見据えてプロダクトをどういうものにしていくかを考え、意思決定を行い続けることが大切だと思います。 マルチテナントなプロダクトにおける要求 複数のテナントがリソースを共用する都合上、テナントごとのデータの分離によるプライバシーの確保が要求の筆頭として挙げられます。異なるテナントのデータがプロダクト上で見えてしまうと、プロダクトの信頼は失われ事業自体の存続が危うくなってしまいます。 他方で、プロダクトを運営・改善していく上ではテナントを横断したデータを見て分析を行い・次の施策へ繋げていきたいというニーズもあり、また運営側のメンバーが各テナントの代わりにプロダクトのデータを閲覧・操作するケースも考えられます。 マルチテナントなプロダクト開発で考えること データの分離 RDB プロダクトやその管理画面に表示されるほとんどのデータが入っているデータベースです。金銭的なコストと DB への負荷とを考え、データベースのインスタンスは共通とし、テナントごとに論理データベースを分離する形にしています。加えて、テナント共通のレコードについては共用のスキーマを作成し、そこに保存しています。RDB ユーザーをテナントごとに作成し、それぞれ対応するテナントのデータ以外にアクセスできないようにしています。 画像などのファイル 各種ファイルを保存するストレージサービス (Amazon S3 を使用しています) についても、RDB と同様にテナントごとのデータの分離が必要であり、テナントごとにバケットを分けてファイルを保管しています。こちらもテナント共通のファイルについては共用のバケットを使用しています。 各種イベントログ、アクセスログ 各種分析に使うクライアントアプリ上でのイベントログやサーバー・ロードバランサへのアクセスログについては、センシティブな情報は入らないこととテナント横断で統計的な見たいケースが多いと考え、テナントごとに分けずに保存しています。テナントの識別子をログに含めることで、テナントごとのログを抽出することができます。 API サーバー・ロードバランサー それぞれテナント共通のものを使用していますが、API リクエストの内容からテナントを識別し、他テナント用の RDB やストレージへの接続が起き得ないようにしています。 開発用のテナント 細かい話ですが、開発時や社内でのデモに使うテナントどうするの?という話があります。弊社ではローカル環境や開発用の環境には社内共通の架空のテナントの環境を用意し、開発や社内用デモアプリでの動作確認にそれを使用しています。 運営メンバーのユースケースの考慮 運営側のメンバーが各テナントの代わりにプロダクトのデータを閲覧・操作しようとした場合に、各テナントごとのユーザーアカウントをいくつも持つ形だと、ログイン・ログアウトを繰り返したり、複数のサイトを渡り歩くなど面倒なことが想定できます。このことを防ぐため、アカウントに複数のテナントが紐づくような形とし、閲覧・操作対象のテナントを選択できるような形としています。 サービスやサーバーの Endpoint の URL をテナントごとに分けるか 世の中の SaaS プロダクトを使っているとテナントごとにサブドメインを付与して {tenant}.example.com のような形とするケースがあります。基本的にはテナントごとに付与したほうがリソースの競合の可能性がなくなると考えてそうしていますが、テナントを切り替えるようなユースケースがある場合にはサブドメインを変えにくいです。 おわりに マルチテナントなシステムの開発を始めている中で考えてきたことを挙げてみました(現在はこうしている、というもので、今後変えるところも出てくると思います)。マルチテナントではないシステムに比べて、基本的なところでも想像以上に考えることが多く、またこの辺の設計がセキュリティや使い勝手の向上に大きく効いてくると感じています。開発が進んできたら、実践的な話にも踏み込んだ話をしたいです。
アバター
概要 TIMELINE開発部の内原です。 株式会社エブリーでは、バックエンド系ソフトウェアをGo言語で記述することが多いです。また、作成したプログラムについては、go testコマンドを用いてテストを記述するようにしています。 今回は、go test時に発生した一見分かりづらいエラーをどのように調査、対策したかについて共有します。 環境 Go言語 Test Frameworkとして Convey DBはMySQL エラー発生時の状況 ある時からローカル環境にてテストを実行したところ、以下のようなエラーが発生するようになりました。 $ go test ./repository/*.go ... Line 30: - Error 1062: Duplicate entry '1' for key 'PRIMARY' ... 一見して、テスト中にレコードを登録しようとした際に、なんらかの理由でPrimary Keyの重複が発生したということが分かります。 ただ、この時は develop ブランチの先頭でテストを実行しており、同じコミットがCI環境では正常にテストが成功していたので不思議に思いました。 テストコードを見てみると以下のような実装になっていました。 import ( . "github.com/smartystreets/goconvey/convey" ) func setup() { err := insertRecord( 1 ) // ID:1でレコードを登録 if err != nil { panic (err) } } func teardown() { err := cleanupTable() // テーブルのレコードを全削除 if err != nil { panic (err) } } func Test_Find(t *testing.T) { Convey( "Find" , t, func () { setup() repo := NewRepository() Convey( "正常系" , func () { record, err := repo.Find( 1 ) // ID:1でレコードを検索 So(err, ShouldBeNil) // エラーは発生しないことを検証 So(record.ID, ShouldEqual, 1 ) // ID:1のレコードが取得できることを検証 }) Convey( "異常系" , func () { _, err := repo.Find( 0 ) // ID:0でレコードを検索 So(err, ShouldEqual, ErrNotFound) // NotFoundエラーが発生することを検証 }) Reset( func () { teardown() }) }) } conveyの使い方について簡単に説明します。 Convey() 関数 テストのコンテキストを表現する関数 第一引数にはコンテキストの説明を、第二引数にはテスト対象の関数を渡す コンテキストはネストすることができる コンテキスト内部のテストコードでpanicが発生した場合、コンテキスト単位でテストは失敗したものとみなし、下位のコンテキストは実行されなくなる So() 関数 assertionを行う関数 ShouldBeNil , ShouldEqual といったマッチャーを用いて記述することができる Reset() 関数 コンテキスト単位でのテストの後処理を表現する関数 ネストしたコンテキストの場合、上位層も含めて実行される 調査 実際に、テスト実行前の状態でテスト用DBに接続してテーブルの中身を確認してみると、たしかにPrimary Keyが1のレコードが存在していました。 これでは当然 setup() で重複エラーが発生します。 しかし、 Reset() で teardown() を呼び出すように実装しているので、テスト終了時にはテーブルの中身は空になっているはずでした。 その後記憶を頼りに原因を調べてみたところ、テスト終了時にもレコードが残ったままになっていた理由は単純なものでした。 develop ブランチの先頭でテストを実行する以前に、ローカル環境にて上記のテストコードと同じテーブルを操作する別のテストコードを実装していました。その際、誤って teardown() の呼び出しをしない状態でテストを実行しました。 func Test_Other(t *testing.T) { Convey( "Other" , t, func () { setup() repo := NewRepository() _, err := repo.DoSomething() So(err, ShouldBeNil) // エラーは発生しないことを検証 }) } このテストを実行したことでテスト終了時にレコードが残ったままになりました。 その後developブランチに移動した際もDBはそのままの状態であったため、 setup() にて重複エラーが発生するようになっていたのでした。 データが残ったままでテスト実行 setup() が失敗 Reset() が登録されない teardown() が呼び出されない データが残ったままでテスト実行 setup() が失敗 ... というループに陥っていたということです。 対応案 要するに、1回でもレコードが残ったままになっているとその後テストデータの登録で必ず失敗し、またその状態から自力で復帰できないというテストコードになっていました。 このテストコードにおける問題点としては以下が挙げられます。 setup() が失敗することがある setup() と teardown() が1対1で対応するとは限らないため、テスト終了時にテストデータが残ったままになることがある これらを解決する案としては以下が考えられます。 テーブルのレコードを削除する処理を setup() を実行する前に移動する setup() が失敗しないようにするアプローチです。あらかじめテーブルを初期化しておきその後に登録という実装になるため、この場合 Reset() 自体不要になります。 登録の先に削除するか後に削除するかはどちらもあり得る実装だと思うのですが、すでに上記のようなコードが大量に存在したため、すべて修正するのは避けたいと考えました。 Reset() の呼び出しを setup() を実行する前に移動する setup() と teardown() が必ず1対1で実行されるようにするアプローチです。 setup() 内でpanicしようとも、すでに Reset() で登録されていればテスト終了時に teardown() が実行されることになります。 func Test_Find(t *testing.T) { Convey( "Find" , t, func () { Reset( func () { teardown() }) setup() repo := NewRepository() ... }) } ただ、convey のドキュメントを読むと Reset() は Convey() の末尾で実行する例が記載されており、convey側の想定としては現状の実装のほうがマッチしているように見受けられます。 また前述の理由と同様に、修正箇所が多くなることは避けたいと考えました。 そもそも setup() 内でpanicしないようにする setup() が失敗しないようにするアプローチです。 setup() でデータ登録が失敗した場合でも、 Reset() まで処理が継続するような実装にします。 例えば setup() を以下のように修正します。 func setup(t *testing.T) { err := insertRecord( 1 ) // ID:1でレコードを登録 if err != nil { t.Error(err) } } ... func Test_Find(t *testing.T) { Convey( "Find" , t, func () { setup(t) ... Reset( func () { teardown() }) }) } これなら、レコードが残っていて setup() でエラーが発生した場合でもテストは継続するので teardown() も実行されることになり、以降のテストでは正常に setup() が成功するようになります。 ただ、テストデータ登録処理のエラー判定は通常のテストとは区別しておきたいと考えました。この実装だとテストに失敗したのか、それともテストの前提処理で失敗したのかが伝わりづらくなるのではと感じました。 また、こちらも同様に修正箇所が多くなることが難点でした。 テスト実行時に1回だけテーブルを初期化する setup() が失敗しないようにするアプローチです。少なくとも、テスト実行前にテーブルが初期化されていればテストコードが間違っていない限りエラーが発生することはなくなるという発想です。 懸念としては、テストに対し無関係のテーブルも初期化されるため実行パフォーマンスの悪化が考えられます。 ただ、Goのtestはパッケージ単位で実行されるため、その単位で初期化するだけならパフォーマンスへの影響はそこまで大きくはならないと考えました。 DBに直接依存したrepository系パッケージのみを対象とすればよく、全体からみるとその割合は高くないためです。 もちろんrepository系パッケージに依存しているパッケージ自体は別に存在しており、それらは間接的にDBに依存していると言えるのですが、これらパッケージの大部分はrepositoryをmockとして扱っており、直接DBに依存しているわけではなかったのでテーブルの初期化処理は不要でした。 そこで、テスト実行時に1回だけテーブルを初期化するため、repository系パッケージにおいて以下のようなコードを実装しました。 import ( "github.com/khaiql/dbcleaner" "github.com/khaiql/dbcleaner/engine" ) func TestMain(m *testing.M) { CleanupAllTables() os.Exit(m.Run()) } func CleanupAllTables() { tables := allTables() dbCleaner := dbcleaner.New() dbCleaner.Clean(tables...) } func allTables() [] string { tables := make ([] string , 0 ) row, err := db.Query( "SHOW TABLES" ) if err != nil { panic (err) } for row.Next() { var table string if err := row.Scan(&table); err != nil { panic (err) } if table == "migrations" { // DB migration用テーブルは除外 continue } tables = append (tables, table) } return tables } テーブルの初期化には dbcleaner を使用しました。 テーブル数にもよりますが、100個程度のテーブルを初期化するのに要した時間は1秒程度でした。 前述の通り、影響を受けるのはDBに依存したパッケージのみであるため、許容範囲としました。 まとめ 今回はGo言語のテストコードにおいてテストデータ登録に失敗するようなケースでの対応について対応した結果をまとめました。 参考になりましたら幸いです。
アバター
はじめに こんにちは。DELISH KITCHEN開発部の村上です。 エブリーが運営しているサービスのDELISH KITCHENやトモニテではプレゼントキャンペーンが定期的に行われており、ユーザーさんは開催中の複数のキャンペーンから気になるものを選んでいくつかの設問に答えることで応募することができるようになっています。 今回はそのプレゼントキャンペーンのETL基盤をStep Functionsを利用してサーバーレスで構築した話を紹介させていただきます。 Step Functions導入の背景 当時の技術選定で意識したことは次のようなことでした。 運用面と費用面で低コストで実現できる データエンジニア以外でも開発・運用できる 将来的なデータの増加や実行頻度の変更に対応してスケールできる 特にこの基盤においては初めは少人数で運用コストをいかに下げてPoCしていけるかが重要だったので運用コストは重要な判断基準になっていました。その中でAWSではワークフローエンジンの選択肢としてGlue WorkflowやMWAAもありましたが、AWSサービスとの連携も豊富でjobの実行基盤が縛られず、当時はデータ量や処理負荷からしてjobとしてlambdaを採用することでコストも最小限にできるStep Functionsを選びました。また、Step FunctionsはGlue jobとも接続可能なので、データ量の増加により処理負荷が高くなってくれば、差し替えも検討でき、柔軟性が高いことも決め手となりました。 ステートマシンの構成 Step Functionsのワークフローはステートマシンと呼ばれています。ここからはエブリーで使っているステートマシンの構成とStep Functionsの機能を使ってどういうふうにETLを実現しているかを説明させていただきます。 全体 全体としては現状は大きく分けて、データ変換とカタログ更新の二つに分けれており、ステートマシンの実行はEventBridgeからスケジュールして定期的に行っています。オンタイムでの別データ基盤への転送をする場合はこのステートマシンを拡張することによって依存関係を制御することができます。次にそれぞれの処理の詳細を説明します。 データ変換 プレゼントキャンペーンの要件として、それぞれのキャンペーンは独自のデータを持っていて、そのデータ変換の要件もキャンペーンやその実施期間によって異なります。また、開催されるキャンペーン数も次第に多くなっていくことが想定されていたのでStep Functionsで提供されているMapステートを用いた動的並列化をしてそれぞれの変換処理からS3の格納までを行なっており、ある程度データ量が多くなってきた時にでもlambdaの実行時間の制限を回避できるようにしています。Mapステートに対してあらかじめ登録してあるキャンペーン内容を以下のような構造で渡して並列実行を行なっており、自身で指定することで特定のキャンペーンの再実行も可能です。 { " campaigns ": [ { " platform ": " service1 ", " campaign ": " campaign1 " } , { " platform ": " service2 ", " campaign ": " campaign2 " } ] } カタログ更新 前のフローで求められた要件でのデータ変換までは満たせていますが、このままだと他の基盤へのデータ転送や分析が困難なため、Athenaで抽出できるようにしています。ただ、先ほど説明させていただいた通り、キャンペーンはそれぞれ独自のスキーマを持っており、自前でやろうとすると毎回自分でデータカタログのスキーマ定義をしないといけません。そこでその運用コストを省くためにGlue Crawlerで変換したS3のデータを読み込ませて自動でカタログが作られるようにしています。Step Functionsには非同期な処理をWaitステートとChoiceステートを活用して待てるようになっており、実行結果が返ってきたらChoiceステートを使ってステートマシンを終了できるようになっています。 活用する上でのTips このようなステートマシンを構築していく中でStep Functionsを活用する上でのTipsがいくつかあったので紹介させていただきます。 エラー通知 Step Functionsはステートマシン自体がエラーになった時を発火タイミングとしてEventBridgeと連携することができるようになっているのでそれを組み合わせることで簡単に通知自体はできてしまいますが、EventBridgeが受け取れる情報には制約があり、ステートマシンの何がどんな理由で失敗したのかがコンソールに行くまでわかりません。また後述しますが、一部処理が失敗したときにステートマシン自体を失敗とできないケースもあるのでEventBridgeだけでは満たせないこともあり、EventBridgeに加えて他の通知手段を用意しないといけない状況でした。 そこで実行エラーをハンドリングするCatchフィールドを使って、エラー情報を取得し、エラー通知を送るlambdaを独自で用意しています。後続のタスクに以下の情報をlambdaであればeventとして受け取れるのでそれを通知したい内容によって加工することで素早くエラー内容を把握できる体制を作ることができます。 { " FunctionName ": " step functionsのarn ", " Payload ": { " Execution ": { " Id ": " 実行ID ", " Input ": { // それぞれの環境でのjobの発火内容の詳細が入る } , " StartTime ": " 実行開始時間 ", " Name ": " 実行名 ", " RoleArn ": " ロールのarn " } , " param ": { " Error ": " Error ", " Cause ": " エラー内容 " } } } 並列実行時のジョブキャンセル回避 先ほどのデータ変換のフェーズでMapステートによる動的並列処理を行なっていると説明しましたが、実はMapステートを使っただけだと並列する処理の一部が失敗した時に後続の処理は全て停止してしまいます。これは求められる要件にもよると思いますが、ある一部の処理が失敗してもそのエラーを通知しつつ、それ以外の処理は続けて欲しいケースはあると思います。 そういった場合に並列処理の中にエラー時の例外処理を追加し、エラー通知を行った上で正常終了させることによって実現可能です。これにより他の処理は後続のタスクに移行させ、影響を最小限にとどめることができます。 ペイロードサイズの制限回避 Step Functionsでは各処理で実行した結果を後続のタスクに渡すためにデータの受け渡しを行うシーンが多いかと思います。ただ、ペイロードサイズには制限があり、遷移時に渡せるデータは256KBまでです。例えばあるMapステートでの並列処理の返り値がサイズを超える場合、以下のようなエラーが出て処理は中断されてしまいます。 The state/task 'Map' returned a result with a size exceeding the maximum number of bytes service limit. これを防ぐためにステートマシンを作る上で意識したい2つのポイントがあります。 大きいペイロードはS3や外部ストレージを使う あらかじめ大きくなると予想されるデータは後続のタスクに渡す前にデータをS3やDBに保存して、その格納先の情報を後続に渡すことにより、データサイズを小さくすることができます。 また、同じ問題は並列処理を実行するMapステートに渡すJSON配列で起きる可能性もあります。現在ではStep FunctionsにDistributed MapステートがMapステートとは別で提供されており、S3内の大規模データをもとに並列処理が実行できるようになっているので、並列数や並列時に渡したいデータ量が増える場合には利用を検討すると良いと思います。 出力をフィルタリングして必要なデータに絞って渡す lambdaなどで挙動を制御できる場合にはその処理の中で出力を最小限にすると思いますが、AWSのサービスとノーコードで連携する場合にその処理が出力するデータ自体は制御することができません。例えば、ステートマシンの中でathenaのクエリ結果を取得しようとすると以下のようなJSONが出力されます。 { " QueryExecution ": { " EngineVersion ": { " EffectiveEngineVersion ": " Athena engine version 2 ", " SelectedEngineVersion ": " Athena engine version 2 " } , " Query ": " 発行したクエリ ", " QueryExecutionContext ": { " Database ": " データベース " } , " QueryExecutionId ": " 実行ID ", " ResultConfiguration ": { " OutputLocation ": " s3のロケーション " } , " ResultReuseConfiguration ": { " ResultReuseByAgeConfiguration ": { " Enabled ": false } } , " StatementType ": " DML ", " Statistics ": { " DataScannedInBytes ": 191831 , " EngineExecutionTimeInMillis ": 1704 , " QueryPlanningTimeInMillis ": 796 , " QueryQueueTimeInMillis ": 647 , " ResultReuseInformation ": { " ReusedPreviousResult ": false } , " ServiceProcessingTimeInMillis ": 85 , " TotalExecutionTimeInMillis ": 2436 } , " Status ": { " CompletionDateTime ": 1693159279869 , " State ": " SUCCEEDED ", " SubmissionDateTime ": 1693159277433 } , " SubstatementType ": " SELECT ", " WorkGroup ": " ワークグループ名 " } } ただ、多くの場合で欲しいデータは実際にクエリ結果がどこのs3に保存されているかだと思います。その場合には次のように入出力制御のためのOutputPathパラメータを使ってフィルタリングをすることによって欲しいデータだけを後続に渡すことができます。 $.QueryExecution.ResultConfiguration 実行結果 { "OutputLocation": "s3のロケーション" } 他にも出力制御にはResultSelectorやResultPathなど出力をコントロールするパラメータが提供されており、これらを活用することにより欲しい結果に絞ったデータを作ることができます。 現状の課題 ここまでエブリーにおけるStep Functionsの活用事例について紹介させていただきましたが、しばらく運用している中で次のような課題も出てきました。 ステートマシンのIaC化 ステートマシン自体はASL(Amazon States Language)で記述されているのでコードで管理できないわけではありません。ただ、個人的にこの記述自体の学習コストが高く、ステートマシンが複雑になればなるほどコンソールのビジュアライズされたWorkflow Studioで組んだ方が直感的なケースが多いと感じます。 今はそれほどステートマシン自体の変更を行う機会が少ないので大きな問題にはなっていませんが、今後の運用を考えるとIaC化は行なっておいた方が良いので、AWS CDKやTerraformでの管理方法などチームでPoCを行っていきたいと思っています。 各lambdaの管理 Step Functionsでステートマシンを作る場合にペイロードのちょっとした加工をして後続の処理に渡したいケースもたまにあり、提供される機能では足りない場合に徐々にlambdaの数が増えていってました。特に今回紹介しているステートマシン以外にいくつか別で新しくステートマシンを作っていくとなった時にコードを管理しているリポジトリでどのlambdaがどのステートマシンに影響しているのか見通しが悪くなってきています。 ここについては共通部分とそれぞれのステートマシン独自に使われるlambdaを階層わけして、まとめていきながら、前述したステートマシンのIaC化を推進できればその依存関係も新しいメンバーが理解しやすくなるのではと考えているので、チームで話しながらリファクタリングを進めていきたいです。 おわりに 今回のStep Functions導入により、コストを最小限まで削減しながらETL基盤構築を行うことができました。今後、運用するチーム体制や扱うデータの規模によってはGlueやMWAA、他のよりデータ処理に特化したサービスを選択した方が良い場合もあると思うので、事業の状況に合わせてシステム改善ができればと考えています。 またエブリーでは日々たくさんのバッチ処理が実行されていますが、その中では依存関係を起動タイミングで暗黙的に制御しているものもあったりするのでデータのETLだけではなく、そういったバッチ処理の実行にも今後ワークフローエンジンの導入を考えていきたいです。 ここまでお読みいただき、ありがとうございました。
アバター
DELISH KITCHEN の Android 版では、ライブラリ名を build.gradle に記載して管理していました。 // こんな感じ implementation "androidx.media3:media3-exoplayer:$MEDIA3_VERSION" implementation "androidx.media3:media3-exoplayer-hls:$MEDIA3_VERSION" implementation "androidx.media3:media3-ui:$MEDIA3_VERSION" ライブラリのバージョンだけは定数化されて別途取りまとめて管理していましたが、それも全てがまとまっているわけではなかったため、現在どのようなライブラリがどのバージョンで利用されているかが一目でわからない状態でした。 そこで、今後ライブラリの棚卸しを行いやすいように、また関係ライブラリを束ねて扱えるようにするため Version Catalog でとりまとめることにしました。 環境 Android Studio Giraffe | 2022.3.1 Gradle 8.0 やったこと Android Developers に移行についての記事(※1)があり、そちらを参考に作業を行いました。 作業の流れは以下の通りです。 libs.versions.toml ファイルとセクションの作成 libs.versions.toml ファイルへライブラリの情報を記載 記載した情報を利用して gradle を修正 それぞれ詳細を見ていきましょう。 libs.versions.toml ファイルとセクションの作成 まずはルートの gradle ディレクトリ配下に libs.versions.toml という名前のファイルを作成します(※2)。 続いてこのファイル中に versions/libraries/bundles/plugins という 4 つのセクションを作成します。 この後、これらのセクションでは以下のような値を記載していきます。 versions ライブラリのバージョン値 libraries ライブラリのエイリアス名 bundles 関係するライブラリたちを束ねたエイリアス名 plugins プラグインのエイリアス名 libs.versions.toml ファイルへライブラリの情報を記載 各モジュールなどにある gradle ファイルを確認し、記載されているライブラリなどの情報を libs.versions.toml ファイルへ書き出していきます。 libs.versions.toml ファイルの各セクションに対して、以下のように記載していきます。 記載した情報を利用して gradle を修正 libs.versions.toml ファイルへ情報を集約した後はいよいよ gradle ファイルへ適用していくことになります。 apply plugin や implementation などをゴリゴリ書き換えていきましょう。 before after この際に利用する名称は libs.{セクション名}.{ハイフンをドットに変換したエイリアス名} となる点に注意が必要です。 どうなったか これで libs.versions.toml ファイルを見るだけでどんなライブラリがどんなバージョンで使われているかを確認できるようになりました。 モジュールに散った gradle をあちこち開かなくてよくなり、今後のライブラリ棚卸しが楽になりました。 また、bundles のおかげで関連ライブラリをまとめて扱えることにより、必要なライブラリの記載が漏れたりバージョンの指定を誤ったりといったミスがなくなります。 まとめ ポジティブな意見として、 Version Catalog は Android アプリ開発に必須な対応ではありませんが、対応することで1ファイルにライブラリ情報が集約されるため、今後のライブラリ管理が楽になると思います。 Version Catalog を用いることでは、特に関連ライブラリをbundleとして扱える点が便利と感じています。 今回、 libs.versions.toml ファイルという専用のファイルへまとめた方が gradle とは切り分けできてよいと考えて着手しましたが、他にも settings.gradle へまとめる方法(※3)も存在しています。 どちらの方法でも1ファイルに集約して管理できるという点はメリットであると思っています。 ネガティブな意見としては、ライブラリの管理の際にgradle以外にもtomlという登場人物が増えることが逆にちょっと嫌だという意見がありました。 また、エイリアス名の命名で名前被りを回避するためにハイフン刻みとするため、それによりエイリアス名が長くなることがあったとの意見もありました。 他に、 Version Catalog の補完やジャンプが行えない点が不便・・・だったのですが、この点含む Version Catalog に対するサポートはAndroid Studio Giraffeではいろいろと改善(※4)されていました。 最終的にはチームメンバみんなの理解を得ての採用となりますが、開発作業を楽にすることができる技術は今後も検討して取り入れていければと思っています。 参考 ※1 ビルドをバージョン カタログに移行する https://developer.android.com/studio/build/migrate-to-catalogs?hl=ja ※2 The libs.versions.toml file https://docs.gradle.org/current/userguide/platforms.html#sub:conventional-dependencies-toml ※3 Declaring a version catalog https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog-declaration ※4 Android Studio Giraffe が安定版に - Gradle ビルド スクリプトの Kotlin DSL https://android-developers-jp.googleblog.com/2023/08/android-studio-giraffe-is-stable.html
アバター
はじめに 2023年8月1日、MAMADAYSはトモニテに生まれかわりました。 tomonite.com iOSアプリもトモニテに名前を変え、これまでのメイン機能である「育児記録」「妊娠週数管理」を軸として、家族やパートナー、家族以外の人や社会との接点を作るためのシェア機能やコミュニティ機能などの拡充をめざしていきます。 トモニテのiOSアプリは新規作成画面を中心にSwiftUIの導入を進めています。一方、既存の画面を全面的にSwiftUIに置き換えることは考えていないため、今後もUIKitの画面のメンテナンスも継続していきます。 今回はUIKitの画面にResult Buildersを導入してメンテナンス性を向上する取り組みをご紹介します。 Result Buildersとは Result BuildersはSwift 5.4で導入されました。 Result Buildersを使うと、リストやツリーなどの構造化されたデータを、より自然で宣言的な構文で作成することができます。 https://docs.swift.org/swift-book/documentation/the-swift-programming-language/advancedoperators/#Result-Builders Result BuildersはSwiftUIの@ViewBuilderや、RegexBuilderで使われています。 ArrayBuilderを作る Result Buildersを使って、任意の型の配列を出力する関数を宣言的に書けるようにするArrayBuilderというものを考えてみようと思います。 以下のようなIntの配列を出力する関数を、 var numbers: [Int] { var numbers: [Int] = [ 1, 2 ] if xxxx { numbers.append(3) } return numbers } ArrayBuilderを用いて以下のように書けるようにします。 @ArrayBuilder<Int> var numbers2: [Int] { 1 2 if xxxx { 3 } } ArrayBuilderを適用した場合、以下のような点で改善されていると思います。 変数宣言、append、returnのような手続き的な記述が不要で、コードが簡潔になった コードの構造と出力する値の構造が一致していて理解しやすい 値の追加、削除などの変更がしやすい ArrayBuilderの実装は以下のようになります。 @resultBuilder struct ArrayBuilder<OutputModel> { static func buildBlock(_ components: [OutputModel]...) -> [OutputModel] { return components.flatMap { $0 } } static func buildExpression(_ expression: OutputModel) -> [OutputModel] { return [expression] } static func buildExpression(_ expression: ()) -> [OutputModel] { return [] } static func buildOptional(_ component: [OutputModel]?) -> [OutputModel] { return component ?? [] } static func buildEither(first component: [OutputModel]) -> [OutputModel] { return component } static func buildEither(second component: [OutputModel]) -> [OutputModel] { return component } static func buildArray(_ components: [[OutputModel]]) -> [OutputModel] { Array(components.joined()) } } UICollectionViewDiffableDataSource / UITableViewDiffableDataSourceのsnapshotに適用 次に、UICollectionViewDiffableDataSource / UITableViewDiffableDataSourceによみこませるためのsnapshotを作成する処理にArrayBuilderを使うことを考えました。 snapshotを生成する処理は以下のようなイメージです。 class ViewModel { enum Section: Hashable { case .section1 case .section2 } enum Row: Hashable { case .row1 case .row2 case .row3 case .row4 case .row5 } public var snapshot: NSDiffableDataSourceSnapshot<SectionType, RowType> { var snapshot = NSDiffableDataSourceSnapshot<SectionType, RowType>() snapshot.appendSections([.section1, .section2]) snapshot.appendItems([.row1, .row2, .row3], toSection: .section1) snapshot.appendItems([.row4, .row5], toSection: .section2) return snapshot } } ArrayBuilderを使用したDiffableTableViewModelプロトコルを作成し、以下のような書き方をできるようにします。 class ViewModel: DiffableTableViewModel { typealias SectionType = Section typealias RowType = Row enum Section: Hashable { case .section1 case .section2 } enum Row: Hashable { case .row1 case .row2 case .row3 case .row4 case .row5 } // この関数が改善されています var tableSections: [TableSection<Section, Row>] { TableSection(.section1) { .row1 .row2 .row3 } TableSection(.section2) { .row4 .row5 } } } ここでは使用していませんが、tableSections関数の中でfor文やif文を用いることもできます。 DiffableTableViewModelを導入することで以下のような効果が得られたと思います。 手続き的な記述が不要でコードが簡潔 画面要素の構造とコードの構造が一致していて理解しやすい 画面要素の追加/削除/並べ替えなどに容易に対応できる DiffableTableViewModelプロトコルは以下のように定義しています。 import UIKit protocol DiffableTableViewModel { associatedtype SectionType: Hashable associatedtype RowType: Hashable @ArrayBuilder<TableSection<SectionType, RowType>> var tableSections: [TableSection<SectionType, RowType>] { get } } extension DiffableTableViewModel { var snapshot: NSDiffableDataSourceSnapshot<SectionType, RowType> { var snapshot = NSDiffableDataSourceSnapshot<SectionType, RowType>() snapshot.appendSections(tableSections.map {$0.sectionType}) tableSections.forEach { tableSection in snapshot.appendItems(tableSection.rowTypes, toSection: tableSection.sectionType) } return snapshot } } struct TableSection<SectionType, RowType> { let sectionType: SectionType let rowTypes: [RowType] init(_ sectionType: SectionType, @ArrayBuilder<RowType> rowsBuilder: () -> [RowType]) { self.sectionType = sectionType self.rowTypes = rowsBuilder() } } 以上参考になれば幸いです。
アバター
はじめに はじめまして。DELISH KITCHEN 開発部 の 羽馬( @NaokiHaba )と申します。 この記事では、DELISH KITCHEN 開発部 で 行っている Node.js のバージョンアップの手順と、その際に発生した問題とその対応についてご紹介します。 対象読者 この記事は、 Node.js のバージョンアップを行いたいが、どのような手順で行えばよいかわからない方や、 Node.js のバージョンアップを行った際に発生した問題の対応方法を知りたい方を対象としています。 この記事で紹介する環境 この記事で紹介する環境は以下の通りです。 Node.js v16.13.1 npm v8.1.2 nodenv v1.4.1 バージョンアップの背景 DELISH KICTHEN WEB では フロントエンドのランタイムとして Node.js を採用しています。 tech.every.tv 現在利用している Node.js のバージョンは v16.13.1 ですが、 2023 年 9 月 11 日 に EOL が予定されているため、今回 Node.js のバージョンアップを行うことにしました。 バージョンアップの手順 リリースノートの確認 Node.js のバージョンアップを行う際は、リリースノートを確認し、バージョンアップによって発生する可能性のある問題を事前に把握する必要がありました。 nodejs.org nodejs.org OpenSSL に関する変更が多く含まれていることから、 Node.js のバージョンアップによって発生する可能性のある問題として、以下のようなものが考えられました。 変更前のバージョンの Node.js で利用していた OpenSSL のバージョンと、変更後のバージョンの Node.js で利用している OpenSSL のバージョンが異なることによって、 OpenSSL に関する問題が発生する可能性がある Node.js のバージョンアップ Node.js のバージョンアップは、 nodenv の install コマンドを利用して行います。 # 現在のバージョン情報を確認 $ node -v 16.18.0 # .node-version を更新 $ vi .node-version # .node-version 18.16.1 # nodenv で 18.16.1 をインストール $ nodenv install $(cat .node-version) # 18.16.1 $ node -v 18.16.1 .node_modules・ package-lock.json を再作成する Node.js v16.18.0 でインストールした node_modules を、 Node.js v18 で利用すると依存関係の不整合等が発生する可能性が高いことから、 node_modules を削除し、 package-lock.json を再作成します。 $ rm -rf node_modules package-lock.json # npm のキャッシュで古いバージョンのパッケージが残っている可能性があるため、キャッシュを削除 $ npm cache clean --force # キャッシュがクリアされたかどうかを確認 $ npm-cache verify # package-lock.json を再作成 $ npm install OpenSSL の 互換性エラー Node.js v18 でアプリケーションが正常に動作するか確認すると、以下のようなエラーが発生しました。 $ npm run dev # 以下のようなエラーが発生する場合は、後述の「`Node.js` v18 での `crypto` モジュールの変更」をご確認ください。 Error: error:0308010C:digital envelope routines::unsupported at new Hash (node:internal/crypto/hash:71:19) at Object.createHash (node:crypto:133:10) Node.js v18 での crypto モジュールの変更 Node.js v18 では、 crypto モジュールのデフォルトの暗号化方式が変更されたことにより、 Node.js v16 で正常に動作していたアプリケーションが正常に動作しなくなる可能性があります。 対象方法としては、以下の 2 つが考えられます。 webpack のバージョンアップ 暫定措置として package.json の scripts に NODE_OPTIONS=--openssl-legacy-provider を追加する 本来は webpack のバージョンアップを行うことが望ましいですが、 webpack のバージョンアップには時間がかかるため、暫定措置として package.json の scripts に NODE_OPTIONS=--openssl-legacy-provider を追加することで対応しました。 Error when running build-storybook with Node 17 · Issue #16555 · storybookjs/storybook · GitHub nodejs.org { " scripts ": { " dev ": " NODE_OPTIONS=--openssl-legacy-provider app/server/index.js --watch " } } 上記の設定を追加することで、 Node.js v18 での crypto モジュールのデフォルトの暗号化方式を変更することができます。 以上の手順で、 Node.js v18 へのバージョンアップを完了することができました。 Dockerfile の変更 Node.js のバージョンアップに伴い、 Dockerfile の FROM で指定している Node.js のバージョンを変更します。 # 変更前 FROM node:14.17.3-alpine3.14 # 変更後 FROM node:18-alpine3.16 最後に Node.js のバージョンアップは、 Node.js のバージョンアップによって発生する可能性のある問題を事前に把握し、対応する必要があります。 この記事が、 Node.js のバージョンアップを行う際の参考になれば幸いです。
アバター
はじめに こんにちは。DELISH KITCHEN開発部でデータサイエンティストをやっている山西です。 今回は、 DELISH KITCHENへバンディットアルゴリズムを採用した経緯 バンディットサーバーおよびそのAWSインフラ構築 をテーマに紹介いたします。 経緯 現在DELISH KITCHENでは、サービスをより良くするために、デザインの改善施策を継続的に行っています。 その手段として、これまでは主にA/Bテストによる効果検証を行ってきました。 参考記事 tech.every.tv tech.every.tv A/Bテストにより、複数のデザイン案の良し悪しを統計的に解釈し、”良い”デザインを見極めたうえでユーザーに展開することが出来ます。 適切なA/Bテストの設計によってその恩恵を最大限に享受できる一方、トレードオフとして以下のようなデメリットにも直面することとなります。 手動操作が多く、”良い”デザインを採用するプロセスを自動化出来ない 例 ユーザーに展開する割合の計算(サンプルサイズの決定) サービス内部へのデザイン実装の手間 「良くないデザイン案」を一定期間露出してしまうリスクがある 複数のデザイン案を同時にテストしようとすると、サンプルサイズが足りなくなる そこで、「複数のデザイン案の中から”良い”ものを見つけ出し、自動で表示する」という場面で採用できないかと試したのがバンディットアルゴリズムになります。 バンディットアルゴリズムとは バンディットアルゴリズムは、探索と活用のジレンマに陥る多腕バンディット問題を効率的に解くために考案されているアルゴリズムです。 まず以下に、これらの用語について解説します。 多腕バンディット問題 以下のような問題設定に当てはまるものを多腕バンディット問題と定義します。 「複数の選択肢の中から一つを選択する」試行を逐次的に行い続ける 選択を繰り返した結果、得られる総報酬を最大化したい 選択試行を繰り返すためのリソースには制約がある(時間的制約、試行回数の上限など) どの選択肢が良いか(=多くの報酬を得られるか)は未知である その具体例として、「当たりの確率が異なる複数のスロットマシンのアーム(腕)を複数回引く中で、最大の報酬を得られるように模索する」問題が挙げられます。 図1 バンディットアルゴリズム スロットマシンの例 ※ スロットマシンがプレイヤーからどんどんお金を奪い取っていく様を盗賊(bandit)に見立てたのが語源となり、多腕バンディット問題と呼ばれるらしいです。 Webサービスの運用においても、「バナーや広告等の表示コンテンツをCTR、 CVRが高くなるように最適化したい」という問題設定等が、多腕バンディット問題に当てはまります。 探索と活用のジレンマ 多腕バンディット問題では「どの選択肢が”良い”(=より多くの報酬が期待できる)のかは事前にわからない」ため、それを試しながら確かめる必要があります。 しかしここで、探索と活用のジレンマに陥ることとなります。 複数の選択肢を代わる代わる試すような”探索”ばかり行っていると、有用な選択肢を発見するまでに機会損失を被るリスクがある ex. 新しいスロットマシンを試し続ける、その中には”悪い”スロットも含まれる とりあえず、とある選択肢を”良い”とみなして活用し続けると、もっと”良い”選択肢があった場合、それを発見できないという機会損失が生じる ex. 現時点で一番”良い”スロットマシンを引き続けることになるが、もしかしたら他にもっと良い選択肢があるのを見過ごしているかもしれない バンディットアルゴリズム バンディットアルゴリズムは、このような多腕バンディット問題、およびそれに起因する探索と活用のジレンマを解決するためのアルゴリズムの総称です。 これらのアルゴリズムは主に統計的なアプローチを用いて、探索と活用のバランスをうまく取り扱いつつ、総報酬の最適化を図るように設計されています。 代表的なバンディットアルゴリズム ε-greedy : シンプルに実装可能なアルゴリズム。小さい確率εをパラメータとし、確率εで選択肢をランダムに選ぶ(探索フェーズ)。残りの確率(1-ε)で、その時点で得られた報酬の期待値が最大となる選択肢を選ぶ(活用フェーズ)。 Thompson sampling : ベイズによるアプローチ。”良い”選択肢を選ぶために、各選択肢の事後分布(これまでの選択回数と報酬が得られた回数を元に更新)を使用する。 UCB(Upper Confidence Bound) : 試行回数が少ない選択肢の「不確かさ≒”良い選択肢かもしれないというポテンシャル”」も考慮しつつ、”良い”選択肢を探索する。そのために、各選択肢の報酬の期待値の信頼区間の上限(UCB値)を利用する。 本記事では詳細な説明を割愛しますが、詳しく知りたい方は以下の書籍を読んでみてください。 www.oreilly.co.jp A/Bテストとの違い A/Bテスト、バンディットアルゴリズムは最終的なゴールとして、「複数の選択肢の中から、最も高い報酬が期待できるものを選びたい」という同じ目標を掲げているように見えます。 しかし、主眼の置き方はそれぞれ、 A/Bテスト : 検証としての振り返りに重きを置く バンディットアルゴリズム : 報酬の最大化そのものを目的とする という差異があり、それぞれに得意、不得意があるとも解釈できます。 以下、私たちなりの解釈にはなりますが、選択肢を選定するプロセスにおける違いをまとめてみたものになります。 選択肢選定プロセスの比較 バンディットアルゴリズム A/Bテスト 目的 選定の最適化による報酬の最大化 どの選択肢が優れているかの判断 自動化 される されない※1 属人性 無し 有り 効果検証 やりにくい やりやすい 環境に対する感度※2 高い 低い ※1 施策として実施した後に、結果を解釈してどの選択肢を採用するか意思決定することになる ※2 流行の変化によって、”良い”選択肢が変化した場合、バンディットアルゴリズムはその変化を追従できる可能性があるが、A/Bテストはやり直しによってしか対処できない DELISH KITCHENでは、「複数のデザイン案から”良い”ものを選定する」A/Bテスト以外の選択肢としてバンディットアルゴリズムならではの強みを活かせるのではないかと考え、採用に至りました。 DELISH KITCHENへのバンディットアルゴリズムの適用 これから、DELISH KITCHENにおけるバンディットアルゴリズムの適用事例を簡単にですが紹介します。 ボタン文言の出し分け事例 DELISH KITCHEN(モバイルブラウザ版)のトップ画面には、アプリ版へのダウンロードを促すボタンが設定されています(下図参照)。 ここを、 選択肢: ボタンに表示させる文言 (5パターンを準備) 報酬: ボタンクリック という多腕バンディット問題に落とし込みます。 そして、バンディットアルゴリズムによって、ここの文言がクリック率の高いものに寄っていくか実験してみました。 ※ Thompson samplingというアルゴリズムを利用 図2 ボタンとその文言の配信面 delishkitchen.tv 結果 実際に配信してみた結果、最初のうちは複数の選択肢を試し、一定期間経過後、一つの文言(赤色部分)を集中的に表示する様子を観察することが出来ました。 図3 表示文言の時系列変化 ※ 縦軸のビン(棒)は、その時々のボタン100回表示の中で、5パターンの文言(凡例の0~4)のどれが何回表示されたかを色分けしています。 そして、横軸は時系列を表し、右に進むほど時間が経過したことを意味します。 全選択肢(文言)の中で、1番の文言(グラフ上の赤色部分)のクリック率が最も高い結果となりました。 このクリック率は、元々ページ上に表示していた文言(グラフ上の0番=青色部分)よりも1.4倍ほど高いものとなります。 よって、アルゴリズムの探索によって、クリック率観点で”良い”文言を見つけ出したことになります。 バンディットサーバーをAWSに構築 ここからは、バンディットアルゴリズムの実装について紹介します。 Pythonでバンディットアルゴリズムを実装 FastAPIにて↑をサーバー化 AWS上にインフラ構築(FastAPIをコンテナ化してECRにアップロード&ECS-Fargateとして立てる) という流れを取りました。 そして、 既存DELISH KITCHENバックエンドサーバーがこのバンディットサーバーに対してAPIリクエストを送信 「バンディットアルゴリズムの判断結果として、どの選択肢を表示すれば良いか」の情報をレスポンスで返す Databricks(データ集計基盤)を経由して、配信に向けたマスタ情報や、バンディットアルゴリズムが必要とする報酬情報等のバッチ更新を実施 という構成にしています。 以下がその構成図になります。 図4 バンディットインフラ構成図 FastAPIコードの例 以下、一部抜粋にはなりますが、バンディットサーバーのコードの実装の雰囲気を紹介します。 内部実装ではMVCアーキテクチャを意識しており、 service層 バンディットアルゴリズムのロジックそのもの APIとして実現したい操作(コード部分) model層 必要なデータソース(上記構成図におけるElasticache-redis)を操作する部分 といった具合に各々の責務を分離し、見通しよく開発できるようにしています。 service層のget_arm(APIとしてどの選択肢をリクエスト元に返すかを操作する部分)の実装↓ from typing import List, Dict, Tuple from model.arm import select_arm_score as redis_select_arm_score from model.environment import select_environment_all_arms as redis_select_environment_all_arms from services.bandit_environment import select_bandit_environment from redis.exceptions import RedisError from exception import InvalidIdentifierTypeException, IdentifierNotExistsException, InvalidArmTypeException, ArmNotExistsException # あるidentifier(配信面)が持つ全てのarm(選択肢)のcount(選択回数)とreward(報酬を受け取った回数)を取得 def select_arms_score (identifier: str , arms:List[ int ]) -> Dict[ int , Tuple[ int , int ]]: try : arms_score = {} for arm in arms: # model層から必要な情報を取得 count, reward = redis_select_arm_score(identifier, arm) # 各選択肢(arm)ごとに、何回選択されたか(count)と、何回報酬を受け取ったか(reward)を格納 arms_score[arm] = (count, reward) except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException, InvalidArmTypeException, ArmNotExistsException): raise else : return arms_score # あるidentifier(配信面)が持つ全てのarm(選択肢)の一覧を取得 def select_environment_all_arms (identifier: str ) -> List[ int ]: try : # model層から必要な情報を取得 arms = redis_select_environment_all_arms(identifier) except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException): raise else : return arms # 配信面に対してどのarm(選択肢)を返せば良いか、バンディットアルゴリズムに判断させる。 # その際に、count(選択回数)とreward(報酬を受け取った回数)の情報を用いる def get_arm (identifier: str , algorithm: str ) -> int : try : # バンディットアルゴリズムのインスタンスを立ち上げる bandit_environment = select_bandit_environment(identifier, algorithm) # identifier(配信面)が持つarm(選択肢)の一覧情報 # およびそれら選択肢に対するscore情報(選択回数:countと報酬回数:reward)を取得 arms = select_environment_all_arms(identifier) arms_score = select_arms_score(identifier, arms) # バンディットアルゴリズムのインスタンスに対して、どのarm(選択肢)を返せば良いかを判断させる arm_number = bandit_environment.get_arm(arms_score) except (RedisError, InvalidIdentifierTypeException, IdentifierNotExistsException, InvalidArmTypeException, ArmNotExistsException): raise else : return arm_number # どのarm(選択肢)にするか、番号で返す よかったこと 次に、インフラ構築、およびバンディットサーバー内部実装それぞれで責務の分離を意識することで得られたメリットについてまとめます。 DELISH KITCHENサーバーとの責務の分離 DELISH KITCHEN自体のバックエンドサーバーと、バンディットサーバーを切り離して実装したことによって、実装の柔軟性や耐障害性の観点で以下のメリットが得られました。 実装の柔軟性 データサイエンティストがバンディットのロジックのみに責務を持ち、開発リソースを集中できる 新しいバンディットアルゴリズムを追加する場面等での拡張性が高い DELISH KITCHEN自体のバックエンド実装を意識せず、ロジックに集中してメンテナンスを行うことができる 耐障害性 万が一問題が発生した時にも、事業に与える悪影響を最小限に抑えやすくなる 予めDELISH KITCHENバックエンド側にフェールセーフ機構※を備えておくことで、バンディットサーバーへのAPIリクエストに失敗した際でも、ユーザー影響無くサービス運用を継続できる ※ バンディットサーバーとの疎通に失敗した場合、複数選択肢の出し分けは中止し、予め決め打ちしたデザインを表示するように設定しておく等 MVC化による責務の分離 さらに、バンディットサーバー内部実装でMVCアーキテクチャによる責務の分離を意識したことで、可読性、およびコード全体のメンテナンス性が向上しました。 Model層、Service層などの各層の役割が明確に分かれ、各々に対する変更が局在化されるため、システム全体の安定性を保ちつつ 新規バンディットアルゴリズムを追加実装する データソースを変更する ことが可能となりました。 大変だったこと “探索と活用”のテストの難しさ バンディットアルゴリズムは確率的に生成される情報を取り扱うため、実装したコードの挙動をテストする際はその性質も考慮する必要があります。 以下は、「バンディットアルゴリズムの試行が100回進んだ際、最も”良い”選択肢が最も多く選ばれることを期待する」unittestのコード例となります。 # 試行を進めた結果、最も報酬が得られる確率が高いarm(選択肢)の選択回数が多くなることを確認する。 # 良いarmを探索し、その結果を活用できているかの観点で簡易テスト def assert_most_selected_arm_is_best_arm (self, agent: Union[EpsilonGreedyAgent, ThompsonSamplingAgent, UCBAgent], thetas: Dict[ int , float ], num_trials= 10000 ): # 最も報酬が得られる確率が高いarm best_arm = max (thetas, key=thetas.get) most_selected_arm = self.get_most_selected_arm_from_bandit_simulation(agent, thetas, num_trials) with self.subTest(test_input=most_selected_arm, expected=best_arm): self.assertEqual(most_selected_arm, best_arm) def test_exploit_best_arm (self): agent = ThompsonSamplingAgent( "test_agent" ) # 後のシミュレーションにおいて、armから報酬が得られる確率を定義 thetas = { 0 : 0.1 , 1 : 0.5 , 2 : 0.8 } # {選択肢の番号:その選択肢から報酬が得られる確率} self.assert_most_selected_arm_is_best_arm(agent, thetas) このように、アルゴリズムが「良い選択肢によっていく性質」を簡易的にテストしたい場合は、確率的な振る舞いを前提にし、ある種の曖昧さを許容するような視点を求められることがあります。 一方、この粒度の単体的なテストではバンディットアルゴリズムが時間経過と共に、探索と活用のバランスを取るように設計されている(図3で示したような様子)ことを確かめるのは困難です。 さらに、ビジネスシーンで実運用する際は、上記のテストとは異なり、各選択肢から報酬が得られる確率は未知となります。   こうした難しさがあるため、本番環境に投入して経過観察してみないと時間推移的な挙動の確からしさが判別しづらいのが現状です。 そのため、オフライン評価手法をもっと整備しなければならないという課題感を感じているところではあります。 最後に バンディットの要件整理&アルゴリズムの実装はもちろん、それに留まらずサーバー実装〜AWSインフラを一気通貫で構築した今回の取り組みは、データ職責として挑戦的な機会となりました。 慣れない技術スタック、苦労も多くあったものの、 MLをどうサービスに組み込むか そのために如何にして開発サイドのエンジニアと連携するか 等の視座を上げることができ、非常に有意義な経験となりました。 また、バンディットアルゴリズムの導入により、「サービス内の表示コンテンツを最適化する手段」としてA/Bテスト以外の選択肢が加わることとなりました。 今後は、検証の目的によってA/Bテストとは使い分けつつ、バンディットアルゴリズムの利用拡大や文脈付きバンディットへの拡張などに取り組んでいきたいと思います。 参考書籍 飯塚 修平. 2020. ウェブ最適化ではじめる機械学習―A/Bテスト、メタヒューリスティクス、バンディットアルゴリズムからベイズ最適化まで. オライリージャパン.
アバター
はじめに こんにちは トモニテ でバックエンド周りの開発を行っている rymiyamoto です。 トモニテ ではこれまで構成を AWS の EKS を使ってきましたが、2 月の初旬に ECS に移行したのでその内容を紹介していきます。 経緯 移行を決断したのは最大の理由は、現状のメンバーで kubernetes(以下 k8s) のをメンテナンスしていくコストが高すぎるためです。 k8s 自体が高頻度にアップデートが進んでおり、日々の業務を進めがらのキャッチアップが難しく、いざアップデートするのは EKS のサポートが切れる間際になってしまい後手に回っていました。 (大体年 1 回ぐらいのペースでやっていました) 対応をすすめる際もバージョンが大きく飛んでしまうため、リリースノートを追ってちゃんとアップデートを完了するにはだいたい 1 メンバー 1 ヶ月ぐらいはかかってしまいます。 かかる工数がもったいなく、また社内の別プロダクトで ECS の運用実績がしっかりとあるので合わせることとなりました。 移行までのロードマップ 基本的には EKS 部分を ECS に乗り換えるに止め、全体的なアーキテクチャの変更はしない方向で進めました。 理由としては諸事情により対応期間があまり取れなかったためです。 実際進めていったときのロードマップは以下のようになります。 AWS コンソール上で ECS 環境を用意 ECS 環境の IaC 化 & CI/CD の整備 ECS 環境に切り替え 本番移行も移行 EKS 環境の破棄 AWS コンソール上で ECS 環境を用意 まずは ECS に乗り換えてアプリケーションレベルので修正が必要かを確認するために、DEV 環境のコンソール上から環境を構築していきました。 クラスターの作成 API server やら web 等をタスク定義 タスク定義をもとに ECS のサービスを Fargate で作成 これらに伴う role や policy の作成/変更 ECS 環境の IaC 化 & CI/CD の整備 アプリケーション側の調整が済み次第、terraform におこし反映していきます。 ただ ECS のタスク定義は初回の生成移行それぞれのサービスで環境変数やコンテナイメージを管理したいため、それぞれの repository で json ファイルとして管理するようになりました。 タスク定義(task-definition.json) { " taskDefinition ": { " containerDefinitions ": [ { " name ": " api-server ", " image ": " 111111111111.dkr.ecr.ap-northeast-1.amazonaws.com/api-server:develop ", " cpu ": 0 , " portMappings ": [ { " containerPort ": 1323 , " hostPort ": 1323 , " protocol ": " tcp " } ] , " essential ": true , " command ": [ " /bin/sh ", " -c ", " './run.sh' " ] , // 起動コマンド、スクリプトあるのでそれを実行 " linuxParameters ": { " initProcessEnabled ": true } , " environment ": [ // more... ] , " ulimits ": [ { " name ": " nofile ", " softLimit ": 1024 , " hardLimit ": 4096 } ] , " mountPoints ": [] , " volumesFrom ": [] , " secrets ": [ // more... ] , " logConfiguration ": { " logDriver ": " awslogs ", " options ": { " awslogs-create-group ": " true ", " awslogs-group ": " /ecs/api-server ", " awslogs-region ": " ap-northeast-1 ", " awslogs-stream-prefix ": " ecs " } } } ] , " family ": " api-server ", " taskRoleArn ": " arn:aws:iam::111111111111:role/server-ecs-task-role ", " executionRoleArn ": " arn:aws:iam::111111111111:role/ecsTaskExecutionRole ", " networkMode ": " awsvpc ", " volumes ": [] , " placementConstraints ": [] , " requiresCompatibilities ": [ " FARGATE " ] , " cpu ": " 256 ", " memory ": " 512 ", " runtimePlatform ": { " cpuArchitecture ": " X86_64 ", " operatingSystemFamily ": " LINUX " } , " tags ": [] } } サービス関連 今回は別プロダクトでも運用している ecs-deploy を利用しました。 (こちらは現在メンテナンスモードで機能追加はありませんが保守は滞りなく進んでいるようです) 先程のパートで作成したタスク定義の json を渡すことで簡単にデプロイすることができます。 # ecs-deployの用意 curl -sL https://github.com/silinternational/ecs-deploy/archive/ 3 . 10 . 7 .tar.gz | tar zxvf - mv ecs-deploy-3. 10 . 7 ecs-deploy chmod +x ecs-deploy/ecs-deploy # 途中でdocker image 作成やECRへのアップロード等の処理 # 省略... # サービスへデプロイ ecs-deploy/ecs-deploy --cluster ecs-cluster \ --task-definition-file task-definition.json \ --service-name api-server \ --region ap-northeast-1 \ --timeout 600 \ --image 111111111111 .dkr.ecr.ap-northeast-1.amazonaws.com/api-server:develop 余談ですが ecspresso の採用も視野にありましたが、今回は対応できる期間も短く社内で運用実績ないため採用は見送りとなっています。 バッチ関連 これまで k8s 上では定期実行を cronjob、ショット実行で job を用いており、またバッチの docker image は全てまとめていました。 (バッチが増えるたびに image を ECR へ登録していくのは面倒なので) そのためそのまま AWS CLI で作成すると CloudWatch のロググループがひとまとめになり見づらくなってしまう + ショット実行の方法が面倒になってしまいます。 今回は scheduled task を管理するために ecschedule で反映するようにしました。理由としては、スケジュール、override 含めて yaml で管理可能なところと run での即時実行にも対応していたためです。 共通化できる部分は yaml のテンプレート化したいので ytt を採用しています。 構成イメージとしては以下のようになります。 . ├── base-task-def.json(テンプレートとなるタスク定義) ├── config.yaml(ecsscheduleで使う設定ファイル) ├── tasks(各batchの設定ファイル) ├── batch-hoge.yaml └── batch-fuga.yaml └── more... base-task-def.json { " executionRoleArn ": " arn:aws:iam::111111111111:role/ecsTaskExecutionRole ", " containerDefinitions ": [ { " logConfiguration ": { " logDriver ": " awslogs ", " options ": { " awslogs-group ": " /ecs/server-batch ", " awslogs-region ": " ap-northeast-1 ", " awslogs-stream-prefix ": $job_name } } , " entryPoint ": [] , " portMappings ": [] , " command ": [] , " cpu ": 0 , " environment ": [ // more... ] , " mountPoints ": [] , " secrets ": [ // more... ] , " volumesFrom ": [] , " image ": " 111111111111.dkr.ecr.ap-northeast-1.amazonaws.com/server-batch:develop ", " name ": " server-batch " } ] , " placementConstraints ": [] , " memory ": " 1024 ", " taskRoleArn ": " arn:aws:iam::111111111111:role/scheduled-task ", " family ": $job_name, " requiresCompatibilities ": [ " FARGATE " ] , " networkMode ": " awsvpc ", " runtimePlatform ": { " operatingSystemFamily ": " LINUX " } , " cpu ": " 512 ", " volumes ": [] } config.yaml #@ load("@ytt:data", "data") region : ap-northeast-1 cluster : ecs-cluster rules : #@ data.values.rules batch-hoge.yaml #@data/values --- rules : - name : batch-hoge scheduleExpression : cron(0 0 * * ? *) taskDefinition : batch-hoge containerOverrides : - name : server-batch command : - ./exec environment : # more... launch_type : FARGATE platform_version : LATEST network_configuration : aws_vpc_configuration : subnets : - subnet-hoge - subnet-fuga - subnet-foo security_groups : - sg-hoge assign_public_ip : DISABLED disabled : false この構成のもとに CI 側でタスク定義と scheduled task の登録を行っています。 # tasks配下のbatchをもとにタスク定義 for item in ` ls tasks` do job = `basename ${item} .yaml` new_task_definition = $( jq -n --argjson job_name " \" $job \" " -f " base-task-def.json " ) aws ecs register-task-definition --cli-input-json " $new_task_definition " done # yttでecscheduleで反映させるためのyaml生成 ytt -f " config.yaml " -f " tasks " --file-mark ' config.yaml:exclusive-for-output=true ' > " ecschedule.yaml " # 生成したyamlを元に反映 ecschedule -conf " ecschedule.yaml " apply -all また余談ですがこの構成のときショット実行の際は以下のように実行できます。 # ショット実行したいタスク item = " batch-hoge.yaml " job = `basename ${item} .yaml` # yttでecscheduleで反映させるためのyaml生成 ytt -f " config.yaml " -f " tasks/ ${job} " --file-mark ' config.yaml:exclusive-for-output=true ' > " ecschedule.yaml " # 生成したyamlを元に反映 ecschedule -conf " ecschedule.yaml " run -rule $job ECS 環境に切り替え 一気に新環境に切り替えていくのは、不測の事態があったときに対応が大変になるので、Route53 で新環境へ加重を数日かけて少しず増やしていきました。 また batch 系は 二重で走らないように EKS 側を停止した状態でスケジュールタスクを active に変更して反映させています。 EKS 環境の破棄 不要になった EKS 向けの CI/CD や関連する aws リソースの削除を進めていきました。 一気に terraform で関連リソースを削除しようとすると、都合上依存すると別のリソースまで影響してしまいます。 そのため信頼関係を見つつ地道にモジュール削除を進めていきました。 移行してみて よかったところ k8s の頃に比べてサービスやバッチの構成をマニュフェストで管理するよりはシンプルな構造になりました。 また最大の課題であった EKS のアップデート作業からの開放でよりサービス開発に重きを降ることができるようになったと感じます。 大変だったこと EKS on EC2 で運用していたものを ECS Fargate に変えたことで厳密にリソースの管理をしないといけないので、これまで見えていなかったメモリリークが起きていたことに気付かされました。 (この対応の話は以下の記事で紹介しています) tech.every.tv また k8s 自体が活発なコミュニティ故にツールが豊富でしたが、ECS は AWS に縛られる形となるのでデファクトスタンダードと呼べるデプロイ方法が見当たらず何かと創意工夫が必要となってしまいました。 最後に 世間的の逆の流れを行く対応となっていますが、k8s を採用する場合はサービスと組織の規模感を意識しておかないと後々のメンテナンスが辛くなってしまいます。 もちろん k8s を正しく使いこなせると様々な機能の恩恵を受けれるので、体制的にちゃんと面倒を見れるかどうかを判断してとり入れるのは問題ないと思います。 EKS(k8s)と ECS をどっちですすめるか迷っている方や、同じように ECS への移行を検討している方の手助けになれば幸いです。
アバター
はじめに こんにちは。MAMADAYS開発部でiOSエンジニアをやってる國吉です。 この度、MAMADAYSから姉妹アプリ第一弾となる”陣痛カウンター”をリリースしました。 MAMADAYSアプリはスーパーアプリになっていて機能数も多く長く利用して頂くユーザさんも多いアプリです。一方で、陣痛時の利用という利用期間が短い用途のものは小さいアプリに切り出して機能特化することでシンプルで使いやすい戦略を取っています。 そしてタイトルにも書いてある通り、陣痛カウンターはSwift Package Managerを採用しています。採用理由は下記で、お試しで導入するには最適だと考え、導入に至りました。 - アプリ自体の規模が小さい - 前からSwift Package Managerを使ってみたかった - 使用するライブラリ数が少ない 今回はそんなSwift Package Managerについてお話ししていきます。 陣痛カウンター 本題に入る前に少し陣痛カウンターアプリについて宣伝をさせてください。 陣痛カウンターは機能・デザイン共にシンプルですごく使いやすい!また、オフライン状態でも動作します! 陣痛がきたかなと感じたら”きたかも”ボタンをタップし、時間計測を始めます。陣痛が治まったら”おさまったかも”ボタンをタップして計測を終了します。 それらデータを履歴として表示して、陣痛間隔が一定の間隔より下回ったらお知らせする。といったアプリです! 実際の使い勝手などは実際にインストールして使ってみてください!是非周りに出産を控える妊婦さんがいたら紹介して頂けると嬉しいです。   陣痛カウンター - すぐ計測できる陣痛アプリ・陣痛タイマー every, Inc. メディカル 無料 apps.apple.com Swift Package Manager さて、本題のSwift Package Manager(以下”SPM”と略)のお話をしていきます。 SPMはApple公式から提供されているパッケージマネージャーになっており、Xcode9以降から使うことができます。 SPMに対応しているか調査 まず、アプリに入れようとしているライブラリがSPMに対応しているのか調べる必要があります。 ライブラリ側がSPMに対応していなかった場合は、SPMは使えません。 調べ方はすごく簡単で、ライブラリのディレクトリ内に「Package.swift」というファイルがあるかどうかを見るだけです。 例:nuke https://github.com/kean/Nuke 1. ライブラリのディレクトリが確認できるページを開きます。 2. ディレクトリ内を確認するとPackage.swiftがあるのが確認できます。 3. これでnukeはSPM対応されていることがわかります。 次にアプリ側でライブラリ使えるようにしていきます。  [File] -> [Add Packages ...]  Search or Enter Package URL のサーチエリアでライブラリのリポジトリURLを入力し検索をかけます。 すると、このようにnukeがサジェストされます。 Dependency Ruleで導入したいバージョン指定を行い、[Add Package]をクリックすると、ダウンロードが開始されます。 複数パッケージが存在する場合は、どのパッケージを導入するか選択する画面がでるので、任意のパッケージを選択しダウンロードしましょう。 ダウンロードが完了すればライブラリが使えるようになります。CocoaPodsと同様にimportして使ってください。  実態はどこに ここまででSPMを用いてライブラリを追加してきましたが、設定ファイルや実態はどこにいるんだ?チーム開発をしているからメンバーにはどうやって共有されるんだ? という疑問が生じました。調べた結果下記の場所に設定ファイルや実態がありました。 依存関係を解決した結果や各ライブラリのバージョンが”Package.resolved”というファイルに書き出されるので、これを他メンバーに共有されることでライブラリのバージョンを揃えることができます。  # ライブラリ群 > /Library/Developer/Xcode/DerivedData/{projectID}/SourcePackages/checkouts/ # Package.resolved(設定ファイル) > /{project}.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved CocoaPodsとの比較 Xcodeのみでライブラリの追加/削除が簡単にでき、バージョン確認等々も完結できます。 "pod init"や"pod install"とかしなくていいので、結果的に操作する箇所が減って楽になりました。 SPMはApple公式が出しているパッケージマネージャーなので、安心感はあります。Xcode13以降ようやく動作も安定してきたようです。  SPM導入で苦労したこと 陣痛カウンターはFirebaseのCrashlyticsを使用しています。 fastlaneでFirebaseにdSYMを上げているんですが、実行しても「upload-symbolsが存在しません」とエラーが吐かれました。 CocoaPodsでライブラリ管理をしているとPodsフォルダ配下にupload-symbolsというスクリプトファイルが存在しますが、 SPMはライブラリの実態がプロジェクトのディレクトリ配下にはいないので、参照できないということでした。 回避策として、Firebase公式のページからupload-symbolsをダウンロードし、fastlaneではそのスクリプトファイルを動かすように対応しました。 導入してみた感想 結論、この規模感のアプリにはSPMは適していると思いました。 まず、CocoaPodsとの比較セクションでも触れましたがライブラリの追加/削除がXcode内で簡単にできるのが一番大きいメリットです。 Pod関連のファイルも無くなるので、ディレクトリの見通しも良くなります。 Crashlyticsとか独自の対応を行いましたが、一度対応しておけば使い回すことができるので、最初だけ我慢しましょう。 ただ、陣痛カウンターのような小規模なアプリに適していると感じているだけで、中規模以上のアプリには不適切かもしれません。 あと、既にCocoaPodsで実装を進めてしまっている場合も、CocoaPodsのままでいいと思います。現状、移行するコストを払うほどのメリットは無いと感じています。 また古いライブラリはSPMに対応していないケースも多々あるので、導入前に追加予定のライブラリがSPMに対応しているかの調査はすごく大事です。その中で1つでもSPM対応されていないライブラリがあるのであれば、手間が増えるだけなのでCocoaPodsにした方がいいです。 最後に ここまで読んでくださりありがとうございました。 SPMについて触りぐらいは伝わったでしょうか?この記事がSPM導入の手助けになることがあれば嬉しいと思います!
アバター
DELISH KITCHENの定期購読 こんにちは、DELISH KITCHEN開発部でバックエンド開発を担当している南です。 主にDELISH KITCHENのプレミアムユーザー向けの機能の開発を行っております。 DELISH KITCHENでは、人気順検索、プレミアムレシピ(ダイエット、ヘルスケア、美容・健康、作りおき)、 プレミアム献立など、さまざまな機能を提供するプレミアムサービスという定期購読(サブスクリプション)商品を販売しております。 プレミアムサービスは、おもにiOSやAndroidのプラットフォーム上で管理、販売されておりDELISH KITCHENアプリ内から購入できます。 ここではiOSの課金をIAP(In-App-Purchase), Androidの課金をIAB(In-App-Billing)と呼んで区別したいと思います。 IAPとIABとDELISH KITCHEN iOSやAndroidのプラットフォームに対してAPIを実行することでそれぞれ課金状態を表したレシートを取得できます。 IAPレシートもIABレシートも課金状態を表現するものという点では共通しているのですが、 表現の仕方がことなるためDELISH KITCHENのサーバー側で違いを吸収する必要があります。 IAPとIABの課金状態について IAPもIABもレシートが表現する課金状態はほぼ同じですが、IABにのみ一時停止という状態があります。 課金状態名と、それがDELISH KITCHENにおいて、どのような状態かを説明した表です。 AndroidのIABレシート IABレシートの構造はシンプルで、現時点の購読状態のみ返します。 購読が更新されれば、 expiryTimeMillis の日時が増加し、支払いに関して変化がおきたら paymentState の値が変化します。 { " kind ": " xxxxxxx ", " startTimeMillis ": 1111111111111 , " expiryTimeMillis ": 2222222222222 , " autoRenewing ": true , " priceCurrencyCode ": " JPY ", " priceAmountMicros ": 480000000 , " countryCode ": " JP ", " developerPayload ": "", " paymentState ": 1 , " cancelReason ": 0 , " userCancellationTimeMillis ": 0 , " orderId ": " GPA.0000-0000-0000-0000 ", " linkedPurchaseToken ": "", " purchaseType ": 0 } IABレシートとユーザー状態 レシートの値からユーザーの課金状態を把握する必要があります。 IABは返す情報がシンプルで、情報と状態を結びつける資料も整備されているので判別することが簡単です。 一方で履歴のような過去の情報が一切ないため、状態の変遷をAPIから知る方法がありません。 Google Play の課金システム > 定期購入を販売する (*) 一時停止状態とはユーザーがPlayストアの定期購読一覧から指定した期間だけ購読を中断して、期間がすぎたら再び自動再開する仕組みです。 AppleStoreのIAPレシート 一方でIAPレシートは、IABレシートと比べると構造が複雑で情報も多めです。 latest_receipt_info には定期購読商品の購入履歴が含まれています。 履歴の1つ1つには、「どんな商品を購入したか?」、「何時購入したか?」、「何時期限切れになるか?」といった変化しない情報が含まれています。 (例外として返金キャンセルが発生すると履歴の値が変化します) また状況に応じて刻々と値が変わる pending_renewal_info という項目があります。 pending_renewal_info からは「次回の更新で購入する予定の情報」、「定期購読を継続するか否か」、「期限切れになった理由」、といった状況に応じて変化する情報が含まれています。 { ... " latest_receipt_info ": [ { " quantity ": " 1 ", " product_id ": " delishkitchen ", " transaction_id ": " 111111111111111 ", " original_transaction_id ": " 111111111111111 ", " purchase_date_ms ": " 1629307052000 ", " purchase_date ": " 2022-02-01 07:00:00 Etc/GMT ", " purchase_date_pst ": " 2021-02-01 00:00:00 America/Los_Angeles ", " original_purchase_date_ms ": " 1643698800000 ", " expires_date_ms ": " 1646118000000 ", " expires_date ": " 2021-03-01 07:00:00 Etc/GMT ", " expires_date_pst ": " 2021-03-01 00:00:00 America/Los_Angeles ", " cancellation_date_ms ": " 0 ", " cancellation_date ": 0 , " cancellation_date_pst ": 0 , " web_order_line_item_id ": " 333333333333333 ", " is_trial_period ": " true ", " is_in_intro_offer_period ": " false ", " promotional_offer_id ": "" } , { " quantity ": " 1 ", " product_id ": " delishkitchen ", " transaction_id ": " 222222222222222 ", " original_transaction_id ": " 111111111111111 ", " purchase_date_ms ": " 1646118000000 ", " purchase_date ": " 2022-03-01 07:00:00 Etc/GMT ", " purchase_date_pst ": " 2021-03-01 00:00:00 America/Los_Angeles ", " original_purchase_date_ms ": " 1629307053000 ", " expires_date_ms ": " 1648796400000 ", " expires_date ": " 2022-04-01 07:00:00 Etc/GMT ", " expires_date_pst ": " 2021-04-01 00:00:00 America/Los_Angeles ", " cancellation_date_ms ": " 0 ", " cancellation_date ": 0 , " cancellation_date_pst ": 0 , " web_order_line_item_id ": " 444444444444444 ", " is_trial_period ": " false ", " is_in_intro_offer_period ": " false ", " promotional_offer_id ": "" } ] , " pending_renewal_info ": [ { " expiration_intent ": "", " auto_renew_product_id ": " delishkitchen ", " original_transaction_id ": " 111111111111111 ", " is_in_billing_retry_period ": "", " product_id ": " delishkitchen ", " auto_renew_status ": " 1 " } ] } IAPレシートとユーザー状態 IAPレシートは情報が多めですが、レシートからユーザーの状態を把握する際は以下の情報を用いています。 latest_receipt_infoの最新のレシート is_trial_period: 無料トライアルか否か expires_date_ms: 有効期限の日時 cancellation_date_ms: 返金した日時 pending_renewal_info auto_renew_status: 購読を継続するか否かを表す。 expiration_intent : レシートが期限切れになった理由を表す。期限内は常に空 is_in_billing_retry_period: 支払いリトライ中か否かを表す。ExpirationIntent=2以外のときは空 grace_period_expiration_date: 猶予期間の期限 auto_renew_product_id: 次回の更新に購入するプロダクトID 課金状態と注意点 猶予期間 と 保留中(支払いリトライ状態) と 一時停止 の扱いは気をつけないといけません。 猶予期間 期限が切れたユーザーを引き続き課金状態として扱うため、IABでは expiryTimeMillis の日時が自動的に伸びます。 一方IAPでは、latest_receipt_infoの expires_date_ms は伸びません。代わりに、 grace_period_expiration_date_ms に猶予期間の日時が入ります。 保留中(支払いリトライ状態) 猶予期間後も支払いに失敗しつづけている状態です。 期限切れになり無料ユーザー状態となりますが、一定期間(デフォルトでiOSは60日間、Androidは30日間)支払いをリトライし続けます。 リトライによって支払いが成功すると購読状態に戻りますが、一定期間以上失敗し続けると、プラットフォームが自動的に解約状態にしてリトライをやめます。 また支払いリトライ期間中に解約することもできます。 一時停止 IAB特有のユーザー状態です。こちらも一度期限が切れるため、一見解約したように見えます。 しかし指定した期間をすぎると何事も無かったかのように購読を再開するため、一時停止状態中だと判別できていないと解約したユーザーが再び戻ってきたかのように見えてしまいます。 また定期購読一時停止中に解約することもできます。 定期購読と状態管理 Choosing a Receipt Validation Technique こちらで述べられている通り定期購読状態を適切に扱うにはサーバーで購読状態を管理し、同期する必要があります。 ですが、これだけでは下記のようなユーザーの行動の変遷を追うことはできません。 ユーザー状態をと経緯を正確に判断するためにも、レシートの履歴をサービスのサーバー側で保存することも大切です。 ユーザーが猶予期間中になっていたか? 保留中から戻ったのか?、それともキャンセルしたのか? 一時停止から戻ったのか?、それともキャンセルしたのか? まとめ 定期購読の難しいところ でも述べられておりますが、 一見単純そうにみえる定期購読ですが、正しくやろうとすると実は面倒なことが多いです。 またIAP、IABの仕様追加にも追従していく必要がありサーバー側の保守コストがかかります。 ですがDELISH KITCHENのプレミアム機能を多くの方に提供し続けるためにも定期購読の管理・アップデートを続けていきたいと思っております。 参考資料 Google Play の課金システム > 定期購入を販売する App Store Receipt Data Types Choosing a Receipt Validation Technique Question About Ios Receipt Fields Addition on July 19 2017 App Store の In-App Purchase の Grace Period対応 アップルはApp Storeのサブスク期限切れに「猶予期間」を導入 Engineering Subscriptions(WWDC 2018) Auto Renewing Subscriptions for iOS Apps
アバター
こんにちは。TIMELINE開発部の齊藤です。好きなエディタはEmacsです。社内の一部エンジニアからは珍獣扱いされてますが、Emacsは最強のエディタなので20年近く愛用しています 1 。 さて、皆様は日頃のサービス運用に、社内向けの管理サイトなどを作っているかと思われますが、弊社でもご多分に漏れず管理サイトを用意して、日々の運用を行なっております。 この管理サイトの出来不出来によっては、運用コストも大きく変わったりするので、案外重要なものだったりするのですが、作るのは正直めんどくさいです。 ユーザさんにお見せするサイトと異なり、MAUは一桁ぐらいですし、いいものを作っても誰かに誉められることも少ないので、正直めんどくさいです(大事なことなのでry なので、めんどうなことは少しでも楽をしつつ、それでいて運用事故/コストを少しでも減らせられるようにがんばっていたりします。 序文 私が所属しているTIMELINE開発部では、「au payマーケット」アプリで提供している「ライブTV」の運用/開発などを主に担当しています。 コロナ禍でライブコマースがEC市場で再燃。複数のプラットフォームで配信できる『TIMELINE』ならではの強みとは 元々、弊社ではCHECKというライブコマースサービスを運用していました。 ライブTVはその時の資産を利用しており、管理サイトなども数年前のリリース時に私が作ったのがそのまま利用されていたりします。 当時は何も考えなしに作ってたのですが、TIMELINEに合流して改めて見ると、あちらこちらでめんどくせーってところが散見されたため、そこらへんを修正していったあれやこれやを紹介させていただきます。 ビルドの自前管理がめんどくさい webpackを用いてビルドしているのですが、元々はそのコンフィグファイルを自前管理していました。 当時は「 grunt に比べてなんて楽なんだろう...」と感動して使っていたのですが、久々に昔書いたコンフィグファイルを眺めていると、「こんなん管理するとかムリー!!」っていう感情に溢れてしまうほどめんどくさいものでした。 そこで、MAMADAYSで採用していた Next.js へ全面的に載せ替えることにしました。 Next.js の利点として SSR/SSGによるものがよく挙げられますが、管理サイトのフレームワークとして採用する利点はそこではなく、「ビルド管理をNext.js に任せられる。また、それに伴う各種恩恵(後述)に与れる」ところです。 もちろん載せ替える手間はあります。しかし、今後発生するであろうめんどくささと比べたら微々たるものですし、元々が React で書いていたので最低限の修正だけで載せ替えることができました。 これにより next build するだけでビルドしてくれます。コンフィグを自前管理しなくてもいい 2 だなんて、神! さらには、開発時のホットリロードも独自で書いてたのですが、それすらも next dev するだけでやってくれます。至れり尽くせりすぎる! 開発効率は格段に上がり、細かい修正とかへのストレスもだいぶ軽減されました。 ルーティングがめんどくさい 元々は react-router-dom を使って、下記のような方法でルーティングを行っていました。 import { HashRouter, Switch, Route } from 'react-router-dom'; import Index from './containers/Index'; import Hoge from './containers/Hoge'; const routes = [ { path: '/', exact: true, component: Index, }, { path: '/hoges, exact: true, component: Hoge, }, ]; export default class App extends Component { render() { return ( <HashRouter> <Switch> {routes.map(({ path, exact, component }) => ( <Route key={path} path={path} exact={exact} component={component} /> ))} <Route render={(props) => <NoMatch {...props} /> } /> </Switch> </HashRouter> ); } } 教科書どおりな react-router-dom の使い方ですし、NestedRouteing などの考えはものすごく良いのですが、管理サイトでそこまでやるメリットが思いつきませんでした。 むしろ、これだとページを追加するたびに routes を修正しないといけないし、path とファイル名が一致しないケースがあったりと、めんどくささが満載です。 ですが、Next.js に載せ替えたため、pages 以下にファイルを置くだけで、ルーティングをよしなにしてくれます。 だいぶめんどくささが軽減されましたし、path とファイル名が一致してるだけで、ものすごく気分が楽になります。 コンポーネント名を覚えるのがめんどくさい JavaScript は型がなく厳密な書き方をせずとも動くので楽っちゃ楽なのですが、型がないがゆえに補完をうまくしてくれません。 そのため、ファイル名や位置とかをある程度、脳内メモリに格納した上で開発しないといけないわけですが、私も本厄を迎える歳となってしまい細かい記憶力に心配がでてくるようになってきました。 TypeScript であれば、最強のエディタであるEmacsがよしなに補完してくれる 3 し、import とかも気にせずに済むんですが...。 という悩みも、Next.js に載せ替えたことにより、 tsconfig.json を置くだけでTypeScript化は完了です。 無事に加齢による衰えをシステムでフォローしてくれようになり、高齢化対策も万全です! デザインがめんどくさい cssが苦手です。書きたくないです。レスポンシブデザインとかになると、めんどくささに溢れてます。 なので、管理サイトは基本的に Bootstrap を使ってデザインしてます。 ただそれでもクラス名を覚えるのがめんどくさいので、 ReactBootstrap を使ってます。 もちろんReactBootstrapのコンポーネント名とかもEmacsが補完してくれます 4 。 また、どうしてもcssを書かなきゃいけない個所であっても、Next.js が Sass に対応してくれるので、生でcssを書くよりも格段に楽ができます。 バリデーションがめんどくさい 元々は下記のように各項目ごとにバリデーションを書いていました。 const [form, setForm] = useState({ name: '', state: 0 }); const isValidName = () => { if (form.name.length < 5) { return false; } if (form.name.length > 10) { return false; } return true; }; const isValidState = () => { if ([0, 1].includes(form.state)) { return false; } return true; }; const onSubmit = (e) => { e.preventDefault(); if (!isValidName() || !isValidState()) { // エラーハンドリング return; } // 正常処理 }; まー、めんどくさい。しかも、漏れも生じまくる。入稿してくれる方の職人芸もありつつの、事故回避でした。 そこで zod を用いることにしました。 zodに関しては uttkさんのこの記事 が秀逸です。めちゃくちゃ参考にしました。 上記の例をzodを使って書き直すと const schema = z.object({ name: z.string().min(5).max(10), state: z.union([z.literal(0), z.literal(1)]), }); const [form, setForm] = useState({ name: '', state: 0 }); const onSubmit = (e) => { e.preventDefault(); try { f = schema.parse(form); } catch (e) { // エラーハンドリング return; } // 正常処理 }; 意識するのはzodの定義のみで、非常にわかりやすくなりました。 しかも、zodからTypeScriptの型も吐き出せます type IForm = z.infer<typeof schema>; // IForm = type { // name: string, // state: 0 | 1, // } なので、一石二鳥!使わない手はないです。 フォームがめんどくさい 運用の大部分を占めるのが入稿作業だと思います。 これも元々はお見せするのも恥ずかしいレベルのオレオレフォームを作っていたのですが、 useForm を使うようにしました。 zodと組み合わせるとすげー便利ですし、すっきりさせることができました。 const methods = useForm<IForm>({ resolver: zodResolver(schema), defaultValues: { name: '', state: 0, }, }); const onSubmit: SubmitHanlder<IForm> = (data) => { // 正常処理 }; 跋文 こんな感じで手を抜けるところは抜いて、それでいて運用コストを少しでも下げられるような改善を日々行っています。 また、手を抜くことにより、理解する箇所を極限まで減らしていくことにもつながるので、普段フロントエンドを書いていないエンジニアでも、運用サイトの更新ができるという面もあったりします。 Emacsは エディタではなくOSである という方もおられますが、ここではエディタとして扱わせていただきます ↩ もちろん細かい設定をいじりたいときは修正する必要がありますが、そうだとしても格段に管理が楽になりました ↩ だいたいのエディタでやってくれます… ↩ だいたいのエディタでやってくれます… ↩
アバター
はじめに こんにちはMAMADAYSバックエンドチームのrymiyamotoです。最近エルデンリングを遊び倒しています。 MAMADAYSではアプリとWebで利用しているAPI(golang)の仕様をドキュメント化するためにSwaggerを利用しています。 導入をしてから3年以上経過したため、APIの開発運用を進める中で出てきた課題点への施策を綴っていこうと思います。 そもそもSwaggerとは? SwaggerはOpenAPIというRESTful APIの仕様を記述するためのフォーマットを使用したツールで、仕様が文章化されることで開発者や関係者での認識が取りやすくなります。 動作環境 MAMADAYSではSwaggerの利用にあたって以下のツールを使っています。 ツール名 用途 バージョン swag ドキュメントの自動生成 v1.8.0 echo-swagger Swagger UIの表示で利用(ドキュメントの可視化) v1.3.0 Swaggerをそのまま使う分にはyamlを表記するだけですが、MAMADAYSではドキュメントを自動生成するための swag を使っています。 swag では定義したstructの型に合わせてドキュメントを生成するのでyamlを直接手で変更する必要がなく楽です。 また生成されたドキュメントのままだと視覚的に分かりにくいため、Swagger UIを表示できるように echo-swagger を利用しています。 以下MAMADAYSので表記に合わせた簡易的な例です。 (goのバージョンは1.17.8です) package main import ( "net/http" _ "github.com/rymiyamoto/swagger-test/docs" "github.com/labstack/echo/v4" echoSwagger "github.com/swaggo/echo-swagger" ) type ( Response struct { Int64 int64 `json:"int64"` String string `json:"string"` World *Item `json:"world"` } Item struct { Text string `json:"text"` } ) // @title example // @version 1.0 // @license.name rymiyamoto // @BasePath / func main() { e := echo.New() e.GET( "/swagger/*" , echoSwagger.WrapHandler) e.GET( "/" , hello) e.Logger.Fatal(e.Start( ":1323" )) } // hello godoc // @Summary Hello World ! // @ID HelloWorldIndex // @Tags HelloWorld // @Produce json // @Success 200 {object} Response // @Router / [get] func hello(c echo.Context) error { return c.JSON(http.StatusOK, &Response{ Int64: 1 , String: "example" , World: &Item{ Text: "hello world !" , }, }) } ※go.modとgo.sumは省略しています $ go install github.com/swaggo/swag/cmd/swag@v1. 8 . 0 $ swag init $ go mod tidy $ go run main.go NULL許容の値を表現する sql.NullString や sql.NullInt64 などのNULL値を含むデータをそのまま使うことができないため swaggertype:"XXX" で対象のキーに表現したい型を定義するかと思います。 しかしこのままだとNULL許容であるかどうかがわかりません。 方法としては2種類あるので紹介します。 descriptionを追加する 対象のキーにコメントとして書くことでdescriptionが追加でき、ここでNULL許容であるかどうかを表現してます。 Hello struct { NullInt64 sql.NullInt64 `json:"null_int64" swaggertype:"integer"` // nullable NullString sql.NullString `json:"null_string" swaggertype:"string"` // nullable } extensionsで任意の追加情報を付与する(echo-swagger v1.3.0では非対応) extensions:"x-XXX" で任意の追加情報を付与することが可能です。 NULL許容を表現するにあたっては extensions:"x-nullable" で指定することにします。 Add extension info to struct field Hello struct { NullInt64 sql.NullInt64 `json:"null_int64" swaggertype:"integer" extensions:"x-nullable"` NullString sql.NullString `json:"null_string" swaggertype:"string" extensions:"x-nullable"` } ただし echo-swagger(v1.3.0) 上では表示できないため、出力されたyamlを Suwagger Editor 上で確認する人向けです。 (表示がうまくいかないのは、依存packageである swaggo/files の内部で保持しているファイルが古そうです) 同一リソース名を扱う 同一リポジトリ内でAppやWeb・Dashboard等でAPIを作成している場合、リソース名が重複します。 このとき swag 側で全体のパスを含めた構造体名に変更してくれますが、その表記が長く冗長になってしまいます。 単純に1つしかSwaggerを利用しなければ気にすることはありませんが、表記が長くならないようにそれぞれprefixを足して見通しを良くしています。 // DashboardHoge 内部向けDashboard用 DashboardHoge struct { Text string `json:"text"` StartAT time.Time `json:"start_at"` EndAT time.Time `json:"end_at"` } // Hoge App用 Hoge struct { Text string `json:"text"` } structでリクエストのbodyを表現する bodyを扱う場合に各APIのコメントに以下のように記載すればよいです。 // post godoc // @Summary Hello World ! // @ID HelloWorldPost // @Tags HelloWorld // @Produce json // @Param title body string true "タイトル" // @Param description body string false "説明" // @Success 200 {object} Response // @Router / [post] func post(c echo.Context) error { // ... } しかしこの状態だと パラメーターが増えると定義が面倒 リクエストの定義とコントローラーの定義を別階層で管理していると抜け漏れが発生しやすくなる 同一のリクエストを使いまわしていると修正が冗長になってしまう となってしまいます。 その対策として、bodyには定義しているformのstructを渡すようにしています。 こうすることで、form部分の修正のみで対応するAPIのbodyも一括で変更できるため管理が簡単になります。 // post godoc // @Summary Hello World ! // @ID HelloWorldPost // @Tags HelloWorld // @Produce json // @Param body body Form true "request body" // @Success 200 {object} Response // @Router / [post] func post(c echo.Context) error { // ... } Form struct { Title string `json:"title"` // require Description string `json:"description"` // option } 終わりに swag を使い続けて3年も経過すると色々と気になるところが出てくるので、利用ルールの制定多くなってきました。 理想を言えばその部分も定義できればより汎用性が出そうです。 しかし適期的にアップデート内容を確認してきましたが、少しずつOpen API 3.0の記法も使えるようになってきているのでしばらくは使っていこうと思います。 皆様良きSwaggerライフを!
アバター