エンジニア転職でのポートフォリオ・ネタに困ったら

f:id:kaminashi-developer:20210302092155p:plain カミナシの浦岡です。

みなさん、ポートフォリオの成果物や学習目的でプログラムを作る場合に 課題題材を設定していますか?

これの有無で、最初の一歩を踏み出す動機づけ、モチベーションの持続に影響します。

この記事では、以下のような人に向けてネタ出しのTipsを書きます。

  • 最新技術に興味はあるけど、解きたい課題はない
  • 技術は持ってるけどアウトプットする題材が思いつかない
  • 何か作り始めてもモチベーションが続かず頓挫する

「掛け合わせ」ではじめてみる

技術に興味があっても、それを自分ごとにできないと、最初の一歩を踏み出す動機にはなりにくいと思います。

例えば、私はフロント好きなエンジニアなので、たまにwebデザイン関連のページを見るのですが、そこで「Parallax scroll」や「3D Visual」が2021年のトレンドだよと言われても、 「面白そうだけど、うちはtoBだし業務で使うことはなさそうだな」くらいでページを閉じそうになります。

そんな時、ワードの「掛け合わせ」を使ってみてください。 上の「Parallax scroll」(Parallax = 視差)に何か別なワードを組み合わせてみます。

f:id:kaminashi-developer:20210302081241p:plain

もとのワードからかけ離れた物がオススメです。

f:id:kaminashi-developer:20210302081427p:plain

桃太郎!今回はこれに決めました!!

これだけで自分が考えた出したアイデアになるので、それを実現するために「休日の1日くらい使うか」とモチベーションが上がります(個人差はあると思います)

鉄は熱いうちに打て

ネタが決まったら、なるべく短期に作るのが良いと思います。

(下スクロールしてみてください) codesandbox.io

興味が飛び火する

ある程度形あるものを作ってみると、 もっとああしたいこうしたいと欲が出てきます。

今回だと、

「ランディング・ページだからSSRしたいなぁ」とか

「3Dも取り入れたいなぁ」とか

このサイクルを回しましょう!

長期戦に持ち込む場合、1人の戦いは辛いです モチベーションを保つために、ブログなども併用してFBもらいましょう。

最後に

以上、ちょっとしたTipsでしたが、ネタに悩んだ際は、「掛け合わせ」法ぜひ試してみてください!

あっ、もし何か面白い制作物ができたら!

下に応募してみてください!! 弊社では絶賛エンジニア募集中です!

open.talentio.com

【Expo SQLite + TypeORM】Jestでexpo-sqliteの接続テストが書けなかった話

f:id:kaminashi-developer:20210228213539j:plain

こんにちは、株式会社カミナシのエンジニア @ImuKnskです。

はじめに

みなさんのプロダクトでテスト書いてますか?

『カミナシ』はオフライン機能を提供しており、ローカルデータベースはSQLite(expo-sqlite)を利用しています。

オフライン機能って何?という話は、こちらの記事をご参照ください。

kaminashi-developer.hatenablog.jp

SQLiteに保存されるデータは『カミナシ』にとって重要なデータが保存されています。 そのため万が一にでもデータを欠損してはいけないため、テストを充実させていきたいと思っています。

まずはCRUD処理を簡単に書いてみようと思ったのですが、SQLite(expo-sqlite)+ TypeORMで接続のテストが書けずに困った話をしようと思います。(これを解決出来たらCRUD処理を書きたい)

もし解決策を知っている方がいたら教えていただけると幸いです。

↓↓↓ 以下は試した環境を書いています ↓↓↓

前準備@インストール編

expoをインストール
npm install -g expo-cli
テンプレートを作成

今回はManaged workflowのblank(TypeScript)を利用します。

expo int sqlite_test

f:id:kaminashi-developer:20210214140137p:plain

しばらくするとプロジェクトが作成されます! f:id:kaminashi-developer:20210214140404p:plain

SQLiteをインストール

expo-sqliteでreact-native-sqlite-storageを利用しているのでインストールします。

yarn add expo-sqlite@~8.5.0 react-native-sqlite-storage@5.0.0 
TypeScriptのORマッパー

SQLiteのDB操作をするためにTypeORMを利用します。

github.com

yarn add github:cuibonobo/typeorm-package reflect-metadata --dev

tsconfig.json で以下の設定を有効にします。

"emitDecoratorMetadata": true
"experimentalDecorators": true
テストフレームワークのインストール

Expoガイドに載っているJestを利用します。

docs.expo.io

yarn add jest-expo @types/jest --dev

テストファイルには以下のインポートを追加しましょう。(TypeORMを使わない場合は不要です)

hoge.test.ts

import 'reflect-metadata'

package.jsonに以下の設定を追加してください。

"scripts": {
  ...
  "test": "jest"
},
"jest": {
  "preset": "jest-expo"
}

前準備@データベース作成編

TypeORMを使ってユーザーテーブルのEntityを作成

注意したいのがSQLiteを利用するので、Columnに指定するtypeがSQLiteの定義になっていることを確認してください。定義が間違っている場合はwarningになってしまいます。

sqlite/entities/user.ts

import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm'

@Entity()
export class EntityTestUser {
  // Primaryの数値型
  @PrimaryColumn({ type: 'integer' })
  id?: number

  // 100文字まで保存
  @Column({ type: 'text', length: 100 })
  name?: string

  // 数値型でNULLを許可する
  @Column({ type: 'integer', nullable: true })
  age?: number

  // 作成日の初期値はdefaultで予め定義しておきます
  @CreateDateColumn({ name: 'created_at', precision: 0, default: () => `DATETIME('now', 'localtime')` })
  readonly createdAt?: string
}
データベース接続処理を作成

Entityを作成したあとは、データベースの接続設定を行いましょう。 今回はユニットテストで使うため、database:memory:とします。

sqlite/types/database.ts

// データベース名
export const DATABASE_NAME = ':memory:'
// コネクション名
export const CONNECTION_NAME = 'sample'

sqlite/connection.ts

import { createConnection } from 'typeorm'
import { EntityTestUser } from './entities/user'
import { DATABASE_NAME, CONNECTION_NAME } from './types/database'

const createConnectionSQLite = async () => {
  return await createConnection({
    database: DATABASE_NAME,
    name: CONNECTION_NAME,
    // expoのsqliteを利用する
    type: 'expo',
    driver: require('expo-sqlite'),
    // 利用するテーブル一覧(entitiesに追加しないと対象とならない点に注意してください)
    entities: [EntityTestUser],
    // createConnectionしたときにentitiesのテーブルを作成する(falseの場合は自動生成しません)
    synchronize: true,
    // queryのログを出力
    logging: ['query']
  })
}

