booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Streamlitでレコメンドの定性評価アプリを作った話

アイキャッチ

はじめに

MLエンジニアのmixidota2です。ブックリスタでは主に推薦システムの開発などをしています。
今回は推薦モデルの定性評価にstreamlitを使った話をします。
どのような課題をstreamlitで解決したか、またなぜstreamlitが推薦モデルの評価というユースケースに合っていたのかを紹介します。
想定読者としては、推薦システム(や機械学習システム)に関わるMLエンジニア・データサイエンティストやソフトウェアエンジニアの方々です。

推薦モデルの評価とは

機械学習モデルを本番投入する前にはオフライン評価をするのが一般的です。
推薦モデルの場合も同様で、オフライン評価により本番投入前にモデルの性能を事前に見積もることでユーザー体験やビジネス指標を損なわないモデルを本番投入できます。
評価についてはビジネス指標に対する定量的な評価は当然必要ですが、一方で定性的な評価も重要です。
定性評価の例としては、ユーザーに推薦されるアイテムを人が確認することで期待通りの出力になっているかを評価するなどがあります。
特に推薦モデルの出力は推薦の文脈によって多様であるため、定量的な評価だけでは見落としてしまいそうな課題も発見できます。

これまでの課題

理想的には定性評価については色んな人に触ってもらえるアプリのような環境があるとより良いのですが、そういう環境を作るのはフロントやバックエンドの知識も必要で、MLエンジニアだけでは難しいです。
そこでこれまでは推薦モデルの定性評価については、モデルを作ったMLエンジニアが推薦結果をバッチ推論でmarkdownにまとめたものを見てもらうという形で行っていました。

ただしこの方法で運用していく中での課題としては次のようなものがありました。

  • 探索的に見るには見辛い
  • 全てのユーザーやアイテムに対しての推薦結果を見ることができない
  • モデルの改善をするたびに手動で更新する必要があり、モデルの改善に伴う定性評価のコストが高い
  • 定性評価を見たいモデルの数に比例して定性評価を見られるようにするためのコストが増加する

どうやって解決したか

streamlitの採用

既存の課題から、次のようなことが実現できる方法を探していました。

  • インターフェースを使って任意のユーザーやアイテムに対しての推薦結果を見ることができる
  • できればモデルの改善をするたびに手動で更新する必要がない、あるいはその手間が現状より減らせる

この課題の解決手段として結局はwebアプリをホストしてその背後で推薦モデルを動かせる環境が一番手っ取り早いという結論に至りました。
またその上でなるべく専門知識を必要とせずそれを実現する方法を探しました。
そこでstreamlitというwebアプリの知識を必要としなくてもアプリが作成できるライブラリーに辿り着き、その簡便さと豊富なインターフェースが我々のユースケースに合っていると判断し採用しました。

streamlit採用のメリット

streamlitを使うことで次のようなメリットがありました。

  • MLエンジニアだけで簡単にアプリを作ることができた
  • 色んな人に触ってもらいやすいインターフェースができたことで、いろんな視点からフィードバックを受けることができるようになった
  • モデルの改善をするたびに手動で更新する手間が大幅に減らせた
  • アプリの背後でリアルタイム推論を走らせることにより、全てのユーザーやアイテムに対しての推論結果を手軽に見ることができるようになった

実際に作ったもの

最終的にはこのようなアプリを作りました。

ではstreamlitが具体的にどんなもので、我々のユースケースにどうfitしていたのかを説明していきます。

streamlitとは

概要

Streamlit turns data scripts into shareable web apps in minutes.
All in pure Python. No front‑end experience required.
ref. https://streamlit.io/

streamlit公式サイトの説明を引用すると、streamlitはデータのスクリプトをシェア可能なWebアプリに変換できるPythonのライブラリーです。
バックエンドやフロントエンドの知識を必要とすることなく、MLエンジニアやデータサイエンティストのみで簡単にWebアプリを作ることができます。
streamlitはデータのアプリ作成に特化しており、pandasのデータフレームやplotlyのグラフなどのデータの操作や可視化を扱うためのインターフェースが豊富に用意されています。

どんなふうに使うことができるか

基本的には表示したいオブジェクトをコードに並べていくだけで、それが自動的に解釈されてWebアプリとして表示されます。
極端に言えば、jupyter notebook内でコードを可視化する感覚でアプリの画面が書けるという感じです。
例えば次のようなコードを書くだけで、データフレームやグラフを表示するだけのアプリが作成できます。

# ref: https://docs.streamlit.io/library/api-reference/write-magic/magic
# Draw a title and some text to the app:
'''
# This is the document title

This is some _markdown_.
'''

import pandas as pd
df = pd.DataFrame({'col1': [1,2,3]})
df  # 👈 Draw the dataframe

x = 10
'x', x  # 👈 Draw the string 'x' and then the value of x

# Also works with most supported chart types
import matplotlib.pyplot as plt
import numpy as np

arr = np.random.normal(1, 1, size=100)
fig, ax = plt.subplots()
ax.hist(arr, bins=20)

fig  # 👈 Draw a Matplotlib chart

もちろん単純にオブジェクトを並べるだけがstreamlitで出来ることの全てではなく、様々なインターフェースや画面構成をstreamlitのモジュールを使用して簡単に表現できます。

streamlitのどんな点が良かったか

今回推薦モデルの定性評価にstreamlitを採用してみて、このユースケースに特にfitしているなと感じた点をいくつか紹介します。

インタラクティブに推薦結果がみれるアプリが簡単に作れる

定性評価のフィードバックを様々な人に受けてもらう観点からすると、使用者の認知負荷が低いインターフェースであることでより多くの人に触ってもらえるようになることが期待できます。
また推薦モデルの推薦起点となるユーザー(あるいはアイテム)の候補数は大量にあることが一般的です。
このような状況において現実的に任意の使用者が任意の対象における推薦結果を定性評価したい場合、コスト的な観点からもリアルタイム推論をできるような仕組みが望ましいです。
streamlitでは、このような仕組みを追加の専門的な知識を必要とせず簡単に作ることができました。
今回のユースケースのように、なるべく多くの人たちに定性評価をしてもらいたい場合には、streamlitでのアプリ作成は手段として有効だと感じました。

jupyter notebookで可視化する感覚でアプリの画面が書ける

streamlit特有の文法をほとんど覚える必要がなく、普段MLエンジニアやデータサイエンティストが使用しているツールをそのまま使用してアプリの開発ができます。
例えばpandasのデータフレームを表示したい場合は簡単には次のように書くことができます。

import pandas as pd 
import streamlit as st

df = pd.DataFrame({'col1': [1,2,3]})
st.write(df) # st.writeに渡すだけで表示してくれる

またグラフを表示したい場合は、次のようにグラフのfigを渡すだけで表示してくれるので普段の可視化のコードをほとんどそのまま使用できます。

import plotly.express as px
import streamlit as st

df = px.data.iris()
fig = px.scatter(df, x="sepal_width", y="sepal_length")
st.plotly_chart(fig) # st.plotly_chartにfigを渡すだけで表示してくれる

markdown形式で書いていた文章もそのまま書けます。

import streamlit as st

st.markdown("*Streamlit* is **really** ***cool***.")
st.markdown('''
    :red[Streamlit] :orange[can] :green[write] :blue[text] :violet[in]
    :gray[pretty] :rainbow[colors].''')

このように普段と同じようにコードを書くことができるので、開発コストを低く抑えられる点が推薦モデルを開発している人にとっては嬉しい点でした。

標準で用意されてるインターフェースが豊富なので、やれる操作の幅が広い

アプリ上でのインターフェースについても、streamlitでは標準で用意されているものを使うだけで多くの表現が簡単に実現できます。
例えば任意の推薦枠で任意のユーザーに対しての推薦結果を表示したい、というようなケースにおいては次のようにインターフェースや画面表示を簡単に作ることができます。

# ドロップダウンを使った推薦枠の選択 
frame = st.selectbox('推薦枠を選択してね', ['枠1', '枠2', '枠3'])


# テキストボックスで任意のアイテムIDを入力させる
item_id = st.text_input('item_idを入力してね')


# 入力したアイテムIDについてのitem2itemの推薦結果を表示する
pred_items: list[str] = predict(item_id, frame) # 適当な関数を想像してください
for item_id in pred_items:
    img_url = get_image_url(item_id) # 適当な関数を想像してください
    st.write(item_id)
    st.image(img_url)

