eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

実質2日でDatastoreをFirestore移行した話

謝罪

ごめんなさいごめんなさいごめんなさいごめんなさいごめんなさいごめんなさいごめんなさいごめんなさい。

本番環境のFirestoreをONにしてしまったのは僕です。

何が起こったのか

  • アプリから利用されるサーバーをGAE/Goで開発していた
  • GCPプロジェクトは、アプリ側とサーバー側が同一プロジェクトを使用していた
    • Service Account使ったり、そっちのほうが都合が良かったから

Datastoreを利用してデータを保存していた

  • サーバーはGoogle App Engine / Go(1.9系)を使用していた
  • データは goon ライブラリを通してDatastoreに保存していた

Firestoreの扱い

  • 開発当初から使用実績のあるDatastoreを使う予定で、必要な複数のGCPプロジェクトで Datastore を選択し、なおかつ Firestore / Datastore Mode を明示的に選択していたはずだった
  • そのうちFirestore Native ModeがDatastoreと共存できるだろう、という見込みだったが、直近では使う予定がなかった
  • そもそも、現時点で FirestoreとDatastoreを同時に利用する手段 は提供されていない。なので、どちらかを使った時点でどちらかを諦めるのだ。

圧倒的速度のFirestore展開

  • アプリ開発中、怒涛の勢いでFirestoreがBlazeしてきた
    1. Firestore 日本上陸
    2. Firestore GAになる
    3. Firestore Released <- WOW!!
  • すでにサーバーサイドは開発中だったため、「うわー、すげー。けどまだ使えなーい」と思いながら様子を見ていた

圧倒的操作ミス

  • 本番環境で Firestore のコンソールにアクセスしてしまった
  • その瞬間、Firestoreは本番環境で完全に有効化されてしまった!!
  • なぜだ?Datastoreを明示的に選択していたはずなのに!?
  • Datastoreにデータがない場合、シュレディンガーのFirestoreとなってしまうようだ!!
    • まだ本番運用されていない(開発中)のため、Datastoreにデータがなかった
    • そのため、Firestoreの選択が未確定と扱われていた(不具合か仕様かはわからんけど)ようだ。

圧倒的幸運

  • 開発中のサービスであったため、本番環境にデータはない
  • プログラムの移行作業はあれど、データの移行はしなくて良い

移行作業量

  • サーバーサイドはmicro-service architectureで開発していた
  • そのなかで、Datastoreを使っていたServiceは2つだった
  • 残りはそのServiceを用いてデータアクセスするアーキテクチャだったため、移行作業はなかった

ちゃんと設計しといてよかったデータアクセスのアーキテクチャ

  • Clean ArchitectureとDDDを取り入れているアーキテクチャだった
  • サーバーサイドの主なレイヤーわけ
    1. APIハンドラ層(httpハンドリングを行う)
    2. Adapter層(ドメインAPIハンドラのオブジェクト変換)
    3. Domain 層(サーバー内でのドメインを扱うやつと、ビジネスロジックを扱うやつ)
    4. データアクセス層(ここがDatastoreにアクセスしている、依存層)
  • ドメイン層はデータアクセスに関して抽象化されており、基本的に appengine とか datastore packageとは完全に切り離していた
  • APIハンドラはDomainを受け取り、Adapterを使って実際のJSONモデル(用の構造体)に落とし込む仕組み
    • 別層から受け取るデータは基本的にinterfaceなので、規定interfaceさえ満たしていれば問題ない
  • Datastore用の構造体とJSON変換用の構造体を共用しているひとは、この時点で致命的な致命傷になってしまうぞ!
    • 個人的には、面倒でもちゃんと分けたほうが平和的な平和を手に入れられると思うぞ!
    • 構造体に json:"hoge" datastore:"hoge" が共存している案件は要注意だ!

データアクセス層の移行

  • HogeService.GetHoge(id) したとき、戻すオブジェクトは datastore格納用の構造体ではなく、ドメインのinterface だったため、影響は HogeService 類の中だけですべて封じ込めた
    • 設計した当時の俺ガンバッタ!
  • 構造体設計を変更し、構造体につけるタグを goon:"id" とかから firestore:"id" とかに変えた
    • 明示しなくても多分使えるけど、Firestoreは 将来的にモバイルからのアクセスが想定される ので、Go構造体名のママにするのは避けた
  • Query移行
    • 単純なソートやフィルタしか使ってなかったので、ほぼそのまま(コードもほとんどそのまま)移植できた
    • ラッキー!

