Studyplus Engineering Blog

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

Elmの何が良いのか?何ができるのか?

こんにちは。ForSchool事業部の石上です。お菓子はばかうけが好きです。今日はElmの話です。

背景

Studyplus for Schoolには、Elmで実装された画面アプリケーションがあります。こういうやつです。

仕様はとても小さく、QRコード読み取る -> APIへ投げるという機能のみだったため、Elmでの実装が許されました。今回は、Elmを普段触っていないチームメンバーに「なんでElmなんて使ってるんだ...?」と思われないように、その良さを伝えておきたいと思います。

Elmの特徴

まずElmについて簡単に書いておきます。Elmの特徴は主に3つでしょう。

The Elm Architecture

The Elm Architectureを構成する要素は、ModelとViewとUpdateです。Modelはアプリケーションの状態を表すデータ構造、ViewはDOMを出力する関数、Updateはイベント1に対して状態を変更する関数です。

Redux経験があれば、あれとほぼ同じものと考えて良いと思います。個人的にReduxとTypeScriptを使う場合と比較して好きなのは、ReduxとTypeScriptだとアクションの型定義が面倒だったり工夫が必要だったりするところ、Elmでは type Msg = MyMsg Payloadのように書けて、記述しやすいのが好きです。

Runtime Errorが起きない

Elmは静的型付け言語です。基本的には2コンパイル時に不正な関数呼び出しを検出することができます。

コンパイルエラーが親切

コンパイルエラーが親切なのもElmの良いところです。たとえば、あるモジュール間で循環参照がある場合に、以下のようにわかりやすいエラーになります。

./src/Main.elm
Error: Compiler process exited with error Compilation failed
Compiling ...-- IMPORT CYCLE ----------------------------------------------------------------

Your module imports form a cycle:

    ┌─────┐
    │    Note
    │     ↓
    │    Main
    └─────┘

Learn more about why this is disallowed and how to break cycles
here:<https://elm-lang.org/0.19.1/import-cycles>

Your module imports form a cycle と言われて意味がわからなくても、この図を見れば、「ああ、MainがNoteをimportして、NoteがMainをimportしているからぐるぐるしちゃうんだな」というのがわかります。そして、必ずと言っていいほど最後に参考リンクが載っています。

SPA化

Elmに限らず、ウェブアプリケーションをSPA(シングルページアプリケーション)にするときには、リンクを少し拡張するような仕組みが必要になります。通常のHTMLのaタグであれば、リンクされたURLへ遷移する際、ドキュメント(HTMLなど)をすべてダウンロードしてブラウザに表示します。しかしSPAでは、それを行わずに、必要なリソースを必要なときに取得しつつ、画面の必要な部分を更新するというようなことをします。雑に書くと以下のようなイメージです。

<script>
const handleSPALink = (e) => {
  e.preventDefault(); // 通常の遷移をしない
  updateState(); // 状態を更新
  render(); // 表示
}
</script>
<a onclick="handleSPALink" href="/hoge">SPA Link</a>

ReactであればReact Routerが使われることが多いです。

Elmの場合、Browser.application という関数を使います。

  1. Browser.applicationの引数に以下のようにメッセージを設定
    1. haskell Broser.application { ... , onUrlRequest = UrlRequest , onUrlChange = UrlChange }
  2. a要素の関数でa要素を表示
  3. a要素をクリックすると、 UrlRequestが発行される
  4. update関数でUrlRequestをつかまえる
    1. 内部リンク(Browser.Internal)の場合:Navigation.pushUrlでURLを変更
      1. UrlChangeが発行される
      2. URLに応じたページの初期化を行う
    2. 外部リンク(Browser.External)の場合:Navigation.loadで外部URLへ遷移

なんだか面倒そうですね。しかしこれには良いところもあって、それはページ遷移してURLが変わったという変更がちゃんとTEA(The Elm Architecture)のなかに収まることです。ReactとReduxの組み合わせで同様のことをやろうとすると、またそれ用のredux middlewareを設定してあげたりする必要がありそうなので、Elmではこういうことが標準の機能として用意されているというのが安心感があります。

詳しくは:https://package.elm-lang.org/packages/elm/browser/latest/Browser#application をご覧ください。

Port