詳しくは公式ドキュメントに任せますが、非常に多くのインターフェースが用意されています。
システムの入出力に決まった形式のあるわけではない推薦モデルのようなシステムを再現して定性評価をするようなケースにおいては、このようなインターフェースが豊富であることは有用な点かなと感じています。

streamlit用のサーバーを簡単にホストできる

streamlitはローカルでも実行できますが、アプリを公開するためにはサーバーを建ててそこでアプリをホストしたいです。
ここについても、streamlit側では簡単にサーバーを建てることのできる機能が用意されています。
ホスト環境でstreamlit run app.pyのようにコマンドを打つだけでサーバーを建てることができます。

コードの変更がリアルタイムに反映されるのでプロトタイピングが楽

これは推薦システムの定性評価にメリットがあるというよりはフロントの知識が少ないエンジニアにとってのメリットです。
streamlitでホストしたアプリはコードを変更すると自動的にリロードされる仕組みになっています。
これの恩恵としては、コードを変更するたびに手動でサーバーをホストしなおす必要がないためプロトタイピングをしやすいという点があります。
アプリ開発の時間を少なく抑えて価値提供(今回の場合定性評価ができるようになること)を素早く行うという観点で地味ながら便利な点です。

streamlitのちょっとコツがいる部分

これまで説明してきたように便利な点が多いstreamlitですが、ちょっとコツがいる部分もあります。
特に機械学習モデルをハンドルするという観点で気にしたい点をいくつか紹介します。

重めの処理をする場合はキャッシュを効かせる必要がある

streamlitはインターフェースへのインタラクションあるいはコードの変更のたびにサーバー側でコード全体の再実行が走る仕組みになっています。
そのため、例えば巨大なデータのロードや重めの前処理など実行時間が長くなるようなコードを書いている場合は再実行のたびに再度実行時間の長い処理が走ってしまいます。
このような場合にはstreamlitの機能でキャッシュを効かせることでこれを防ぐことができるのですが、この使い方に少しコツが必要です。

簡単には次のようにオブジェクトを取得する関数にデコレーターをつけることでキャッシュを効かせることができます。

@st.cache_data
def get_data():
    # 重めの処理
    return data

基本的にはデコレーターをつけた関数は、引数が同じであればキャッシュを効かせてくれるので、このように書くだけでキャッシュを効かせることができます。
なのですが、このデコレーターを使う場合にはいくつか注意しなければいけない点があります。

まずはキャッシュに使うデコレーターの種類についてです。
キャッシュのために使用するデコレーターには次の2種類が存在します。

  • st.cache_dataを使う方法

    • pythonでシリアライズ可能なオブジェクトをキャッシュできる
    • キャッシュはセッションベースで行われる
  • st.cache_resourceを使う方法

    • シリアライズ可能でないオブジェクトをキャッシュできる
    • キャッシュはglobalで行われる

どのようなオブジェクトをどのようにキャッシュしたいのかによってこれらを使い分ける必要があります。

次にデコレーターをつける関数の引数についてです。
キャッシュを使うか関数を実行するかは同じ引数を使っているかどうかで決まるのですが、ここの引数はハッシュ可能なものでなければなりません。
なので関数の引数についてはハッシュ可能なものを与えるように実装する必要があります。

まだできてないけどやりたいこと

実験管理との統合

推薦モデルの開発においては、モデルの実験設定や評価結果などを実験管理しています。
現状はアプリ側でこの実験管理の結果を取得して、ベストモデルの推薦結果を見られるようにしています。
ただしこの方法だと過去のモデルと最新のモデルの結果の定性的な比較ができません。
モデルの実験設定によってどのようにモデルの推薦結果が変わるのかが分かると、実験結果をより深く解釈できることが期待できます。
そこで定性評価のアプリと実験管理結果の連携部分を改善して、任意の実験設定で生まれたモデルに対する推薦結果を見られるような仕組みにしたいです。

テンプレート化

現状だと新しい推薦モデルを開発した際に、アプリ側の実装も新しく作っています。
アプリ側に関しては複数箇所について汎用的に表現できそうなことが実装を進める中でわかってきました。
よって汎用表現できる部分はテンプレート化することで、初期実装の開発コストを下げてより早く定性評価を回せるようにしたいです。

実際のプロダクト上と同じ表示のシミュレート

推薦結果を定性評価するにあたって、実際のプロダクト上と同じ表示をシミュレートしたいとも考えています。
理由としては、実際にプロダクト上で見られる推薦結果とただ単に推薦結果を並べただけのものでは、同じアイテムが並んでいたとしても観測され方に異なるバイアスが発生し得るからです。
例としては次のようなケースでバイアスが発生し得ると考えています。

  • プロダクト上では推薦結果をn列m行で表示している場合、ただ配列としてアイテムを並べただけとは発生するポジションバイアスが異なる
  • プロダクト上では推薦結果のおすすめ順ごとに表示するアイテムのサイズが異なる

このようなケースを考慮することで、単に推薦結果を並べただけでは気付けないバイアスを事前に発見できるメリットがあると考えられるため、可能な限り実現したい要素です。

まとめ

今回ご紹介したように、streamlitはひとりのMLエンジニア・データサイエンティストが出せる価値の幅を広くするツールとして有用だと感じています。
推薦モデルの定性評価の手法の選択肢の1つとして、streamlitを検討してみるのも良いのではないでしょうか。

ブックリスタのレコメンドチームでは様々な角度からより良い推薦をより高速に提供するための取り組みをしています。
またブックリスタでは推薦システムの開発を加速させるため、現在MLOpsエンジニアを募集中です。
興味のある方は是非ご応募ください。

機能リリースに干渉しない定期的なパッケージ更新のやり方

アイキャッチ

目次

はじめに

はじめまして、株式会社ブックリスタのプロダクト開発部に所属している伊藤です。
弊社開発のコミックアプリ、「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のバックエンドエンジニアとして保守・開発を担当しています。

コミックROLLYは2023年8月1日にローンチしたアプリです。弊社のwebtoon制作スタジオである「booklistaSTUDIO」の作品をはじめ、さまざまなコミック作品を閲覧できます。ご興味ある方は是非下記のリンクからダウンロードいただけると嬉しいです。

https://rolly.jp/

この記事の目的

「コミックROLLY」のプロジェクトでは以下2点の課題を抱えていました。 - オープンソースパッケージを定期的にアップデート確認したい - パッケージアップデートのデプロイを機能リリースに干渉せず実行したい

この記事では課題にどう対応していったかをお話しさせていただきます。

コミックROLLYのシステムの構成について

まずコミックROLLYのシステム構成について説明します。

コミックROLLYのシステムは以下の3つで構成され、日々保守・開発しています。

  • モバイルアプリ
  • アプリに情報を渡すAPI
  • アプリに出すコミック情報を管理するCMS

上記3つのシステムは使用言語、機能などオープンソースのものを使用しており、使用バージョンはyarnなどのパッケージマネージャーを通して管理しています。

パッケージ更新の悩み

上記の通り、オープンソースのソフトウェアを使用しているので、使用する機能は継続的にアップデートされて行きます。これらのアップデート反映を長期間放置すると、セキュリティの問題があったり重大なバグの修正を見落としたりするので、こちらもなるべく最新のアップデート状態を保つ必要があります。

ただし、コミックROLLYのプロジェクトがパッケージを随時アップデートしていくにあたっては以下2つの課題があります。

1つ目は「パッケージ更新の検知と内容確認が出来ない」ことです。
ローンチ前はパッケージマネージャーから直接アップデートで解決していました。ですがこの方法だとアップデート内容がどの程度影響するかがわかりません。ローンチ直前やローンチ以降アップデートの検知と変更・更新内容は確認してからアップデートするかを判断したいので、方法を再度検討する必要がありました。

2つ目は「機能リリースとパッケージアップデートリリースは同時に行えない」ことです。
コミックROLLYはソースをGitでバージョン管理しているので、パッケージのアップデートも同様にGitで管理したいです。ですが、普段の機能開発とパッケージアップデートを同じバージョンで管理すると、バグが発生したときに開発した機能自体の問題なのか、更新されたパッケージの問題なのかの切り分けが困難になります。

Renovate導入とブランチの分岐運用で解決

私たちは、上記の問題をRenovateの使用と、パッケージ更新用のブランチを用意することで解決しました。

Renovateとは
https://github.com/renovatebot/renovate

パッケージアップデートを自動で検出してくれるシステムです。GitHubなどのプラットフォームから使用できて、ボットがアップデートを検知、自動で更新用のプルリクエストを作成してくれます。 依存関係のあるパッケージも合わせてアップデート検知と更新をしてくれるので、悩みで提示した「①検知が必要なバージョンアップの数が多すぎる」はRenovateの使用で解決できます。