export default createConnectionSQLite

この例ではexpo-sqliteドライバを利用したexpoデータベースを利用します。 別のデータベースを利用する場合はtypemysql, postgres, react-native等を利用できます。その際は各データベースの設定にホストやポート等を変更してください。

SQLiteのテスト書いてみよう

これで前準備はバッチリですね!早速テストを書いてみましょう!

sqlite/connection.test.ts

import createConnectionSQLite from './connection'
import 'reflect-metadata'

describe('expo-sqlite testing', () => {
  test('exist connection', async () => {
    const connection = await createConnectionSQLite()
    expect(connection).not.toBe(undefined)
  })
})

接続が出来ていればテストが成功するので、yarn test を実行してみましょう!

% yarn test
yarn run v1.19.1
$ jest
 FAIL  sqlite/connection.test.ts
  expo-sqlite testing
    ✕ exist connection (362ms)

  ● expo-sqlite testing › exist connection

    TypeError: Cannot read property 'map' of undefined

      at node_modules/expo-sqlite/src/SQLite.ts:27:41

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        3.073s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

ムムム…。華麗にコケました。

エラー箇所のコードを確認したところ、expo-sqliteSQLiteDatabase.exec でエラーになっていました。 thenで正常に終わっているが、nativeResultSetsに入っていないようです。 f:id:kaminashi-developer:20210223145150p:plain

ここから数時間近くハマって解決策が見つかっていません...。

確認したことは

  • createConnectionのプロパティでdatabaseにmemoryが利用できないのか
    • App.tsxに記述して正常に動作しているため、memoryの問題ではなかった
    • この関数自体は正常に動作している
  • TypeORM, ExpoのGithubのissue
    • 似たようなissueがあったけどenumは使っていなかった github.com

もしかして、Jestのテストで利用出来ないのでは?と思い、素のSQLiteを使ってみました。

まずはSQLite接続用のドライバーが別途必要なためインストールを行います。

yarn add sqlite3 --dev  

sqlite/connection.ts

const createConnectionSQLite = async () => {
  return await createConnection({
    database: DATABASE_NAME,
    name: CONNECTION_NAME,
    type: 'sqlite',
    entities: [EntityTestUser],
    synchronize: true
  })
}

再度 yarn test を実行してみましょう!

