Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

アプリ内課金の定期購入(サブスクリプション)をFlutterとFirebaseで実装するときのポイント

こんにちは、スタディプラスの須藤(id:kurotyann)です。

昨年の9月にFlutterとFirebaseで新規サービス「ポルト Porto」をリリースしました。

tech.studyplus.co.jp

ポルトはアプリ内課金を未実装でローンチしました1。ローンチ時の決済手段は、Stripeを使ったWebクレジット決済(月額制で無料トライアル14日間)のみです。

高校生をメインターゲットとしながらもアプリ内課金がないのは大きな課題であったため、今年の2月25日にアプリ内課金をリリースしました。

そこで、今回は「FlutterとFirebaseに焦点をあてて」アプリ内課金の定期購入の実装ポイントを紹介します。

1. 技術選定

システム構成図

まず、システム構成図で全体像を示します。

アプリ内課金(定期購入)
f:id:kurotyann:20200413101935j:plain
定期購入のステータス変更通知
f:id:kurotyann:20200413101939j:plain

Flutter(アプリ側)

Flutterにはアプリ内課金の実装をサポートするライブラリがいくつか存在します2。結論から言うと、ポルトではFlutterの公式ライブラリである in_app_purchase を採用しました。

採用理由はFlutterの公式ライブラリであることや、ポルトの料金体系(月額制で無料トライアル14日間)を実装できるライブラリだったからです。ライブラリのおかけでDartのみで実装できました。SwiftやKotlinを書く必要ありません。

このライブラリの使い方は、 packages/in_app_purchase/example を参考にするのが一番の近道です。ポルトでもDartのコードは、 in_app_purchaseのexampleを参考にリファクタリングする程度で済みました。

Firebase(サーバー側)

アプリ側はin_app_purchaseのおかげで楽に完了しました。一方で、サーバー側の実装は大変です。利用したサービスは、Firestore / Cloud Functions / Cloud Storage(GCP)3です。それぞれの役割は次の通りです。

Firestore

  • 定期購入の製品IDの保存
  • ユーザーの課金状態の保存
  • レシート情報の保存

Cloud Functions

  • onCallトリガー

    • 購入 / 復元の処理
  • onRequestトリガー

    • Apple定期購入のステータス変更通知を処理する
    • ユーザーIDでレシートを検証する
  • Pub/Subトピック

    • Google定期購入のステータス変更通知を処理する
  • Pub/Subスケジューラ

    • スケジューラの実行時間の前後n時間以内に有効期限が含まれるレシートを検証する

Cloud Storage(GCP)

  • Pub/Subスケジューラで実行したレシートの検証結果をテキスト形式で保存

2. 実装のポイント

App Store ConnectとGoogle Play Consoleに環境別でアプリを登録

ポルトは本番・ステージング・開発の3環境を準備しており、Firebaseも環境に応じてプロジェクトを分けています。したがって、iOSは PRODUCT_BUNDLE_IDENTIFIERが、Androidは applicationId が環境ごとに異なります。そして、これらのIDはApp Store ConnectやGoogle Play Consoleの登録アプリの情報と同じです。

アプリ内課金の製品は、App Store ConnectやGoogle Play Consoleで事前登録が必要であり、登録しているアプリ情報ごとに製品情報を登録します。つまり、ポルトは環境ごとにIDが異なるため、例えば開発環境のアプリから本番環境の製品情報を取得することはiOSやAndroidでも不可能です。

AppStoreやPlayStoreにリリースするのは本番環境のアプリだけなので、通常はリリースする環境のアプリしか登録しません。これだと開発やステージングでアプリ内課金の動作確認ができないので、App Store ConnectとGoogle Play Consoleに環境別でアプリを用意しました。4

有効期限など確認できるデバッグ画面を用意

アプリの通常利用には必要ないが、開発中に閲覧できると便利なデータを特定の環境や操作で表示できる画面のことを「デバッグ画面」と私は呼んでいます。アプリ内課金の実装時、このデバッグ画面は必須です。

特にiOSのSandbox環境で必要になります。Appleの自動更新の定期購入(auto-renewable subscription)はレシートの自動更新判定が厳しいため、有効期限を確認しながらAppleのレシートを検証することになります。

例えば、開発環境のサブスクリプションを1週間の自動更新にしたとします。このとき、Sandbox環境は3分と短く5なります。そして、自動更新の判定は有効期限の1分前ぐらいから有効になります。購入日や有効期限をアプリのデバッグ画面から確認できないと、どのタイミングでレシート検証APIにリクエストすれば更新されるのかわかりません。

一方、Androidも自動更新判定が必要ですが、iOSに比べると対応はかなり楽です。Androidは、テストユーザーで購入した場合でも本番と同様に定期購入のステータス変更通知(リアルタイム デベロッパー通知)を受け取れます。Cloud Functions for FirebaseのPub/Subトピックを利用して SUBSCRIPTION_RENEWED に合わせて課金データを更新すれば自動更新できます。

特定のユーザーの最新レシートを検証するAPIを用意

Firebase AuthenticationのUIDを渡せば、該当ユーザーの最新レシートを取得して、AppleやGoogleの検証APIへリクエストするようにします。

このAPIは基本的には開発やステージングで使うAPIであり、本番からのリクエストも想定はしますが利用することは、ほとんどありません。後述するPub/Subスケジューラでレシート検証処理をポーリングして、さらに定期購入のステータス変更通知を用意しておけば、アプリ内課金の定期購入は実装できます。

しかし、開発中のデモユーザーのレシートのみ検証したいときや、CSの対応で特定のユーザーのレシートを再検証したい場合など、事前に用意しておくと開発効率が上がります。Cloud Functions for FirebaseのonRequestで実装すれば、ターミナルやPostmanなどのWeb APIクライアントツールでも楽に利用できます。ただし、APIのエンドポイントを外部に漏らさず、漏れたとしても正当なリクエストなのか判定できるような仕組みは入れておくべきです。

collection groupで最新レシートを取得

ユーザーのレシート情報を保存するコレクションIDは、プロジェクト内で一意にしておきます。これで、collection groupを使えばプロジェクト内の全レシートに対して特定の条件をあてながらレシートを抽出できます。

AppleとGoogleのレシート情報は異なるので、 appleReceiptgoogleReceipt などのコレクションIDにして、ユーザーIDのサブコレクションに持たせます。これで特定のユーザーIDで該当ユーザーのレシート情報を抽出できますし、collection groupでプロジェクトの全てのiOSまたはAndroidのレシートを抽出することも可能です。

