コネヒト開発者ブログ

コネヒト開発者ブログ

AWS OpenSearchでの技術検証をスムーズにしたTIPS

こんにちは。インフラエンジニアの永井(shnagai)です

今回は3ヶ月ほど行っていたAWS OpenSearchの技術検証をしている中で、技術検証のスピードアップに貢献してくれたTIPSを2つご紹介出来ればと思います。

内容はざっくり下記2項目です。

  • ユーザ辞書やシノニムOpenSearchのカスタムパッケージを使って管理する
  • Reindex APIを使ったIndex変更内容の反映高速化

ユーザ辞書やシノニムOpenSearchのカスタムパッケージを使って管理する

カスタム辞書と日本語全文検索

今回、日本語の全文検索を行うための技術検証でOpenSearchを触りました。

全文検索を行うにあたっては、ユーザ辞書やシノニム、ストップワードの設定が肝になります。

例えば、OpenSearchで使える日本語の形態素解析エンジンであるkuromojiを使って文章を分割するケースを考えてみます。

ユーザ辞書を使わないプレーンなkuromojiの解析結果が下記です。

抱っこ/紐/と/朝/ごはん/を/食べる/こと

抱っこ紐が「抱っこ」と「紐」に分割されているので、このままだと「抱っこ紐」と全文検索した時にこの文章のスコアは高くなりません。(厳密に言うとsearch時にどう単語を分割して当てにいくかの話だがここでは単純なパターンを想定して話します)

「抱っこ紐」と検索したら、「抱っこ紐」の情報が一番に出てきてほしいので、ユーザ辞書で「抱っこ紐」を定義したインデックスを作ります。

tst_custom_dicというカスタム辞書を使ったインデックスのサンプル

PUT tst_custom_dic
{

    "settings": {
      "index": {
      "analysis": {
          "tokenizer": {
            "kuromoji_user_dict": {
              "type": "kuromoji_tokenizer",
              "user_dictionary_rules": [
              "抱っこ紐,抱っこ紐,ダッコヒモ,カスタム名詞"
              ]
            }
          },
          "analyzer": {
            "tst_analyzer": {
              "type": "custom",
              "tokenizer": "kuromoji_user_dict"
            }
          }
        }
      }
  }
}

user_dictionary_rules 部分でカスタム辞書を定義し、インデックスを再作成することで下記のように「抱っこ紐」を一単語として分割することが可能になります。

抱っこ紐/と/朝/ごはん/を/食べる/こと

カスタムパッケージを利用してインデックスを定義

先程説明した形は、インデックスのtokenizerの定義で辞書を管理する方法なのですが、カスタム辞書は何百、何千というワードを登録するためインデックスの定義で辞書を管理すると本質的ではない部分で定義が冗長になり可読性や保守性が著しく下がってしまいます。

このような課題を解決するために、OpenSearchではs3に保存したカスタム辞書ファイルを使える機能がカスタムパッケージという名前で提供されています。 シノニムとストップワードも同じ方法で外部ファイル化が可能です。

詳細な設定方法は、下記の公式ブログで詳しく解説されているのでこちらをご覧ください。

https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/custom-packages.html

カスタムパッケージを使う際のインデックスの定義は下記のようになります。

PUT tst_custom_dic
{

    "settings": {
      "index": {
      "analysis": {
          "tokenizer": {
            "kuromoji_user_dict": {
              "type": "kuromoji_tokenizer",
              "user_dictionary": "analyzers/F72444002"
            }
          },
          "analyzer": {
            "tst_analyzer": {
              "type": "custom",
              "tokenizer": "kuromoji_user_dict"
            }
          }
        }
      }
  }
}

tokenizer内の user_dictionaryanalyzers/パッケージID を指定することで利用可能になります。

f:id:nagais:20220318091812p:plain

カスタムパッケージを使うことで、辞書更新が下記のようなフローで可能になります。

  1. カスタム辞書を手元のエディタで追加
  2. s3にファイルをアップロード
  3. OpenSearchのカスタムパッケージ機能で2のファイルをインポート
    1. 新しいversionが付与される
  4. 辞書更新をしたいOpenSearchクラスタで「更新を適用」するとパッケージが最新になる(5分くらいかかるがその間は前のverを使える)
    1. 同じカスタムパッケージを利用していても、クラスタ単位で適用タイミングをずらせるので、stgで先にカスタム辞書を更新して動作確認してから、本番にも適用するというようなフローを組むことも出来ます。
  5. インデックスを更新(Reindexもしくは作り直し)することで新たなカスタム辞書でトークン分割されたインデックスを利用可能に
    1. Reindexについては、この後紹介します
  6. エイリアス更新するかインデックス名を変えて参照元からの参照先を変えるか
    1. この辺はまだ設計途上

今考えているデプロイパターンだと、

2のs3アップロードをGitHubマージトリガで動かして、3以降はs3のputトリガでStepFunctionsを動かすも良し、CIツールでAWS SDK使ったフロー組むのも良しという形で自由度高く組めると考えています。

まだ、その部分の検証はしていないので手を動かしながら最適解を探していきたいと思います。

最後に、もっとこうなるとうれしいと思った部分を一つだけ

  • パッケージの更新時にバリデーション走ると尚うれしい
    • 現状だと辞書の内容の間違いに気づくのは、openSearchのインデックスを更新するタイミングでのエラーで気づく
    • 只、自動適用考える時にCI側でバリデーションするのがフローとしては健全と思っている(この部分はまだ未検証)

Reindex APIを使ったIndex変更内容の反映高速化

