読者です 読者をやめる 読者になる 読者になる

僕らのデータ同期プラクティス #DroidKaigi

こんにちは。モバイルチームの中川@です。

droidkaigi.github.io

Androidエンジニアのためのカンファレンス「DroidKaigi」に登壇する機会をいただきまして、サイバーエージェントのセミナールームでお話してきました。

本エントリは、発表原稿としてスライドの元にした文章です。技術的には同一の内容になっています。

スライド

www.slideshare.net

はじめに

f:id:water-cell:20150426203711j:plain

ユーザーがどんな場所にアプリを持っていこうと、私達はそれを制止できません。自分たちのWebサービスを携帯網もWi-Fiもない場所でも使ってほしいと思ったとき、私たち開発者には何ができるでしょうか。

GmailアプリやEvernoteアプリのように、オフライン時に閲覧・作成・編集されたデータをサーバーと同期させるための仕組みが、Androidには用意されています。

ただ、サーバーと同期する際のアルゴリズムについては開発者任せです。

本エントリでは、データ同期を実装するにあたって参考にしたものや気にしたことを紹介していきます。

自社アプリにオフライン機能を実装しよう

弊社サービスについて

  • アグリノート
  • 農業生産者向けの農作業管理システム
  • 費用は4万円/年
  • Webブラウザ版、Androidアプリで提供
    iOS版は開発準備中
  • 農業版Redmineに近づいてる

f:id:water-cell:20150426153820p:plain

業務システムにはよくある話ですが、マスタデータとトランザクションデータの組み合わせにより農作業記録のデータ構造が作られています。

オフライン機能への要望の高まり

2012年3月にAndroidアプリのファーストバージョンをリリースしました。この頃は、画面を表示する時点でデータをfetchしてくる方式でしたが、しばらくすると、似たような要望が散見されるようになりました。そう、「圏外の地域でも利用したい」という要望です。

近年では人が住んでいる場所がエリア外ということは減ってきているようですが、山の中だったり、広大な北の大地のど真ん中に行けば、容易に圏外になるそうです。

f:id:water-cell:20150426153935j:plain

「現場で使うアプリ」を標榜しているのに、現場で使えないというのも辛いところです。この問題は、長いこと課題として残っていました。

また、アグリノートの導入を機に法人契約で社員にスマホを持たせている法人農家で、通信費のほうが高く付いている事例も聞こえるようになったことも気になっていました。オフライン動作ができる分には、Wi-Fi接続のみのタブレットという選択肢が生まれるので、これもまた大きなモチベーションになりました。

現場では電波が無くてもデータの保存ができて、電波がある場所に戻ってきたときにデータを同期できる。2013年の終わり頃からアプリの全面リニューアルを始めたタイミングで、そんなオフライン機能の検討を始めました。

気合で同期機能を実装する

さて、自動同期の機能を実現しようとしたら、何が必要でしょうか。

  • 定期的に、または何らかのキックにより
  • バックグラウンドで通信を行い
  • アプリ内のDBを更新する

こんなところですね。

実は2013年にも仕事で同期機能を持ったAndroidアプリを作成したことがありました。当時の私がなんとか知恵を絞って考えたのが下記の構成です。

  • android.app.AlarmManager で定期的にインテントを発行
  • android.app.IntentService でバックグラウンドプロセスを作成
  • ORMLiteでDB処理

f:id:water-cell:20150426154046j:plain

実際にこの方針で実装していく中で、色んな問題が発生しました。

  • AlarmManagerがときどき消える
  • IntentServiceが連続で走って止まらない
  • 画面からの更新も混じえてマルチスレッドでDB叩く状態に突入
  • synchronized祭り
  • ネットワーク有無の検知のために割と頻繁に起動

並行処理プログラミングに強くない人間が首を突っ込んではいけない領域に足を突っ込んでいたような感じでした。

ちなみに同期アルゴリズムとしては、朝イチでログインさせてそのときに前回データを全部消して、それから最近のデータを丸々引っ張ってくるという感じだったので、同期というかただのダウンロードでした。

Androidが提供するデータ同期機能

まずはAndroid側が用意してくれている仕組みの概要や経緯についてお話します。

Android 2.0(API Level 5)で、アカウント管理の機能が導入されると同時に、データ同期のためのAPIが提供されました。

Developers can create sync adapters that provide synchronization with additional data sources.

http://developer.android.com/about/versions/android-2.0-highlights.html

もちろん、この機能は現在でも利用できます。

バックグラウンドでデータの送受信を行いたい場合、スレッドの管理や、ネットワーク有無の検知などを自分で管理するのは手間ですが、このSyncAdapterを利用することでAndroid側にそういった管理を任せられるようになるのです。

導入が面倒

便利には便利なのですが、このSyncAdapterという仕組み、なかなか導入が面倒です。結構な数のファイルを作って適切に配置・設定してあげないと、動き出してくれません。

雰囲気としては、AndroidManifest.xmlを起点として、下記のように各機能を繋いであげる必要があります。