ポルトの場合は、collection groupで取得したレシートの持ち主を特定しやすくするため、レシートを保存するときレシートの情報にユーザーIDを付与して保存しています。

Pub/Subスケジューラでレシートの有効期限を監視して検証

Appleは最新レシートの有効期限が切れる前の24時間の間に自動更新が有効になり6、Googleは公式ドキュメントに明記されていませんがAppleと同様の範囲で自動更新が有効になっています。Pub/Subスケジューラの頻度はサービスの質によって様々なのでFirebaseの料金と相談しながら、適切な頻度を探ってください。

ポルトでは、毎日3時間ごとに実行時間の前後6時間以内に有効期限が含まれるレシートを抽出して検証しています。そして、定期実行で処理した結果をテキストにして、Cloud Storage(GCP)に保存しています。保存したテキストのURLをslackへ送信することで、ログを閲覧しやすい仕組みにしています。

ステータス変更通知で課金状態を更新

AppleはApp Store Server Notifications、Googleはリアルタイムデベロッパー通知と、定期購入のステータス変更を通知する機能があります。ポルトではAppleはCloud Functions for FirebaseのonRequestで、GoogleはCloud Functions for FirebaseのPub/Subスケジューラで実装しました。

Googleはその名のとおり、本番でもテストでもほぼリアルタイムで定期購入のステータス変更を通知してくれます。通知種別も豊富で自由度が高いです。一方で、Appleは不便です。本番でもテスト(Sandbox)でもリアルタイムで通知されることは稀です。かなりラグがあります。さらに、Googleは自動更新されたことを通知する SUBSCRIPTION_RENEWED がありますが、Appleにはこれと同等のものがありません。必ず自前でレシートの有効期限をポーリング(Pub/Subスケジューラ)して、自動更新を判定しないといけません。

App Store Server Notificationsが役立つと感じたパターンは、支払いエラーで課金が失敗したときや、支払いエラーが解消されたときです。この通知に合わせて支払いエラーの有無を切り替えたり、エラーが起きたユーザーのIDをSlackへ通知しておくと、CS対応が少し捗ります。

3. 終わりに

コードが一切なく、文章ばかりのブログになりました。その理由は、私がアプリ内課金を実装していたとき、欲しかった情報がコードではなく、FlutterとFirebaseでアプリ内課金を実装するときの構成や実装のポイントだったからです。

開発中に調査しましたが、FlutterとFirebaseに関してアプリ内課金の定期購入に触れている記事を見かけませんでした。もちろん、私の検索能力が低い可能性もあるので、良い情報があれば教えて下さい。一方で、AppleやGoogleのアプリ内課金の公式ドキュメントを解説したブログや、どちらか一方の実装でかつアプリ側に寄ったものが多く、サーバー側の視点(レシート検証のポーリングやステータス変更通知 etc)があまりない印象を受けました。

これはアプリとサーバーの担当者が別である場合が多いことや、課金はセキュアな対応のために外へ情報が出づらいなどが理由ではないかと考えています。今回のアプリ内課金の実装は私一人で対応したため、アプリ内課金の全体像を知る良い機会となりました。