OpenSearch(Elasticsearchでも一緒)では辞書の更新はもちろん、インデックス自体の定義を更新するには、インデックス自体を新規で作り直す必要があります。

これはインデックスの作成時に、定義に基づいてトークン化が行われるからです。

検証中は特に、インデックスの定義を更新して、データがどう変わるかを見るというオペレーションが頻発します。

ストレートにこのインデックス更新を行うと、下記手順を繰り返します。

  • 定義の更新
  • 元のインデックスの削除 DELETE Index
  • インデックスの新規作成 PUT Index ~BodyにJSONの定義
  • 新規のインデックスにデータを投入

あまりにも非効率だなと思い、色々と調べるとelastic社の公式ドキュメントにReindexというAPIが紹介されていることを発見しました。

https://www.elastic.co/guide/en/elasticsearch/reference/7.6/docs-reindex.html

下記のような簡単なAPIを叩くことで、インデックスのコピーが出来ることを発見しました。

量により処理時間はマチマチですがbulkでデータ投入するよりは圧倒的に早いです。

POST _reindex
{
  "source": {
    "index": "元のインデックス名"
  },
  "dest": {
    "index": "新しいインデックス名"
  }
}

このReindexを使うようになったことで技術検証のスピードは圧倒的に早くなりました。(始めから気づけよという話ではあるのですが。。)

Reindexを使った本格的な運用について、二三歩先に進んだ事例も下記のブログで紹介されています。 https://tech.legalforce.co.jp/entry/2021/12/21/190129

今回はOpenSearch(Elasticsearch)の運用に役立ちそうなTIPSを2つ紹介しました。

次回はもう少し踏み込んだ内容の日本語の形態素解析でハマったことを中心に書こうと思います。

コネヒトではサービスの信頼性向上をミッションに幅広い領域をカバーしながらエンジニアリングの力でサービスをよりよくしていけるエンジニアを募集しています。 少しでも興味もたれた方は、是非気軽にお話出来るとうれしいです。

サービスファーストな思考ができるインフラエンジニア募集! | コネヒト株式会社

iOS/Android と WebView でデータを連携する仕組みを作りました

はじめに

こんにちは! フロントエンドエンジニアの もりや です。

今回は、ママリアプリ内で iOS/Android と WebView 間でデータを連携する仕組みを作った事例を紹介します。 2021年6月頃に実装してリリースし、現在(2022年3月)も問題なく使えています。

データの連携を使いたい場面

ママリの場合、例えば以下のような場面で使っています。

  • 【WebView → iOS/Android の例】
    • WebView で作った入力画面で編集中の時に、閉じるボタンを押した場合は iOS/Android 側で確認ダイアログを出す
  • 【iOS/Android → WebView の例】
    • iOS/Android 側で処理を行った後で、WebView 側で何らかのアクションを行いたい場合

それまではその場その場で対応していましたが、これらを共通で便利に扱うための仕組みをそろそろ作りたいね、という話がでてきたので実装をしました。

JavaScript (TypeScript) での実装方法

WebView → iOS/Android の連携

この場合は window.mamariq.state という名前空間を用意して、iOS/Android からそこを参照してもらう形にしました。

window.mamariq = {
  state: {
    PAGE: {       // ページごとに名前空間を作る
      KEY: VALUE, // 参照してもらいたい値を入れる
    }
  }
}

状態が変わったら、その値を更新していきます。 React の場合は、以下のように useEffect で更新するようにする実装が多いです。

useEffect(() => {
  window.mamariq.PAGE.KEY = value
}, [value])

あとは iOS/Android から必要な時にセットされている値を見にいけばOKです。

(iOS/Android での実装方法は「iOS/Android からの呼び出し方」の章を参照してください)

補足1: WebView からプッシュしたい場合

上記の方法は、状態を参照して処理するタイミングが iOS/Android 側で決めるものなので、すぐに iOS/Android へ状態を伝えたい場合には使えません。 そういった用途は(ページ遷移を伴わない)専用のディープリンクを作って対応しています。

補足2: iOS の一般的なやり方

ちなみに iOS でプッシュする場合あれば、以下のやり方が一般的だそうです(yanamura からお聞きしました)

【Swift】WKWebViewでJavaScriptのコールバックを受けつける(WKUserContentControllerの使い方)

ただ、このやり方だと iOS と Android で方式を変えないといけないので、共通で使えるやり方を考えました。

iOS/Android → WebView の連携

この場合は window.mamariq.action という関数を用意しておき、iOS/Android から呼び出してもらう形にしました。 また、UI側では必要なシーンでリスナーを登録しておき、イベントの内容に応じて処理を登録する、という形にしています。

ざっくりと以下のような構造になっています。

+-----------------------+
|     iOS/Android       |
+-----------------------+
           |
           | イベント発行
           v
+-----------------------+
| window.mamariq.action |
+-----------------------+
           |
           | イベント発行
           v
+-----------------------+
|       listeners       |
+-----------------------+
          ^  |
 登録/削除 |  | イベント発行
          |  v
+-----------------------+
|        UI (React)     |
+-----------------------+

リスナーの管理・登録・削除

リスナーは以下のような型定義になっています。 type というイベントを識別するキーと、必要な場合は(iOS/Android 側で)payload に情報を詰めて呼び出してくれます。

// リスナーに渡されるイベントの型定義
export interface MamariqBridgeEvent {
  type: string
  payload: ObjectType
}

// リスナーの型定義
type MamariqBridgeEventListener = (event: MamariqBridgeEvent) => void

リスナーを管理する配列と、それに追加・削除をするための関数を用意しておきます。

// リスナーを管理する配列
let listeners: MamariqBridgeEventListener[] = []