このRenovateで更新用プルリクエストのマージ先を指定できるので、パッケージ更新用のブランチを機能開発用のブランチと別で用意して管理することにしました。ブランチを分けることで、パッケージ更新のみのリリースのスケジュールを開発のリリースと別対応することが可能になり、悩みの「②機能リリースとパッケージアップデートリリースは同時に行えない」が解決します。

Renovateの設定と運用

以下に実際のパッケージ更新とリリースの解決方法の詳細を記載していきます。

①パッケージ更新検知の自動化

Renovateを使用してパッケージの更新の検知、パッケージ更新した状態のPR作成を自動で行うよう設定します。

私たちはGitHubでコード管理しているので、Renovateで用意されているGithub Actionsでの自動検知・更新のアプリを導入していきます。 登録対象のアプリは以下です。

https://github.com/apps/renovate

この設定時点では、すでに社内でRenovateを導入しているチームがあり、GitHubの組織アカウント単位で導入済みでした。なので組織アカウントの管理者に使用を要求して許可をいただくことですぐに使用可能になりました。

GitHubにRenovateのアプリを導入できたら、以下画像のように自動でRenovateの設定ファイル作成のプルリクエストが作成されるので、ここから各種設定をしていきます。

Renovateの設定用プルリクエスト

https://github.com/marketplace/renovate から画像を引用しています。

設定値はプルリクエストの実施スケジュールやレビュワー、アサイニーの指定ができますが、コミックROLLYでは以下のように設定しています。

{
  "extends": ["config:base", "schedule:nonOfficeHours"],
  "timezone": "Asia/Tokyo",
  "baseBranches": [{マージ元のブランチ名を設定}],
  "labels": ["renovate"],
  "automerge": false,
  "assignees": [{担当者のGitHubアカウント名を設定}],
  "assigneesSampleSize": 1,
  "prHourlyLimit": 5,
  "prConcurrentLimit": 100,
  "ignorePaths": [{renovateの自動更新対象から除外したいファイルのパスを設定}]
}

この設定のプルリクエストをマージすると、以降はこちらが設定したスケジュールをもとに自動でパッケージ更新したプルリクエストを作成してくれます。 私たちのチームではassigneeに指定された人が主担当になるルールがあるのでassigneesを指定していますが、reviewersの指定も可能です。

②パッケージ更新用のブランチを用意する

Renovateの設定が終わったので、パッケージ更新用のブランチを設定していきます。
コミックROLLYではGitflowモデルを基準に以下のブランチで運用しています。

運用するブランチ名 役割
feature 機能追加用ブランチ
機能追加するためにdevelopブランチから切られる
develop 開発用ブランチ
機能の追加・改修が継続的にマージされる
staging リリース前確認用ブランチ
Gitflowでいうreleaseブランチだが、随時確認できるようにプロダクト環境と同じ状態にして確認用環境のために常駐させる
main プロダクトリリースブランチ
Gitflowでいうmasterブランチ

パッケージ更新は機能開発と違うバージョンで管理したいので、stagingブランチから更新用ブランチ(名称:update)を作成します。
このブランチをRenovateでbaseBranchesに設定することで、パッケージ更新のマージ先がupdateブランチになり、機能開発とパッケージ更新の管理が別々に出来ます。

③パッケージ更新と機能改善のリリーススケジュールを分ける

ブランチを分けたことで関心が分離できたので、それぞれのブランチで更新・リリースが実行できます。
ただし、リリースを分けたことで、以下の問題が発生してしまいます。 - 機能開発で追加したパッケージが更新用ブランチにないので更新対象にならない - パッケージ更新が一方で行われているので、機能開発側のパッケージが古いままになる

私たちのチームでは以下の画像のようにブランチ運用をして解決しています。

パッケージ更新のリリース

updateブランチにまとめられたパッケージ更新をリリース管理用ブランチ(stagingブランチ)にマージし動作検証を実施・完了後、mainブランチにマージしてリリースします。 リリース後はstagingブランチの内容をdevelopとupdateブランチにそれぞれマージすることで、updateブランチがdevelopと同期されます。

機能開発のリリース

developブランチにまとめられた開発機能をstagingブランチにマージ、検証後、mainにマージしてリリースします。
リリース後はstagingブランチの内容をdevelopとupdateブランチにそれぞれマージすることで、updateブランチがdevelopと同期されます。

上記のようにパッケージ更新・機能追加を別々にリリース、その内容をdevelop、updateブランチにそれぞれマージすることで先ほどの課題を解決できました。

まとめ

このようにパッケージ更新の自動検知と機能開発とパッケージ更新を分けることで、パッケージを最新の状態に保つことが出来ました。 同じような悩みを抱えている方の参考に少しでもなれば幸いです。

DynamoDB を使用した API ページネーションの実現

アイキャッチ

こんにちは。私は株式会社ブックリスタのプロダクト開発部の姚と申します。
現在、コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)のバックエンド開発を担当しています。
この記事では、DynamoDB を使用した API ページネーションの作成方法を紹介します。

はじめに

なぜ DynamoDB を使用するのか

DynamoDB は大量の非構造化データの処理に適しており、スケーラビリティと柔軟性に優れています。
データの性質や成長の見込みによっては、DynamoDB が最適な選択肢になることがあります。

ページネーション方式

一般的なデータベースのページネーションは、主にオフセット型とカーソル型の 2 種類が存在します。

オフセット型

オフセット型は、指定した offset から limit 件のデータを取得する方法です。RDBMS では一般的によく使われていますが、DynamoDB ではサポートされていません。

GET /items?limit=<limit>&offset=<offset>

リクエストした limit と offset の値から、次のページを取得する場合の offset の値が決まるため、レスポンスにはページングに関する情報を含める必要がありません。

{
  "items": [
    ...
  ]
}

カーソル型(本記事で紹介する方法)

カーソル型は、直前のページの最後のデータと limit 件数を指定して、次のページを取得する方法です。
本記事ではカーソル型ページネーションの実現方式を紹介します。

GET /items?limit=<limit>&lastKey=<lastKey>

レスポンスには、取得したデータと次のページのための lastKey を返します。

{
  "items": [
    ...
  ],
  "lastKey": "xxx"
}


カーソル型のページネーションはオフセット型と比べて、以下のメリットとデメリットがあります。

メリット

  • 重複・不足データが発生しない: 連続してデータを取得する際、データの増減が発生しても、データの重複や漏れが発生しません
  • パフォーマンス向上: カーソル方式を採用しているため、後のページに移動してもレスポンス速度が低下しません

デメリット

  • 任意のページに直接移動できません
    • クライアント側が移動したページのカーソル情報を保持すれば、擬似的なページ移動が可能です
  • クエリのソートで使用されるカラムに、インデックスを貼る必要があります
  • 実装が少し複雑になります

前提知識

DynamoDB のインデックス

DynamoDB テーブル内のデータのクエリを行う場合、インデックス(プライマリキー もしくは セカンダリインデックス)を指定することで、効率的にデータを取得できます。

プライマリキー

テーブルを作成する際に、テーブルのプライマリキーを指定する必要があります。
プライマリキーは以下 2 種類あります。

  • パーティションキー
  • パーティションキーとソートキーの組み合わせ

セカンダリインデックス

プライマリキー以外のインデックスを定義する場合、セカンダリインデックスを作成する必要があります。
セカンダリインデックスはパーティションキーとソートキーの組み合わせの形ですが、以下 2 種類あります。

  • グローバルセカンダリインデックス
    • パーティションキーとソートキーがテーブルと異なるインデックス
  • ローカルセカンダリインデックス
    • パーティションキーはテーブルと同じですが、ソートキーが異なるインデックス

本記事では、パーティションキーとソートキーの組み合わせを使用した場合のページネーションを紹介します。

インデックスとソートキーについて、もっと詳しく知りたい人は以下のドキュメントを参照してください。

DynamoDB テーブルクエリのページネーション

1MB の制限

DynamoDB の クエリー操作では、最大 1MB のデータしか取得できません。

クエリー結果が 1MB 以上の場合、1MB 以内の結果セットだけを返して、クエリー結果に LastEvaluatedKey という要素が返されます。
後続のデータを取得するには、LastEvaluatedKey を次のクエリーの ExclusiveStartKey として使用する必要があります。
これを繰り返して、一度に 1 ページずつ結果を取り出せます。
クエリー結果に LastEvaluatedKey がない場合、これ以上取得する項目がないことを示します。