Unit Test

  • ちゃんとテストを書いてたよ!
    • テストはほとんどブラックボックステスト(外部仕様テスト)で構築してた
    • なので、テストコードは未修正で、テスト前後の挙動が(テストを書いた範囲で)正しく一致することを速やかに確認できた
    • Index貼り忘れとかQueryのミスもコレで気づけたよ
    • たまに「本当にdatastore入ってる?」みたいなテストがあったので、それは適宜置き換えか削除

今後のために

  • FirestoreかDatastoreを選択したら、ダミーでいいから1個くらいデータを書いて完全に有効化しておこう!!
  • お兄さんとの約束だぞ!!

Androidアプリエンジニアが触れたAngularDartの所感

なぜWebアプリを作ることになったか

  • Androidアプリ内でオープンソースライセンスを表示するためのUIが必要だった
  • アプリとブラウザ両方からアクセスできるようにしたかった
  • 規模が小さいため、Webアプリ開発の勉強としてちょうどよいので、作ることにした
    • GAE/GoでAPIを作る機会も多いので、最低限の管理コンソール(Swagger UI以外の)が作れたほうがいいだろう、きっと。

アプリ内容

  • 2画面構成
    • ソフトウェア名を一覧で表示する画面
    • ライセンステキストを表示する詳細画面
  • ライセンステキストはFirebaseから取得する
  • たったこれだけ
    • これだけで勉強含めて1週間かかった

Angular Dartを選んだ理由

  • 前職ではWebアプリ開発にAngularJSを使っていた
  • ココ最近、Flutterを使っている
  • 以上の悪魔合体により、AngularDartを使うことにした

AngularDartの情報量

  • AngularJSと比べれば、情報は天と地ほどに少ない
    • 参考にしようと思って、日本語書籍を探したが、0である
      • JS版はそれなりにある
      • 書籍があるということは、それなりに需要を見込めているのであろう
    • 公式チュートリアルも日本語版が用意されていない

将来性は?わざわざAngularDartを覚える意味は?

  • Dartは最近Googleのお気に入りだ
  • そう、あの先進的なGoogle WaveをつくったGoogleが最近お気に入りなのだ

AngularJSにさわる

  • 開発時点で、Webアプリ開発スキルはほぼ無い
    • HTMLはホームページビルダーで書いたことあります!!
    • マーキータグが好きでした!!
  • AngularDartをやる前に、AngularJSのチュートリアルをやる
    • 1〜2日程度。
    • 両方をやることで、差分や考え方の違いを知る
      • それがわかれば、AngularJSの情報から差分を考えて自分でなんとかできるだろう
    • 実際、開発中はAngularJSの情報を参照することが多かった
    • むしろ「JSの情報でも英語でもロシア語でもなんでもいいからとりあえず情報でてこいや」っていう気分になれる

AndroidアプリエンジニアからみたAngularDartあれこれ

開発環境

  • IntelliJ Idea(or WebStormとかいろいろ)を使える
  • Android Studioに慣れていれば、あんまり困らない
  • ブレークポイントで止まる
    • dart2jsされているので、完全なマッピングはされていない
    • 変数ウォッチはちょっとしにくい
  • IntelliJからデバッガ接続可能
    • 公式はChrome Dev Console上でみる方法を案内してる
    • IDEから観たほうが楽

HTMLとComponentの関係性

  • プロジェクトを作ると勝手に作られてる
  • HTMLはレイアウトXMLにあたる
  • ComponentはAndroid ArchitectureのViewModelにあたる

依存管理

  • pubspec.yamlに書くとつながる
  • package.jsonよりは楽
    • コメント行 # が使えるが人類に優しい

JavaScriptとの親和性

  • JSのインターフェースを呼び出せる
    • いくつかのライブラリはラッパーを用意している
    • Firebaseも用意されているので、すぐに使える
  • つまり超気合入れればKotlinで書いたものをJS出力してAngularDartから呼び出せるのである
    • その気合がどの程度実るかはわからないけど。

Material Designの組み込みやすさ

UnitTest

  • テストが遅い
  • アセット読み込みとかしづらい
    • index.htmlと同一ディレクトリにfirebase.jsonを置いといて、UnitTest時に読み込むとかやりたいけど、方法がまだわからない
    • テストの情報が少ない