f:id:water-cell:20150426154014j:plain

それぞれの役割としては下記のような感じです。

  • AccountAuthenticatorで認証情報を管理する処理を行う
    • バックグラウンドで動かすためのServiceも実装する
  • SyncAdapterで同期処理を実装する
    • バックグラウンドで動かすためのServiceも実装する
  • ContentProviderでデータを管理する
  • それぞれの処理をAndroidManifest.xmlで繋ぐ
    • Authority文字列やAccountType文字列を揃える

非常に面倒ですね。とはいえ、サンプルがあれば充分に把握可能なものだと思います。

しかし・・・?

ドキュメンテッドになったのが割と最近

GmailGoogleカレンダーなどはSyncAdapterの便利さを享受していると思われますが、デベロッパーの間での利用が増えてきたのは割と最近のように思われます。

調べてみると、2009年にAndroid 2.0が出てから約4年後、2013年のGoogle I/Oくらいの時期まで、リファレンスの方には断片的に使い方が書いてあったものの、使い方の全体像になる公式ドキュメントが存在しなかったらしいことが見えてきました。

Transferring Data Using Sync Adaptersインターネットアーカイブサービスで調べてみると、初出が2013年7月13日ということになっています。

f:id:water-cell:20150426181835j:plain

http://web.archive.org/web/20130601000000*/https://developer.android.com/training/sync-adapters/index.html

また、同年のやんざむ氏のGoogle I/Oレポを覗いてみると、SyncAdapterが紹介されているのが伺えます。

前述のとおり、それなりに複雑な設定をしないと動かないものですので、公式ドキュメントの登場により、ようやくデベロッパーが「どんなものか試してみようか」と取り組める下地ができたのではないでしょうか。

補足

@氏もやんざむ氏の記事と同じことを言ってるので、I/Oのときにそういう話があったのは確かみたいです。

2013〜2014にかけての資料の充実

Google I/Oでの言及や公式ドキュメントの整備を皮切りに、少しずつまとまった資料が増えてきたように思います。

時期を同じくして、2013年6月に出版された「50 Android Hacks」の中でも、SyncAdapterの使い方が紹介されました。この書籍は江川さんらにより翻訳され、2013年11月に日本語版が出版されています。

tatsu-zine.com

この後、2014年の初めにmixi-inc/AndroidTrainingの中で基礎編の中に組み込まれたりしたのを初め、Web上での情報が増えていったような気がします。

補足

AndroidTrainingのSyncAdapter項を書いた @ さんによると、社内の知見をかき集めて何とかしていたようです。

同期アルゴリズムの検討

50 Android Hacksのお陰でSyncAdapterを導入すること自体には大きな問題はありませんでしたが、その一方で、どんなアルゴリズムで同期を実現するかという思想的な部分では悩まされました。

結果的に、参考にした資料は下記の2つです。

  • 50 Android Hacksのサンプルコード
  • Evernote Synchronization via EDAM

50 Android Hacks

github.com

SyncAdapterはHack 23として紹介されています。紙面では要点を押さえた感じで紹介されていますが、サンプルコードに入っているTODOアプリがかなりしっかりしており、基本を掴むための助けになりました。

サンプルにはPython製のサーバーやブラウザ版クライアントも同梱されており、データ同期をより実践的な形で体験できます。

Evernote Synchronization via EDAM

Evernoteのクライアント・サーバー間データ授受のプロトコル "Evernote Data Access and Management" の仕様書PDFです。全15ページ。

サーバー、クライアントにそれぞれどんな要素を持たせることで "state based replication" を実現しているのかが解説されています。サーバー側にも手を入れるリソースがあれば、この資料の内容をそのまま模倣するのもアリだと思います。

なお、運営の @ さんが日本語訳を用意してくれていたりするので、こちらを読んだほうが楽かもしれません。(発表前日に見つけました)

github.com

採用した仕組み

前述の資料を参考にしながら、仕組みとして取り入れたのが、下記の要素です。

  • Full Sync, Incremental Sync
  • StatusFlag("dirty" flag)

Full Sync, Incremental Sync

f:id:water-cell:20150426182345j:plain

Evernoteからもらってきた考え方です。

Full Syncは完全同期と訳せそうです。その時手に入る情報を全部落としてきてしまう種類の同期です。差分について考えなくてもいい代わりに、データ量が多くなりがちです。

一方、Incremental Syncは逐次同期とでも訳せばよいでしょうか。クライアントがどの時点までのデータを持っているのかをサーバーに伝えることで、それ以降の差分となるデータだけを請求できます。一度の同期に関わるデータの量は少なくなりますので、定期的に同期する際にはこちらの方法のほうが有効です。

差分をもらうためのパラメータ

f:id:water-cell:20150426182421j:plain

アグリノートでは、最終同期時刻 last_fetched をアプリ内に持っています。API呼び出しのパラメータにこの時刻を載せることで、それ以降に変更のあったデータのみを受け取ることができるのです。