limit の指定

ほとんどの場合、1 ページを表示するために DB から取得するデータは 1MB 以下であるため、暗黙なページネーションをそのまま利用できません。
そのため、意図的にページネーションしたい場合には、テーブルクエリーのオプレーションに Limit のパラメータを使用して、1 ページのデータ件数を制限できます。

DynamoDB のテーブルクエリー結果のページ分割について、もっと詳細をしたい場合は以下のドキュメントを参照してください。

実現

API インターフェースの設計

DynamoDB のテーブルクエリーを使用する場合、以下の情報が必要です。

limit: number;
lastEvaluatedKey: {
  partitionKey: string;
  sortKey: string;
}

これを API で受け取る場合、LastEvaluatedKey がオブジェクト構造であるため、GET リクエストの URL に直接指定できない問題があります。

素直に設計した場合

最初に考えられる設計は、LastEvaluatedKeyの属性を分解し、それを GET リクエストのクエリパラメーターに指定する方法です。

リクエスト

  • limit: 必須
  • partitionKey: 任意
  • sortKey: 任意
GET /items?limit=<limit>&partitionKey=<partitionKey>&sortKey=<sortKey>

レスポンス

{
  "items": [
    ...
  ],
  "lastEvaluatedKey": {
    "partitionKey": "xxx",
    "sortKey": "yyy"
  }
}

しかし、この設計には以下の問題があります。

  • クエリーのパラメータが多くなるため、API のリクエストが複雑になります。
  • DynamoDB のキー構造のキー構造は内部的な情報なので、可能な限り露出させたくありません。

これらの問題を解決するために、以下のように設計を改善しました。

改善された設計

改善された設計は、 LastEvaluatedKey を Base64 エンコードした文字列を GET リクエストのクエリパラメーターに指定する方法です。

必要に応じて、エンコードした文字列をさらに暗号化できます。

リクエスト

  • limit: 必須
  • lastKey: 任意(Base64 エンコードした文字列)
GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>

レスポンス

{
  "items": [
    ...
  ],
  "lastKey": "base64_encoded_lastEvaluatedKey"
}

この設計には以下のメリットがあります。

  • API のインタフェースがシンプルになりました。
  • クライアント側も、DynamoDB のキー構造を意識する必要がなくなります。

ただし、Base64 エンコードは JSON オブジェクトをエンコードするためのものではないため、LastEvaluatedKey をエンコードする前に、JSON 文字列に変換する必要があります。

リクエスト仕方

以下に、提案する設計に基づいたリクエストの方法を示します。

初回のリクエスト

初回のリクエストでは、lastKeyパラメーターは指定しません。

GET /items?limit=<limit>

2 回目以降のリクエスト

2 回目以降のリクエストでは、前回のレスポンスで得られた lastKey を指定します。

GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>

最終ページの判定

レスポンスの lastKey がない場合、最終ページと判定し、次のページを取得する必要がないです。

処理のシーケンス図

シーケンス図

注意点

DynamoDB のテーブルクエリーの特性ですが、指定した limit 件数までデータがある場合、LastEvaluatedKey を返します。
例えば、limit が 10 件で、テーブルに 10 件のデータがある場合、LastEvaluatedKey を返します。
そのため、ちょうど最後の 1 件まで返した時、LastKey があるが、次のページを取得するとデータがないという状況が発生します。
クライアント側で、この状況を判定する必要があります。

まとめ

DynamoDB でサポートされているカーソル型のページネーションは、大規模データを扱い、頻繁にデータを書き込み又は無限スクロールでデータを表示する場合など、効率よくデータを取得できます。
しかしオフセット型と違って、ページネーションのパラメーター設定と実装が少し複雑になります。
今回は LastEvaluatedKey の Base64 化と暗号化によって、クライアントからリクエストが行いやすい形に実装ができました。
DynamoDB を採用してサービスの開発する際に、ご参考になれば幸いです。

iOS17の新機能「スタンバイ」を推し活アプリに導入してみた話

アイキャッチ

株式会社ブックリスタ プロダクト開発部の酒井です。

2023年9月18日にiOS17が正式リリースされました。
メッセージやAirDropが強化されたり、ウィジェット上でボタン操作などができる「インタラクティブウィジェット」が実装されるなど、いくつかの新機能が追加されました。
その中でも「スタンバイ」という新機能に着目し、弊社で開発している推し活アプリ「Oshibana」に実装してみることにしました。

過去にはiOS16の新機能「ロック画面ウィジェット」に関する記事も執筆していますので、そちらもご一読ください。

iOS16の新機能「ロック画面ウィジェット」を推し活アプリに導入してみた話
https://techblog.booklista.co.jp/entry/2022/09/13/110000

スタンバイについて

スタンバイとは、スマホを横向きに置くことでウィジェットを常時表示させておくことのできる機能のことです。
ウィジェットは小サイズのものが2つ配置できるようになっており、カレンダーウィジェットを表示させれば卓上カレンダーのようになり、時計ウィジェットを表示させれば置き時計のような役割を担うことができます。
ウィジェットは小サイズであれば種類を問わず表示させることができ、もちろんOshibanaで実装されているウィジェットも全て配置できます。

スタンバイ1

使い方は以下の通りです。

  • 設定アプリからスタンバイを選択し、「スタンバイ」をONにする
  • 充電中のスマホをロック状態にし、横向きに立てる(スマホスタンドがあると一番良いです)
  • しばらくするとスタンバイの画面になる
  • 表示されているウィジェット部分を長押しすると、ウィジェットを変更できる
  • ウィジェット変更画面の左上の+ボタンを押すことでウィジェットを追加できる

スタンバイ2
スタンバイ3

スタンバイの実装方法

スタンバイの実装方法ですが、実は特別なコーディングを行う必要はありません。
iOS17のSDKをサポートしているXcode15以降でビルドすれば使用可能になります。

ただし、iOS17ではウィジェットのViewの仕様にいくつか変更点があり、Xcode15でビルドすると必然的に仕様変更の影響を受けます。
今回スタンバイを使用するにあたり、iOS16以下の端末でOshibanaのウィジェットが今まで通り動くようにするため、いくつかソースを修正する必要がありました。
※Xcode14以前でビルドしていればiOS16の互換性でウィジェットが動きますが、スタンバイにアプリが表示されないため、Xcode15へのバージョンアップが必須です。

まず、ウィジェットがiPhoneのホーム画面以外の様々な場所へ配置できるようになったことで、ウィジェットの背景を各画面の背景に合わせる指定が必要になりました。
よって、ビューの背景に関する定義であるcontainerBackground(_:for:))を追記する必要があります。

今回は特に背景の指定はないため、以下のように記述します。

var body: some WidgetConfiguration {
    return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
        return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
            .containerBackground(for: .widget) {
                EmptyView()
            }
    }
}

これの指定がないと、ビルドは通るのですが、以下のようなエラーがウィジェット上に表示されて動かないという事象が起こります。

スタンバイ4

更に、iOS17からウィジェットにマージンが追加され、元々のレイアウトが崩れてしまうという事象も発生します。

スタンバイ5

余分なマージンを取り除くには、以下のようにcontentMarginsDisabled())を指定し、デフォルトのコンテンツマージンを無効にする必要があります。

var body: some WidgetConfiguration {
    return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
        return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
    }
    .contentMarginsDisabled()
}

これらはiOS17固有の事象のため、#available(iOS 17.0, *)でiOS16以前と動きを分ける必要があります。

修正したソースの全体像は以下の通りです。

struct oshibana_widgetSmall: Widget {
    let kind: String = "oshibana_widget_small"
    var body: some WidgetConfiguration {
        if #available(iOS 17.0, *) {
            return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
                return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
                    .containerBackground(for: .widget) {
                        EmptyView()
                    }
            }
            .configurationDisplayName("小サイズ")
            .description("設置したいウィジェットを選択しましょう")
            .supportedFamilies([.systemSmall])
            .contentMarginsDisabled()
        } else {
            return IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
                return oshibana_widgetEntryView(entry: entry, widgetSize: .SMALL)
            }
            .configurationDisplayName("小サイズ")
            .description("設置したいウィジェットを選択しましょう")
            .supportedFamilies([.systemSmall])
        }
    }
}

これらに関してはWWDC2023のBring widgets to new placesというセッションでも詳しく解説されています。
https://developer.apple.com/videos/play/wwdc2023/10027/

感想