// リスナーを削除する関数
export const removeMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
  listeners = listeners.filter((_listene) => _listener !== listener)
}

// リスナーを追加する関数
export const addMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
  removeMamariqEventListener(listener) // 重複登録を避けるため、念の為一度削除する(通常は何も起こらない)
  listeners.push(listener)
}

window.mamariq.action

iOS/Android からイベントを受け取るための関数を定義しておきます。

window.mamariq = {
  action: (event: unknown): void => {
    const type = `${checkObject(event).type ?? '(unknown)'}`
    const payload = checkObject(checkObject(event).payload) ?? {}
    listeners.forEach((listener) => listener({ type, payload }))
  }
}

checkObject はオブジェクト型であるかを確認して、違う型であっても必ずオブジェクト型で返してくれる関数です。

type ObjectType = { [key: string]: unknown }
const checkObject = (target: unknown): ObjectType => {
  if (typeof target === 'object' && target != null && !Array.isArray(target)) {
    return target as ObjectType
  }
  return {}
}

React からのリスナーの登録・削除

実際に使用する側では、useEffect を使ってこういう感じで実装してます。

useEffect(() => {
  const receiveMamariqBridgeEvent = (event: MamariqBridgeEvent): void => {
    switch (event.type) {
      case 'EVENT_TYPE':
        // do something
        break
    }
  }
  addMamariqEventListener(receiveMamariqBridgeEvent)
  return () => removeMamariqEventListener(receiveMamariqBridgeEvent)
}, [])

そのコンテキストで必要なイベントのみハンドリングするようにしておくことで、リスナーを複数登録したり新しいイベントを追加した場合でも問題が起きにくくしています。

この実装のメリット

iOS/Android で同じ形でできることが一つのメリットかな、と思います。

また DevTools のコンソールを使って、実機を繋がなくてもブラウザ単体で動作確認ができるのも一つのメリットだと思います。 (他の方法はあまり知りませんので、推測です)

// 現状のステートを確認できる
console.log(window.mamariq.state.PAGE.KEY)

// iOS/Android からイベントが来た場合の動作を確認できる
window.mamariq.action({ event: "EVENT_TYPE"})

ハマったところ

iOS で boolean が数値扱いされる

iOS の場合、boolean値 (true, false) が何故か 0, 1 の数値として取得できてしまうとのことでした。 その原因はわかりません・・・。 (知っている方いましたらコメントいただけると嬉しいです)

今回は、それぞれ文字列 ("true", "false") とすることで対応しました。

iOS/Android からの呼び出し方

yanamuratommykw にご協力頂き、それぞれの OS での実装箇所を抜粋しました。

iOS での呼び出し方

状態の読み取り

window.mamariq.xxx のデータを以下のように取得します。

webView
    .evaluateJavaScript(
        "window.mamariq.xxx"
    ) { [weak self] result, _ in
        if let result = result as? String, result == "true" {
            // do something
        } else {
            // do something
        }
    }

イベントの発行

window.mamariq.action() で以下のようにイベントを発行します。

let params: [String: Any] = [
    "type": "xxx",
    "payload": [
        "yyy": "zzz"
    ],
]
do {
    let data = try JSONSerialization.data(withJSONObject: params, options: [])
    guard let stringValue = String(data: data, encoding: .utf8) else {
        assertionFailure()
        return
    }
    contentViewController.webView.evaluateJavaScript(
        "window.mamariq.action(\(stringValue))"
    ) { _, _ in }
} catch {
    // do something
}

Android での呼び出し方

状態の読み取り

window.mamariq.xxx のデータを以下のように取得します。

// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.xxx") { result ->
    if (result == "true") {
        // do something
    } else {
        // do something
    }
}

イベントの発行

window.mamariq.action() で以下のようにイベントを発行します。

// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.action({type:'xxx'})") {}

おわりに

既に実装して8ヶ月以上が経ち、本番環境でも使っていますが、現状では特に問題なく使えています。 やっていることがシンプルなので、あまり不具合が起きにくいというのもあるかもしれません。

この仕組みを作っておいたおかげで、iOS/Android と WebView でデータを連携する時に実装方法に迷うことがなくなり、実装に集中できるようになりました。 最初が少し面倒ですが、早めにやっておくとコスト的にペイできたな〜、と思っています。

PR

コネヒトではエンジニアを募集しています!

hrmos.co

1年間プロダクトゴールを運用していく中で行った3つの工夫

こんにちは。バックエンドエンジニアのTOCです。

弊社ではミッションごとにいくつかチームに分かれており、それぞれのチームでスクラム開発を行なっています。 (弊社開発体制についてはこちらの記事に詳しく記載があります) 今回は僕が所属しているチームで去年から運用しているプロダクトゴールについて、運用するにあたって、どういった工夫をしているのかを書きたいと思います。

※当エントリーでは「プロダクトゴールとは何か」という点については言及しませんが、同じチームの方が共有してくれたブログにプロダクトゴールの例が記されているので、もしよければ参考にしていただければと思います。

Product Goal & Sprint Goals – A Simple Example


目次


プロダクトゴールを運用するようになった背景

僕が所属するチームでは、スクラムガイドが改訂になったタイミングでスクラムガイドの理解を深める会を行いました。この会ではスクラムガイド2020解説ビデオをみんなで鑑賞した後に、それぞれが感想や疑問に思ったことを出し合い議論することを行いました。

(一人で黙々と読んでも理解が深まりづらい僕にとって、こういった場があるのは非常にありがたいです🙌 )

この会の中で、プロダクトゴールってなんなの?という疑問が議論の中心になり、チームで深ぼってみる価値がありそうだ、ということで継続してプロダクトゴールについて考えようということになりました。