FlutterやFirebaseの人気は年々増している印象を受けます。このブログがFlutterとFirebaseでアプリ内課金の実装を検討している人に、少しでも役に立つと嬉しいです。


  1. ローンチ当初、アプリ内課金が未実装なのはビジネス的な話もありますが、Flutterのアプリ内課金の公式ライブラリ(in_app_purchase)が、AndroidのGoogle Play Billing Library 2系をサポートしていなかったことも理由の一つです。in_app_purchaseが2系をサポートしたのは2020年1月7日([In_app_purchases] migrate to Play Billing Library 2.0. #2287)のことでした。

  2. 少し情報は古いですが、https://speakerdeck.com/yasi/present-situation-of-in-app-purchase-in-flutter のスライドが参考になりました。

  3. 課金の分析をするために、Firebase AnalyticsやBigQueryも利用しています。ただ、AnalyticsはUser Propertiesに新しいプロパティを追加したり、Firestoreの課金データをBigQueryへインポートするなど課金以外の分析対応と代わり映えしないので省略しました。

  4. こちらの実装ポイントについては、Twitterで @_monoさんからアドバイスを受けて対応しました。当時のやり取りはこちらのツイートから追えます。

  5. アプリ内課金のテスト時間は、Appleは https://help.apple.com/app-store-connect/#/dev7e89e149d 、Googleはhttps://developer.android.com/google/play/billing/billing_testing#testing-subscriptions です

  6. 公式ドキュメントのDetect an Expiration or Renewalを参照してください。

弊社のリモート事情

こんにちは。サーバーサイドエンジニアの金澤です。
コロナウィルスがいまだに猛威を振るっていますが、みなさんはご無事に過ごされているでしょうか。
この奇禍によってリモートワークを余儀なくされている方も多いかと思います。

弊社も例外ではなく、現在はほぼ全ての社員がフルリモートで勤務しています。
以前からリモートワークの導入を進めていたとはいえ、実際に顔を合わせるタイミングがあるのと、それが無いのとでは全く違う働き方が必要なのだなと実感させられています。
事態が収束したら体制がどうなるかというのはまだ未定ですが、我々がどうやってこの状況に対応しているかというお話をさせていただきます。

仕事の進め方

弊チームは一週間単位のスプリントで開発を進めており、スプリントレビュー、レトロスペクティブ、そして次週のプランニングを毎週行なっています。

フルリモートという性質上どうしても顔を合わせる機会は減ってしまいます。ビデオチャットを繋ぎっぱなしにするというような選択肢もありますが、生活音の問題や煩わしく感じる人もいると思うのでそういう方針にはなりませんでした。

週に各一度のミーティングの質を高めてしっかりタスクを管理しさえすれば、あとはslackやGitHub上の非同期コミュニケーションだけで仕事が回るような状態を目指しています。
逆に言うと、それらの質が低いと一週間何をしているのかよくわからないという状態になりかねません。
正直なところ当初は生産性がかなり落ちていたと思いますが、いくつかのツールを導入し、いままでのツールも使い方を多少変えるなどしてなんとか以前と同じぐらいには働ける状態になったと思います。

今回導入したもの

ビデオチャット

当初はgoogle hangoutsを利用していましたが今はzoomを利用しています。 通話可能時間や品質に満足できるなら何を使ってもいいと思います。

しかし脆弱性や安全性に多くの指摘があるので、動向を注視しなければいけないですね。

parabol

レトロスペクティブ用のツールです。
対面で振り返りをしていた時は付箋を使ってその場で書いていたので、その流れでリモートでも通話中にその場でみんなが数分沈黙するという時間がありました。 parabolに前もって書いておいてもらう運用は以下のような利点があると思います。

  • 前もって書いているのでミーティングの時間が短縮できる
  • 自分以外には見えないので同調圧力がかかることもない(文書共有サービスだとこれができない、やりにくい)
  • 思いついたときに書いておけるので、対面のその時に思い出せず書き忘れる、ということがなくなる
  • 時間的制約がないので内容や伝え方など吟味できる
  • 無料

チームでレトロスペクティブを導入している方はぜひ一度触ってみていただきたいです。

hatjitsu

スプリントプランニングの時に利用しています。
オンラインのプランニングポーカーツールは巷に溢れていますが、これが一番必要最小限の機能で使いやすかったです。 プランニングポーカーのためだけにユーザー作ってログインしたいですか?
これ以外にもいいツールはあるかもしれませんが、今我々に必要な機能は全て揃っていて使いやすくて気に入っています。

以前から使っているもの

GitHub

slack

これらに関しては説明不要だと思います。 無いと仕事ができません。

monday.com

タスク管理はmonday.comを利用しています。
slackとの連携がしやすいなど機能的にも満足していますが、個人的に一番優れていると感じるのは「エンジニアがちゃんと使う率の高さ」と「非エンジニアの使ってくれる率の高さ」です。
デザインやUIがとっつきやすいのでみんなが使ってくれるため、結果として多重管理になりにくいという長所は、軽視されがちですがかなり重要だと思います。 今まで使ってきたタスク管理ツールの中で一番満足度が高いです。
詳細については以前の記事をぜひご覧ください。

プロダクトバックログへの追加は各自が必要なものを必要な時に追加し、スプリントに取り込むときに見積もりをする、必要であれば妥当な大きさの複数バックログに分解するという運用です。

ルールなど

前述のように、ビデオチャットに常に参加して監視するようなルールはありません。
呼びかけに応えるのが遅れてもペナルティなどありません。
必ずしもすぐに返事が来ないことを前提として仕事を進める癖をつければ通常それで困ることは無いと思います。

障害対応など緊急時には別ですが、それはリモート導入前にも家から各自が連携して対応していたので特に変わりがありません。

終わりに

最近あの絶景オフィスが話題になったDHHですが、少し前にこういう発言をしています。

リモートが合う人もいるし合わない人もいる、合う職種も合わない職種もある、体調を崩した、チャットが苦手、家族子供ペットなどなど様々な条件で生産性が上下することは当たり前です。
リモートは生産性が高いとか低いとか主語の大きすぎる議論はせず、自分たちの仕事のやり方を現実に合わせて改善していくことだけを考えていきたいですね。

オリンピックの延期も決まり、まだまだ事態が収まる気配はありません。
皆さん体調に気をつけて、この苦難を乗り切りましょう。

HTMLとCSSでStudyplusのロゴをざっくり描いたり動かしたりする

こんにちは。ForSchool事業部の石上です。だし巻き卵が好きです。

やりたいこと

さて、今回はCSSで遊ぶだけの記事です。以前、RubyWorld Conferenceへ参加させてもらった際、自社ブースでStudyplusのロゴに付箋をはっつけてベストRuby本を投票してもらうみたいなやつをやっていたことがあります1。あれをオンラインでやれたら面白そうだなと思ったのですが、思っただけで何もしていませんでした。今回それをふと思い出したので、ロゴをCSSで描いてみようという感じです。

ロゴを見てみる

f:id:shgam:20200317163747j:plain

ロゴを見てみると、長方形と三角形、そしてそれらを傾けて並べることが必要そうです。ひとつずつやっていきます。

長方形

長方形は簡単です。widthheightbackgroundを指定するだけです。

三角形

三角形はちょっとむずかしいです。調べてみると、どうやら三角形をCSSで描くときは、border-widthを使えば良いようです。

border-width

MDNでborder-widthのページを見てみます。

border-widthに値を4つ指定すると、 border-width: 上 右 下 左の指定になるみたいです。さらにここに色をつけてみるとわかりやすいです。borderは上下左右指定すると、それぞれが台形になるんですね。

それぞれ別の色を指定すれば、きれいに三角形ができるまでの過程がわかりやすいので、アニメーションさせてみました。

See the Pen Animation of making triangle by gaaamii (@gaaamii-the-sasster) on CodePen.

それぞれのborder-widthをboxの幅の半分に指定したところ、boxの中身が全部borderで埋め尽くされ、きれいに三角形で四等分されました。なお、border-box: 0にしておかないとbox内の領域をそのまま保とうとしてしまうので、boxに大きいborderが付くだけになってしまうので注意です。

表示したい部分以外をtransparentにすることで1つの三角形にする

下のborderだけ色を付けほかは透明にすることで、色をつけたところの三角形だけを表示できます。さらに、伸ばしたい辺のborder-widthをboxと同じ長さに、かつ向かい合う辺のborder-widthを0に、それ以外の辺はboxと半分の長さにすることで、いい感じにboxの幅と同じ長さの底辺の二等辺三角形が描けました。

描いていく

長方形と三角形が描けるということは、なんだか描けそうな気がしてきました。やっていきます。

マークアップ

まずはHTMLを書きます。これでどうでしょうか。

<i class="logo">
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="line"></span>  
  <span class="tip"></span>
</i>

ロゴを構成するのは5本の線と鉛筆の先でしょう。

5本の線をCSSで描く

スクリーンショット 2020-03-16 23.23.14.png (14.0 kB)

まずは、こんな感じの線を描いてみたいです。何年もStudyplusを利用したり開発に関わってきた身としては、もうすでにこれでStudyplusという感じさえします。これくらいなら自分のCSS力でもスッと書けそうです。

:root {
  --line-width: 20px;
  --line-height: 100px;
}
.line {
  margin-left: calc(var(--line-width) / 4);
  width: var(--line-width);
  height: var(--line-height);
  transition: 0.5s;
}
.line:nth-child(1) {
  background: #e74126;
  height: var(--line-height);
}
.line:nth-child(2) {
  background: #f3b418;
  height: calc(var(--line-height) * 0.8);
}
.line:nth-child(3) {
  background: #8dc32e;
  height: calc(var(--line-height) * 0.7);
}
.line:nth-child(4) {
  background: #36b397;
  height: var(--line-height);
}
.line:nth-child(5) {
  background: #2f71b7;
  height: calc(var(--line-height) * 1.2);
}

これでどうでしょうか。ここではCSS変数とcalcを使っています。プロダクションではIE 11対応が必要だったりしてSassを入れたりしてますが、早く時代が進んでCSSだけで全部できるようになるといいですね。

大きい三角形と小さい三角形を描く

スクリーンショット 2020-03-16 23.34.40.png (15.1 kB)

次に、鉛筆の先の部分を描きます。鉛筆でいう木の部分と芯の黒い部分は、今回はHTMLで1つの要素としてマークアップしてあります。

  <span class="tip"></span>

なので、::after という疑似要素にスタイルを当てて、黒い芯の部分を表現します。

:root {
  --line-width: 20px;
  --line-height: 100px;
  --tip-width: calc(var(--line-width) * 3.8);
  --tip-height: var(--line-height);
}

.tip {
  width: var(--tip-width);
  height: var(--tip-height);
  margin-left: calc(var(--line-width) / 4);
  box-sizing: border-box;
  border-style: solid;
  border-color: transparent;
  border-width: calc(var(--tip-height) / 2) 0 calc(var(--tip-height) / 2) var(--tip-width);
  border-left-color: #efdab3;
  border-radius: 5px;
}
.tip:after {
  --width: calc(var(--tip-width) / 3.5);
  --height: calc(var(--tip-height) / 3.5);
  width: var(--width);
  height: var(--height);
  display: block;
  content: "";
  position: relative;
  left: calc(var(--width) * -1);
  top: calc(calc(var(--height) / 2) * -1);
  box-sizing: border-box;
  border-style: solid;
  border-color: transparent;
  border-width: calc(var(--height) / 2) 0 calc(var(--height) / 2) var(--width);
  border-left-color: #000;
}

傾ける

スクリーンショット 2020-03-16 23.37.15.png (36.8 kB)

最後に、これらの全体を傾けます。

.logo {
  transform: rotate(40deg);
}

まとめ

よくよく見るとスタイルが雑なせいでまがいものみたいな出来になってしまいました。デザイナーの方々に見られたら怒られそうな気もします。が、とりあえずはHTMLとCSSだけでざっくりStudyplusのロゴを描くことができました。自分のCSS力では今の所これが精一杯です。.tipの部分に立体感を出したり、細かいところが難しいですね。

とりあえずCSSで表現できたことによって、好きなように動かしたりすることができるようになりました。.lineを上下にうにょうにょさせたり、全体を揺らしたりができます。これだけだと何が面白いのという感じですね。

今後はもうちょっと細部をちゃんとしつつ、JSからDOMのstyle属性をいじってデータを流し込んだりして遊んでみたいです。

See the Pen Studyplus logo drawn by CSS (with Text) by gaaamii (@gaaamii-the-sasster) on CodePen.

Kubernetes上でのFluentdを使ったログ収集について

こんにちは。ご機嫌いかがでしょうか? SREチームの栗山(id:shepherdMaster)です。 弊社ではKubernetesを導入するために着々と準備を進めております。 どんなシステム上でアプリケーションを動かすにせよ、ログ収集は必要になってきます。
Kubernetes上でログ収集をするために色々調べましたが実用的な情報があまり豊富ではなかったので、今回はKubernetes上でのログ収集、特にFluentdの設定について共有をしたいと思います。
なおまだ実運用は開始してないので今後細かい部分は変わるかもしれません。

ログ収集&ログ分析の構成

構成は以下にしました。

Fluentd + S3 + Amazon Athena

理由は以下です。

  • S3に保存すると非常に安い
  • SQLでログを検索できるのは非常に便利
  • Fluentdの設定の柔軟性
  • 既存のログ収集基盤がFluentd + S3 + Amazon Athenaになっていたため、資産の流用ができ、学習コストや管理コストも抑えられる

ログ収集ツールとしてはより軽量なFluent Bitも考えましたが、S3に保存するためのoutput pluginがなかったので諦めました。

Kubernetes上でのログ取集の課題

Kubernetes上でのログ収集を進めていくうちに課題がいくつか出てきました。
まずログ収集の全体の流れですが、コンテナの標準出力/標準エラー出力結果がホストマシンの/var/log/containers以下にファイル出力されます。そしてFluentdがそのファイルをtailし、S3に保存する流れになります。

このとき、/var/log/containers以下出力されるファイルに難があります。 具体的に/var/log/containers以下にあるログを見てみましょう。

{
  "log": "time:2020-03-11T11:09:55+00:00\thost:10.3.48.x\tvhost:health-check.studyplus.jp\tserver:deployment-7f94cc5958-bgs7g\treq:GET /health_check HTTP/1.1\turi:/health_check\tstatus:200\tmethod:GET\treferer:-\tua:kube-probe/1.14+\treq_time:0.011\tapp_revision:-\truntime:0.009058\tcache:-\tapp_time:0.008\trequest_id:9967a0a0a6486435accf64b495da67b0\tx_request_id:-\tres_size:0\treq_size:177\n",
  "stream": "stdout",
  "time": "2020-03-11T11:09:55.217300042Z"
}

logの部分にコンテナが出力したログが入っています。 ちなみに上記はnginxのログですが、nginxはLTSV形式でログ出力するようにしています。なので\tという文字がところどころ入っています。また厄介なことに末尾に\nが入ってます。
streamにはstdout(標準出力)かstderr(標準エラー)かが入ります。

このことから、

  • コンテナが出力したログをS3に保存するためには、JSONのlog値を取り出さないといけない。
  • log値の末尾の\nを削除する必要がある(じゃないとJSONとしてはinvalid扱いになる)
  • ログの中を見ないと標準出力ログなのか、標準エラー出力ログなのか分からない。

ということが分かります。 つまり、単純に /var/log/containers以下のログをそのままS3に放り込んでもAthenaでは、log値をlike検索するくらいしかできません。 それだと調査のときにログを柔軟に絞り込むことが出来なくて困るので、ログが適切な形でS3に保存されるようにFluentdの設定をする必要があります。

最終的にやりたいことを整理すると、

  • コンテナごとに保存先を変えたい
  • 標準出力ログ、標準エラーログで、保存先を変えたい
    • なぜかというとnginxはエラーログに対してログフォーマットを指定できないので、保存先を変えてエラーログはAthenaから通常のログとは別に検索したい
  • コンテナが出力したログをそのまま保存したい

FluentdをDaemonSetで動かすには

Fluentdの設定ファイルの前にまずFluentdをDaemonSetで動かします。
https://github.com/fluent/fluentd-kubernetes-daemonset で提供されているものを使うと比較的楽にDaemonSetで動かすことができます。
https://github.com/fluent/fluentd-kubernetes-daemonset/tree/master/docker-image 以下にバージョンごとにディレクトリがありますが、さらにそのディレクトリ以下をみると出力先に応じたimageが用意されています。 S3に保存したいので、debian-s3を選びました。

以下が具体的なマニフェストファイルの内容です。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-daemonset
spec:
  selector:
    matchLabels:
      name: fluentd-pod
  template:
    metadata:
      labels:
        name: fluentd-pod
    spec:
      containers:
        - name: fluentd-container
          image: fluent/fluentd-kubernetes-daemonset:v1.8-debian-s3-1
          env:
            - name: S3_BUCKET_NAME
              value: <バケット名>
            - name: FLUENTD_SYSTEMD_CONF
              value: disable
          resources:
            requests:
              cpu: 200m
              memory: 500Mi
            limits:
              memory: 1Gi
          volumeMounts:
            - name: config-volume
              mountPath: /fluentd/etc
            - name: varlog
              mountPath: /var/log
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
      volumes:
        - name: config-volume
          configMap:
            name: fluentd-configmap
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers

独自のfluent.confを配置するために、ConfigMapを使ってvolumeマウントをしています。こうすることで、/fluentd/etc/以下にfluent.confが配置されます。 そうしないと https://github.com/fluent/fluentd-kubernetes-daemonset/tree/master/docker-image/v1.9/debian-s3/conf 以下にあるfluent.confが使用されます。

/var/logと/var/lib/docker/containersの両方をvolumeマウントしている理由は、/var/log以下のログは/var/lib/docker/containers以下のログにシンボリックリンクがはられているので両方マウントしないとログが読み込めないためです。

FLUENTD_SYSTEMD_CONFをdisableにしているのはここにあるように不要なログを出力しないためです。

fluent.confの設定

実際には、RailsのログやFluentdのログも処理するように設定を書いてますが、今回は話をシンプルにするためにnginxのログ設定のみを書いてます。

# /var/log/containers以下には様々なコンテナのログが保存されているので、pathで該当のコンテナのログを指定します。
<source>
  @type tail
  path "/var/log/containers/*_nginx-container-*.log"
  pos_file /var/log/nginx-container.log.pos
  tag nginx
  read_from_head true
  <parse>
    @type json
    time_format %Y-%m-%dT%H:%M:%S.%NZ
  </parse>
</source>

# logの値の末尾に\nがつくので削除する
<filter nginx>
  @type record_transformer
  enable_ruby
  <record>
    log ${record["log"].strip}
  </record>
</filter>

# 標準出力と標準エラーでtagを分ける
<match nginx>
  @type rewrite_tag_filter
  <rule>
      key     stream
      pattern /^stdout$/
      tag     "${tag}.stdout"
  </rule>
  <rule>
      key     stream
      pattern /^stderr$/
      tag     "${tag}.stderr"
  </rule>
</match>

# jsonのlog値にコンテナが出力ログが入っているのでを取り出す
<filter nginx.stdout>
  @type parser
  key_name log
  <parse>
    @type ltsv
    keep_time_key true
    types status:integer, req_time:float, runtime:float, app_time:float, res_size:integer, req_size:integer
  </parse>
</filter>

<filter nginx.stderr>
  @type parser
  key_name log
  <parse>
    @type none
  </parse>
</filter>

# S3に保存する
<match nginx.stdout>
  @type s3
  format json
  s3_bucket "#{ENV['S3_BUCKET_NAME']}"
  s3_region ap-northeast-1
  s3_object_key_format "${tag[0]}-log/%{time_slice}/${tag[0]}-%{index}.log.%{file_extension}"
  time_slice_format year=%Y/month=%m/day=%d/hour=%H
  <buffer tag,time>
    @type file
    path "/var/log/fluentd-buffers/s3.buffer"
    timekey 3600
    timekey_wait 10m
    timekey_use_utc true
    chunk_limit_size 1G
    flush_at_shutdown true
  </buffer>
</match>

<match nginx.stderr>
  @type s3
  format single_value
  s3_bucket "#{ENV['S3_BUCKET_NAME']}"
  s3_region ap-northeast-1
  s3_object_key_format "${tag[0]}-error-log/%{time_slice}/${tag[0]}-error-%{index}.log.%{file_extension}"
  time_slice_format year=%Y/month=%m/day=%d/hour=%H
  <buffer tag,time>
    @type file
    path "/var/log/fluentd-buffers/s3-error.buffer"
    timekey 3600
    timekey_wait 10m
    timekey_use_utc true
    chunk_limit_size 1G
    flush_at_shutdown true
  </buffer>
</match>

ちなみに、fluent.conf内で使えるfluentd pluginは https://github.com/fluent/fluentd-kubernetes-daemonset/blob/master/docker-image/ 以下にあるGemfile(たとえばこれ)内に定義されているものが使えます。

おまけ

Fluentdの設定ファイルを書いていると、すぐにログをflushしてS3に保存し、保存されたログファイルの中身を確認したいケースがでてきます。そういうときは、USR1 signalを送ると強制的にflushしてくれます。 たとえば以下のようなワンライナーを用意しておくと便利です。

kubectl exec `kubectl get pod -o name | grep fluentd` -- /bin/sh -c "pkill -USR1 -f fluentd"

まとめ

Kubernetes上で動くアプリケーションのログ収集のために、FluentdのDaemonSetリソースファイルとfluent.confの紹介をしました。 Dockerが出力する癖のあるログによって苦戦しましたが、Fluentdの柔軟さによって助けられました。

それでは、みなさん良きログ収集ライフを。

AWS Lambdaを使ったStudyplus for SchoolのLINE連携

こんちにちは、ForSchool事業部の島田です。

今回はStudyplus for School(以下FS)のLINE連携について紹介させていただきます。

LINE連携とは?

LINEの「FS公式アカウント」と生徒の保護者が友だちになることで保護者と塾(講師)が連絡をとれたり、生徒(子供)の塾への入退室情報や勉強の状況を共有できる機能です。

LINE連携でできること

  • 保護者が
    • 塾とメッセージのやりとりが出来る
    • 生徒の塾の入室・退室のお知らせを受信できる
  • 塾が保護者へ
    • 指導報告や面談報告を送信できる
    • 生徒の学習記録を送信できる

LINE連携のフロー概要

LINEとのメッセージの送受信には、Messaging APIを使っています。

developers.line.biz

LINE連携でのメッセージをやりとりするフローの概要は以下のようになります。

  1. LINEと連携するためのステップ(最初の1度のみ)
  2. 保護者へのメッセージの送受信
  3. 保護者からのメッセージの送信

f:id:yo-shimada:20200220151245p:plain

システム構成について

FSとLINEとのやりとりをする実装には AWS Lambda + API Gateway を利用しています。 外部に公開するURLはできるだけFSシステムと疎結合にしておき、LINEとのやりとりはLambdaに責任を持たせて、FSのシステムではなるべく関知しないようにしたいという意図があります。

f:id:yo-shimada:20200210195854p:plain

Lambda関数について

Lambdaに2つの関数を実装しました。説明の便宜上/webhook/messages と記載します。

  • /webhook:LINEからのwebhookを受けるエンドポイント。LINE上でFS公式アカウントに対してイベントが起きたときにこのエンドポイントでPOSTリクエストを受けます。
  • /messages:FSからメッセージを送信する際に受けるエンドポイント。

関数の実装について

Lambdaは、FSがRuby on Railsを利用している観点から「Ruby 2.5」を利用しています。
LINE APIとのやりとりにはLINE Messaging API SDK for Rubyを利用しています。

github.com

以下は実装のイメージを掴んでいただくための概略したコードです。
それぞれpost_handlerがLambdaのハンドラー関数になります。

webhook.rb

require 'json'
require 'line/bot'
require 'net/https'
require 'uri'

# LINEにメッセージがあった場合にwebhookから呼び出される関数
def post_handler(event:, context:)
  signature = event["headers"].fetch("X-Line-Signature")
  body = event["body"]

  raise "LINEの署名が不正です" unless client.validate_signature(body, signature)

  events = client.parse_events_from(body)
  events.each do |event|
    case event
    when Line::Bot::Event::Message
      case event.type
      when Line::Bot::Event::MessageType::Text
        line_user_id = event["source"]["userId"]
        # 初めて連携をする場合にFSから発行した連携コードをメッセージしてもらう
        if line_code?(event.message["text"])
          line_code = event.message["text"]
          # FS側に連携コードとLINE UserIDを渡して、FSの生徒と保護者のLINEを紐づける
          connect(line_code, line_user_id, reply_token: event["replyToken"])
        else
          # LINEメッセージの受信。保護者から来たメッセージをFSに渡し塾が確認できるようにする
          message = event.message["text"]
          send_fs_message(message, line_user_id)
        end
    when Line::Bot::Event::Unfollow
      # FS公式アカウントをブロックした場合
      disconnect(event["source"]["userId"])
    when Line::Bot::Event::Postback
      # FlexMessageを利用して場合に利用
      ...
    end
  end

  { statusCode: 200, body: JSON.generate('ok') }
rescue => e
  puts e, e.backtrace
  { statusCode: 400, body: JSON.generate("Bad Request") }
end

def client
  @client ||= Line::Bot::Client.new do |config|
    config.channel_secret = LINE_CHANNEL_SECRET
    config.channel_token = LINE_CHANNEL_TOKEN
  end
end

def connect(line_code, line_user_id, reply_token:)
  res = post(URI.join(FS_URL, CONNECT_PATH), {
    code: line_code,
    line_user_id: line_user_id
  })
  case res.code
  when "200"
    send_line_message("#{student_name}さん」と連携しました!", reply_token: reply_token)
  when "404"
  ...
  end
end

# FSにメッセージを送信
def send_fs_message(message, line_user_id)
  post(URI.join(FS_URL, MESSAGE_PATH), {
    line_user_id: line_user_id,
    message: message,
  })
end

# LINEにメッセージを送信
def send_line_message(message, reply_token:)
  client.reply_message(reply_token, {
    type: "text",
    text: message
  })
end

messages.rb

require 'json'
require 'line/bot'

# FSからのメッセージ受けてをLINEへ送信する関数
def post_handler(event:, context:)
  body = JSON.parse(event["body"])

  # メッセージとLINE UserIDを取得
  message = body["message"]
  line_user_id = body["line_user_id"]

  # LINEにメッセージを送信
  result = client.push_message(line_user_id, message)
  if result.all? { |res| res.code.start_with?('20') }
    { statusCode: 200, body: JSON.generate('success') }
  else
    response_body = result.map { |res| { code: res.code, body: res.body } } 
    { statusCode: 400, body: JSON.generate(response_body) }
  end
end

def client
  # webhook.rb と同じ 
end

最後に

スタディプラスでは、前例がなくても要件を実現するためには新しいサービスやツールを積極的に取り入れています。
ForSchool事業部でLambdaを採用することは今回が初めてでしたが、大きな問題なく開発・運用ができています。
今後も外部との連携やシステムを疎結合にしていく際に、この事例を参考に必要なサービスを利用していこうと考えています。

LINEでのやりとりのキャプチャー f:id:yo-shimada:20200219144120j:plain

redux-thunkを使っているプロジェクトでのAPIリクエストの競合をAbortControllerで素朴に解決する

こんにちは。ForSchool事業部の石上です。今年の抱負はラーメンを月2食に抑えることです。今の所はなんとか達成できております。

さて今回は、Studyplus for School(以下、社内での呼び方でFSと書きます)のフロントエンドで、どうやってAPIリクエストの競合を回避したかという話について書きます。

背景

FSのフロントエンドには、非同期の処理をするためにredux-thunkを使っています。

Reduxで非同期処理といえばredux-sagaとredux-thunkどっちを使うのか、というのがよく話題に上がると思います。FSでのredux-thunkの採用理由は単純で、使い方をすぐ理解できるからでした。結果としてactionに非同期処理が入ってくることによる苦しみを味わうことになったのですが、その話はまた別でしたいと思います(今回の話もその一部です)。

FSのフロントエンドはシングルページアプリケーション(以下、SPA)です。HTMLを毎回ダウンロードするのではなく、必要なデータを必要なときにAPIから取得して、画面の特定の部分を更新します。

そのため何も考えずに実装をすると、うっかり間違った画面を表示することになります。まずはその問題について簡単に、なるべく具体的な例で書いていきます。

検索状態に対して画面に表示される結果が合わなくなる可能性

FSには、生徒一覧を表示する画面があります。この画面はとても一般的な機能を持っていて、検索条件を指定すると画面が更新されて、それにマッチする生徒が表示されます。

SPAでなければ、検索条件のクエリパラメータをもとにSQLで生徒一覧を取得、それをHTMLに埋め込んで表示という流れになるかと思います。

SPAの場合は、検索条件のクエリパラメータをつけたAPIのURLへリクエストを投げ、その結果を画面に表示します。基本的にはSPAでない場合と変わりはないですね。

ただ、気をつける必要があるのはその結果の反映順序です。APIへのリクエストとレスポンスは、工夫をせずに行うと、リクエストした順番とは違う順番でレスポンスを処理する可能性があります。

今回の例で考えてみます。高校生のタグをつけられた生徒を取得するリクエストの直後に、中学生のタグをつけられた生徒を取得するリクエストをしたとします。工夫をせずにただリクエストを投げた場合、選択したタグは高校生なのに表示されるのは中学生の生徒一覧、ということが起きうるのです。

検索条件と結果がちぐはぐになってしまった場合のイメージ
検索条件と結果がちぐはぐになってしまった場合のイメージ

解決方法

うちの場合、AbortControllerというブラウザの機能を利用してこの問題を回避しています1。Abortとは中断という意味の英単語なので、中断制御するやつという感じですね。機能もまさにその名のとおりです。

使い方は簡単で、このMDNのリンクに書かれている例の通りです。これにコメントを書き加えると以下のような感じです。

// AbortControllerを生成
var controller = new AbortController();
var signal = controller.signal;
var downloadBtn = document.querySelector('.download');
var abortBtn = document.querySelector('.abort');
downloadBtn.addEventListener('click', fetchVideo);
// 中断ボタンをクリックすると
abortBtn.addEventListener('click', function() {
  // リクエストを中断する
  controller.abort();
  console.log('Download aborted');
});
function fetchVideo() {
  ...
  // fetchの引数にAbortControllerのsignalを指定
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = 'Download error: ' + e.message;
  })
}