% yarn test
yarn run v1.19.1
$ jest
 PASS  sqlite/connection.test.ts
  expo-sqlite testing
    ✓ exist connection (311ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.739s
Ran all test suites.
✨  Done in 5.89s.

テストが通りました!

SQLiteの問題ではなく、expo-sqliteを使ったテストが動かないということだと思います。これが環境問題なのか、設定不足なのかが現時点では分かっていない状態です。

分かっていないこと

  • TypeORMのcreateConnectionでexpo-sqliteのドライバを使ったテストの書き方が分からない!

解決策を知っている方がいたら、コメント等で教えていただけると幸いです。

おわりに

今回は記事はTypeORM + SQLite(expo-sqlite)のテストが書けなかった話でした。 ホントは出来たことを公開したかったのですが…。

出来ていないことを外部に公開することを躊躇いましたが、弊社のValueに全開オープンがあるので大丈夫なはず!

note.com

最後に弊社ではエンジニアを募集しております。 興味がある、話を聞いてみたい、応募したいという方はお気軽にご応募ください!

open.talentio.com

RDBMSの先を行く?NewSQLを支えるアルゴリズムRaftをGoで紐解く

f:id:kaminashi-developer:20210226020850p:plain

初めまして。株式会社カミナシPMの@gtongy1です。

みなさんはNewSQLをご存知ですか?
強い整合性を持つ分散型のSQLデータベースサービスのことをNewSQLと呼びます。
RDBMSではなし得なかった分散アーキテクチャを、またNoSQLではなし得なかった強い整合性をいいとこ取りした新しいSQLデータベースサービスです。
なんかとても理想的な仕組みに見えますね。この裏にはどのような知識が詰め込まれているのでしょうか。

今回はそんなNewSQLを支える仕組みを一緒に紐解いていきましょう!

NewSQLが乗り越えた壁

どんな仕組みが動いているのか、の前にNewSQLはこのSQLデータベース界へ何を投げ込んだのでしょうか。
NewSQLには以下のような特徴があります。

NewSQLの有名所であるCockroachDBでいうと、この仕組みを以下のようなArchitectureで実装をしています。
各階層ごとでうまく役割分担しているのが見て取れます。

f:id:kaminashi-developer:20210224090822p:plain
Cockroach Architecture

github.com

分散されたKVS内で複数のNodeとStoreで領域分けられていることが分かります。
では、このStore中身はどうなっているのでしょうか。中を見てみましょう。

f:id:kaminashi-developer:20210224230124p:plain

このように、Storeの内部には複数のRangeと呼ばれる領域を含んでおり、この色分けされている中で共通な部分はレプリカとして生成されています。
つまり、Store間を跨いだ状態で 同一Rangeの関連を持つことが出来るのです

  • NoSQLでは実現不可能だったACIDサポートのトランザクション/SQL-Likeなクエリ言語を記述出来なかった心労を乗り越えていること
  • またRDBMSでは苦労するShardingの仕組みを、分散システムとして内包しマネージドな形でのサービスの提供を行っていること

がNewSQLがこれまでの仕組みを乗り越えた壁であることがわかります。

では一体、どのような仕組みを使いレプリカ間でのデータの関連性を担保しているのでしょうか?
その謎に迫るためにはRaftについて説明していくことにしましょう!

なぜRaftが使われるのか

詳細を話す前に、なぜRaftが使われてきたのでしょうか。その実体に迫ります。
分散システムを実際に設計していくことになると、複数のノードに分散されたアプリを協調する必要があります。
例えば、どのノードで更新がかかったのか、その更新の値はどんなものなのか、そして今その値は更新しても良いものなのか等。
分散されているが故に、他のノードの状態がわかりづらくなってしまいます。
そこでノード間の関係に一貫性を保つアルゴリズムこそがRaftです。

thesecretlivesofdata.com

Raft登場以前はPaxosと呼ばれるアルゴリズムが主流でした。 このアルゴリズム自体実装がとにかく難しい(らしく)、Raftが流行っていったそうです。
なぜそんなに差が生まれたのでしょうか?
実はRaftのアルゴリズム

のこの2つが最低機能。つまりこれだけを覚えると実装出来るんです!とてもシンプルですね。
そんなRaftのコードの中身気になって来ましたか?
OKです!順を追ってコードを使いながら説明していきますね。

リーダー選出(Leader Election)

gyazo.com

上記のgifをご覧ください。図を見てどのような変化が見られるでしょうか?

  • S1~S5のノードが存在し、そのノードそれぞれがタイマーを持っている
  • 最もタイマーが早く終わったS1が、他のノードに対してメッセージを送信している
  • メッセージを受け取ったS2~S5がレスポンスを返却し、S1へ返している
  • S2~S5から3つのメッセージを受け取ったS1はステータスが変わっている
  • S1の色は変化し、S1からメッセージを送っている

このような流れがあったのではないでしょうか?これこそがリーダー選出(Leader Election)の動作になります。
まず上記の流れを説明するために、ノードのステータスに対して説明していく必要があります。
Raftにおいて、ノードは以下の3つのステータスが動的に切り替わっています。

  • Leader
  • Candidate
  • Follower

f:id:kaminashi-developer:20210225204656p:plain

func NewRaft() (*Raft, error) {
    // ...
    go r.run()
    return r, nil
}

func (r *Raft) run() {
    for {
        select {
        switch r.getState() {
        case Follower: r.runFollower()
        case Candidate: r.runCandidate()
        case Leader: r.runLeader()
        }
    }
}

func (r *Raft) runFollower() {
    for {
        select {
        // 時間切れでcandidateへstateの変更
        case <-randomTimeout(r.config.HeartbeatTimeout):
            logrus.Info("heartbeat timeout reached, starting election")
          // Candidateへの昇格
            r.setState(Candidate)
            return
    }
}
func (r *Raft) runCandidate() {
    // 投票リクエストの送信、channelの作成
    voteCh := r.electSelf()
    grantedVotes := 0
    votesNeeded := r.quorumSize()
    for {
        select {
      // 投票リクエストの受け取り
        case vote := <-voteCh:
            // ...
            // grantedな投票の票数確認
            if vote.VoteGranted {
                grantedVotes++
                logrus.Printf("vote granted. tally: %d", grantedVotes)
            }

            // 過半数表を得ているか。leaderになり得るか
            if grantedVotes >= votesNeeded {
                logrus.Printf("election won. tally: %d", grantedVotes)
              // Leaderへの昇格
                r.setState(Leader)
                return
            }
            // ...
    }
}

上記実装の中では、

  1. 最初はどのノードもFollowerから開始
  2. 初回Followerから開始し、各node内で設定されているタイマー(HeartbeatTimeout)で設定された時間が経ったのちCandidateへ状態遷移
  3. その後各ノードから投票を受け(RequestVoteRPC)、この結果で投票が過半数を獲得したノードに対してLeaderへ状態遷移

の流れによって、リーダーの選出を行うことが出来ます。 goroutineのchannelを利用して1プロセス: 1ノードで表しています。

ログ複製(Log Replication)

gyazo.com

Raftはクライアントからの値の変更に対して、Leaderを介して更新を行います。
またその受け取った値に対して、Leaderは各Followerに対し変更の合意をとる必要があります。
この時のコマンドをログと呼び、このコマンド自体は永続化することにより、状態を保存しておきます。

// クライアントからのログの書き込み
func (r *Raft) Apply(cmd []byte, timeout time.Duration) ApplyFuture {
    // ...
    logFuture := &logFuture{
        log: Log{
            Type: LogCommand,
            Data: cmd,
        },
        errCh: make(chan error, 1),
    }
    select {
    case r.applyCh <- logFuture:
        return logFuture
    }
}

func (r *Raft) runLeader() {
    // Leaderから各ノードに対して複製の実行
    for i, peer := range r.peers {
        go r.replicate(inflight, triggers[i], stopCh, peer)
    }
}

func (r *Raft) replicate(inflight *inflight, triggerCh, stopCh chan struct{}, peer net.Addr) {
    last := r.getLastLog()
    // Followerの対象Indexと次のIndexを取得
    indexes := followerReplication{
        matchIndex: last,
        nextIndex:  last + 1,
    }
    shouldStop := false
    for !shouldStop {
        select {

        case <-triggerCh:
            // 書き込みの実行
            shouldStop = r.replicateTo(inflight, &indexes, r.getLastLog(), peer)
        }
    }
}
func (r *Raft) replicateTo(inflight *inflight, indexes *followerReplication, lastIndex uint64, peer net.Addr) (shouldStop bool) {
    req = AppendEntriesRequest{
        Term:         r.getCurrentTerm(),
        LeaderID:     r.CandidateID(),
        LeaderCommit: 0,
    }
    // RPCの送信
    if err := r.trans.AppendEntries(peer, &req, &resp); err != nil {
        logrus.Printf("fail to AppendEntries to %v: %v", peer, err)
        return
    }
    // 送信成功時に永続化層の書き換え(Commit)
    if resp.Success {
        for i := indexes.matchIndex; i <= maxIndex; i++ {
            inflight.Commit(i)
        }
        indexes.matchIndex = maxIndex
        indexes.nextIndex = maxIndex + 1
    }
    return
}

ログ複製は以下の手順を踏み、値の更新を行っていきます。

  1. Leaderはログにコマンドを追加し、そのコマンドでAppendEntries RPCを送信
  2. 各ノードはローカルにエントリを追加し、成功すると返信
  3. Followerの過半数がログエントリのローカルコミットに成功すると、Leaderはコマンドをコミット。この時に成功応答をクライアントに返信
  4. 次のLeaderからのリクエストでは、更新されたコミットインデックスをすべてのFollowerに複製
  5. Leaderがエントリをコミットすると、現在のログインデックスより前のすべてのエントリもコミット

ログを複製するタイミングには、LeaderはFollowerに対し2回の合意を行う(最初にログへの書き込み、2回目には変更の合意)ことによって、更新を完了することが出来ます。

結論

この二つの仕組みを利用することにより

  • リーダーの選出を行うことにより、分散された各ノードに対して自動的に役割が選出されていく
    • 一つのノードが障害が起こった場合においても、再度投票が自動的に行われていくことによって、分散で散らばった各ノードに役割がつけられる
  • ノードに対しての変更はLeaderを介して、各Followerノードに通知される
    • その結果はLeaderは送信された結果に対して、またFollowerは受け取った結果に対しての結果を返却するのみ
    • そのため一つのノードの障害に依存しない形を実現可能

が分かります。
つまりRaftを使うことによりレプリカ間でのデータを、分散されたノード間で関連付けられることが分かりました!

まとめと所感

今回はNewSQLで利用されているRaftの技術を深堀りしてみました。
学習レベルですが、Raftの技術を実装から追いかけてみることにより、仕組みが一つ腹落ち出来た気がします。
そして、2つの仕組みを学ぶと全体の動きを見れるのはとても良いですね。
Raftを知るためには、このサイトをまず見てみてください。

thesecretlivesofdata.com

とてもグラフィカルに分かりやすく作られていて、雰囲気は掴みやすかったです。

所感として、Raftを進めて行く中で改めてNewSQLに対していろいろと疑問に感じた部分があります。

  • Leaderノードに対する書きこみ遅延が発生しそう
    • リーダーに選出されるノードは一つ。ここをSPOFにならないのだろうか?
      • 最初に出した図のように、Rangeの範囲でシャーディングを行っている
      • そのため、アプリケーションレベルで考えるとRangeに対する分割単位をどのように行うかがスケールさせる肝になってきたりするのかな?
  • Raftとは関係ないけど、SQLレイヤーで作成しているコードはどんな感じになってるんだろう
  • NewSQLはRDBMSでいうB-Tree構造とは違うと思うんだけど、何があるの?
    • LSM(Log-structured merge-tree)たるものがあるらしい。記事もいろいろある。この高速化はどう行うのかとか、仕組みはいまいちわかっていない。

など、Raftを知ることは、NewSQLを知るにはまだ氷山の一角なのだなと感じました。
これから他の技術を触れ合ってみたいですね。

最終的なコードはこちらに載せています。詳細気になったらご覧ください!

github.com

最後に宣伝です。

カミナシでは今動いている技術の選択肢だけじゃなくて、他にも事業が前にすすむような技術的なチャレンジを出来る企業です。
柔軟なインフラとサーバーを構築し、事業全体を前に進めてくれるようなSREの方や、エンジニアを募集しています!

エントリーお待ちしております!!

open.talentio.com open.talentio.com

参考文献

開発環境で送信したメールを確認するためにMailHogを導入しました(勝手に)

こんにちは、カミナシの@tomiです。

開発環境で送信したメールを確認するためにMailHogを導入してみました。 MailHogとは送信したメールをブラウザで確認できるツールです。

f:id:kaminashi-developer:20210224112022p:plain

導入のきっかけ

以前、Ruby on Railsで開発している際に、letter_openerという送信したメールをブラウザで確認できるgemを使っていて、メール周りの開発が捗りました。

Golangでもメールが送信されたかを確認する方法を探してたらMailHogというツールを見つけたので、とりあえず導入してみました。(勝手にプルリクを出した)

導入方法

導入はとても簡単で、

  • 公開されているDockerイメージでコンテナを作成する
  • アプリ側のメール送信先SMTPサーバーを変更する

この2点だけです。

Dockerコンテナの作成

Docker Hubに公開されているイメージを使います

https://hub.docker.com/r/mailhog/mailhog/

docker-compose.yml を作成

version: "3"
services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - "1025:1025"
      - "8025:8025"
    logging:
      driver: json-file
      options:
        max-file: '1'
        max-size: 1m

1025はSMTPサーバー、8025はブラウザでメール確認用のHTTPサーバーのポートとなっています。

今回はホスト側からMailHogのSMTPサーバーに送信するので、portsに設定して外部に公開しています。

docker-compose up -dで起動すれば、http://localhost:8025でメールクライアントが開けます。

f:id:kaminashi-developer:20210224112048p:plain

メール送信先SMTPサーバーを変更する

MailHogのSMTPサーバーに送信する関数を用意しました。

func (e *Email) SendEmailLocal(subject string, content string, user *orm.User) error {
    auth := smtp.PlainAuth("", "username", "password", "localhost")
    host := entity.GetLocalSMTPHost()
    message := []byte("From: " + emailFromName + ":" + emailFromAddress + "\r\n" +
        "To: " + user.Email + "\r\n" +
        "Subject: " + subject + "\r\n" +
        "\r\n" +
        content + "\r\n")
    err := smtp.SendMail(
        host,
        auth,
        emailFromAddress,
        []string{user.Email},
        []byte(message),
    )

    if err != nil {
        return internals.NewRequestBindError("[SendEmailLocal] send request error ", err)
    }
    return nil
}

host には、MailHogのSMTPサーバーである localhost:1025がはいります。

ローカル開発時はこちらの関数でメールを送信するように書き換えれば・・・

f:id:kaminashi-developer:20210224112110p:plain

メールがMailHogの方に届くことが確認できました。

これで仕様通り、想定したタイミングで想定したユーザーにメールが送られるかが開発しながらチェックできるようになりました。

また、メール周りの要件ってすぐ忘れてしまうので、「このタイミングでメール送られるっけ?」と思ったときに、ささっと操作してメールが送られるかの確認と内容のチェックが気軽できるのが嬉しいポイントです。

まとめ

送信したメールをブラウザで確認できるツールMailHogを勝手に導入してみました。

まだエンジニアの誰にも言っていないので、MailHogが使われるかは未定です。

デバッグすればええやんとか、こっちのツールの方が便利とか、あるかもしれないので、これをきっかけに、みんなで話し合いながら、メール周りの開発がもっと便利になればと思います。

【Golang】で【Amazon API Gateway Lambda オーソライザー】と【FirebaseAuth】を利用しての認証をやってみた

f:id:kaminashi-developer:20210224144112j:plain

初めに

初めまして。2021年3月より株式会社カミナシにジョインすることとなりました、エンジニアの@Takuと申します。

業務とは直接関係ないのですが、API Gateway Lambda オーソライザーFirebaseAuthを組み合わせた認証をやってみたので記載させていただきます。

概要

以下のチュートリアルを元に Amazon API Gateway Lambda オーソライザーを利用した認証機能を作成しました。 docs.aws.amazon.com

Amazon API Gateway Lambda オーソライザーを利用することで、 認証・認可部分をAPI Gateway側で共通化できるため、

  • マイクロサービス化(認証・認可と業務の責務分け)
  • サービスを提供するサーバーの負荷軽減

などのメリットが見込めるのではと考えております。

その際チュートリアルから変更した点として、

  • OAuth アクセストークンを発行するOAuthプロバイダーに Firebaseを利用
  • API Gateway Lambda オーソライザー関数Golangで作成

としてますので、ご興味ある方はご覧いただけますと幸いです。

Amazon API Gateway Lambda オーソライザーとは

詳細な説明は公式へ役割をお譲りしますが、概要としては以下の通りです(公式より引用)

Lambda オーソライザー (以前のカスタムオーソライザー) は、Lambda 関数を使用して API へのアクセスを制御する API Gateway の機能です。

以下も公式より引用させていただいた図が分かり易いのですが、 API Gatewayが受けたリクエストをLambda関数で評価し、

  • アクセスが拒否された場合は 403 ACCESS_DENIED などのHTTPステータスコードを返す
  • アクセスが許可されている場合、メソッドを実行する

というワークフローとなっております。

f:id:kaminashi-developer:20210224130804p:plain (公式より引用)

開発環境

設計・アーキテクチャ

今回の設計は以下の通りとしました。 f:id:kaminashi-developer:20210224131025p:plain

今回のOAuthプロバイダーには前述の通り、Firebaseを採用しております。

AWSにはAmazon Cognitoという認証のサービスがありますのでそちらを使う方がAWS内で完結して良い気もしますが、今回は電話認証以外の認証は無料で使え、私自身使ったことがあるFirebaseで試してみました。

Lambda オーソライザーには 以下の2 種類がありますが、Firebaseが認証後返すのはJWTトークンとなりますので、今回は トークンベース の Lambda オーソライザーを利用することになります。

トークンベース の Lambda オーソライザー (TOKEN オーソライザーとも呼ばれる) は、JSON ウェブトークン (JWT) や OAuth トークンなどのベアラートークンで発信者 ID を受け取ります。

・リクエストパラメータベースの Lambda オーソライザー (REQUEST オーソライザーとも呼ばれる) は、ヘッダー、クエリ文字列パラメータ、stageVariables、および $context 変数の組み合わせで、発信者 ID を受け取ります。WebSocket API では、リクエストパラメータベースのオーソライザーのみがサポートされています。

公式より引用

また、カミナシではバックエンドの開発にGolangを採用しておりますので、 Lambdaオーソライザーの関数の作成にはGolangを利用しました。

実装

認証の機能を実現するため、以下を実施します。

  1. Firebaseプロジェクトの作成
  2. Firebaseへログインし、JWTトークンを取得するクライアント(簡素なWebアプリケーション)の作成
  3. FirebaseのJWTトークンを検証するAWS Lambda関数の作成
  4. AWS API Gatewayの作成及び、でLambda Authorizerの設定

1. Firebaseプロジェクトの作成

Firebaseについては主題から外れるため深くは解説しませんが、簡単に設定だけ載せておきます。

(※ですのでFirebaseについてご存知な方は1. 2. を飛ばして3. から読み進めていただいても良いと思います)

まず、Firebaseコンソールを開き、新規プロジェクトを作成、 「Authentication」→「Sign-in method」のログイン プロバイダから、「メール / パスワード」を有効にします。 f:id:kaminashi-developer:20210224131056p:plain

その後、今回Userの作成はコンソールで済ませたため、テスト用に適当なUserを作成してください。

今回は例として、

  • 管理者ユーザー:admin@example.com
  • 一般ユーザー:user@example.com
  • アクセス権限なしユーザー:deny@example.com

という3ユーザーを作成してみました。 f:id:kaminashi-developer:20210224131149p:plain

2. Firebaseへログインし、JWTトークンを取得するクライアントの作成

Firebase認証を行い、トークンを取得するクライアントの情報は多くあるため詳細は省きます。

今回はFirebaseUIを利用したとても簡素なWebページを作成し、ログイン後取得したJWTトークンをヘッダーに付与してリクエストを送る形をしてます。

ログインページ

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Lambda Authorizer Sample</title>
  <script src="https://www.gstatic.com/firebasejs/ui/4.7.3/firebase-ui-auth.js"></script>
  <link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/4.7.3/firebase-ui-auth.css" />
  <style>h1{text-align: center;}</style>
</head>
<body>
  <h1>Lambda Authorizer Sample</h1>
  <div id="firebaseui-auth-container"></div>

  <!-- FirebaseSDKのインポート -->
  <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-app.js"></script>

  <!-- FirebaseAuthのインポート -->
  <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-auth.js"></script>

  <!-- configファイルのインポート -->
  <script src="./js/config.js"></script>
  <script>
    // FirebaseUIの設定
    var ui = new firebaseui.auth.AuthUI(firebase.auth());
    ui.start('#firebaseui-auth-container', {
      // ログイン完了時のリダイレクト先
      signInSuccessUrl: './auth.html',

      // 利用する認証機能
      signInOptions: [
        firebase.auth.EmailAuthProvider.PROVIDER_ID
      ],
    });

    // User情報の表示
    firebase.auth().onAuthStateChanged(function(user) {
      if (user) {
        console.log('user', user)
        user.getIdToken().then(function(accessToken) {
          console.log('accessToken', accessToken)
        }, null, ' ');
      }
    });
</script>
</body>
</html>

ログイン後ページ

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Lambda Authorizer Sample</title>
</head>
<body>
  <h1>...Please wait</h1>
  <div id="email"></div>
  <div id="token"></div>
  <div id="pets"></div>

  <!-- FirebaseSDKのインポート -->
  <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-app.js"></script>

  <!-- FirebaseAuthのインポート -->
  <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-auth.js"></script>

  <!-- configファイルのインポート -->
  <script src="./js/config.js"></script>

  <!-- axiosのインポート -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js" integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ==" crossorigin="anonymous"></script>
  <script>
    // User情報の表示
    firebase.auth().onAuthStateChanged(function(user) {
      let h1    = document.querySelector('h1');
      let email = document.querySelector('#email');
      let token = document.querySelector('#token');
      let pets  = document.querySelector('#pets');

      // API Gatewayで作成したエンドポイントを設定
      const url = "https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/pets"

      if (user) {
        console.log('user', user)
        h1.innerText   = 'ログインしました';
        email.innerHTML = `メールアドレス:${user.email}`;
        user.getIdToken().then(function(accessToken) {
          console.log('accessToken', accessToken)
          token.innerHTML = `JWTトークン:${accessToken}`;

          // リクエスト送信
          axios
            .get(url, {
              headers: {
                'AuthorizationToken': accessToken
              }
            }).then(res => {
              console.log('res', res)
              pets.innerHTML = `PetsType: ${res.data[1].type}`;
            })
            .catch(error => {
              pets.innerHTML = `error: ${error}`;
            });
          }, null, ' ');
        }
      });
    </script>
  </body>
</html>

※ログイン後にトークンの送り先のURLは後ほどAPI Gatewayで作成したエンドポイントを記載します。

上記HTMLでFirebaseログインを行うためにはFirebaseSDKの設定が必要なのですが、API Keyなど認証情報があるため、これだけ ./js/config.jsとして別ファイルで置いてます。

こちらは、「プロジェクトの設定(プロジェクトの概要横の歯車)」→「全般」→「アプリを追加」することで表示された <script>~</script> 内の内容をまるっとコピーして貼り付けてください。 f:id:kaminashi-developer:20210224131230p:plain (認証情報が含まれるため一部隠してます)

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
var firebaseConfig = {
  apiKey: "XXXXXXXXXXX-XXXXXXXXXXXXXXXXX-XXXXXXXXXX",
  authDomain: "XXXXXX.firebaseapp.com",
  projectId: "XXXXXX",
  storageBucket: "XXXXXX.appspot.com",
  messagingSenderId: "XXXXXXXXXXXX",
  appId: "1:XXXXXXXXXXXX:web:XXXXXXXXXXXXXXXXXXXXXXXX",
  measurementId: "G-XXXXXXXXXXXX"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);

以下のようなページが出ればOKです f:id:kaminashi-developer:20210224131506p:plain

3. FirebaseのJWTトークンを検証するAWS Lambda関数

続いて、送られてくるJWTトークンを検証するLambda関数を作成します。

最初に記載したチュートリアルにもサンプル関数があるのですが、

Node.jsで記載されていたため、以下のサンプルを元に、FirebaseのJWTトークンを検証するように修正しました。

github.com

FirebaseのJWTトークンの検証には、公式のFirebase Admin Go SDKを利用しています。

firebase.google.com

pkg.go.dev

PythonやNode.jsといったスクリプト言語はLambdaのコンソールからコードを入力できるのですが、Golangは非対応のため、zipファイル or ECR経由でのDockerコンテナでのアップロードになります。

今回はCLI経由でのzipファイルアップで実装しましたので、エディタでファイルを作成していきます。

サンプルのディレクトリ構成とコードは以下となります。

-> % tree
.
├── config
│   └── jwt-lambda-auth-firebase-adminsdk-xxxxx-xxxxxxxxxx.json
├── go.mod
├── go.sum
├── main
└── main.go

1 directory, 5 files
package main

import (
    "context"
    "errors"
    "fmt"
    "log"

    firebase "firebase.google.com/go/v4"
    "firebase.google.com/go/v4/auth"
    "google.golang.org/api/option"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

// generatePolicy IAM policyを生成する
func generatePolicy(principalId, effect, resource string) events.APIGatewayCustomAuthorizerResponse {
    authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalId}

    if effect != "" && resource != "" {
        authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
            Version: "2012-10-17",
            Statement: []events.IAMPolicyStatement{
                {
                    Action:   []string{"execute-api:Invoke"},
                    Effect:   effect,
                    Resource: []string{resource},
                },
            },
        }
    }

    return authResponse
}

// verifyFirebaseJwtToken JwtTokenを検証
func verifyFirebaseJwtToken(idToken string) (*auth.Token, error) {
    ctx := context.Background()
    opt := option.WithCredentialsFile("./config/jwt-lambda-auth-firebase-adminsdk-xxxx-xxxxxxxxxx.json") // コンソールからダウンロードしたjsonファイルのパスを指定
    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        return nil, err
    }

    client, err := app.Auth(ctx)
    if err != nil {
        return nil, err
    }

    token, err := client.VerifyIDToken(ctx, idToken)
    if err != nil {
        return nil, err
    }

    return token, err
}

func handleRequest(ctx context.Context, event events.APIGatewayCustomAuthorizerRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
    // JWTトークンを検証する
    jwt := event.AuthorizationToken
  token, err := verifyFirebaseJwtToken(jwt)
    if err != nil {
        log.Fatalln("error decode jwt token: ", err)
    }
    fmt.Println("token", token)

    // メールアドレスを取得
    email := token.Claims["email"]
    switch email {
    case "admin@example.com":
        return generatePolicy("admin", "Allow", event.MethodArn), nil
    case "user@example.com":
        return generatePolicy("user", "Allow", event.MethodArn), nil
    case "deny@example.com":
        return generatePolicy("deny", "Deny", event.MethodArn), nil
    case "unauthorized":
        return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Unauthorized") // Return a 401 Unauthorized response
    default:
        return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Error: Invalid token")
    }
}

func main() {
    lambda.Start(handleRequest)
}

あと、LambdaでFirebaseのJWTトークンを評価する際に、Firebaseプロジェクトの「サービス アカウントの認証情報を含む構成ファイル」が必要となりますので、

「プロジェクトの設定(プロジェクトの概要横の歯車)」→「サービスアカウント」→「新しい秘密鍵の生成」からjsonファイルをダウンロードして ./config 置いてください。 f:id:kaminashi-developer:20210224131619p:plain

こちらのキーを利用するとFirebaseプロジェクトにアクセスできてしまうため、 注意書きにある通り、絶対に外部に漏らさない(Githubの公開レポジトリにアップするなどしない)ように注意してください。

ファイルの作成ができましたら、以下コマンドでzip化→関数の作成をします。

-> % GOOS=linux go build main.go; zip -r ../function.zip ./

-> % aws lambda create-function --function-name api-gateway-authorizer-golang --runtime go1.x \
--zip-file fileb://../function.zip --handler main \
--role arn:aws:iam::000000000000:role/service-role/xxxxxx-role-xxxxxxxx // Lambda関数を作成する権限をもつIAMのARNを指定

なお、一度作成した関数のコードを更新する場合は以下になります。

-> % GOOS=linux go build main.go; zip -r ../function.zip ./

-> % aws lambda update-function-code --function-name api-gateway-authorizer-golang \
--zip-file fileb://../function.zip

(参考: LambdaのCLIコマンド) docs.aws.amazon.com

docs.aws.amazon.com

コマンド実行後、コンソールを作成し、以下の関数ができていると思います。 f:id:kaminashi-developer:20210224131708p:plain

Lambdaはテストイベントを設定し、関数のテストができます。

右上の「テストイベントの設定」→イベントテンプレートで「Amazon API Gateway Authorizer」を選択し、イベント名に適当な名前を入力、 autorizationToken: にFirebaseログイン後返却されたJWTトークンを入力してテストを実行してください。 f:id:kaminashi-developer:20210224131807p:plain

f:id:kaminashi-developer:20210224131823p:plain

なお、FirebaseのJWTトークンは、2. で作成したWebアプリでログインした後、トークンを画面に表示するようにしておりますので、そちらをコピーして使うことができます。 f:id:kaminashi-developer:20210224131849p:plain

テストを実行して、実行結果が「成功」であればここまではOKです。 f:id:kaminashi-developer:20210224131938p:plain

4. AWS API Gatewayの作成及び、でLambda Authorizerの設定

最後に、API Gatewayの設定を行います。

今回サンプルのAPIAmazon API Gatewayチュートリアルを利用しますので、以下の手順に従い、簡単なAPIを作成してください。(こちらについては説明は割愛させていただきます) docs.aws.amazon.com

チュートリアルが完了しましたら、以下のようなAPIが作成できているかと思います。 f:id:kaminashi-developer:20210224132043p:plain

作成が完了しましたら、Lambda Authorizerの設定を行なっていきます。

設定は、APIを選択後、[Authorizers (オーソライザー)] →[新しいオーソライザーの作成]を選択し、 以下の通り入力してください。 f:id:kaminashi-developer:20210224132123p:plain

[設定]

  • 名前:任意
  • タイプ:Lambdaを選択
  • Lambda関数:3.で作成した関数を選択
  • Lambda呼び出しロール:空白のまま
  • Lambdaイベントペイロードトークンを選択
  • トークンのソース:Authorization
  • トークンの検証:空白のまま

トークンのソースについては、最初に記載したLambdaオーソライザーのチュートリアルだと authorizationTokenと入力することとなってますが、これだと後で設定するCORSを有効化した際、デフォルトで許可されるヘッダーにならないため Authorizationとするのが無難と思います(手動で追記してもOK)。

オーソライザーの設定が完了しましたら、こちらもLambda関数と同様にテストができますので、Webアプリから取得したJWTトークンを「認可トークン」の欄に入力して実行し、レスポンスが 200で返って来ればここまでは問題ないかと思います。 f:id:kaminashi-developer:20210224132144p:plain

オーソライザーの設定が完了した時点で3.のLambda関数を見てみると、 以下のようにAPI Gatewayがトリガーとして追加されているかと思います。 f:id:kaminashi-developer:20210224132221p:plain

次に、Webアプリからのトークンの送り先URLを変更したいと思いますので、

  1. で作成したログイン後のページに記載のエンドポイントを、API Gatewayで作成したAPIの「ステージ」→対象のステージを選択し、「URLの呼び出し」の横にあるURLに書き換えてください。
  <script>
    // User情報の表示
    firebase.auth().onAuthStateChanged(function(user) {
      let h1    = document.querySelector('h1');
           ...

      // API Gatewayで作成したエンドポイントを設定
      const url = "https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/pets"

      if (user) {

f:id:kaminashi-developer:20210224132250p:plain

続いて、メソッドに作成したオーソライザーを紐づけていきます。

「リソース」→「/pets の GET」→「メソッドリクエスト」を選択します。

そして「設定」の「認可」のプルダウンから先ほど作成したオーソライザーを選択し、横のチェックマークをクリックしてください(わかりにくいですが、チェックマークを押さないと設定されません) f:id:kaminashi-developer:20210224132329p:plain

ここまでできましたら、2.で作成したWebアプリを利用してトークンを利用したリクエストを行えるのですが、このままですとCORSの制限に引っかかり、Webアプリ(ブラウザ)でAPI Gatewayからのリクエストを受け取ることができません。

ですので以下の手順でCORSを有効にします。

docs.aws.amazon.com

「リソース」→「/pets」を選択した後、「アクション」→「CORSの有効化」を選択してください。

f:id:kaminashi-developer:20210224132417p:plain

設定につきましては今回はそのままデフォルトから変更せず、「CORSを有効にして既存のCORSヘッダーを置換」のボタンをクリックしてください。 f:id:kaminashi-developer:20210224132459p:plain

今回は検証で、ローカルから実行することもありAccess-Control-Allow-Origin * を許容しておりますが、本来はXSSCSRFといった脆弱性の原因になるという点から望ましくないため、実際のプロダクトで利用する場合は避け、正しいOriginを設定するようにしてください。

また、前述のオーソライザー設定時に記載した通り、トークンのソースを Authorization以外にした場合は、 Access-Control-Allow-headersにそのトークン名を追記してください。

※しないと以下のように request header field is not allowed by access-control-allow-headersといったエラーが出ます。 f:id:kaminashi-developer:20210224132523p:plain

これにて実装は全て完了です。

  1. のWebアプリから、

  2. admin@example.comuser@example.comのアクセス許可されたユーザーでログインするとPetsTypeが表示される f:id:kaminashi-developer:20210224132559p:plain

  3. deny@example.comのアクセスが許可されていないユーザーでログインすると403のErrorが表示される f:id:kaminashi-developer:20210224132615p:plain

(簡易なページで申し訳ないです・・)

であればAPI Gateway Lambda オーソライザーでの認証およびユーザー別のアクセス制御ができたと思います!

やってみた感想

今回やってみた感想として、Firebaseでも簡単にLambda Authorizerを利用した認証ができるという印象でした。

この様子ですとAuth0やAmazon Cognitoといった他のOAuthプロバイダーでも問題なく実装できるかと思います。

なお、今回は私のスキルスタックからGolangでLambda関数を作成しましたが、コンソールでコード編集ができず、検証時は毎回zipのアップロードを行うのが手間でしたので、PythonやNode.jsが得意な方はそちらで実装した方が楽かと思いました。

今回はユーザー毎の認可部分の設計が深くできなかったため、次回はそのあたりも実装できればと考えております。(adminとuserで実行可能なリクエストを分けたりしたかった・・)

最後までご覧いただき、ありがとうございました。 コードの不備やご意見等あればコメントいただけますと幸いです。

最後に

弊社では一緒に開発してくれるエンジニア(正社員・副業)を絶賛募集しています! デスクレスSaaSのプロダクト開発に興味がある、話を聞いてみたい方のご応募をお待ちしております!

open.talentio.com

NoSQLで悩ましいインデックスとアクセス権限管理を、Firestoreのサブコレクションで実装する

概要

FirestoreはNoSQLの中でもデータ構造に特徴があります。 本記事では私自身が実際に設計したデータ構造がサブコレクションでどう変わったかをみていくことで、サブコレクションでできることを書いていきたいと思います。

開発環境

Firestoreについて

Googleが提供しているNoSQLのマネージドサービスです。 他のNoSQLと比較してWeb, Mobileからローカルデータベースのように利用できることやデフォルトで単一フィールドのインデックスが作成されていることが特徴です。

データ構造

今回はチャット機能のデータを考えてみます。 登場人物は以下になります。

  • ユーザー
  • チャットルーム
  • 投稿(コメント、画像を含む)

チャットルームには2人以上のユーザーが参加することができます。

f:id:kaminashi-developer:20210125093352p:plain
チャット機能のデータ構造

Before

改善する前の設計方法を書いていきます。

Posts コレクションに Rooms のIDを持たせてクライアントで結合していました。

f:id:kaminashi-developer:20210222101821p:plain
改善前のPostsコレクション

特定の Rooms に含まれる投稿を作成順に取得するときは以下のようになります。

const db = firebase.firestore()
const collection = db.collection('posts')

collection
  .where('roomID', '==', '0ff04b17-5e8f-11eb-ac15-067462cbca66')
  .orderBy('createdAt', 'asc')
  .get()
  .then((docs) => {
    docs.forEach((doc) => {
      const post = doc.data() // { text: 'こんにちは'... }
    })
  })

この場合、以下のような課題がありました。

  1. 投稿を取得するために roomID および createdAt のフィールドを条件にしているため複合インデックスを作成する必要がある
  2. Posts コレクションに対してセキュリティルールを作る情報が足りていない

1について、Firestoreはデフォルトで単一フィールドのインデックスが有効になっています。しかし、複数条件に対してはインデックスの条件ををあらかじめ指定しておく必要があります。そのため今後新しく検索したいパターンが増えるたびに複合インデックスを追加する必要があります。

2について、 Posts コレクションはチャットルームに参加しているユーザーデータを持っていません。そのためセキュリティルール上で参加者以外閲覧不可のような設定ができなく、Rooms に含まれるユーザーデータを複製して持たせないといけないです。複製するとユーザーが Rooms から抜けたりしたときに、 Rooms 内の全 Posts コレクションのユーザーデータを更新する必要があります。

これらを改善するためにサブコレクションを使いました。

After

Beforeでは RoomsPosts のパスはそれぞれ /rooms/posts とそれぞれ別のルートから作成していました。サブコレクションを使うとパスは /rooms/{roomID}/posts として作成します。

f:id:kaminashi-developer:20210125093445p:plain
改善後のPostsコレクション

改善前でも書いた Posts コレクションを取得する処理は以下のようになります。

const db = firebase.firestore()
// コレクション作成時にroomsのIDを指定
const collection = db.collection('rooms/0ff04b17-5e8f-11eb-ac15-067462cbca66/posts/')

collection
  .orderBy('createdAt', 'asc')
  .get()
  .then((docs) => {
    docs.forEach((doc) => {
      const post = doc.data() // { text: 'こんにちは'... }
    })
  })

Rooms のIDがWhere文からCollectionの取得条件になっているため単一フィールドのインデックスで取得できます。 また Rooms に適応するセキュリティルールを Posts に反映できるので、ユーザーデータを Posts に持たせる必要がなくなりました。

投稿の追加は以下のようにできます。

const db = firebase.firestore()
const collection = db.collection('rooms/0ff04b17-5e8f-11eb-ac15-067462cbca66/posts/')

const uuid = uuidv4()
collection
  .doc(uuid)
  .set({
    id: uuid,
    ownerID: currentUser.uid,
    text: 'はじめまして',
    type: 'message'
  })

まとめ

サブコレクションを使うことで1対多のデータ構造のインデックスおよびセキュリティルールを簡略にできました。 最初に設計したIDによる結合方法とサブコレクションはデータ構造に応じて柔軟に選択することができます。

今後、設計時にセキュリティールールやインデックスも考慮して拡張しやすい構造を作っていきたいです。

React Native Debuggerを使った、React Native+Expo開発の流れ

こんにちは、カミナシの@tomiです。

React Native + Expoで開発する上で、React Native Debuggerが自分にとって必須のものとなっています。 このツールを使って日々どういった感じで開発しているかをまとめました。

f:id:kaminashi-developer:20210130182519p:plain

他にもReactotronやReact Native Toolsなどのデバッグツールもありますが、私は様々な情報をまとめて確認できるReact Native Debuggerに落ち着きました。

スタイルの検証やpropsのデバッグ、Reduxのstate確認にactionの発火確認などができ、開発する上で欲しい要素全てが揃っています。

Expoの標準デバッガーへの不満

Expoに標準で搭載されているElement Inspectorでは、選択した要素のスタイルの確認はできますが、プロパティの変更をしたり、コンポーネントが持っているstateやpropsの確認などできないので、はっきり言ってなんの役にも立ちません、、

標準デバッガーだけで戦っている人は、直ちに導入をおすすめします。

React Native Debuggerを使って行っていること

React Native Debugger では、以下の機能を利用することができます。

  • DevTools
  • React Developer Tools
  • Redux DevTools

それぞれの機能を使って、どういったことをしているかを見ていきましょう。

DevTools

ChromeのDevToolsと同様の機能をもっています。

Consoleでのconsole.logの確認をしたり、NetworkでAPIのレスポンスを確認ができます。

ConsoleはChromeと同様

f:id:kaminashi-developer:20210125232303p:plain

Consoleでやっていること

  • useEffectが期待するタイミングで発火しているかを確認するのに、console.logを仕込んで確認しています。
  • AsyncStorageが更新されているかを確認するのに使ったりもしています。
    • デバック用にwindow.hoge = ・・・とすれば、Consoleから実行できます。

Networkでは、Enable Network Inspectを有効にすると、APIの結果を確認できます。

React Native Debugger上で右クリックして、Enable Network Inspectをクリックすることで有効にできます。

f:id:kaminashi-developer:20210125232324p:plain

APIのレスポンス確認。

f:id:kaminashi-developer:20210125232347p:plain

Networkでやっていること

  • リクエストのパラメーターの確認
  • レスポンスの確認
    • APIのレスポンスが正しければ、フロントエンド側に原因があると切り分けができます。

React Developer Tools

Chromeの拡張ツールであるReact Developer Toolsと同様の機能があります。

コンポーネント毎にstyleやprops,hooksの確認が行えます。 値を変更することもできるので、スタイルの微調整などに役に立ちます。

また、コンポーネントひとつひとつの中身を確認できるので、どこでバグが発生しているかを見つけやすくなります。

f:id:kaminashi-developer:20210127230721g:plain

React Developer Toolsでやっていること

  • スタイルの微調整
    • fontSizeやmarginの調整、言うことを聞いてくれないflexの調整に役立ちます。
  • Textのchildrenを変更して、短長なテキストでのスタイル崩れが無いかの確認、改行の確認を行っています。
  • props, stateを確認して期待しない動作をしているコンポーネント探しをしています。

Redux DevTools

Chromeの拡張ツールであるRedux DevToolsと同様の機能があります。

Actionが正しく発火されているか、Actionによって正しくStateが変更されているかを確認できるので、バグがReduxが原因なのかComponentが原因なのかの切り分けができます。

f:id:kaminashi-developer:20210125232700g:plain

Redux DevToolsでやっていること

  • Stateの中身の確認
  • Actionの期待通りに発火しているか確認しています。
  • Action毎のStateの差分が確認できるので、期待通りに変更されているかを確認しています。

まとめ

React Native + Expoで開発を進めていくのに、

  • スタイルの確認・調整
  • コンポーネント内の値がどうなっているのか
  • ReduxのActionが発火しているか、値がどうなっているか
  • APIから正しい値が返ってきているか

といったWeb開発と同等の機能が提供されているので、スムーズに開発ができています。

React Native + Expoで開発をするときは、React Native Debuggerの導入をおすすめします!