何度かプロダクトゴールについて話し合い、チームとしてプロダクトゴールを実際に作って試してみよう!ということで自分たちなりに解釈をし、作成したプロダクトゴールを運用してみることに決定しました。

プロダクトゴールを運用するために行なってる3つの工夫

運用することが決まってから、半期ごとにプロダクトゴールを設定し、日々開発を行なっているのですが、運用する上で行なっている工夫について3つご紹介したいと思います。

1. 日常的に振り返る

プロダクトゴールを設定したはいいものの、ただ設定しただけでは自分達って何を目指しているんだっけ?というような状態になり、目の前のことに追われてしまいかねません。 そこで、自分達が達成したいゴールを意識しながら開発ができるように日常的にプロダクトゴールを目にする機会を作るようにしています。

具体的にはデイリースクラムやレトロスペクティブなど、日々行なっているスクラムイベントのタイムラインにプロダクトゴールを確認する時間を設けるようにしています。

例えばデイリースクラム時は先日リリースした機能がプロダクトゴールにどう寄与しているのかをPdMから共有してもらったりしています。 また、デイリースクラムの進行表にはプロダクトゴールを一番上に書いてるので、日々目に入ってプロダクトゴールがチームの合言葉みたいな感じになりました💪

ちょっとした工夫で盛り上がりながらチームで運用できているので、継続して振り返ることができています!

2. 定期的に振り返る

日常的にプロダクトゴールの振り返りを行なっていますが、時には深い議論をしたり、「本当に達成できるのか」「達成するためにできることはないのか」といったちょっとした時間ではできない議論をしたくなったりします。 なので月に一回、「プロダクトゴールの現状確認と振り返り」という会を1時間半かけて行なっています。 この会はモデレーターをチームで交代制にしていて、それぞれが考えた流れで進行していきます。モデレーターが毎回変わるので、今までにいろんな会が行われました!

  • プロダクトゴールを眺めてKPTをやってみる
  • プロダクトゴールの達成ができそうかをグラフ化してみる
  • プロダクトゴール達成に向けて勢いづけるためにウィンセッションしてみる
  • チームメンバーが考えたワークを行なって、日々の仕事とプロダクトゴールの関係性を考えてみる

f:id:toc8:20220311112746p:plain
達成できそうかの表明や、ウィンセッションの様子

メンバーの個性が出るので、今回はどんな感じなんだろうと毎月ワクワクしています!

実際に運用してみると、計画通りにいかないこともあるので、一回立ち止まって考える機会を設けると日々の変化に対応した運用ができるかなと思っています。

3. 達成を諦めないアイデア会

2.でも記載したように、プロダクトゴール達成のための計画を遂行していく中で、やってみて初めてわかることだったり、思うようにいかなかったりすることはあるかと思います。結果を見た上で、もっとこういうことできるんじゃない?というような具体的な施策案を思いついたとしても、あまりチームで案出しをして話し合える機会って意外とない、という意見があったので、プロダクトゴールを達成するためのアイデア出し会を最近行いました。 やり方としてはアイデアをブレストしてからインパクトと工数の2軸でマッピングをして、それを元にPdMの方がブラッシュアップした施策案リストをNotionに作成してくれます。

f:id:toc8:20220310233927p:plain
出てきたアイデアをマッピング
f:id:toc8:20220311094341p:plain
施策案リスト

実際に行ってみると、プロダクトゴール設定時には出てこなかったアイデアだったり、最近リリースした機能を応用して少ない工数でできそうなアイデアが出てきて、非常に有用な会になりました。

チーム内でも、こういった機会は定期的にあっても良さそう、という話になり、隔週くらいのペースで時間をとってみることをトライしています。 この取り組みは最近始めたばかりなので、まだ試行錯誤中ですが、ブラッシュアップしていきながら、より有用なものにしていきたいと思っています!

おわりに

以上がプロダクトゴールを運用するにあたってチーム内で行なっている工夫になります。 日々運用する中で、チーム内で「これってこのままでいいんだっけ?」「もっと良くできるんじゃない?」といったアイデアが出てくるのが素敵だなぁと思いながら、運用方法をブラッシュアップしています! 今回紹介した内容が、何かしら日々の開発に役立つようなことがあれば幸いです。

コネヒトでは、各チームで様々な工夫を行い日々スクラム開発を進めています。 今回のような活動に少しでも興味をもたれた方は、ぜひ一度お話させてもらえるとうれしいです。

hrmos.co

Android版ママリアプリのリファクタ事情 ~パッケージ構成編~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

前回のエントリーから、髪の毛はアップデートされておりません。そろそろ髪の毛切りに行ってさっぱりと4月を迎えたいと思っています!

さて今回は、パッケージ構成のリファクタリングについて紹介いたします。

初めに

コネヒト社で開発しているママリ Android 版は、開発が始まってから 5 年以上経過しました。

開発当初からの歴史の中で、さまざまなコードを継ぎ足してきたママリ Android 版は、いくつもの改善ポイントを抱えています。この記事では、ようやくメスを入れられた 「目的のコードにたどり着くまでに時間がかかる」 という問題への取り組みを紹介します。

結論

まず、問題へ取り組んだ結果を紹介します。

対応としては、パッケージの構成を変更しました。 以下の画像は、ママリ Android 版の今までのパッケージ構成です。

f:id:katsutomu0124:20220311112336p:plain

そして、改善後のパッケージ構成がこちらです。

f:id:katsutomu0124:20220311112405p:plain

なぜ問題が発生してたか