これをredux-thunkの中で使うために、以下のような実装にしました。

  1. APIリクエストの処理を担当するクラスをつくる
  2. そのクラスに、AbortControllerも管理させる
  3. interruptGetというメソッドを生やして、そのメソッドでAPIを叩いたときは、競合するリクエストを中断してからリクエストを投げるようにする
  4. AbortErrorはキャッチして無視する(エラー表示などはしないようにしておく)
client.interruptGet('/api/hoge')
  .then(res => {
    dispatch(getHogeSuccess(res))
  })
  .catch(err => {
    if (err.name === 'AbortError') {
      return
    }
    dispatch(getHogeError(err))
  })

ライブラリを入れず素朴に実装したつもりが、初見の人にはやや実装がわかりにくくなってしまった感もあります。ただ、これを利用したリクエストの挙動をブラウザで見てみると、やっていることはわかりやすいはずです。以下は検索条件のタグを2つ指定している状態から、ががっと2回のクリックでタグを外した様子です。下に見えているのがChromeのNetworkタブで、ここに発生したAPIリクエストが表示されています。

1回目のクリックで生徒APIへのリクエストを投げようとしますが、すぐ次のクリックによってそれが中断され、Statusがcanceledになっています。上記したinterruptGetでAPIを叩くと、必ずそれ以前の同APIへのリクエストをキャンセルするようになっているため、画面に反映されるのは最後に投げたリクエストのレスポンスとなります。

