降りつぶし管理ソリューションの中核機能
降りつぶし.netは、「日本の鉄道駅」にいつ乗下車したのか、を管理するソリューションです。そして、過去の記録はWebアプリケーション(前回第3回で説明)で管理し、降りつぶし中の記録はスマホアプリ(次回以降に説明)でリアルタイム更新する、という要求仕様を実現するために必要不可欠なのが、今回説明する、Webアプリとスマホアプリの同期機能です。
同期が必要なデータは2種類、さらに特殊ケースも
Webアプリとスマホアプリのデータ同期といえば、Gmail、あるいはEvernoteやDropboxなどが思い浮かびます。これらは、ユーザの個人的なデータを、サーバ(クラウド)と複数のユーザ端末との間で同期させるものです。
しかし降りつぶし.netの場合、同期させなければならないのは、降りつぶしの記録=ユーザデータだけではありません。駅データベースの更新(駅の改廃など)=マスタデータも同期させる必要があります。
ユーザデータでは双方向の同期が必要ですが、マスタデータはサーバ側の更新をユーザ端末に同期させるのみの片方向となります。さらに、後述しますが、降りつぶし.netならではの、どちらにも分類できない特殊な同期が発生し得ます。それへの対応も必要です。
ユーザデータの同期のフロー
第2回で説明した駅データベースの、乗下車記録テーブルcompletionsには、降りつぶし管理のために必要な、ユーザID・駅コード(以上2つがキー)・乗下車日・メモ(以上2つがユーザデータ)の他に、更新日時カラムが設けられています。これが、ユーザデータの同期機能のために必要な唯一のデータです。
そして、同期のフローはシンプルです。
- スマホアプリが、スマホ内のユーザデータのうち、直近の同期開始日時以降に更新された乗下車記録データを取得、TSV形式のテキストを生成する
- スマホアプリが、直前の同期完了日時、および生成したTSVデータを、gzip圧縮してWebアプリに送信する
- Webアプリは、受信したTSVデータをパースし、各行について同じ駅コードの乗下車記録データが存在するかどうか調べる
- 存在していなければそのまま挿入する
- 存在していて、かつ受信データのほうが更新日時が新しければ、上書きする
- Webアプリは、このユーザの統計情報(前回第3回で紹介したstatisticsテーブル)を更新する
- Webアプリは、改めて、直近の同期開始日時以降の更新日時を持つ乗下車記録データを取得、SQLite用のSQL文を生成し、gzip圧縮してスマホアプリに返信する
- スマホアプリは、返信されたSQL文をトランザクション内でそのまま実行する
- スマホアプリは、すべてのSQL文が正常に実行できた場合、直近の同期開始時刻を今回の開始時刻に更新する
全体として、Webアプリ側にもスマホアプリ側にも更新タイムスタンプがあり、その最新のものを双方に反映する、というシンプルな仕組みです。また、同じユーザが複数のスマホアプリを使用している場合でも、すべてのスマホ間でデータを同期できます。
マスタデータの同期
一方のマスタデータですが、これらにも、ユーザデータと同じ仕組みは使えます。しかし、それではあまりに非効率です。マスタデータの更新には、以下の決定的な違いがあります。
- a.更新されるマスタデータはWebアプリ側でのみ管理される
- b.更新情報をWebアプリからスマホアプリに一方的に送信すればこと足りる
- c.スマホアプリ側のマスタデータは、アプリの更新を行うユーザと行わないユーザとの間で、内容が異なることがある
マスタデータは、Webアプリの管理者が一方的に更新し、それをスマホアプリに通知すればよく、双方のデータのすり合わせは不要です。
しかし、ではスマホ側のマスタデータが常に共通かと言えば、もちろんそうではありません。スマホアプリは、同期機能を使わなくても、すなわちWebアプリにユーザ登録をせずとも、乗下車記録の管理が可能でなければなりません。そのため、マスタデータが更新されるたび、アプリ自体も更新し公開しなければならないのです。そして、その更新を適用したスマホと適用していないスマホでは、マスタデータ自体が異なることになります。つまり、すべてのスマホアプリに同じ更新データを送信して済むわけではありません。
そのため、マスタデータについては、タイムスタンプではなく、バージョン番号により管理することとしました。
マスタデータ同期のフロー
マスタデータの同期は、以下のフローで実現しています。
- スマホアプリから、現在のマスタデータバージョン(SQLiteデータベースの「ユーザバージョン」番号を利用しています)を送信する
- Webアプリは、受信したマスタデータバージョンより新しいバージョンの駅データ更新情報を、updatesテーブルから取得する
- Webアプリは、updatesテーブルから取得した各レコードについて、SQLite用のSQL文を生成し、gzip圧縮してスマホアプリに返信する
- Webアプリは、最後に、SQLite用のユーザバージョンを、updatesテーブル内の最新のマスタデータバージョンに更新するSQL文を生成し、スマホアプリに返信する
- スマホアプリは、返信されたSQL文をトランザクション内でそのまま実行する
このうちの5は、ユーザデータ同期の7と完全に同一です。
実際の動作では、ユーザデータとマスタデータの同期は同時に行われ、両者のスマホ側の更新は同一トランザクション内で行われます。
データベース更新情報テーブル:updates
第2回の駅データベースの紹介時点で触れていなかったテーブル、updatesについて、ここで説明します。
データベース更新情報テーブル:updates
up_id | プライマリキー |
type | 更新種別を示す文字列 |
table_name | 更新対象のテーブル名 |
key_id | 更新対象のレコードのプライマリキー値 |
user_version | マスタデータバージョン |
update_date | 更新日時 |
memo | メモ |
「更新種別を表す文字列」は、以下のいずれかとなります。
更新種別とその意味
UPDATE | 鉄道事業者、路線、駅のいずれかの更新 |
INSERT | 鉄道事業者、路線、駅のいずれかの追加 |
DELETE | 鉄道事業者、路線、駅のいずれかの削除 |
RESTORE | 鉄道事業者、路線、駅のいずれかの復活 |
DUPLICATE | ユーザデータの複製 |
テーブル名を文字列で格納するなどスマートでない設計ですが、DUPLICATE以外の動作はシンプルです。
テーブル名で示されたテーブルを、プライマリキー値で検索し、そのデータに基づいてスマホ側で実行されるSQL文を生成しています。
なお、DELETEとRESTOREは特別な更新種別です。DELETEは鉄道事業者・路線・駅の廃止または長期休止を表し、RESTOREはいったんDELETEされた鉄道事業者・路線・駅の長期休止からの復活を表しています。しかしこれらはいずれも、UPDATEで代替可能です。両者はともに、対象テーブルのenabledカラムが変更された、という意味であり、他のカラムの更新と等価だからです。
ただし、駅の休止や復活は、ユーザデータの無効・有効に直結する重要な項目です。これは、このupdatesテーブルのメンテナーにとっての可読性を高めるための、特別な「更新内容」を表しているのです。結果としては、これらの場合は「enabledカラムのみの更新で良い」ということになるため、わずかながら返信するSQL文が短くはなるのですが。
これと同様に、memoカラムも、メンテナーだけのために用意されているもので、決して外部公開されることはないものです。
マスタデータの変更によりユーザデータが変更される、特殊ケースへの対応
一方、DUPLICATEは、降りつぶしという特殊趣味ならではの、特殊な更新を表しています。この種別はマスタデータの更新だけではなく、ユーザデータの更新をも表しているからです。
具体的に言えば「それまで1つの駅だった駅データが分裂し、2つの駅データになった」というケースです。この場合、元の駅の乗下車記録を、新しい駅にもコピーする必要があります。種別がDUPLICATEだった場合に限り、table_nameには元の駅コードが格納され、key_idには新しい駅コードが格納されます。もちろん、後者の駅コードは、予めINSERT更新種別により新設済とします。
そしてこのレコードは、スマホ側アプリの乗下車記録テーブルcompletionsにおいて、元の駅コードの記録をSELECTし、新しい駅コードに換えてINSERTする、というSQL文を生成することになります。
なおこのケース、近年で言えば、2010年末、JR東日本の八戸駅・野辺地駅・青森駅で発生しています。それぞれ、東北新幹線全通時に東北本線が青い森鉄道へと経営分離されたものです。
降りつぶし.netでは、あらゆる事業者の変更(路線の譲渡、法人の新旧分離など)について、駅コードを変更せず、事業者コードの変更と駅-路線の対応テーブルの変更により、元の駅の乗下車記録をそのまま引き継ぐ仕様としています。これは、利用者から見てほとんど変化のない新旧分離まで、過去の乗下車記録をリセットするのは実務的でない、という判断からのものです。
しかしそれでは困るのが上記3駅です。これらの駅は、JR東日本の在来線である八戸線・大湊線・奥羽本線などが接続しています。乗下車記録は駅に紐づいており、たとえば八戸駅なら、東北本線の八戸駅に乗下車しても、八戸線の八戸駅に乗下車しても、「JR東日本の八戸駅に乗下車した」という記録となります。
このことと、前述の「事業者の変更があっても乗下車記録は引き継ぐ」というのを合わせ、「JR東日本の八戸駅に乗下車した」記録をそのまま残し、これを「青い森鉄道の八戸駅にも乗下車した」として残す、という仕様を定めました。そしてこの仕様を実現するための更新種別が、DUPLICATEとなります。
いくつかの落とし穴
実は、前述のユーザデータ同期のフローには、落とし穴があります。
- A.スマホの時刻がWebアプリサーバとずれていた場合、同期が破綻する
- B.複数のアプリで同じ時刻に同じ駅のユーザデータを異なる値で更新した場合、同期が行われず、各アプリに異なる値が残ったままとなる
これらは、それこそGmailやEvernoteやDropboxでは「あってはならない」レベルの問題です。この後、それらへの対応について、考察します(実は無対処という対応なのですが……)。
なお、前述のフローには、トランザクション分離レベルにおけるPhantom readsが発生し得る部分があります。3~6の処理の間に、ユーザがWebアプリで新たな記録を挿入しコミット、その直後に7が処理された場合、その新たな記録がここに混入するのが、Phantom readsです。
しかし、降りつぶし.netのデータベーステーブルはMySQLのInnoDBです。デフォルトの分離レベルREPEATABLE READのままですが、InnoDBの場合Phantom readsが発生しないため、7においては、新たな記録は読み取られないこととなります。
一方、それはそれで問題があり、新たな記録がその同期での返信に含まれないことになってしまいます。これについては、常に「同期開始時刻」を操作の基準とすることにより解決できます。次回の同期実行時に取得されるデータは、前回の同期開始時刻以降のデータです。そして送信されなかった新たな記録は、まさに「前回の開始時刻以降」であり、スマホアプリに返信されることになります。
スマホの時刻ずれ
降りつぶし.netのスマホアプリの主たるユーザとして想定されているのは、日本の鉄道に乗下車し、その記録をスマホで管理したいと考えている人です。前回第3回のミニコラムのとおり、まじめに駅めぐりを行うことは時間との真剣勝負でもあり、そのユーザが使うスマホの時刻が大幅にずれていることはまず考えられません。
ただしそれでも、時刻が多少ずれることはあり得ます。そしてこれは、原理上は解決が困難な問題です。NTPと類似の実装を行えば、同期実行時の時刻をすり合わせることはできます。しかし、個々のユーザデータが更新されたときの時刻が、同時実行時の時刻と同じずれであるはずもなく、補正は不可能です。
しかし、数秒~多くて数分のずれが発生しているとして、その間に、複数のアプリに対して矛盾する記録を入力することは、本来の使い方からはあり得ないはずです。
以上の割り切りにより、この問題には対処しないことにしました。
同じ時刻での同時更新が反映されない
「同じ時刻」となる確率は、更新タイムスタンプの粒度によります。iOSにおいては現在時刻は倍精度浮動小数点値の秒単位で、またAndroidにおいてはミリ秒単位で取得できます。が、第2回で説明したとおり、WebアプリのMySQLでこの値を4バイト整数カラムに格納するため、全体としては秒単位の粒度しか確保できません。
ここをはじめから8バイトのミリ秒値として管理していれば、「同じ時刻」に複数のアプリで衝突するデータが入力されてしまうリスクは大幅に減少していたところです。しかしこれも、時刻ずれ問題で割り切ったとおり、アプリの性格からして、まず起こりえない状況です。なのでこちらも、秒単位の管理のままで何も対処しない、としました。
もし対応するならば、たとえば、ランキング機能などを実装する可能性があるWebサーバ側を「正」と考え、同時刻の矛盾データがあった場合はWebアプリのデータをスマホ側に上書きすれば解決できます。
同期実行のタイミング
これらの仕様上の割り切りとともに、同期の実行タイミングについても、仕様の割り切りを行っています。
ひとくちに複数の端末間でのデータの同期といっても、その実行のトリガにはいくつかのパターンがあります。設定された日時なり曜日なりに、バックグラウンドで定期的に自動実行される同期。あるいはユーザが能動的に指示することで実行される同期。その中間として、アプリ起動中にのみ定期的に自動実行される同期があります。
しかし、バックグラウンドでの定期的な自動実行は、iOSにおいて不可能なのです。
iOSでは、サードパーティアプリに許されているバックグラウンド実行は、おおまかに言えば、
- フォアグラウンドで実行されていたアプリがユーザ操作によりバックグラウンドに回された直後
- 予め設定したアラーム、またはプッシュ通知によって、画面および音などでその旨が通知され、ユーザがUI操作によりその通知に対する何らかの実行を選択した場合
の2つしかありません。つまり、ユーザの能動的な操作抜きに定期的な動作を実行することは不可能なのです。
よって、スマホアプリの同期動作は、常に「手動実行のみ」または「アプリがフォアグラウンドにある間の自動実行」となります。
「手動同期のみ」のメリット
降りつぶし.netでは、「自動実行は一切行わない」と割り切ることとしました。
また自身の話になってしまいますが(第1回で書いたとおり、そもそも自分のために開発をはじめたソリューションなのでお許しください)、筆者は旅行ブログでリアルタイムレポートを書いています。そしてそのブログ内には、ブログパーツとして、乗下車記録の表示が埋め込まれています。このとき、記事の追加に合わせて、乗下車記録を更新させたかったのです。これは手動更新でしか実現できませんし、また自動更新されると齟齬をきたすこととなってしまいます。
そしてこの割り切りにより、スマホアプリ側での同期実行中に、少なくとも同じスマホから乗下車記録を更新することができなくなりました。実はこれは、スマホアプリにとって、大変好都合なのです。
詳細は次回以降となりますが、スマホアプリ側のSQLiteでは、確立した接続は同じスレッドからしか呼び出せません。またスクロールやスワイプのたびにクエリが発生する地図アプリにおいては、そのクエリをメインUIスレッドで行うことは不可能です。そこで、データベース専用のスレッドを作成し、そのスレッドにキューを介してクエリを渡し、結果をメインスレッドで受け取る、という処理を行っています。
この処理を複数のスレッドを立てて複数走らせることは、スマホの限られたリソースの中では、できればやりたくありませんでした。それらの結果、データベースへの接続は1つとなり、アクセスはすべてシリアル化され、1つの処理の途中で次の処理を行うことができなくなりました。同期中の別操作は実質上ブロックされ、同期が完了した直後に実行されることとなります。
しかし同期実行を完全手動にすることにより、スマホの表示は同期実行用の画面となり、必然的に同期以外のデータベースアクセスが発生しないこととなったのです。
もちろん、「デメリットは承知のうえで、自動実行の可否をユーザが設定できるようにすべき」という考え方もあります。たとえば起動時やタブ(iOS)・アクティビティ(Android)の切替時のみの自動実行とし、同期中のユーザ操作はさせないことにする、あるいは同期を長らくしていない場合にアラートで手動実行を促す等々、さまざまな仕様が考えられます。今後の機能追加の課題の1つです。
次回からスマホアプリ編
降りつぶし.net最大のキモである同期機能の紹介も終わり、次回からはスマホアプリ編となります。次回は、Webアプリの次に公開した、iPhoneアプリ「i降りつぶし」の紹介です。