「目的のコードにたどり着くまでに時間がかかる」 という問題が起こっていた理由を考察したところ、「ある機能を実現しているコードがまとまっていないから」という話になりました。

普段のプロダクト開発では、新規機能の開発もありますが、すでに実装している機能に対しての改修も頻繁に行われています。

その機能を実現している既存のコードが、複数のパッケージに分散している、同じロジックなのに複数箇所に分散している、などまとまっていない状態になっていた場合、まず分散している箇所の把握から始める必要があり、その知識があった上で改修を行う必要があります。

日々開発している我々が「この機能のコードがどこにあるのか」というのを常に覚えられているか、というと、人によってはなかなか難しいことと感じることもあります。思い出す時間や探す時間が少しばかり必要になることもあり、そこにパワーを割けなくてもよいならそれに越したことはないですね。

改修内容

コードをまとめる、にしてもいろいろな観点がありますが、まずパッケージを見直すことを最初の一手としてメスを入れました。

今までのパッケージ構成は以下の通りでした。

f:id:katsutomu0124:20220311112336p:plain

この構成で発生していた問題は、例えばある機能を構築するクラス FooActivity FooFragment FooAdapter FooViewModel があった場合、パッケージレベルで分散しているので、これらのクラスを探すことに無駄なパワーを割く必要がありました。

- .activity
  - FooActivity
- .adapter
  - FooAdapter

改修後のパッケージは以下の通りです。

f:id:katsutomu0124:20220311112405p:plain f:id:katsutomu0124:20220311112501p:plain

この構成にのっとると以下のような配置になります。 こうすることで、ある画面への改修をするときに、この .foo パッケージさえ知っていれば探す手間も省けます。またパッケージの命名は、普段の業務中の会話で使われている言葉を流用することで、より直感的なコードに近くなるのも良いです。

- .foo
    - FooActivity
    - FooAdapter

おわりに

今回は、なかなか手をつけられていなかったパッケージ構成の改善について紹介させていただきました。今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

今回の改修を主導してくれた、もっさん*1に感謝します!!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

*1:業務委託で参画してくれている水元さんです

Google オプティマイズのリダイレクトテストを使ってABテストを実施する

こんにちは。 @otukutunです。 今回とあるキャンペーンサイトでGoogle オプティマイズのリダイレクトテストとCakePHPを連携して、すばやくABテストできる仕組みを構築したのでその方法について説明します。

Google オプティマイズとは

Google オプティマイズはGoogleが提供しているABテストツールで、無料で提供されています(2022/03/11時点)。Google製ということもあり、Googleアナリティクス連携がスムーズにできる使い勝手のよいツールです。Google オプティマイズには様々なテスト方法が提供されていて、A/B テストやサーバーサイドテスト、リダイレクトテストなど様々な方法(エクスペリエンス)が提供されています。

各方法の具体的な説明はこちらの公式ページに見ていただくといいですが、ざっくりと説明すると

  • A/B テストはHTMLや画像などのクライアントサイドの要素をテストできる、各種variantの振り分けはGoogleオプティマイズに任せることができる
  • サーバーサイドテストはサーバーサイド側の要素もテストできる、ただし各種variantは自前でする必要がある(サーバーサイドテストの詳細はこちら)
  • リダイレクトテストはランディングページ(以下LP)のテストができる、各種variantの振り分けはGoogleオプティマイズで行われ、ランディング後にリダイレクトされる

になっています。

サーバーサイドの変更をテストする場合は、サーバーサイドテストとリダイレクトテストが候補として上がりましたが、今回はリダイレクトテストを選択して行いました。理由としてはいくつかあるんですが

  • 今回の対象がキャンペーンサイトでLPは1つになるので、リダイレクトテストでも要件を満たせた(サーバーサイドテストである必要がなかった)
  • リダイレクトテストではVariantの比率などをwebページから操作できるので柔軟に設定できる(後から比率を変更することもできます)。サーバーサイドテストではサーバー側で実装する必要がある
  • 結果が分かった段階で、一旦成果がでたvariantに寄せることができる
  • Google Optimizeの設定が実装依存が少ないのでシンプルになる 

などの利点があり、リダイレクトテストにしました。

リダイレクトテストを実施する

リダイレクトテストと連携するために

  • LPでvariantを表すクエリストリングストリングからvariant設定する
  • variantを取得する機能を提供(切り替えのため)
  • (任意) 完了ページでURLでクエリストリングを付与する

を実装して連携できるようにしました。実装例はCakePHPですが、他の言語やフレームワークでも同じような実装でできるかと思います。

オプティマイズの設定

f:id:azuki_mihomiho:20220308192044p:plain
a

パターンでLPのvariantごとのURLを設定し、ページターゲティングを設定するだけです(目標数値などの設定は今回のテーマではないので省略します)。

CakePHPの実装

実装はシンプルでLPでクエリパラメータをみてvariantを設定して、それによって挙動を変えてあげるだけです。endページではクエリパラメータをつけることでどのvariantかをGAだけでなく広告のコンバージョンタグでも判別が容易になるようにしています。

CampaignsController.php

<?php

class CampaignsController extends AppController
{
    public function lp()
    {
        // ABテスト開始
        $this->startABTest();
        // variant毎に処理を変える
        if ($this->getABTestVariant() === 'a') {
            // オリジナルの場合
        } else if ($this->getABTestVariant() === 'b') {
            // variant Bの場合
        }
        // variantをクエリストリングに付与する
        $this->redirect('controller' => 'Campaings', 'action' => 'end', '_method' => 'GET', ['?' => ['variant' => $this->request->getSession()->read('ab_test_session')]]);
    }

    public function end()
    {
         // クエリストリングにvariantが付与される
    }