その他のアプローチ

以下のようなアプローチもあるかと思います。

thunkの中で状態を見て、1つ前のリクエストの処理が完全に終わるまで次のリクエストを投げないようにする

このやり方は、Redux作者のDan Abramov氏のスクリーンキャストで紹介されています。

実はこのスクリーンキャストはこの記事を書いているときに知りまして、観てみたらAbortControllerをつかった実装よりもよさそうだと感じました。今回紹介した画面を今後リファクタリングする際には、画面のstateの正規化をした上でこの方法を採用したいと思います。

takeLatestという関数があるらしい

redux-sagaを使っているのであれば、これが使えそうです。FSではredux-thunkを利用しているのですが、この問題のためにredux-sagaへ乗り換えるということはしませんでした。

まとめ

素朴にやってみたものの、thunk側でAbortErrorをキャッチして無視しないといけない不便さもあります。今後はそういった約束事を意識しないでも、正しい状態を保てるようなつくりへとリファクタリングしていくことが必要だと感じています。


  1. 対応ブラウザによって、polyfillが必要です。

WorkManager とViewModelの間でデータを受け渡しした話

こんにちは、モバイルクライアントグループの中島です。

今回はWorkManagerを使った非同期処理で、WorkManagerとViewModelの間でデータのやりとりを行なう方法について話したいと思います。