サーバー側の仕組みとしては、各APIに対応するテーブルのmodified_at列を見に行って、パラメータに指定した時刻以降の変更を返すようにしてもらっています。

StatusFlag ("dirty" flag)

Full SyncやIncremental Syncはどちらかというと取得するデータに主眼を置いた仕組みでしたが、こちらは送信するデータに主眼を置いています。

50 Android HacksではStatusFlag、Evernoteでは "dirty" flagと呼ばれていましたが、未同期のクライアント環境でデータに変更があったことを表すフラグという点では同じ性質のものです。

私達は50 Android Hacksに倣い、CLEAN, ADD, MOD, DELETEの4つのステータスを持つフラグを切り替えることで、各データの状態を表すことにしました。

status_flag 意味
CLEAN サーバーから受け取ったままの状態
ADD 新規に作成された, まだクライアント側にしかない
MOD サーバーから受け取ったものに変更を施した
DELETE サーバーから受け取ったものを削除した

同期の流れ

それでは実際に同期の流れを見て行きましょう。基本的には50 Android Hacksでの流れを踏襲しました。

  1. データのダウンロードを行う
    • 端末内に last_fetched が保存されていないとき = 初めて同期するときはFull Syncを行う
    • 端末内に last_fetched が保存されている時 = 既に前回データがある場合はIncremental Syncを行う
  2. サーバーで削除されていたデータをクライアントでも削除する
  3. サーバーで更新されていたデータをクライアントでも更新する
  4. クライアント側で作成(ADD)・更新(MOD)・削除(DELETE)されたデータをサーバへ送信する
  5. 送信が済んだデータのStatusFlagをCLEANにする
  6. last_fetched に1の時刻を保存する

これで同期が成立しているはずです。

競合問題

同期の理屈はできましたが、もう一つ考えないといけないことがあります。サーバーが行なったデータ操作と"dirty"なデータの間で競合があった場合です。

具体的には、以下の2つの段階で問題になります。

2. サーバーで削除されていたデータをクライアントでも削除する
3. サーバーで更新されていたデータをクライアントでも更新する

クライアント側のデータが変更されていて、なおかつサーバーからもデータを貰ってしまったとき、取れる選択肢は限られそうです。

  • 常にサーバー側が勝つ(送信しない)
  • 常にクライアント側が勝つ(サーバーからのデータを捨てる)
  • クライアント側でマージしてからサーバーへ送る(なんとか全部生かす)

せっかく手元で作ったデータですから、送信したいですよね。サーバーに勝たれるのは困ります。クライアント側のデータをそのまま送るか、サーバーから貰ったデータとなんとかマージしてから送るか、といったところになりそうです。

アグリノートアプリではクライアントが勝つことにしてあります。マージといってもどこからどこまでマージしたものか検討するコストが高そうだったからです。

なお、Evernoteで以前試したときには、マージしてから送信する方法を取っていました。EDAMにもそう書かれています。

まあそもそも、ブラウザ対ブラウザでも画面の表示タイミングや保存を押すタイミングによっては起こることですし、そのときには後勝ちになる場合が多いと思うので、この件もクライアント側が勝つのが自然なんじゃないかと思うのです。

同期パターンの確認

さて、流石にそろそろ話がごちゃごちゃしてきたので、頭の中を整理するために同期のパターンを表にまとめてみました。

f:id:water-cell:20150426182528p:plain

  • 元々のstatus_flagはADD, MOD, DELETE, CLEANのどれだったか
  • 更新されたものが降ってきたのか、削除されたものが降ってきたのか、何も降ってこなかったのか
  • 結果としてクライアント側のデータがどう更新され、サーバーにはどのデータが送られるのか

このへんについてまとめられています。チーム内での認識合わせに役立ちました。

まとめ

同期が必要になるような要件が出てきたら、この話を思い出してください。

  • mixi-inc/AndroidTrainingでSyncAdapterを勉強して
  • 50AHでSyncAdapterのサンプルを知って
  • EDAMの理屈を参考にして同期の仕組みを考える

こんな流れで乗り越えられる山があるはずです。

最後に

ウォーターセル株式会社では、地球人口100億の時代に見合う食料生産のための農業革命を一緒に引っ張っていってくれるAndroid/iOSエンジニアを探しています。

弊社事業に興味のある方は、是非ご連絡ください。

おまけ:50AHの注意点

50 Android Hacksのサンプルは読み応えがあり良い資料なので是非読むべきですし、本の内容も概ね良いのですが、SyncAdapter章に致命的な誤訳があるので気をつけましょう。

  • 誤 :23.2.2 サーバの中でデータベースを扱う
  • 原著:23.2.2 Hitting a database instead of the server
  • 直訳:23.2.2 サーバーの代わりにデータベースを叩く

原著がサンプルとして公開されているので、英語版と読み比べながら読むくらいでちょうどいいかもしれませんね。(Sample chapter5に該当の章があります)

正誤表が見つからなかったので、この場を借りて紹介しました。もし正誤表のページがあれば教えていただければ幸甚です。