Studyplus for Schoolで実装したアプリケーションではカメラやオーディオを扱う必要があったため、Elmだけでは実装が完結しませんでした。そこで、Portという機能を使う必要がありました。

Portとは、Elmの世界とJavaScriptの世界をきれいに分けて実装する仕組みです。公式のガイドには、localStorageを扱う例が書かれています。

JavaScript側

var app = Elm.Main.init({
  node: document.getElementById('elm')
});
app.ports.cache.subscribe(function(data) {
  localStorage.setItem('cache', JSON.stringify(data));
});

Elm側

port module Main exposing (..)

import Json.Encode as E

port cache : E.Value -> Cmd msg

上記のコードは、Elm側で起こったイベントを、JavaScript側で処理しています。これを使うには、Elm側のupdate関数のなかで以下のようにCmd.batchという関数にこのcache関数の返り値を指定します。

update msg model = 
    case msg of
        Cache value ->
            (model, Cmd.batch [cache value])

実装がElmだけで完結せず、JavaScriptの依存(package.jsonのことです)がたくさん入ってしまうのは残念なところですが、作りとしてはElmとJavaScriptがしっかり分けられるのは良いことだと思います。

テスト

Elmには、https://github.com/elm オーガニゼーションで管理されている標準のライブラリのほかに、https://github.com/elm-explorations で管理されている準標準ライブラリのようなものがあります3。そこにテストライブラリもあるので、基本的にはそれが使えます。

まとめると?

ごちゃごちゃと書きましたが、まとめるとどういうところが良いのでしょう。

  • SPAをつくるために必要なものがちゃんと標準ライブラリ(あるいは準標準ライブラリ)に入っている
  • 実行時エラーが起きにくい

逆に良くないところは、以下の2つです。

  • localStorageやAudioなどブラウザのAPIを直接触れないところが多く、そういうのが必要なところはJSのコードを書かないといけない
  • 学習コスト。特に型の読み方に慣れるまで大変。

アプリケーション実装にElmを使わなくても、Elmを学ぶことで恩恵はあると思います。たとえば、Elmを学ぶ前は、サーバーから来たデータをJSON.parseして型をanyにしてしまうことに、疑問を持ったことなどありませんでした。もしこの記事を読んで興味を持たれたら、仕事で使う予定がなくても、堅牢なフロントエンドを作る仕組みを知るためにElmに触れてもらえればと思います。


  1. これはElmの世界ではMsgと呼びます。ReduxではActionと呼んだりします。ライブラリによっていろんな呼び方があってややこしいですね。

  2. ElmからJavaScriptの関数を呼び出すようなこともできるため、絶対に起きないわけではありません。

  3. オーガニゼーションの説明にはPackages that may one day be in @elm-langと書かれています

Google Play定期購入でプランを切り替えるモードの話

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

Studyplusでは3月17日に、有料会員サービスである Studyplus Pro をiOS/Android両OSでリリースしました。

info.studyplus.co.jp

Studyplus Pro の開発では、アプリ内課金について様々な要件を調査しました。 今回は、調査したものの中でも知見が少ないと感じたGoogle Play Billing Library による グレード切り替え についてお話ししたいと思います。

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

developer.android.com

Studyplus Pro の定期購入プランについて

Studyplus Proでは以下の2つのプランを用意しています。

  • 600円/月
  • 4,800円/年

ただ、大変申し訳ありませんが諸事情により、現在Androidでは年額プランには未対応です。

導入までもう少々お待ちいただければと思います。

Google Playで定期購入のプランを切り替える

以下は Billing Library において、切り替えを考慮しない定期購入でGoogleの購入画面を開く実装です。

val flowParams = BillingFlowParams.newBuilder()
        .setSkuDetails(skuDetails)
        .build()

val responseCode = billingClient.launchBillingFlow(activity, flowParams)

余談ですが、SkuDetails(Google Playにおける課金アイテムの情報を持ったクラス)は自身が通常の消費商品/定期購入商品のどちらなのかについて情報を保持しています。 そのため、消費商品の場合と定期購入の場合でここの実装に違いはありません。

本題に入ります、 Billing Library では、定期購入のアップグレード/ダウングレードがサポートされています。

developer.android.com

アプリにおいては変更点も少なく、簡単に実装できます。