ここで「データのやりとり」と言っているのは、WorkManagerに処理をリクエストするViewModelとWorkManager内で実際に処理を行なうWorkerクラス間のデータ受け渡しを指します。

なお、執筆時に利用している WorkManager のバージョンは2.3.0です。

Studyplus AndroidアプリにおけるWorkManagerの導入については、下記をご参照ください。

tech.studyplus.co.jp

やりたいこと

具体的にやりたいことはこんな感じです。

  • TODO1: ViewModel -> Worker

    • Workerで行なう処理のためにパラメータを渡したい
  • TODO2: Worker -> ViewModel

    • Workerで行なった処理の結果をViewModelに返したい
class MyViewModel @Inject constructor(
    private val workManager: WorkManager
) : ViewModel() {

    fun request(data: String) {
        // TODO 1: ここでWorkerにデータを渡したい
        val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>().build()
        workManager.enqueue(request)
        // TODO 2: ここでWorkerの実行結果を処理したい
    }
}

Workerですが、Kotlin Coroutinesを使ったCoroutineWorkerが用意されていますので、Studyplus ではそれを利用しています。 CoroutineWorker の実行メソッドは suspend function のdoWork() です。

class MyEventWorker @AssistedInject constructor(
    @Assisted private val appContext: Context,
    @Assisted private val params: WorkerParameters
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        // TODO 1': ここでViewModelからのデータを扱いたい
    
        return runCatching { 
            // サーバのAPI呼び出しなど
        }.fold(
            // TODO 2': ここからViewModelへ結果を返したい
            onSuccess = { Result.success() },
            onFailure = { Result.failure() }
        )
    }
}