CI

  • CircleCIで流した
  • UnitTestでchromeブラウザが必要っぽいので、google/dart イメージだけだt webdev テストが完結しなかった
  • CI中に別途インストールしたりキャッシュしたり気合い入れてするくらいなら、カスタムイメージを作ったほうが良いかも
  • ビルドチェックと標準フォーマッタ dartfmtフォーマットチェックはやれた

Deploy

  • webdevビルドするとjsとかの成果物が /build 配下に配置される
  • 要らんファイルもあるので、必要なファイルを拾ってGAEにDeployする

今後

  • まだ触れていない機能がたくさんある
  • しばらくはGoogleからWaveされずに元気にメンテされていてください

Swagger 2.0は枯れた技術か負債なのか

swagger 2.0

  • 2014年にリリース
  • 多くのソリューションで使われている
  • メジャーで1つバージョンが古い
  • かなりの数の言語で吐き出せる
    • しかし対応ライブラリの選定が古い
    • Android用の出力はVolleyが使われてるぞ

swagger 3.0(OpenAPI)

  • 最新!新しい!
  • Kotlin用クライアントが吐き出せる
  • Golangライブラリが吐き出せない

どうするか

FlutterとXamarinとAndroid Nativeと

Flutter を使う機会があったので、所管をざっくりと。

個人的に思うFlutterを使う際に留意すべき点

  • FlutterのレンダリングSkia ベースの独自エンジンで動いている
  • Android的に言えば、どんな画面もView1枚である
  • Plugin実装を読むと、 TextureId を取得してカスタムレンダリングしているものも多い
  • マルチプラットフォーム対応 でありながら、 よく使われる WebView とのかみ合わせが悪い
    • WebViewの上にFlutterのViewを置くことが難しい
      • Pluginをforkして頑張るか?
    • これが を示すかは、人それぞれである
  • レンダリングOpenGLベースとは限らない ので、iOS/Android両対応と言いつつ気軽に「おっしゃーごりっとOpenGLで書くかー」みたいなことはできない
    • iOSはDeprecatedである
    • Metal Native or Vulkan on Metal で頑張る
  • ビルドが圧倒的に簡単である
    • iOS、テメーは辛い
    • Mac必須。CIもCircleCIに個別連絡 -> Mac有効化でメンドイ。CIのVM起動も遅い
    • 手軽にDockerでポンとビルド用コンテナに放り込めれば楽なのだが
    • iOS、テメーは辛い
  • flutter doctor コマンドが良く出来てる
    • 何かあったらとりあえずdoctorすれば良い
  • IDEのサポートが未熟
    • hoge.var で左辺生成とか標準にない
    • Dart の好き嫌いは置いとく
    • Widget組立時に、末尾に // Text みたいなフッターを自動的につける(表示上のみ)けど、たまにウザい
      • 個人的にはコードとレイアウトは切り離したいが、郷に入っては郷に従おう
  • Flutterが提供するWidgetやUI原則に従う限り、Flutterは強い
    • そこから外れるなら、外れる可能性があるなら、外れなければならないのなら?
    • 明日に向かってドロップキック
    • 頭を抱えて予算とろう

Xamarinとどっちを使う?

  • Flutterが便利なのは、Pluginがかなり充実していて、なおかつ各プラットフォームの事情をある程度知らなくても良いこと
    • 自作Pluginが必要になると、途端につらい
  • Android/iOSの両方に関して深い知見があるのなら、Xamarinもまた選択肢として残ると思う
    • 言語がC#で統一できるということは、言語の切替時(Dart/Kotlin/Java/Gradle/Swift...)の頭の切り替えにかかる時間が少なくなる
    • だけど開発環境辛い。はっ倒す。

Android / iOS Nativeは?

  • 大抵のことができる
  • 無理なもんは無理
  • 予算の光あれ

AS3.2系から3.3系へのマイグレーション

graldeバージョン更新

  • before: gradle-4.10
  • after: gradle-4.10.1

google-services Plugin更新

3.2系ではGoogle Services Plugin 4.1 系じゃないと正常動作しなかったけど、3.3系では 4.2 系じゃないと正常動作しない。

同時に複数バージョンAndroid Studioを使うのは素直に諦めたほうが良さそう。

- classpath 'com.google.gms:google-services:4.1.0'
+ classpath 'com.google.gms:google-services:4.2.0'