val flowParams = BillingFlowParams.newBuilder()
        .setSkuDetails(newSkuDetails)
        .setOldSku(oldPurchase.sku, oldPurchase.purchaseToken) // ← Purchaseクラスは購入済みアイテムの情報をまとめたクラス
        .build()

billingClient.launchBillingFlow(activity, flowParams)

切り替える先のSkuDetailsのほか、setOldSku() で現在購入済みの購入情報をセットします。 セットするのはSku(SkuDetailsではなくIDの文字列のみ)と購入のユニーク判定に用いられるPurchaseTokenの2つです。 これらがセットされていると、Billing Libraryの方で判断して切り替えであることがわかるUIを出してくれます。

なお、公式ドキュメントでセットしているのはskuのみですが、リリースノートを見るとBilling Library のバージョン2.2.1からはPurchaseTokenを入れることが推奨されています。 以下、該当部分の実装を抜粋します。

/** @deprecated */
@Deprecated
@NonNull
public BillingFlowParams.Builder setOldSku(String var1) {
    this.zzc = var1;
    return this;
}

@NonNull
public BillingFlowParams.Builder setOldSku(String var1, String var2) {
    this.zzc = var1;
    this.zzd = var2;
    return this;
}

サーバー側については構成に依りますが、Google Playではグレード切り替え前後でPurchaseTokenという購入のユニーク判定に用いる値が変わります。 そのため、切り替え前のPurchaseTokenに紐づいた登録情報を無効にする必要があります。

更新方法としては、

の二通りという認識です。本記事の趣旨からは外れるため、今回の紹介はここまでにさせてください。

Studyplus Pro では、このアップグレード/ダウングレード機能を利用して、月額プランと年額プランの切り替えを行う予定です。

プランの切り替えモードについて

今回の本題です。 前節では setOldSku() のみ追加すればOKと言いましたが、実はそれ以外にも考慮すべきところがあります。 Billing Library ではアップグレード/ダウングレードの際に、比例配分モードというものを設定できます。

developer.android.com

val flowParams = BillingFlowParams.newBuilder()
        .setSkuDetails(skuDetails)
        .setOldSku(oldPurchase.sku, oldPurchase.purchaseToken)
        .setReplaceSkusProrationMode(replaceSkusProrationMode) // ← 比例配分モード設定
        .build()

billingClient.launchBillingFlow(activity, flowParams)

以下公式ドキュメントから抜粋します。

フラグ名 挙動
IMMEDIATE_WITH_TIME_PRORATION 切り替えは直ちに有効になり、新しい有効期限が比例配分され、ユーザーへの入金または請求が行われます。これが現在のデフォルトの動作です。
IMMEDIATE_AND_CHARGE_PRORATED_PRICE 切り替えは直ちに有効になりますが、請求期間は変わりません。残りの期間の価格に対する請求が行われます。
注: このオプションは定期購入のアップグレードでのみ利用可能です。
IMMEDIATE_WITHOUT_PRORATION 切り替えは直ちに有効になり、新しい価格が次回契約期間に請求されます。請求期間は変わりません。
DEFERRED 切り替えは次回契約期間に有効になります。

公式ドキュメントの詳細説明では、月額200円と月額300円という「同じ期間でサービス内容が異なる」ケースで比較しています。 なので、Studyplus Pro のように「長く登録してもらう代わりに割引する」といったプラン形式の場合にはどうするのかを改めて検証する必要がありました。

検証結果

実際に動かして検証した結果をまとめます。