やり方

公式ドキュメントに倣います。

developer.android.com

Workerとのデータのやり取りにはandroidx.work.Dataクラスを用います。 このクラスはデータをMapで保持しています。

  • ViewModel -> Worker

    • WorkRequest のBuilderに用意されている setInputData(@NonNull Data inputData) で渡します
    • WorkerのinputDataから取得します
  • Worker -> ViewModel

    • Resultを返す際にDataを渡します
    • getWorkInfoByIdLiveData(request.id) メソッドを用いてWorkInfoクラスのLiveDataを取得します
const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

fun request(data: String) {
    // TODO 1: ここでWorkerにデータを渡したい -> setInputData(Data)
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    // TODO 2: ここでWorkerの実行結果を処理したい -> getWorkInfoByIdLiveData(request.id)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // 処理が終わった時に処理する場合はisFinished
        if (info.state.isFinished) {
            // info.outputDataで Data を受け取れる
            workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
        }
    }
}
override suspend fun doWork(): Result {
    // TODO 1': ここでViewModelからのデータを扱いたい -> inputData.get~~
    val requestData = inputData.getString(REQUEST_DATA_MAP_KEY)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        // TODO 2': ここからViewModelへ結果を返したい -> Result.~~(data)
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

これでデータの受け渡しを行えます。

さらにやりたいこと

Data でプリミティブな型以外を受け渡しする

Data クラスにはBuilder処理をラップした拡張関数である workDataOf が用意されています。

androidx.work.Data.kt から抜粋

/**
 * Converts a list of pairs to a [Data] object.
 *
 * If multiple pairs have the same key, the resulting map will contain the value
 * from the last of those pairs.
 *
 * Entries of the map are iterated in the order they were specified.
 */
inline fun workDataOf(vararg pairs: Pair<String, Any?>): Data {
    val dataBuilder = Data.Builder()
    for (pair in pairs) {
        dataBuilder.put(pair.first, pair.second)
    }
    return dataBuilder.build()
}

このパラメータを見る限り、 Pair<String, Any?> で一見なんでも入れられるように見えます。 なので私は最初、通常のデータクラスを入れるコードを書いたのですが実行したところクラッシュしました。 関数内で使われているput()メソッドについて、Data.Builderクラス本体の実装を追ってみます。

androidx.work.Data.java から抜粋

/**
 * Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer,
 * Long, Float, Double, String, and array versions of each of those types.
 * Invalid types throw an {@link IllegalArgumentException}.
 *
 * @param key A {@link String} key to add
 * @param value A nullable {@link Object} value to add of the valid types
 * @return The {@link Builder}
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public @NonNull Builder put(@NonNull String key, @Nullable Object value) {
    if (value == null) {
        mValues.put(key, null);
    } else {
        Class<?> valueType = value.getClass();
        if (valueType == Boolean.class
                || valueType == Byte.class
                || valueType == Integer.class
                || valueType == Long.class
                || valueType == Float.class
                || valueType == Double.class
                || valueType == String.class
                || valueType == Boolean[].class
                || valueType == Byte[].class
                || valueType == Integer[].class
                || valueType == Long[].class
                || valueType == Float[].class
                || valueType == Double[].class
                || valueType == String[].class) {
            mValues.put(key, value);
        } else if (valueType == boolean[].class) {
            mValues.put(key, convertPrimitiveBooleanArray((boolean[]) value));
        } else if (valueType == byte[].class) {
            mValues.put(key, convertPrimitiveByteArray((byte[]) value));
        } else if (valueType == int[].class) {
            mValues.put(key, convertPrimitiveIntArray((int[]) value));
        } else if (valueType == long[].class) {
            mValues.put(key, convertPrimitiveLongArray((long[]) value));
        } else if (valueType == float[].class) {
            mValues.put(key, convertPrimitiveFloatArray((float[]) value));
        } else if (valueType == double[].class) {
            mValues.put(key, convertPrimitiveDoubleArray((double[]) value));
        } else {
            throw new IllegalArgumentException(
                    String.format("Key %s has invalid type %s", key, valueType));
        }
    }
    return this;
}

Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer, Long, Float, Double, String, and array versions of each of those types. Invalid types throw an {@link IllegalArgumentException}.

Boolean, Integer, Long, Float, Double, String 及びそれらのArrayのみ受け付けていることがわかります。 ドキュメントを参照すると、こちらにも明記されていますね。

ですので、プリミティブ型以外のデータクラスなどを受け渡したい場合は、JSON文字列にしてStringで受け渡しを行ないました。 SerializablePercelable も受け付けていないのは少々意外でしたが、今後追加されたら便利そうですね。

エラーハンドリングと進捗管理をする

Workerの処理が終わった時に処理を行いたいのであれば、doWork()の返り値を受け取り、isFinished()で確認して終了時のみ処理すれば十分です。

ですが、例えばエラー時のみの処理を実装したい、処理の進捗を取得したいなどの場合もあるかと思います。 その際には直接 workInfo.state で分岐してやりましょう。

進捗処理については 2.3.0-alpha1 から機能が追加されています。

developer.android.com

doWork()内でWorkerのsetProgressAsync(Data)を呼ぶことで、任意の箇所からDataを渡すことができます。 このDataWorkInfo.State.RUNNING ステータスとともに流れてきますので、その分岐の中で受け取ります。

const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"
const val PROGRESS_DATA_MAP_KEY = "progress_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

// 進捗状態を受け取るLiveData
val workProgressLiveData = MediatorLiveData<Int>()

fun request(data: String) {
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // stateで分岐
        when (info.state) {
            WorkInfo.State.RUNNING -> {
                // 進捗処理、進捗はここで受け取る。info.outputDataではなくinfo.progress
                workProgressLiveData.value = info.progress.getInt(PROGRESS_DATA_MAP_KEY)
            },
            WorkInfo.State.SUCCEEDED -> { 
                // 成功時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.FAILED -> { 
                // エラー時時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.CANCELLED -> { 
                // 処理のキャンセル時処理(isFinishedに含まれる)
                workResultLiveData.value = "CANCELLED"
            },
            else -> {
                // ここでは説明しませんが、他にENQUEUEDとBLOCKEDがあります
            }
        }
    }
}
override suspend fun doWork(): Result {
    val requestData = inputData.keyValueMap[REQUEST_DATA_MAP_KEY] as? String

    val progressDataStart = workDataOf(
        PROGRESS_DATA_MAP_KEY to 0
    )
    setProgressAsync(progressDataStart)

    // ~~ 何かしら時間のかかる処理など
    // 50%
    val progressDataHalf = workDataOf(
        PROGRESS_DATA_MAP_KEY to 50
    )
    setProgressAsync(progressDataHalf)

    // ~~
    
    val progressDataEnd = workDataOf(
        PROGRESS_DATA_MAP_KEY to 100
    )
    setProgressAsync(progressDataEnd)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

最後に

今回は、WorkManager を使った非同期処理とのデータのやり取りについてお話しいたしました。

WorkManager は主にバックグラウンドの処理に使うものですが、結果をViewに反映したいケースなどもカバーされておりとても便利に使えるものだと思います。 今後もリリース動向にも注目していきたいですね。