    /**
     * ABテストのタイプを設定
     */
    private function startABTest()
    {
        // 設定済みの値
        $current = $this->getRequest()->getSession()->read('ab_test_session');
        $variant = $this->request->getQuery('variant');

        if (!in_array($variant, ['a', 'b'])) {
            // クエリパラメーターなしの場合は既に設定されている値、それもない場合はaとする
            $userType = $current ?? 'a';
        }
        $this->getRequest()->getSession()->write(
            'ab_test_session',
            $userType
        );
    }
}

Viewで挙動を変えたい場合はこんな感じで、切り分ければいけます。

lp.ctp

<?php
// variantを取得
$abTestVariant = $this->request->getSession()->read('ab_test_session')
?>


<?php if ($abTestVariant === 'a'): ?>
<p>Aだよ~</p>
<?php elseif ($abTestVariant === 'b'): ?>
<p>Bだよ~</p>
<?php endif; ?>

おわりに

Google オプティマイズのリダイレクトテストと簡単な仕組みでvariantの振り分けは任せつつvariant毎の実装に集中できるようになりました。今はComponentとHelper化してさらに使いやすくなり、素早くABテストを行えるようになっています。何かの参考になれば幸いです。ではまた!

HimotokiからSwift.Decodableに徐々に移行させる

こんにちは!コネヒトでiOSエンジニアをやっていますyanamuraです。

コネヒトではAPIで取得したJSONをDecodeするのにHimotokiを使ってきましたが、Swift4でSwift.Decodableが追加されてからは新しいコードはSwift.Decodableを使っています。共存状態はのぞましくなく全てSwift.Decodableにしたいところですが、かなりの量があるので一気に変更するのが大変で影響範囲も大きいです。そのため、徐々に移行していく方針を取りました。

ステップ1

徐々に移行するためにまずAPI通信部分のユニットテストを用意しました。古いAPI周りはテストが書かれていなかったのでちょっと大変でしたが、これをしたことで変更に凡ミスが入らないという安心感がえられ、また、置き換え作業自体もコンパイルとテストが通っていれば実機で動かして確認する必要がなく効率的に行うことができました。

ステップ2

ここからHimotoki.DecodableをSwift.Decodableに置き換えていきます。

置き換える上で問題となってくるのが、以下の例のようにDecodableなstruct間で依存が発生しているパターンです。

struct Question: Himotoki.Decodable {
    let user: User  // UserもHimotoki.Decodable
    let answers: [Answer]  // AnswerもHimotoki.Decodable
}

struct Video: Himotoki.Decodable {
    let user: User
    let videoId: Int
}

QuestionをSwift.Decodableにしようとすると、その子のUser, AnswerもSwift.Decodableにしなければならず、さらにUserがSwift.Decodableに変えると、それを使っているVideoもSwift.Decodableに変える・・というように芋づる式に変更が必要となり大変なことになります。

そこでこのような場合は、次のようなパターンで対応していきます。

case1: 親がない場合

Swift.Decodableに変更するだけでOK

case2: 親が一つしかない場合

子:Swift.Decodableに変更

親:

before

extension DailyMessageResponse: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> DailyMessageResponse {
                return try DailyMessageContent(
            dailyMessage: e <| "daily_message",
            child: e <|  "child"
        )
    }
}

after

// daily_messageだけSwift.Decodableにする
extension DailyMessageResponse: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> DailyMessageResponse {
                let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        if let json = e.rawValue as? [String: Any], let dailyMessageJSON = json["daily_message"] {
            let dailyMessage = try decoder.decode(DailyMessage.self, from: try JSONSerialization.data(withJSONObject: dailyMessageJSON, options: []))
            return try DailyMessageContent(
                dailyMessage: dailyMessage,
                child: e <|  "child"
            )
        } else {
            throw Himotoki.DecodeError.missingKeyPath("daily_message")
        }
    }
}

case3: 親が複数ある場合

子:Himotoki.Decodable, Swift.Decodable両方に準拠する

extension User: Himotoki.Decodable {
    static func decode(_ e: Extractor) throws -> User {
        return try User(
            id: e <| "id",
            name: e <| "name"
        )
    }
}

extension User: Swift.Decodable {}

親:

親はすぐに変更しなくても問題ない。

親を変更する場合は、親が1つのパターンを使って一つずつ変更する。

まとめ

これを地道に続けてると移行が完了します。

コネヒトで開発しているママリiOSアプリではようやく移行が完了し、トータルで20~30個くらいのPullRequestになりました。移行完了までかなり時間を要しましたが、コネヒトでは2週間に一日くらいの頻度で、丸一日は普段開発しているプロダクトの目標とは関係のない負債解消などの技術的なことを行うようにしていまして、これを活用してやりきることができました。

大規模な変更となりましたが、問題なく移行完了しました。1つだけリグレッションテストで不具合が見つかりヒヤリとしましたが、その不具合は唯一ユニットテストの書き漏れがあった箇所のコードで発生しておりユニットテストの重要さを改めて感じました。

コネヒトではこういった負債解消だけでなくSwiftUIの導入など技術的なチャレンジも盛り込みながら開発しています。もしご興味ありましたら一度話を聞きに来てください!

hrmos.co

Android版ママリアプリのリファクタ事情 ~ Google Play Billing Library 4導入編 ~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。前回のエントリーから、髪の毛はアップデートされておりません。

さて今回は、Android版ママリアプリの課金機能にまつわるお話をお伝えできればと思います。

はじめに

2021 年 11 月 1 日にGoogle Playのアップデートにより、アプリ内課金機能ライブラリの Billing Library で v3 以降が必須となりました。