今回Oshibanaにスタンバイを実装してみましたが、思った以上に簡単に追加できました。
Oshibanaは推し活アプリであるため、常に推しに関するウィジェットが画面に表示されていることの需要も高いと感じています。
Appleとしても、iPhoneのホーム画面以外にもウィジェットを表示させる対応を実施し続けていることから、ユーザーがより長くウィジェットに触れることを重要視していることがうかがえます。
今後も様々な方法でユーザーに情報を通達する手段が確立されていくと思われるので、新しい機能が発表されたら積極的に活用していけるよう心がけたいです。

新規プロジェクトをスクラム開発でローンチまで完了しておもうこと

アイキャッチ

株式会社ブックリスタ プロダクト開発部の藤井です。 現在、2023年8月1日から始まった「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」というサービスのスクラムマスターをしております。

今回は新サービスプロジェクトを立ち上げるにあたって、スクラム開発を採用した話をさせていただきます。

近年ソフトウェア開発の現場でスクラムを採用するケースをよく聞きます。

これから採用する方・採用してるけど暗中模索してる方の参考になればと考えています。

あと、このやり方は専門のひとにはスクラムじゃないと言われるものではあるかなと自覚もしているので、その様な場合、改善の意見を私まで指摘を届けていただけたら嬉しいです。

なぜスクラムを選んだのか

今回新規プロジェクトを始めるに当たって開発手法も特にきまっておらず、 課題としては次の様なことが挙げられました。

  • 初めてのZEROからプロジェクトで具体的なイメージが決まりきっていない
  • 開発期間が約一年程度しかない

この状態では完全な要件定義は難しく、暫定のまま進めてユーザーテストを実施した後に再度要望を吸い上げて修正する期間を取ることも難しいと考えました。

そのため以下の様な効果が得られることを期待してスクラム開発をとり入れてみようとなりました。

  • 短期間で細かな要求・要望に対してのリリース可能な成果物を作成可能
  • 進捗状況の把握がしやすい
  • 要求・要望が変わった場合に柔軟に対応ができる

スクラムイベントと期間について

今回私達は水曜日を始まりとした2週間スプリントで実施しました。

水曜日始まりにしたのは「月曜日と金曜日は休みな事も多いので余裕ある方が良いよね」といった考えによるものです。

参考にどんなスケジュールだったのかもあげておきます。

時間割

スクラムイベントについては参考書等に記載があるのでここでは細かく記載しません。

スクラム導入して良かったこと

スプリント完了タイミングで常に現在の完成品を見せられる

これはベストプラクティスだったなと感じました。

特にスプリント毎に機能を見せることができるため、徐々にシステムが完成していくことで業務側からのフィードバックが即時受け取れる状態を作れたのがよかったです。

実際に触って動かせる成果物があることで、「必要だった物」とのギャップを即時に確認でき新たな要望として積み上げることが容易でした。

成果物に対して褒めていただける反響もたくさんありモチベーションアップにも繋がりました。

また、ローンチ直前に外部テストベンダーを使ったアドホックテストも実施したのですがテストベンダーさんからもバグ数が少ないという評価を頂くことができました。

すべての開発サイクルに関わることができる

スクラムのスプリントでは、要件定義、設計、実装、テスト、リリースなどの一連の工程をメンバーが実行していくことになります。

そのため、経験の少ないメンバーも自ずとやったことのない工程を体験していくことになりました。

そういった経験を積むことでメンバーから「今まではサービスとしてどうすべきかをあまり考えたことがなかったが、考えることができた」という発言をもらった際はとても嬉しくなりました。

また、リファインメントでも最初期は発言するメンバーが固定化されており、どうしても経験が浅いメンバーの発言が少ない傾向にありましたが、徐々にその傾向も少なくなってきました。

発言の少なかったメンバーが率先して発言したり、質問をしてくれる様になっていきました。

スクラム導入して見えた課題

全体像の把握とスケジュール管理が難しい

そもそも開発したいことの全量が決まっていなかった初期は、プロジェクト全体としてどれくらい進んでいるのかどうかなどは把握できていませんでした。

また、全量が出てもローンチまでの期間が決まっているので開発したいもの全てが入っているプロダクトバックログのみではローンチまでの作業のみを絞り込んで管理できませんでした。

その結果、別途管理表を作成しローンチまでに消化すべきポイントのみに焦点を当てて管理をしていましたが、ローンチまでに必須なものとそうじゃないものの区別がメンバーには把握できずらいものになっていました。

数値のみの消化をメンバーに共有していたこともあり、ローンチまでに完了が本当にされるのかメンバーに不安を与えるとともにスプリント以外の期限を意識させてしまいました。

ただ実際プロジェクトローンチはお尻があるものなので、この辺りどうしたらよかったかは今後のために考えていかないとなと思っています。

スプリントレビューにステークホルダーが参加できない

これはエンジニアリングだけの問題ではないのですが、ステークホルダーの方達が専任ではないためスクラムイベント関連に出席ができてませんでした。

結果、定期的にスプリントレビューのタイミングで複数のスプリントをまとめた総合レビューを挟むことになりました。

当然そこで初めて触る方も出てくるので、色々な意見が出てきて他の優先事項を処理するため後回しになってしまった改修も発生してしまいました。

ここはスクラムを行う時にステークホルダーを含めて定期的な時間をとってもらう様にすべきであるという事を強く学べました。

チーム内、コミュニケーションの難しさ

よくあることですが、今回のチームは今まで存在していたチームではなく他チームから集められたかつ、ここ数年でエンジニアを積極採用していたのもあり特に社歴の長い人が少ない状態です。

ちなみにこの時私は入社して半年たったくらいでした。つまり、皆さんほぼ初見。

どんな人なのか、どんなスキルを持っているのかは不明の状態でした。

そこでスクラムイベントとは別に問題を解決するための会議や知識共有を目的とした会議の設定をしました。

これらの会議自体は機能しているのですが、まだまだチームとして自然な状態にはなっていないかなと感じています。

この問題は徐々に改善していっていますが、これぞといった解決策が取れていない状態です。

リファインメントがすごく難しい

リファインメントは大きな問題がたくさんありました。

分割する大きさがまちまち

プロダクトバックログアイテムがより小さく詳細になるように分割および定義していくことが必要だと考えています。

その中で、透明度が高い要望は小さく詳細なチケットができるのですが、不透明な案件は分解できずに大きなチケットのまま実施することになってしまいました。

その結果、大きなチケットに関してはスプリント期間に完了しなかったり、実作業時間が見積より小さくなることが頻発しました。

評価がどうしても絶対評価になりがち

事前にストーリーポイントで見積をし、見積数値は相対見積で求めることを決めていましたが、経験上から絶対見積をしてしまうことが多かったです。

「絶対見積=自分ならこれぐらいでできる」というものになるため、作業する人が固定化されてしまったりすることもありました。

発言者の偏りが生じる

経験豊富なメンバーが主導して話すことがおおく、控えめなメンバーの意見が埋もれがちになっていました。

空気的にも経験者が言うなら特にないと言った雰囲気もでており、質問などの発言も初期の頃は少なかったと記憶しております。

会話が発散してしまう

チーム全員のスキルレベルが同じ状態ではないので、リファインメント対象の要件を話している際にどこまですべきかどこまで考えなければいけないかよくブレていました。

考えることや話あうこと自体は間違いではないですが、1時間のリファインメント時間で1つの要件しか見積できないということがよく発生していました。

改善について

これらの課題は解消してきているものもあれば、まだ継続して発生している課題もあります。

現在は「会話が発散してしまう」に対してスケジュールに載せているリファインメント時間以外にも個人で事前にチェックする時間を設けています。

そうすることで、一度は目を通して方向性を考えることで発散しすぎる事を解消できるのではと考えています。

ローンチが完了してみて

この度2023年8月1日にAndroid、iOSに向けてコミックROLLYは無事ローンチされました。

大体開発プロジェクト自体が2022年4月から開始、エンジニアが2022年6月ごろから徐々に集まり始めたことを考えても約1年ぐらいでの開発期間で開発できたのは驚きでした。

特に感じたことは今までのプロジェクトより終盤に向けての絶望感がかなり少なかったです。

なぜなのかと振り返ってみると、やはり常にできた成果物を第三者が確認できる状態を作れていたことが大きいのかなと。

ウォーターフォール型で開発してる場合は、開発終盤になっても第三者に見てもらうことは基本なく最終のユーザーテストや受け入れテストに入った段階で初めて意見の収集ができます。

そのため、規模が大きい場合はフィードバックによる修正の大きさがそのままプロジェクトの遅れにつながることが多く怖かったのですが今回はその心配がそこまでなかったからでした。