各モードの挙動

  • IMMEDIATE_WITH_TIME_PRORATION

    • デフォルトの変更方法
    • ストア上における所有プランは即変更
    • 日割計算して残り日数を変更する
    • 変更時の支払いはなし
    • 月額->年額の場合、月額分の600円がすでに払われているので、4,800円/年で換算して1ヶ月半分が先払いされた換算になる。そのため次回更新日が1ヶ月半後となる
    • 年額->月額の場合、年額分の4,800円がすでに払われているので、600円/月で換算して8ヶ月分が先払いされた換算になる。そのため次回更新日が8ヶ月後となる
    • 次回更新日まで何回でも他方へ変更(買い直し)が可能、毎購入時に残り期間をベースに金額を計算し、所有しているプランの割合に換算して継続する
  • IMMEDIATE_AND_CHARGE_PRORATED_PRICE

    • アップグレードにしか使用できない

      • ここでいうアップグレードとは、時間単位で金額が高い方に変更することを言う
      • Studyplus Pro でいうと年額->月額
    • ストア上における所有プランは即変更

    • 日割計算して追加金額を支払う
    • 次回更新日は最初に買ったプランのもので固定されている
    • 月額->年額の場合、エラーが返ってきて購入できない

    • 年額->月額の場合、年額分の4,800円がすでに払われている。残り1年分を月額プランの金額で払い直すことになる、600円/月で換算して1年分は7,200円。なので、差額の2,400円を払って残りの期間のプランをグレードアップする換算になる

      • 例えば半年後に切り替えた場合は2400円分が余っている状態となり、600円/月で換算して残りの半年分3,600円が必要となるので、差額の1,200円を払って残りの半年をグレードアップした状態にする
    • プランごとでサービス内容に差があるときに利用すべき変更方法と思われる
      • 例として公式ドキュメントの例のように200円/月、300円/月というような形
  • IMMEDIATE_WITHOUT_PRORATION

    • ストア上における所有プランは即変更
    • 更新日は最初に買ったプランのもので固定されている
    • 変更時の支払いはなし
    • アップグレード/ダウングレードに差異はない
    • 次回更新日まで何回でも他方へ変更(買い直し)が可能、更新日時点で所有しているプランを用いて継続する
  • DEFERRED

    • ストア上における所有プランは次回更新日に変更。それまでは旧プランのみ所持の扱いで、ストア上の所有プランに新プランの追加はされない
    • 変更時の支払いはなし
    • アプリから Billing Library を通して所有プランをフェッチしても旧プランしか確認できない
    • 更新日まで、旧プランは所有済みなので購入できない、新プランは別のエラーが表示されて購入できない -> 次回更新日まで変更ができなくなる
    • アップグレード/ダウングレードに差異はない
    • iOSの期間変更挙動に最も近いと思われたが、切り替え購入した際にレシートが返ってこないためアプリからサーバに反映するタイミングがない
      • 更新日に切り替わるがそこから3日以内に購入の承認をしないと払い戻されてしまう、リアルタイムデベロッパー通知で変更取得が必要と思われる

参考:iOSの挙動

iOSのApp Storeによる定期購入では、Google Playとかなり事情が異なります。 いわゆるアップグレード/ダウングレードに当たる変更と、同一サービス内容での期間変更でそれぞれ切り替え時の挙動が一意に定義されています。 Studyplus Pro では現在後者のみをサポートしています。

https://help.apple.com/app-store-connect/#/dev75708c031help.apple.com

  • ストア上における現在有効なプランは次回更新日に変更。それまでは旧プラン新プラン両方所持の扱い
  • 更新日は最初に買ったプランのもので固定されている
  • 変更時の支払いはなし
  • そもそもアップグレード/ダウングレードという概念が分かれているので月額->年額/年額->月額に違いはない

iOSで切り替え時の挙動を任意に変えられないことから、Studyplus Pro ではAndroidでもなるべくそちらに近いモードを選択しようとなっていました。

調査結果から

iOSに一番近いと思われるDEFERREDですが、再度の切り替えができないなどの取り回しの悪さが懸念点として上がります。 そのため、Studyplus Pro では次に近いと思われるIMMEDIATE_WITHOUT_PRORATIONを利用して実装する予定です。

備考

IMMEDIATE~~の3つは変更操作直後にプランが切り替わる際に前のプラン購入は即時無効になります。 この影響で、プラン変更時の購入承認操作に失敗した場合は(3日後に)払い戻しが起こり、Google Play上では完全に未登録の扱いになります。 変更操作のみがキャンセルになるのではないので注意が必要です。

まとめ

今回は、Google Playを通したアプリ内課金による定期購入における、購入プランの切り替えモードについてお話しいたしました。

なかなか実装の機会がないものかと思いますが、Google側で様々な用意がされているんだなと実感しました。 Billing Libraryも公言されている1年に1回の更新だけでなく、ktxの追加なども細かくされていますし、今後も色々注目していきたいところです。

アプリ内課金の定期購入(サブスクリプション)を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