Google Play Billing Library のバージョンのサポート終了  |  Google Play の課金システム  |  Android Developers

ママリアプリではアプリ内課金の定期購入機能で、プレミアムサービスを提供していますが、Billing Library v2に依存していたため、期日までにライブラリのアップデートをする必要がありました。 今回は、BillingLibrary v4の紹介をしつつ、ライブラリのアップデート時の課金機能のリファクタリングの事例を紹介します。

Billing Library v4の紹介

まず初めにアプリ内課金を実装するための、最新のライブラリであるBilling Library v4の使い方をを紹介をします。

接続と破棄

アプリ内課金機能を利用するには、Google Playと接続し課金処理を実行する必要があるため、まずはその準備が必要となります。

BillingClientを生成し、Google Playに接続を行います。課金アイテムの取得、購入/リストアといった課金操作は、基本的にBilingClientのメソッドを使用します。また接続を残し続けると、メモリリークが発生するため、不要になったら破棄が必要になります。

// BillingClientの生成
private val billingClient by lazy {
        BillingClient.newBuilder(context)
          .setListener(object : PurchasesUpdatedListener {
              override fun onPurchasesUpdated(result: BillingResult, list: MutableList<Purchase>?) {
                  // 課金状態が更新されると呼び出される。
              }
          })
          .enablePendingPurchases()
          .build()
}

// Google Playに接続し、課金機能の呼び出し準備をする
fun initialize() {
        billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingServiceDisconnected() {
                    // 切断が切れたら呼び出される。必要に応じて再接続処理など
        }

        override fun onBillingSetupFinished(result: BillingResult) {
                    // 初期化が完了すると呼び出される。ここが呼ばれないと課金処理が始められない。
        }
    })
}

// 不要になったら接続を切る
fun release() {
    billingClient.endConnection()
}

課金情報の取得

課金を開始する前に、現在の課金状態を確認する必要がある場合、queryPurchasesAsyncメソッドを利用して現在の課金情報を取得します。

queryPurchasesAsyncメソッドは、Google Playから現時点の課金情報をリクエストします。 契約が存在する場合、Purchaseクラスのリストが返却されるので、Purchaseクラスが提供する購読情報を参照することができます。

private fun queryPurchases() {
      billingClient.queryPurchasesAsync(
                    BillingClient.SkuType.SUBS // 課金種別の指定
            ) { result, list ->
          val responseCode = result.responseCode
          if (responseCode == BillingClient.BillingResponseCode.OK) {
                            // 成功の場合は、Purchase型がリストで返される
          } else {
                            // OK以外はエラー
          }
      }
  }

ママリアプリでは、重複課金を防ぐために、現在の課金情報を取得してバリデーションを行なっています。

課金履歴の取得

過去の課金履歴が必要な場合は、queryPurchaseHistoryAsyncメソッドを呼び出します。購入の有効期限が切れていたり、キャンセルされていても、最後に購入した購読情報が返却されます。

billingClient.queryPurchaseHistoryAsync(
        BillingClient.SkuType.SUBS
) { result, list ->
    val responseCode = result.responseCode
    if (responseCode == BillingClient.BillingResponseCode.OK) {
        // 成功の場合は、PurchaseHistoryRecordがリストで返される
    } else {
                // OK以外はエラー
    }
}

ママリアプリでは、過去の課金状態に応じて、ユーザーにお得なキャンペーンを訴求することをしています。

課金処理の実行

実際に課金を行うためには、2つの作業が必要になります。

  1. 購読に必要なSkuDetailsを取得する
  2. SkuDetailsを元に課金処理を起動する

このクラスをlaunchBillingFlowメソッドに渡すことで、課金処理が開始されます。画面操作を伴うためか、activityを渡す必要があります。

※SkuDetailsには課金アイテムの名称や価格の情報が含まれているため、ユーザーに見せるために使うことも可能です。

var skuDetails: List<SkuDetails> = emptyList()

private fun querySkuDetails() {
        val params = SkuDetailsParams.newBuilder()
            .setType(BillingClient.SkuType.SUBS)
            .setSkusList(listOf(itemType.id)) // 課金アイテムのIDリスト
            .build()
        
    billingClient.querySkuDetailsAsync(params) { result, list ->
        val responseCode = result.responseCode
        if (responseCode == BillingClient.BillingResponseCode.OK) {
            skuDetails = list // 成功するとSkuDetailsがリストで返される。このリストを元に購読処理を起動。
        } else {
        }
    }
}

private fun launchBilling() {
        val builder = BillingFlowParams.newBuilder()
        builder.setSkuDetails(skuDetails).build()
        // 購読処理にはactivityが必要
        billingClient.launchBillingFlow(activity, builder.build()) 
}

リファクタリングの方向性

冒頭で書いた通りママリアプリではv2に依存したライブラリで、定期購読の機能を実現していましたが、複数の画面や機能から購読機能を利用したいというビジネス要件や自社APIやBillingClientとの連携でステータスを管理する必要があるといった技術的な制約を実現するために、既存のコードは非常にファットな状態でした。今回はv4への置き換えと並行して課金ロジックのリファクタリングも進めていました。

これらの事情を踏まえて、最終的にBillingActivity、BillingViewModel、BillingManagerの3つのクラスに責務を分離した設計となりました。