重ねてになりますが、1つの事例として迷えるプロジェクト管理者さんに届けば幸いです。

Reader StoreでInfrastructure as Codeしてみた

アイキャッチ

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のOS EOL対応の一環として行った、「Infrastructure as Code(以降IaC)」についてお伝えします。

IaC化検討の背景

  1. Elastic Beanstalk platforms based on Amazon Linux AMI (AL1)のEOL
    https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2022-07-18-linux-al1-retire.html

  2. Amazon Linux AMI (2018.03) のEOL
    https://aws.amazon.com/jp/amazon-linux-ami/faqs/

この2つのEOLを対応する上で最大の課題として、以下が存在していました。

  • 設計書含む過去資料がない
    手順書もないため、再構築すらできない

  • 構築担当した者が既に離任してしまっている
    既存で動いているものが正で誰も把握できていない状態になっていた

  • 手順書があるものの、荒く、環境再現できない
    そもそも手順が複雑であった

結果、一から新規構築するとなるため将来性を考え、これまで困難だった環境複製や同様のものを再構築できるようにこれを機にIaC化する運びとなりました。

IaCとは

IaC (Infrastructure as Code) は、手動のプロセスではなく、コードを使用してインフラストラクチャの管理とプロビジョニングを行うことを言います。

引用:https://www.redhat.com/ja/topics/automation/what-is-infrastructure-as-code-iac

簡単に説明すると、AWSなどのクラウド環境やOS・ミドルウェアの構築・管理・運用を私のようなインフラエンジニアが手作業で行なうのではなく、コードで管理することです。

IaCを何で行うか

まずは、IaC化を実現する上でツールを何にするかを検討しました。
今回のターゲットとしてはAWS周りがメインだったので主流としては以下があります。

  • CloudFormation
    AWSが提供している、プロビジョニングにおける自動化および構成管理のためのサービスです。
    JSONまたはYAML形式でリソースの設定を記述し、リソースの作成や設定の変更ができます。
    https://aws.amazon.com/jp/cloudformation/
  • CDK
    AWSが提供している、プログラミング言語(Python/Java等)を使用してアプリケーションをモデル化し、AWS CloudFormation を通じてプロビジョニングするツールです。
    https://aws.amazon.com/jp/cdk/
  • Terraform
    HashiCorp社によって提供されているオープンソースのツールです。
    クラウドのみならずSaaS/PaaS、オンプレミス(VMwareなど)との連携にも対応しています。
    https://www.terraform.io/

どれでも可能ではありましたが、対応期間も迫ってきていたこともあり、私が前職でも利用していたTerraformを採用しました。
なお、OS・ミドルウェアをコード化する主流なツールとしては、AnsibleやChefがあります。
Reader StoreではAnsibleでOS・ミドルウェアはコード化されていました。

IaC化までの流れ

今回私が対応した流れとしては以下となります。

コード管理

IaC化するにあたり必須になるコード管理は、アプリケーション開発で利用しているコード管理ツールにインフラ用のリポジトリーを準備しました。

設計

これが一番大変でした。
まず既存で動いているAWSリソースから次期構成図を作成しました。
そこから各種リソースで既存流用するもの、新規構築するものを選定していきました。
既存の命名規則が不明だったので、リソース命名規則も定義しました。
各種リソース作成のソースはできるだけ1モジュール1リソースにし、実際にリソース作成するモジュールは機能単位でソースをラッピングするような形をとりました。
環境単位で変わるものは環境変数を利用する方針にして、環境変数ファイルは環境単位で環境ファイルとして準備しました。
またAWSアカウントを本番と検証開発で分離するという案件も並行して実施することになったのでアカウント単位の環境変数もファイル化。
ディレクトリー構成も悩みましたが以下のような構成にしました。

├── <account>.tfvars          # <-- AWSアカウントの共通環境変数ファイル
├── <environment>             # <-- 環境ごとの機能リソース集格納
|      └─ <environment>.tfvars # <-- 環境個別の環境変数ファイル
|      └─ <function>           # <-- 機能毎のリソース作成用モジュール格納。モジュールはsourceをラッピング
└── <modules>                 # <-- リソース作成モジュールソース集格納
        └─ <source>             # <-- リソース作成モジュールソース格納
            └─ <prebuild>         # <-- 対象リソースで事前に作成しておく必要があるモジュールソース格納
            └─ <postbuild>        # <-- 対象リソースで事後に作成する必要があるモジュールソース格納

コーディング

Terraformコードをコーディングしていきます。
検証で事前構築したリソースがあればTerraformerを使って既存リソースからコードを書き出すことも可能でした。
環境変数化など手直しが必要になりますがベース作りには有効でした。

コードテスト

コードテストはterraform planを実施しコードに問題がないか、作成するリソースの予想結果を確認します。
今回はコードテスト+コード実行してテスト構築し意図したリソースが構築できるかの評価も実施しました。
1環境をベース環境として必要なコード全てテスト用に利用しReader Store全機能分のリソースをコード化していきました。

インフラ構築

ベース環境のテスト構築ができたので、環境単位でディレクトリーを準備し環境に応じた環境変数ファイルを準備しました。
あとは構築していく環境単位でterraform applyを順次実施していく形をとりました。
手作業でやると工数はそれなりにかかりますが、コード実行だけなので構築工数はだいぶ削減されました。

具体的な例でEC2構築を少しだけ紹介します。

functionコードサンプル

locals {
  configs = merge(var.acccount_configs, var.environment_configs)
}

module "batch_instance" {
  source = "../../../modules/components/ec2-single"

  configs = local.configs
  instance_configs = {
    service                       = "batch"
    instance_type                 = "t3.medium"
    root_block_device_volume_size = 30
    vpc_security_group_ids        = [local.configs.batch.security_group.id]
    private_ip                    = "${local.configs.batch.private_ip}"
  }
}

module "batch_postbuild" {
  source = "../../../modules/batch/postbuild"

  configs = local.configs
  batch_configs = {
    instance_role                  = module.batch_instance.instance_role
    instance                       = module.batch_instance.instance
    extra_block_device_volume_size = 100
    dynamodb_table_name  = "dynamo_table"
  }
}

moduleコードサンプル

#
#IAM Role
#
resource "aws_iam_role" "instance_role" {
  name               = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path               = "/"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path = "/"
  role = "${var.configs.environment_name}-${var.instance_configs.service}-role"
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::${var.configs.aws_account_id}:policy/${var.configs.common.log_bucket_policy.name}"
}

resource "aws_iam_role_policy_attachment" "attach_ssm_managed_instance_policy" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}


#
#EC2
#
resource "aws_instance" "instance" {
  ami                         = var.configs.ec2.plain_ami
  availability_zone           = "${var.configs.region}${var.configs.main_az}" # e.g. "ap-northeast-1a"
  ebs_optimized               = true
  instance_type               = var.instance_configs.instance_type
  monitoring                  = false
  key_name                    = var.configs.ec2.key_name
  subnet_id                   = var.configs.subnets["${var.configs.main_az}_private"].id
  vpc_security_group_ids      = var.instance_configs.vpc_security_group_ids
  associate_public_ip_address = false
  source_dest_check           = true
  iam_instance_profile        = aws_iam_instance_profile.instance_profile.name
  private_ip                  = var.instance_configs.private_ip


  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.instance_configs.root_block_device_volume_size
    delete_on_termination = true
  }

  tags = {
    "Name" = "${var.configs.environment_name}-${var.instance_configs.service}"
    "AutoStopStart" = "true"
  }
}

postbuildコードサンプル

#
# IAM Policyの追加
#