GolandでGAE/Goのコードにブレークポイントを貼る

for Ubuntu

  • root権限で /etc/sysctl.d/10-ptrace.conf を編集して、 kernel.yama.ptrace_scope = 0 に変更 -> 再起動
  • あとは普通にdebugアイコンでApp Engineを起動するとブレークポイントで止まる
    • さすがに init() は止まらなかった

Kotlin Android-Parcelize拡張の後方互換問題を考える

Parcelize拡張概要

  • Androidアプリ開発者にとってメンドウな Parcelable の定形実装を自動化できる
  • Annotationをつけるだけで、フィールドを全部Parcelableに突っ込んでくkれる
    • 突っ込めないオブジェクトは警告が出る
    • Annotationで除外を指定できる

Example

// これだけでParcelableを実装できる
// BundleにもIntentにも突っ込める
@Parcelize
class ExampleUser(
    val name: String
) : Parcelable

何が問題か?

  • 生成されたオブジェクトをアプリ間で受け渡す際に、シリアライズ・デシリアライズの問題が発生する恐れがあるのでは?と考えた
  • フィールドが増減した時、フィールドの記述順が変更になった時、コイツは死ぬのでは?
  • 例えば、自社のアプリ間で通信したい場合とか。

問題のある例

// アプリAではこのバージョンのクラスを使用している
// ライブラリVersion 1.0
@Parcelize
class ExampleUser(
    val name: String
) : Parcelable
// アプリBでは、ちょっと機能が増えた
// ライブラリVersion 1.1
@Parcelize
class ExampleUser(
    val name: String,
    val age: Int // 年齢フィールドを増やしたぞ
) : Parcelable
  • この状態で、アプリAからアプリBにデータを渡すとどうなる?
  • 多分死ぬ

現状を確認する

生成されるClassをjad

  • こんなCreatorが自動生成される
  • in.readString() している
public final class ExampleUser
    implements Parcelable
{
    public static class Creator
        implements android.os.Parcelable.Creator
    {

        public final Object[] newArray(int size)
        {
            return new ExampleUser[size];
        }

        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readString());
        }

        public Creator()
        {
        }
    }
}

フィールドを増やすとどうなるか?

  • 案の定、順番通りにreadする
  • このタイミングでデータ長が足りずに壊れる
        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readString(), in.readInt());
        }

順番が変わるとどうなる?

  • 当然、順番が変わる
  • Classは死ぬ
        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readInt(), in.readString());
        }

どうするか?

  • 外見仕様を変えずに、互換性を何とかする
  • Parcelable互換で、高機能な Bundle とカスタムプロパティでなんとかする
  • bundle.get をすることで、古いデータ仕様の場合はdefaultを詰め込むことができる
@Parcelize
class ExampleUser internal constructor(
    private val bundle: Bundle
) : Parcelable {

    constructor(name: String, age: Int) : this(
        bundleOf(
            Pair("name", name),
            Pair("age", age)
        )
    )

    val age: Int
        get() = bundle.getInt("age", -1)

    val name: String
        get() = bundle.getString("name")!!
}
// jadるとこんな感じになる

    public final int getAge()
    {
        return bundle.getInt("age", -1);
    }

    public final String getName()
    {
        String s = bundle.getString("name");
        s;
        if(s == null)
            Intrinsics.throwNpe();
        return;
    }

    public ExampleUser(Bundle bundle)
    {
        Intrinsics.checkParameterIsNotNull(bundle, "bundle");
        super();
        this.bundle = bundle;
    }

    public ExampleUser(String name, int age)
    {
        Intrinsics.checkParameterIsNotNull(name, "name");
        this(BundleKt.bundleOf(new Pair[] {
            new Pair("name", name), new Pair("age", Integer.valueOf(age))
        }));
    }

Bundle in Bundleすると死ぬ

単純に実装すると、 Bundle.putParcelable() で突っ込んだBundleが死ぬ。 復元時に ClassLoader が読み込まれていないようだ。

なので、次のようにするとClassLoaderを解決できる

@Parcelize
class ExampleUser internal constructor(
    private val bundle: Bundle
) : Parcelable {
    init {
        // class loaderを指定して復元させる
        bundle.classLoader = javaClass.classLoader
    }
}
  • 実装はモノグサしたので、あとは interface なり abstract なりで隠蔽してキレイに見せかけてあげれば手抜きで後方互換もある程度担保できるんじゃないかな