f:id:katsutomu0124:20220304185704p:plain 処理の流れは以下の通りです。

  1. プレミアム会員登録画面で、課金ボタンをタップするとBillingActivityが起動する
    1. 半透明なActivityなため元の画面にオーバーレイされる
  2. ActivityのonCreateをトリガーにBillingViewModelがBillingManagerを呼び出しGooglePlayの課金処理を開始する
  3. BillingManagerでBillingClientのメソッドを呼び出しGoogle Playの課金処理を開始する
  4. BillingManagerが結果を元にBillingViewModelが自社APIをコール、ステータスに応じて、BillingActivityのUIを更新する

 

それぞれの役割と処理の流れをソースコードと合わせて紹介します。

BillingManager

このクラスはGooglePlayの課金機能を呼び出すことと、BillingClientの結果を元にしたステータス管理を担当しています。課金機能の初期化の成否や、Google Playの操作、課金結果の待ち受けなど複数の状態を監視する必要があるため、RxJavaのCombineLatestを利用し、複数の状態を集約してステータスを更新しています。

処理の流れは以下の通りです。

  1. BillingClientとの接続と現在の課金情報の取得を開始
  2. Activityを引渡し、GooglePlay課金の開始
  3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
// 1. BillingClientとの接続と現在の課金情報の取得を開始
fun initialize(billingType: BillingType) {
    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            val responseCode = billingResult.responseCode
            if (responseCode == BillingClient.BillingResponseCode.OK) {
                when (billingType) {
                    is BillingType.Purchase, BillingType.Restore -> {
                        querySkuDetails()
                        queryPurchases()
                    }
                }
            }
        }
    })
}

・・・・・

// 2. Activityを引渡し、GooglePlay課金の開始
fun doSubscribe(activity: AppCompatActivity, readyToPurchased: ReadyToPurchased) {
    val type = readyToPurchased.billingType
      val builder = BillingFlowParams.newBuilder()
    builder.setSubscriptionUpdateParams(
        BillingFlowParams.SubscriptionUpdateParams.newBuilder()
            .setOldSkuPurchaseToken(readyToPurchased.purchases[0].purchaseToken)
            .build()
    )
    builder.setSkuDetails(readyToPurchased.skuDetails).build()
    billingClient.launchBillingFlow(activity, builder.build())
    _purchasing.onNext(true)
}

・・・・・

// 3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
Observables.combineLatest(
    _initialized, // 課金手続き準備完了のステータスの監視
    _purchasing, // 課金手続き中ステータスの監視
    _billingResult // 課金結果の監視
) { initializeStatus, purchasing, billingResult ->
    var nextStatus = _status.value
    when (_status.value) {
                
                ・・・・・             

        is ReadyToPurchased -> {
                        nextStatus = // 一度Activityに準備完了を通して、課金手続き中ステータスに移行
        }
        is Purchasing -> {
                        nextStatus = // 課金完了後に社内APIのコールを行うReadyToRegisterのステータスに移行
        }
    }
    _status.onNext(nextStatus)
}.subscribe().addTo(compositeDisposable)

BillingViewModel

このクラスはGooglePlayの課金処理の呼び出し社内APIの登録と、その間の状態変化をActivityに伝える責務を担当しています。処理の流れは以下の通りです。

  1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する.
  2. BillingManagerのステータスを監視し、UI表示をActivityに通知する。
  3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
        billingManager.status.distinctUntilChanged().subscribe { status ->
            when (status) {
                        // 1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する
                is BillingManger.ReadyToPurchased -> { startPurchase.postValue(status) }
                        // 3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
                is BillingManger.ReadyToRegister -> { registerReceipt(status.purchasedData) }
            }

                // 2. BillingManagerのステータスを監視し、UI表示をActivitiyに通知する。
        // ※ユーザー操作ブロックのためのローディング表示
            showProgress.postValue(
                status is BillingManger.Initializing ||
                    status is BillingManger.ReadyToPurchased ||
                    status is BillingManger.ReadyToRegister
            )
        
        }.addTo(compositeDisposable)
}

 // 不要になったら解放
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
    billingManager.release()
    compositeDisposable.clear()
}

BillingActivity

このクラスはUI操作をViewModelに伝えることと、状態に応じたUI操作を担当しています。処理の流れは以下の通りです。

  1. 課金機能の画面で課金ボタンやリストアボタンのタップをトリガーに、Activityが起動する。
  2. GooglePlay課金の準備が完了したら、BillingActivity自身を渡して課金処理を開始する
  3. 課金処理が終了するまでBillingViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
// 1. 課金機能の画面で課金ボタンやリストアボタンをタップをトリガーに、Activityが起動する。
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

                // 2. GooglePlay課金の準備が完了したら、自分自身を渡して課金処理を開始する
                viewModel.startPurchase.observe(this) {
            viewModel.onSubscribe(this, it)
        }

                // 3. 課金処理が終了するまでViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
                // ローディング表示
        viewModel.showProgress.observe(this) {
            if (it) {
                binding.progress.toVisible()
            } else {
                binding.progress.toGone()
            }
        }
                // プレミアムユーザー昇格の通知
                viewModel.purchaseRegistered.observe(this) {
            Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG)
                .show()
        }
    }

抜粋した内容となりますが、以上のように、3つのクラスに分けて責務を分離し、それぞれの役割を明確にすることで、見通しの良いコードになるようにリファクタリングを進めました。大規模なリファクタリングとなったため、アップデート後の不具合が懸念されましたが、アップデート以降課金に関係したトラブルは発生せず、数ヶ月が経過したので安心しています。

※もちろん事前に全ての課金機能の動作確認は実施しています。

おわりに

今回はライブラリアップデートのきっかけに、課金機能のリファクタリングを行なった事例をお伝えいたしました。前回までに紹介したリファクタリングの方針が、課金機能のリファクタリング後に決まったので、一部既存の理想系と外れる部分もありますが、今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co