resource "aws_iam_policy" "batch_policy" {
  name        = "${var.configs.environment_name}-batch-policy"
  path        = "/"
  description = "for batch instances."
  policy      = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": ["arn:aws:s3:::codepipeline-${var.configs.region}-*/*",
                         "arn:aws:s3:::codepipeline-${var.configs.region}-*"]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}", 
                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}/*"

                        ]
        },
        {
            "Effect": "Allow",
            "Action": "dynamodb:GetItem",
            "Resource": "arn:aws:dynamodb:${var.configs.region}:${var.configs.aws_account_id}:table/${var.batch_configs.dynamodb_mediainfo_table_name}"
        },
        {
            "Sid":"",
            "Effect":"Allow",
            "Action":[
                "logs:PutLogEvents",
                "logs:CreateLogStream"
            ],
            "Resource":"arn:aws:logs:${var.configs.region}:${var.configs.aws_account_id}:log-group:${var.configs.environment_name}/batch:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "SQS:ChangeMessageVisibility",
                "SQS:DeleteMessage",
                "SQS:ReceiveMessage",
                "SQS:SendMessage"
            ],
            "Resource": "arn:aws:sqs:${var.configs.region}:${var.configs.aws_account_id}:voucher-customer-linking-${var.configs.environment_name}"
        }
    ]
}
POLICY
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = var.batch_configs.instance_role.name
  policy_arn = aws_iam_policy.batch_policy.arn
}


resource "aws_ebs_volume" "extra_volume" {
  availability_zone = "${var.configs.region}${var.configs.main_az}"
  size              = var.batch_configs.extra_block_device_volume_size
  type              = "gp3"
}

resource "aws_volume_attachment" "ebs_attach" {
  device_name = "/dev/xvdf"
  volume_id   = aws_ebs_volume.extra_volume.id
  instance_id = var.batch_configs.instance.id
}


#
# CodeBuild
#

# バイナリビルド用
resource "aws_codebuild_project" "batch_code_build" {
  artifacts {
    encryption_disabled    = false
    name                   = "${var.configs.environment_name}-batch-build"
    override_artifact_name = false
    packaging              = "NONE"
    type                   = "CODEPIPELINE"
  }

  badge_enabled = false
  build_timeout = 60

  cache {
    type = "NO_CACHE"
  }

  concurrent_build_limit = 1
  description            = "for build batch"
  encryption_key         = "arn:aws:kms:${var.configs.region}:${var.configs.aws_account_id}:alias/aws/s3"



  environment {
    compute_type = "BUILD_GENERAL1_MEDIUM"

    image                       = "${var.configs.aws_account_id}.dkr.ecr.${var.configs.region}.amazonaws.com/java-gradle:5.0"
    image_pull_credentials_type = "SERVICE_ROLE"
    privileged_mode             = false
    type                        = "LINUX_CONTAINER"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "CodeBuild"
      status      = "ENABLED"
      stream_name = "${var.configs.environment_name}-batch-build-log"
    }

    s3_logs {
      encryption_disabled = false
      status              = "DISABLED"
    }
  }

  name           = "${var.configs.environment_name}-batch-build"
  queued_timeout = 480
  service_role   = "arn:aws:iam::${var.configs.aws_account_id}:role/${var.configs.environment_name}-build-role"

  source {
    buildspec           = "container/batch/buildspec.yml"
    git_clone_depth     = 0
    insecure_ssl        = false
    report_build_status = false
    type                = "CODEPIPELINE"
  }
}

#
# CodeDeploy
#

resource "aws_codedeploy_app" "batch_deploy" {
  compute_platform = "Server"
  name             = "${var.configs.environment_name}-batch-deploy"
}


resource "aws_codedeploy_deployment_group" "batch_deploy_group" {
  app_name              = aws_codedeploy_app.batch_deploy.name
  deployment_group_name = "${var.configs.environment_name}-batch-deploy-group"
  service_role_arn      = var.configs.ci.code_deploy_role_arn

  ec2_tag_set {
    ec2_tag_filter {
      type  = "KEY_AND_VALUE"
      key   = "Name"
      value = "${var.configs.environment_name}-batch"
    }
  }

  trigger_configuration {
    trigger_events     = ["DeploymentFailure"]
    trigger_name       = "failure-notice-trigger"
    trigger_target_arn = var.configs.sns.notice_topic_arn
  }

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  alarm_configuration {
    alarms  = ["${var.configs.environment_name}-batch-deploy-alarm"]
    enabled = true
  }
}


#
# CodePipeline
#

data "template_file" "codebuild_environment_data" {
  template = file("${path.module}/templates/environment-variables.json")
  vars = {
    environment_short_name = var.configs.environment_short_name,
    environment_name       = var.configs.environment_name
  }
}


resource "aws_codepipeline" "batch_pipeline" {
  artifact_store {
    location = "codepipeline-${var.configs.region}-524718723338"
    type     = "S3"
  }

  name     = "${var.configs.environment_name}-batch-pipeline"
  role_arn = "arn:aws:iam::${var.configs.aws_account_id}:role/service-role/common-code-pipeline-role"

  stage {
    action {
      category = "Source"

      configuration = {
        BranchName           = var.configs.batch.branch_name
        ConnectionArn        = var.configs.ci.connection_arn
        FullRepositoryId     = var.configs.ci.full_repository_id
        OutputArtifactFormat = "CODE_ZIP"
      }

      name             = "Source"
      namespace        = "SourceVariables"
      output_artifacts = ["SourceArtifact"]
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Source"
  }

  stage {
    action {
      category = "Build"
      configuration = {
        EnvironmentVariables = data.template_file.codebuild_environment_data.rendered
        ProjectName          = aws_codebuild_project.batch_code_build.name
      }

      input_artifacts  = ["SourceArtifact"]
      name             = "Build"
      namespace        = "BuildVariables"
      output_artifacts = ["BuildArtifact"]
      owner            = "AWS"
      provider         = "CodeBuild"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Build"
  }

  stage {
    action {
      category = "Deploy"

      configuration = {
        ApplicationName     = aws_codedeploy_app.batch_deploy.name
        DeploymentGroupName = "${var.configs.environment_name}-batch-deploy-group"
      }

      input_artifacts = ["BuildArtifact"]
      name            = "Deploy"
      namespace       = "DeployVariables"
      owner           = "AWS"
      provider        = "CodeDeploy"
      region          = var.configs.region
      run_order       = 1
      version         = 1
    }

    name = "Deploy"
  }
}


構築コマンドは以下になります。

  • terraform init実行
    tfファイルを格納しているフォルダーを初期化させます。
    フォルダー内直下のtfファイルが読み込まれ、必要なpluginがインターネットからフォルダーにダウンロードされます。
Initializing modules...
- batch_instance in ../../../modules/components/ec2-single
- batch_postbuild in ../../../modules/batch/postbuild

Initializing the backend...

Initializing provider plugins...
(略)

Terraform has been successfully initialized!


  • terraform plan実行
    構築前にどのようなリソースが作成(変更)されるかを事前に確認できます。
    基本的にはmodulesにコーティングしてあるリソースしか表示されませんが、念の為確認しておきます。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

--- 

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run
"terraform apply" now.


  • terraform apply実行
    Enter a value:が表示され止まるので、yesを入力してEnterキーを押下すると、リソースの構築が開始されます。
    構築進捗状況が出力されるので数分ほど待ち、Apply complete!が出力されれば構築が完了しています。
    Apply complete!の後の文言は、terraform plan時の出力結果と一緒になります。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: <yes>

module.batch_instance.aws_iam_instance_profile.instance_profile: Creating...
module.batch_instance.aws_iam_role.instance_role: Creating...

(略)

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.


terraform apply中にインスタンスIDが表示されるので、AWSコンソールのEC2画面にてインスタンスIDでフィルターし、該当するインスタンスが表示され作成されているか確認します。
EC2

インフラテスト

テストの内容としては、コードから構築されたリソースがちゃんと作成できているか、リソース設定が環境変数で指定した内容になっているか、リソース間疎通確認などを実施しました。

アプリケーション実装評価

ここからは実際にアプリケーションを作成した環境に乗せてみて画面が確認できるかを確認しました。
CI/CDテストもここで実施しました。

システムテスト

このテストはAurora1からAurora3へアップグレードした際に、全機能テスト項目をまとめていたためそちらを実施しました。
またシステムリプレイスのため性能負荷試験も実施し問題がないかも確認しました。

直面した課題・反省点

Beanstalk EOLとOS EOLでモジュール配置のディレクトリー構成を変えてしまった

ディレクトリー構成を決めたもののその配下まで詳細に決めきれてなかったため、Beanstalk EOL時とOS EOL時でモジュール配置を安易に変えてしまいました。
そのため先行リリースしたBeanstalk用のモジュール群が動作しない状態に陥ってしまいました。
結果Beanstalk用とOS用でECSリソースモジュールは分離した状態になりました。

Terraformローカル実行前提だったのでMac M1(ARM)考慮できてなかった

Terraform運用まで検討しておらずローカル実行をするようにしていました。
当初ARM環境がなかったため参画してきたメンバーがTerraform実行環境を整備したときに発覚しました。
結果としてTerraform実行環境をAWS上に準備しTerraform実行はその環境で実行する運用としました。

tfstateを外部参照できるように考慮すればよかった

tfstate(Terraformがリソースの現在の状態を管理するファイル)も、コード管理する必要がありました。
terraform実行後にマージするしかなく、コード修正しながら構築可能な状態となってしまいました。
結果ブランチのままterraform実行することになってしまっていてプルリクエストが構築後になってしまいました。
今後の検討でtfstateはS3保存できるような形にする予定です。

モジュールは1つにつき1リソース作成するものにできなかった

設計でできるだけ1モジュール1リソースとしていたものの関連するリソースを含む形になってしまいました。
構築だけを考えれば現在のままでも問題はないですが、運用を考えると他リソースまで変更される可能性もあるため徹底すればよかったなということが反省点です。
運用に関しては今後対応していく中で少しずつ改善していく予定です。

まとめ

今回EOL対応におけるIaC化についてお話ししました。

本EOL対応は私が入社してまもない頃から今まで対応してきた内容にもなります。
他にもEOL対応の中で実施した内容はあるのでどこかでまたお話しできたらと思っています。

Flutter開発におけるアプリ内課金で注意すべき点

アイキャッチ

自己紹介

はじめまして。株式会社ブックリスタ プロダクト開発部の横山です。

現在はモバイルアプリエンジニアとしてコミックアプリ「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」の開発を行なっています。

コミックROLLYとは

2023年8月1日にリリースされたiOS/Android向けのコミックアプリです。
フルカラーの縦読みコミック「webtoon」をはじめ、電子コミックをスマートフォンやタブレットで読むことができます。

コミックROLLY

https://rolly.jp/

はじめに

ここではFlutter開発において、iOS/Androidのアプリ内課金に対応したことについて話します。
その中でも、特に必要だったAndroid独自の対応について注目して話します。

アプリ内課金(消耗型)のシステム構成

アプリ内課金は、1回購入する度に消費する消耗型の課金アイテムを使用します。
システム構成については、アプリ、ストア(AppStoreおよびGooglePlayストア)、独自サーバーのよくある構成を取ります。
独自サーバーでは、ストア購入後のレシート検証および、課金アイテムの対価を付与する処理を実施します。

AppleのStoreKitのドキュメントにあるシステム構成図は以下のとおりです。

システム構成図

https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase

in_app_purchaseパッケージの導入

FlutterでiOS/Androidのアプリ内課金を共通的に実装するために、in_app_purchaseパッケージを使用しました。

https://pub.dev/packages/in_app_purchase

以下は、上記ページに記載されているサンプルコードの抜粋です。

消耗型課金アイテムを購入する

課金アイテムに対して、購入処理を開始します。

final ProductDetails productDetails = ... // Saved earlier from queryProductDetails().
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam);

購入処理の更新をlistenする

上記の購入処理に対し、購入成功やキャンセル、エラーなどのステータスを受け取れるため、対応した処理を書くことができます。

Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
  for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
    if (purchaseDetails.status == PurchaseStatus.pending) {
      showPendingUI();
    } else {
      if (purchaseDetails.status == PurchaseStatus.error) {
        handleError(purchaseDetails.error!);
      } else if (purchaseDetails.status == PurchaseStatus.purchased ||
        purchaseDetails.status == PurchaseStatus.restored) {
        final bool valid = await _verifyPurchase(purchaseDetails);
        if (valid) {
          unawaited(deliverProduct(purchaseDetails));
        } else {
          _handleInvalidPurchase(purchaseDetails);
          return;
        }
      }
      if (Platform.isAndroid) {
        if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
          final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
          await androidAddition.consumePurchase(purchaseDetails);
        }
      }
      if (purchaseDetails.pendingCompletePurchase) {
        await _inAppPurchase.completePurchase(purchaseDetails);
      }
    }
  }
}

注意点

ストア購入成功後のサーバー処理の失敗を考慮したリトライ

課金アイテムの購入成功を受けて、独自サーバーでの課金アイテムの対価を付与することになりますが、付与が完了しなかった場合に備えてリトライできるような設計が必要です。

例えば、ネットワークエラーや、ユーザーがアプリを終了させてしまった場合などがあります。

ネットワークエラーであれば、その場でダイアログを表示してリトライを促すことができます。
しかし、アプリを終了した場合は、次回起動時にリトライできるような仕組みが必要です。

リトライ時に、保留状態になった課金アイテムは、別途取得できます。 但しiOSとAndroidとでは取得方法が異なることに注意です。

iOS

SKPaymentQueueWrapperを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_storekit パッケージが必要)

https://pub.dev/packages/in_app_purchase_storekit

final paymentQueueWrapper = SKPaymentQueueWrapper();
final transactions = await paymentQueueWrapper.transactions();
for (final transaction in transactions) {
    // 独自サーバーへの購入処理
    await deliverProduct(transaction);
    // 購入トランザクション終了
    await paymentQueueWrapper.finishTransaction(transaction);
}

Android

InAppPurchaseAndroidPlatformAdditionを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_android パッケージが必要)

https://pub.dev/packages/in_app_purchase_android

final androidAddition = InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
for (final purchaseDetails in response.pastPurchases) {
    if (purchaseDetails.pendingCompletePurchase) {
        // 独自サーバーへの購入処理
        await deliverProduct(purchaseDetails);
        // 購入トランザクション終了
        await InAppPurchase.instance.completePurchase(purchaseDetails);
    }
}

尚、購入処理の更新をlistenするコード において、独自サーバーでの購入処理が失敗して未完了だった場合を考えます。
その場合、そのまま_inAppPurchase.completePurchase()を呼ぶと、購入トランザクションが終了してしまい、上記のリトライもできなくなってしまいます。
そのため、独自サーバーでの購入処理が失敗した場合は呼ばないように制御する必要があります。

逆に、独自サーバーでの購入処理が成功した場合には、リトライ時にもきちんと購入トランザクションを終了しましょう。

更にAndroidで注意すべき点

Androidでは購入アイテムの消費を明示的にする

先程の 消耗型課金アイテムを購入するコードbuyConsumable()において、第2引数のautoConsumeがデフォルトtrueになっています。

Future<bool> buyConsumable({
  required PurchaseParam purchaseParam,
  bool autoConsume = true,
})

しかし、Androidでは先程の 購入処理の更新をlistenするコード でもあったとおり、以下のとおり明示的にconsumePurchase()を呼ぶ必要があります。

if (Platform.isAndroid) {
  if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
    final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
    await androidAddition.consumePurchase(purchaseDetails);
  }
}

そのため、buyConsumable()においては、autoConsumeを使用せずに、以下のとおりiOSとAndroidで分けましょう。

final bool _kAutoConsume = Platform.isIOS || true;
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam,
 autoConsume: _kAutoConsume);

これをしないと、購入が成功した場合でも消費が正常に完了せずに、同じ課金アイテムを購入する際に「このアイテムはすでに所有しています」とエラーが出てしまう場合があります。

consumePurchaseでエラーが返る場合に備えてリトライ処理を追加する

前述のconsumePurchase()の対応を入れても、テスト中に端末によっては「このアイテムはすでに所有しています」とエラー表示されてしまう場合がありました。

それは、購入処理中に機内モードでオフラインにするなどの動作をした後、リトライのテストをした場合に限ります。

同じ条件において様々なOSバージョンで検証したところ、 Android10以下で発生し、Android11以上では発生しないという差がありました。

また、Android10以下でも、オンラインに復帰してからある程度時間が経っていた場合、もしくはDebugモードだった場合では発生しないこともありました。

Android10以下でエラーの詳細を確認したところ、リトライ時のconsumePurchase()において、SERVICE_UNAVAILABLE(エラーコード 2)が返っていることが分かりました。

デベロッパーサイトのリファレンスによると、 このエラーは一時的なもので、再試行で解決する類のエラーということが分かりました。

https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode

したがって、解決策として、 consumePurchase()が失敗した場合、非同期でconsumePurchase()のリトライを実施するようにした方がよいでしょう。

リトライ方法については、以下デベロッパーサイトのページを参考になります。

https://developer.android.com/google/play/billing/errors?hl=ja

さいごに

in_app_purchaseパッケージを活用して、iOS/Androidそれぞれのアプリ内課金を共通的に書くことができました。
しかし、それと同時にOS固有の処理が必要な部分もあり、その処理についての理解も必要だということも分かりました。
今回は消耗型の課金アイテムについて触れましたが、自動更新サブスクリプションであれば更に複雑度も増すことでしょう。
自動更新サブスクリプションの対応機会がありましたら、また注意点をシェアできればと考えています。
この記事が、これからFlutterにてアプリ内課金を対応される方の参考になれば幸いです。