FlutterとFirebaseとFacebookと、クロスプラットフォームログインのはなし

この記事はJX通信社 Advent Calendar 2019 6日目の記事です。

Flutter に Firebase Authentication と Facebook認証を組み合わせる話

こんにちは、Flutter(Andorid)エンジニアのぬまっちです。(@nuMatch)
先月は登壇してきましたブログ

tech.jxpress.net

を投稿しました。

記事の最後に予告した通り、今回はFlutter と Firebaseの相性の良さについて筆を走らせたいと思います。

特にFirebase AuthenticationFacebook認証が今回の主役になります。

おしながき

  • ログイン機構と3つのF
  • Firebase Authentication の仕組みについて
  • Flutter側での実装

ログイン機構と3つのF

初めに、この記事は

Flutterでクロスプラットフォームアプリを構築したエンジニアが、
少ない労力でユーザーログイン機能を実装できる世界

を目指して、 FlutterアプリにFirebase Authentication (以下、Firebase Auth)を実装し、 Facebookアカウントを用いたログイン機能を実現させることを目的としています。

彼らをまとめて3つのFと呼称したいと思います。

アプリを作る上でユーザーを識別したい、という場面は想像に難くありません。
それくらいにアカウント管理という機能は私達に密接ですが、いざ組み込もうと思うと考慮しなければいけない事は無数にあります。

当然、セキュリティ対策は考えなければなりませんし、ユーザーにとってもログインのし易い作りになっている事も重要です。

Flutterを選択して効率の良いクロスプラットフォームアプリを構築された方にはぜひ、Firebase Authを組み合わせて欲しいです。

Firebase Authentication の仕組みについて

Firebase Authとはメールアドレスとパスワードによる管理のほか、 FacebookやTwitterといった複数のプロバイダOAuth トークンによるログイン認証に対応したBaaSのことです。

世の中にはすでに各プロバイダから Facebook OAuthや Twitter OAuthを使ったログイン機能が溢れていますが、
Firebase Authを組み合わせる事によって、ダッシュボード上での複数プロバイダの管理を行うことが出来るようになります。

f:id:numatch-jx:20191206115157p:plain:w500

上記のダッシュボードでは、Firebase Authが管理出来るログインプロバイダ覧が見て取れると思います。 また、この画面から各プロバイダの有効/無効の管理をする事も出来ます。

つまりメールアドレス&パスワードによるログインユーザーと、各プロバイダのログインユーザーを紐付けて
Firebase Auth上では一人のアカウントとして管理する事が出来るという事なのです。

f:id:numatch-jx:20191206170421p:plain

Cloud FirestoreのようなFirebaseが提供するオンラインデータベースサービスも、
Firebase Auth上のアカウント単位でユーザーを管理する事出来ます。

Facebook認証の準備

今回はフェデレーションIDプロバイダ*1としてFacebookを選択しています。 他のプロバイダよりも実名で登録されている事が多いので、そういった理由でプロバイダを選択するのもいいかもしれません。

今回、詳細は割愛させて頂きますがFacebook認証をアプリに実装するまでの簡単な流れを説明します。

  1. Facebook Developer上でアプリIDを申請する

下記サイトから申請することが出来ます。(要 Facebook開発者アカウント) developers.facebook.com

iOS、またはAndroidのページから新規のアプリIDを申請しますが、Facebook Developer上のアプリIDは一つのものとして管理されます。

2.開発環境を設定する

Flutterアプリとして iOS / Android のクロスプラットフォームに対応するなら、両OS用に設定が必要になります。 それぞれ日本語ドキュメントでしっかり説明しているので、順番に対応しましょう。

  • iOS

iOS - Facebookログイン - ドキュメンテーション - Facebook for Developers

Flutter上に実装するなら 4.プロジェクトを構成する まで準備すれば問題ないと思います。

  • Android

Android - Facebookログイン - ドキュメンテーション - Facebook for Developers

こちらも全部やる必要はありません。 7. アプリのシングルサインオンを有効にする まで準備しましょう。

注意する点としては

  • iOS : バンドルID
  • Android : パッケージ名,Default Activity のクラス名

を記入する必要があります。Flutterアプリ側で確認して間違えの無いように設定しましょう。

  1. Firebase Auth とFacebook 認証側を紐付ける

Facebook開発者画面で先ほど設定したアプリの設定画面に入るとアプリID , app secret を確認する事が出来ると思います。

f:id:numatch-jx:20191206120415p:plain

次に、Firebase Auth のダッシュボードでログインプロバイダの中からFacebookを選んで有効にして下さい。

するとアプリID , アプリ シークレット を入力する項目があるので、それぞれFacebookアプリ側のものを入力しましょう。

f:id:numatch-jx:20191206120606p:plain

また、Firebase Auth側には OAuth リダイレクト URI が表示されるので、
Facebookアプリ側の クライアントOAuth設定画面有効なOAuthリダイレクトURI に貼って上げましょう。

f:id:numatch-jx:20191206120819p:plain

これでFacebook認証の準備は完了です。

Flutter 側の実装

いよいよFlutterアプリ側への実装です。

大まかには

  1. Facebook認証を行ってOAuth トークンを取得する
  2. OAuth トークンをFirebase Auth側に渡す
  3. Firebase Auth 側で認証情報を検証してクライアント(Flutterアプリ)にレスポンスを返却する

という流れになります。

まず、Firebase Auth用とFacebook認証のFlutterプラグインを実装しましょう。

【pubspec.yaml】

dependencies:
  flutter:
    sdk: flutter

  firebase_auth: ^0.15.1
  firebase_core: 0.4.2+1
  flutter_facebook_login: ^3.0.0

(各プラグインのVersionは執筆時点のものです。Dart packages にて最新Versionをご確認ください。)

【dart】
  var facebookLogin = FacebookLogin();
  var permissions = ["email"]; // ユーザーがFacebookに登録してるメールアドレスを要求している。複数要求できる。
  var facebookLoginResult = await facebookLogin.logIn(permissions); // ログイン認証の結果が返却されている

上記の内容が実行されると、アプリ上でFacebookに遷移してログイン認証が行われます。 リダイレクト先の設定(Firebase Auth とFacebook認証の紐付け)が正常に出来ていれば、
Facebookにログイン後、Flutterアプリ側に戻ってくると思います。

【注意】
Facebook Login プラグイン*2のReadmeに紹介されている実装方法は古くなっています。 Version3.0.0以降で使うのなら上記の書き方にしてください。

↓Readmeでは下記の書き方のままになっている。

facebookLogin.logInWithReadPermissions(['email']);


facebookLoginResult.status でログインの状態を確認出来ます。

ログインステータスが、 FacebookLoginStatus.loggedIn で返却されているならログイン成功です!

次にFirebase Auth 側にユーザーアカウントを作成しましょう。

【dart】
/// FirebaseAuth インスタンスの取得
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final FirebaseUser _user =  await _createFirebaseUesr(_auth, facebookLoginResult);

/// FirebaseAuth のインスタンスとFacebook認証トークンを使ってFirebase User を作成する
   Future<FirebaseUser> _createFirebaseUesr(
    FirebaseAuth _auth, FacebookLoginResult _result) async {
  var facebookAccessToken = await _result.accessToken;
  final AuthCredential credential = FacebookAuthProvider.getCredential( accessToken: facebookAccessToken.token);
  final AuthResult authResult = await _auth.signInWithCredential(credential);
  return authResult.user;
}

この

AuthResult.user

が取得出来ていればFirebase Authによるログイン認証は成功です!

Android iOS
f:id:numatch-jx:20191206163235g:plain f:id:numatch-jx:20191206163253g:plain

↑はサンプルアプリですがFirebase Authでのログインが成功して、Firestoreに接続出来ている状態です。

Firebase Auth のダッシュボードでユーザータブを確認すると、識別子にFacebookのメールアドレスが充てられたユーザーがいると思います。

また、ユーザーUIDがFirebase上で横断して使われるユニークIDになっており、 このIDで Cloud Firestore等の別サービスとの紐付けが行われます。

おわりに

今回はFlutter × Firebase Auth × Facebook認証 という3つのFを組み合わせた方法で、
アカウントを管理する手順を紹介しました。

限られたリソースでiOS、Androidのクロスプラットフォームを実現しつつユーザーアカウントも管理出来るようになる、一つの方法として有効なのではないでしょうか?

結婚式のスピーチではよく、「3つの袋」の話が使われますが、今度は代わりに3つのFをネタするのも良いじゃないかな?と思います!

*1:ログイン認証に使われるプロバイダ。Facebookの他にはTwitterが多く使われる。

*2:flutter_facebook_login | Flutter Package

Auth0を使ってSPA(Vue.js, Python)の認証機能を作ってみた

はじめに

インターンでお世話になっている、コウゲと申します。現在担当させていただいている仕事は業務で使用している管理画面のマイクロサービス化です。

マイクロサービス間をつなぐ認証機能が必要だったので、認証プラットフォームとして使いやすそうな 「Auth0」で どんなことができるのか、実現したいことは可能なのかを検証してみることになりました。

なぜAuth0なのか

  • ダッシュボードの設定 + 少ないコード で認証機能が簡単に実装できる。

  • Auth0専用ライブラリやSDKが多数存在し、目的に応じてそれを利用することで 簡単にログイン機能を実装することができる。

実現したいこと

f:id:kohgeat:20191120183944p:plain

発表

ありがたいことに、調べた結果を社内で発表する場を設けていただきました。

ブログの内容より少し詳しく書いています。読んだら即実装できるようなスライドを心がけて作りました!

speakerdeck.com

Vue.jsのSPAに認証機能を追加

Vue.jsのSPAにログイン機能を追加するだけなら、このチュートリアルさえやればできるようになる感じでした。

この他にも、Auth0は公式が出しているドキュメント類が豊富なので(分からないことを調べると最終的に公式ドキュメントに辿り着く)、とても良いプラットフォームだなと感じました。

スライドでハンズオンっぽくまとめてあるので見ていただけたら幸いです。

auth0.com

バックエンド(Python)について

SPAにおけるAuth0認証の流れは以下のようになっています。

f:id:kohgeat:20191127165519p:plain
参照元(https://auth0.com/docs/microsites/call-api/call-api-single-page-app)

画像の通り、バックエンドのAPIは フロントエンドのSPAとしか繋がっていません。

Auth0 はJWTを用いて認証しているので、フロントエンドがログイン成功時 Auth0 サーバーから受け取るJWTをバックエンドへ渡し、そのJWTに書かれている署名が正しいかを公開鍵でチェックすることでバックエンドに認証機能を持たせることができます。

JWT って何?

JWT: 電子署名付きのJSON

ユーザーの情報, 発行元, 電子署名の形式 とかがJSONで書かれていて、改ざんされてないことを電子署名で示しているものです。 電子署名は人間がハンコを押す感じと同じ気がしています

f:id:kohgeat:20191127172106p:plain
JWTがどんな感じか見れます(https://jwt.io/)

上の画像を見ると payload の部分に permissions: [ "read:api" ] があり、これがRBACに必要な部分です。

RBACって何?

RBAC: 役割ベースのアクセス制御

簡単に説明すると、ログインしているユーザーに [read:api] みたいな役割を持たせて、これを持っているユーザーだけがAPIにアクセスできるようにすることです。

auth0.com

JWTに "permissions": [read: api] みたいな項目 を加えるにはAuth0 ダッシュボード側で設定が必要で、発表スライドで設定方法を説明しています。

バックエンドの処理の流れ

f:id:kohgeat:20191127180015p:plain
fastAPでJWTの検証を行う方法(スライドから引用)

Auth0でSPAの認証機能作成時、バックエンドAPIに "JWTの検証" という処理が必要になります。

auth0.com

最初この言葉の意味が分からなかったのですが、簡単に言うと、『JWTが書き換えられていないか』、『Auth0が発行したJWTか』を電子署名を用いて確認しています。(JWTの説明もスライドにまとめているので気になった方は見てみてください。)

そしてこの "JWTの検証" を行うために、以下の三つ処理を バックエンド に追記しました。

  1. フロントから認証用の JWTトークン を受け取る
  2. Auth0API へ JWK(JWTトークン検証用の公開鍵) をリクエスト
  3. JWK を用いて JWTトークン の検証(署名が適切かの検証)を行う (使用ライブラリ: https://python-jose.readthedocs.io/en/latest/jwt/api.html)

それと、今回はログインしているユーザーによってアクセスできるエンドポイントを制御したい( RBAC を実現したい)ので permissions をチェックするコードも追加しています。

以下書いたコードとスライドの引用です

f:id:kohgeat:20191127180015p:plain
fastAPでJWTの検証を行う方法(スライドから引用)

# JWT検証用のクラス

import requests
from jose import jwt
from fastapi import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from starlette.requests import Request
import enum


auth_config = {
    'domain': "dev-928ngan9.auth0.com",
    'audience': "https://api.mysite.com",
    'algorithms': ["RS256"]
}


class Permission(enum.Enum):
    READ_API = 'read:api'


class JWTBearer():
    def __init__(self, auth_config, permission: Permission, verify_exp=True):
        self.auth_config = auth_config
        self.permission = permission
        self.verify_exp = verify_exp

    def get_jwk_from_auth0API(self):
        # Auth0APIからJWK(公開鍵)を取得
        json_url = f"https://{self.auth_config['domain']}/.well-known/jwks.json"
        jwks = requests.get(json_url).json()
        return jwks

    def get_token_from_client(self, request: Request):
        # フロントエンドから送られてきたJWTの必要な部分を取得
        auth = request.headers.get("authorization", None)
        _, token = auth.split()
        return token

    def check_permissions(self, token):
        # RBAC用にpermissionsの確認
        permissions = jwt.get_unverified_claims(token)['permissions']
        if self.permission.value in permissions:
            return True
        else:
            return False

    async def __call__(self, request: Request):
        # このクラスを関数的に使えるようにする
        jwks = self.get_jwk_from_auth0API()
        token = self.get_token_from_client(request)
        unverified_header = jwt.get_unverified_header(token)
        rsa_key = {}

        for key in jwks['keys']:
            if key['kid'] == unverified_header['kid']:
                rsa_key = {
                    "kty": key["kty"],
                    "kid": key["kid"],
                    "use": key["use"],
                    "n": key["n"],
                    "e": key["e"]
                }
        if rsa_key:
            try:
                payload = jwt.decode(
                    token,
                    rsa_key,
                    algorithms=self.auth_config['algorithms'],
                    audience=self.auth_config['audience'],
                    issuer="https://" + self.auth_config['domain'] + "/",
                    options={'verify_exp': self.verify_exp}
                )
                if self.permission:
                    if self.check_permissions(token) is False:
                        raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")
            except jwt.ExpiredSignatureError:
                    raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")
            return payload
        raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")

まとめ

Auth0は認証機能を作成したことがなかった自分でも簡単に扱えたので、すごいサービスだなと感じました。そして、公式ドキュメントに基礎から実装方法まで丁寧に書いてあるので勉強にもなります。

これからSPAやアプリに認証機能を追加したいと考えている方は使ってみてはいかがでしょうか?

さいごに

今回、Auth0の技術検証から発表、ブログ執筆までやらせていただいたのですが全ての段階でためになることばかりでした。技術検証では認証機能の裏で動いていることを知るきっかけになり、プログラミングでブラックボックスにしていた部分が一つ明るくなった気がします。

この場をお借りして、様々な知見を与えてくださったエンジニアの方々や発表を聞いてくださった方々、お世話になっている会社の方々にお礼を申し上げたいと思います。ありがとうございます!

明日の記事は ぬまっち(@nuMatch) さん の Flutter と Firebase Authentication の話です!

Apollo のキャッシュ機構、完全に理解した(い)

この記事は JX 通信社アドベントカレンダーGraphQL アドベントカレンダーの4日目です

こんにちは、JX通信社の小笠原(@yamitzky)です。普段はエンジニア部門の統括をしています。

弊社の NewsDigest ではアプリ向けのバックグラウンド API として GraphQL を使っています(サーバーは gqlgen、アプリはライブラリなし)。

tech.jxpress.net

GraphQL の技術スタックとしては Apollo というライブラリが有名だと思うのですが、弊社では使ったことがありませんでした。そこで、今年の 9 月に箱根で開催した開発合宿にて、Apollo Client(React) と Apollo Server の検証を行いました。

Apollo Client を使うときに唯一鬼門だったのが、Apollo の持っているキャッシュ機構です。本日は、Apollo のキャッシュを完全に理解した(い)小笠原が、完全に理解するための方法をお教えいたします。想定読者としては、Apollo はなんとなく触ったことがある方です。

Apollo とは

Apollo は GraphQL のためのプラットフォーム(OSS群というのがわかりやすいでしょうか)で、

  • Apollo Server・・・GraphQL の API(バックエンド)を作るためのもの
  • Apollo Client・・・GraphQL のフロントエンド向けライブラリ。React や Vue などと連携できる
  • Apollo iOS、Apollo Android・・・アプリ向けのクライアントライブラリ

などの OSS から成り立ちます。今回はこの中の Apollo Client 中心の話です。

Apollo Client は、ざっくりと

  • API クライアント本体
  • キャッシュ・状態管理
  • コンポーネントから便利に取ってくるクライアントのラッパー(Hooks)

のような機能から成り立っています*1。雑な例えとしては、Axios(通信)、Redux(状態)、React Redux Hooks(便利ラッパー) を all-in-one にしたようなライブラリ、というようなイメージです。

f:id:yamitzky:20191204201343p:plain

使っているときにはあまり意識しないかもしれませんが、何か困ったら どの部分の問題なのか切り分けて調査をするのが重要 です。また、Apollo Client はキャッシュなしで使うこともできますし、React を経由せず Client 本体だけを使うこともできます。

Apolloのキャッシュの役割

Apollo のキャッシュの役割は、単に「キャッシュ」というよりは、「正規化された、Redux のような巨大な単一の state」という概念が近いです。

例えば次のように画面遷移するユーザー管理アプリを考えたとき、ページ遷移するたびに通信するのではなく、メモリ上の状態を賢く再利用したいケースがあると思います。*2

f:id:yamitzky:20191204204216p:plain

が、実際にはこのあたりが高機能過ぎて、キャッシュの仕組みが複雑であるという意見もあるようです。完全に理解したい。

Apollo キャッシュの読み書き

Apollo のキャッシュ読み書きする際の操作は、次の4つです。

  • readQuery
  • writeQuery
  • readFragment
  • writeFragment

{read,write}Queryはトップレベルの状態の読み書き、{read,write}Fragmentは構造化されたエンティティの読み書きというイメージです。この4つの読み書きと状態変化を理解したら「完全に理解した」と言えるでしょう(か)。

これらの操作は、基本的には ApolloClient 側で勝手に状態更新をする(故に難しい)のですが、自力で使うこともできます。例えば「あるアイテムを新規作成する mutation」など、自力で操作せざるを得ない場合もあります。

Apollo キャッシュを完全に理解する

では、 node の REPL を使って、この4つの読み書きを完全に理解してみます。先程の例でいうところの「キャッシュ」は使うが、「API クライアント」「コンポーネントから便利に取ってくるクライアントのラッパー」は使わないイメージです*3

# ライブラリをインストール
npm install graphql graphql-tag apollo-cache-inmemory
# repl を起動
node

node の REPL には次のように入れてください。

// ライブラリの読み込み
const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache
const gql = require('graphql-tag')

// クライアントの初期化
const cache = new InMemoryCache()

ここまでで事前準備は完了です!

まずは、writeQuery を実行してみます。これは、クエリの発行をしたときにキャッシュにデータを追加/更新するためのものです。writeQuery の引数は、クエリと、GraphQL で取得したデータです。

// name=john に該当するユーザー一覧を取得
cache.writeQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`,
    data: { users: [{ id: '1111', name: 'john', __typename: 'User' }] } 
})

さて、これによってデータはどう変わっているか、確認してみましょう。

// 不格好なフィールド名からお察しかもしれませんが、.data.data は TypeScript だと private 扱いです
console.dir(cache.data.data, {depth: null})

// 結果(一部略)
{
  'User:1111': { id: '1111', name: 'john', __typename: 'User' },
  ROOT_QUERY:
   { 'users({"name":"john"})':
      [ { type: 'id',
          generated: false,
          id: 'User:1111',
          typename: 'User' } ] } }

結果の中身は Object (連想配列/辞書型的な扱い)で、

  • 型名:id というキーに対して、その ID にあたるアイテムの中身が格納される
  • ROOT_QUERY というキーに対して、 { "変数つきのクエリフィールド": [ アイテムへの参照 ] } という構造でクエリ結果が格納される
  • データは正規化されている

となっています。また、GraphQL スキーマは InMemoryCache に与えていないので、この処理は 動的に行われている というのも注目ポイントです。

次に、 readQuery をしてみます。

cache.readQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`
})

// 結果
{ users: [ { id: '1111', name: 'john', __typename: 'User' } ] }

クエリ結果は正規化した状態で格納されていたので、正規化されたデータを結合しながら、キャッシュされた結果を返しているということがわかります。

次に、writeFragment をしてみます。writeFragment は GraphQL のフラグメントのデータを更新(例: あるユーザーの名前を変更する、など)のためのものです。

// john さんは yamada さんに改名します
cache.writeFragment({
    id: 'User:1111',
    fragment: gql`fragment UserFields on User {
        name
    }`,
    data: { name: 'yamada', __typename: 'User' }
})

console.dir(cache.data.data, {depth: null})
// 結果
 {
  'User:1111': { id: '1111', name: 'yamada', __typename: 'User' },
  ROOT_QUERY:
   { 'users({"name":"john"})':
      [ { type: 'id',
          generated: false,
          id: 'User:1111',
          typename: 'User' } ] } }

この結果からわかることは、

  • 正規化された個別のアイテムに対して更新できている
  • name というフィールドだけ部分更新ができている

次に、readFragment をしてみます。

cache.readFragment({
    id: 'User:1111',
    fragment: gql`fragment UserFields on User {
        name
    }`
})

// 結果
{ name: 'yamada', __typename: 'User' }

この結果から、指定した ID のアイテムが取れてる他、fragment に指定したフィールドだけの部分取得もできていることがわかります! {read,write}Fragment は GraphQL の fragment のための機能ではありますが、実際には、正規化された各アイテムを部分取得/更新するためのものに過ぎないと言えるでしょう。

最後に、再度 readQuery をして、クエリ結果が正しく更新されたことを確認しましょう。

cache.readQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`
})
{ users: [ { id: '1111', name: 'yamada', __typename: 'User' } ] }

無事、クエリ結果としても、名前が変更されていますね!

まとめ

今回は、Apollo Client の鬼門?であるキャッシュの読み書きを完全理解しました。 おっとすみません、完全に理解したは言い過ぎました。

ポイントとしては、

  • Apollo Client は「クライアント本体」「キャッシュ」「便利ラッパー」の要素からなる
  • Apollo Client のキャッシュを理解するには、 cache.data.data を見るのが手っ取り早い
  • キャッシュの中身は、正規化された巨大な Object
    • writeQuery は、クエリ結果を ROOT_QUERY などに正規化した状態で保存する
    • readQuery は、正規化した状態で保存された状態を結合しながら、キャッシュ結果を返す
    • {read,write}Fragment は、正規化されたアイテムを部分的に読み書きするためのもの

といったことを紹介しました。

それでは、良い GraphQL ライフを!

*1:実際の概念としては通信周りを Link として切り出しているので、あくまでイメージ図です

*2:fetchPolicy を変更すれば、逆にキャッシュを使わずに常にアクセスしに行くとかもできます

*3:今回は簡略化のため ApolloClient を経由せずキャッシュを直接触っていますが、この4つの読み書きは、ApolloClient を通じて行うべきです

データをいい感じに活用する文化を育てる - アジャイルな言語化とその取り組み

JX通信社Advent Calendar 2019」3日目の記事です.*1

昨日は, @kimihiro_nさんの「Scala で書いたマイクロサービスを Go で書き直した話」でした.

改めまして, こんにちは. JX通信社でエンジニアをしています, @shinyorke(しんよーく)と申します.

今年の10月に入社してから, CTO室*2のデータ・エンジニアとして基盤の構築から運用のサポートまで色々しています. ちなみに趣味は野球のデータ分析です.*3

入社後最初のエントリーとして, 私が好きかつ情熱を持って育てているデータ基盤の話を語りたい...ところですが!?

  • 入社してプロジェクト内でやった言語化は記録・知見として残しておきたい.
  • 世の中, アジャイルなやり方・プラクティスは多数あれどデータ・サイエンスや分析という文脈での言及って案外少ない.
  • チームメンバー以外からの評判も良かったし今後もやりたい.

と思い, 今回は,

「データをいい感じに活用する文化を育てる」ためのアジャイルな言語化とその取り組み

というテーマでお送りしたいと思います.

なお, 予め断っておくと,

ほとんどがアジャイルのお話で, データサイエンス・データ基盤の話は一切出てきません.*4

文化作りのためにやったこと, これから育てるぞ!という意思表示として見て頂けると幸いです(&期待されてた方申し訳ない).

TL;DR

  • 「Be Agile(アジャイルな状態)」を目指す価値とし, 言語化の手段・プラクティスを状況によって変えていく, そして計測可能であることが重要.
  • 「アジャイルな状態」かどうかは, 常に「アジャイル・マニフェスト」と向き合う.
  • 手段・プラクティスは「言語化が必要な事柄の濃淡」に合わせていい感じに(選球眼が大切). 最後は見るべきメトリクス・データが言語化されるように引っ張る.

目次

Who am I ?(お前誰よ)

  • @shinyorke(しんよーく)
  • Pythonと野球, そしてアジャイルの人. *5
  • 認定スクラムマスター(CSM)持ってます&過去に在籍したチーム何社かでスクラムマスターをやってました.

アジャイルな言語化 #とは

アジャイルな状態とはなんぞや?という話を元にアジャイル・マニフェストをふりかえりつつ, 自分が心がけていること・やっていることを紹介します.

アジャイルな状態

これはシンプルに言うと,

黙っていても対話・動くソフトウェアを元に変化への対応ができている状態(なお手段は問わない)

と私は解釈しています. もうちょっと具体例を上げると,

  • フレックスやリモートで働いていても, チーム・メンバー間の対話・コミュニケーション(対話)が取れている
  • CIやテスト自動化等の手段で常に「動くソフトウェア」が手元に届く状態である
  • ダッシュボード(例えばRedash)もしくはBigQuery等のデータを見ながら, 対象ユーザーさん(つまり顧客)の状態を把握し, 上手く変化できている

という世界に近づいているか?ということです.

今挙げた例だと実現しているところもあればまだまだストレッチ・改善できる所もありますが, 日々起こる状況・ISSUEが何処に刺さる話か?を私は重要視してみています.

また, 手段を問わないのは重要で,

アジャイルな状態ができていれば, 手段はスクラムだろうがXPだろうがモブだろうがなんだっていいのです, 自分で作っても構わないし.

ぐらいに考えて手段・施策をアレンジして適用しています.*6

言語化も同様で,

  • 運用しているのでメトリクスを監視している
  • いま一生懸命開発してる
  • スケジュールは決まったけど何をどの順番で(ry
  • 何をやればいいんだったっけ?

という, チーム及びプロジェクトの解像度の違いでいい感じに変えていけばいいかなという感じでやりました.

言語化のフェーズを絵にした

そんなJX通信社のチーム&メンバーに合わせて言語化のフェーズ・選ぶ視点がこちらの絵です.

f:id:shinyorke:20191202153715p:plain
フェーズによる言語化の区分け

ここから先は, 実際に自分が入社してからやったこと(もしくはこれからやること)ベースで書いていきます.

言い換えると, 「オンボーディングされた側の記録」です笑

1. 「さっぱりわからん」期

やったこと:「ひたすらヒアリングとメモ」

入社した直後(10月), 初日と2日目ぐらいまで.

通常通りの入社手続きにPCの設定をしながら, MTGに参加してた時期です.

幸いにも, 同じデータ基盤をやるメンバーが既に関係各所にヒアリング*7したり, 必要な技術・使う予定のFWやライブラリをまとめていてくれたので,

  • ヒアリングの結果および, 関連する文章・データをひたすら読む
  • 上記結果で(自分的に)腹落ちしていない・理解していない箇所をSlackで聞く
  • 公開メモは自分のtimes(times_shinyorke)やちょっとした独り言を自分宛てDMで記録

といったことを繰り返し, 次のフェーズにつなぐ言語化をしました.

2. 「断片的に理解した」期

やったこと:「一枚の絵にする, 議論する. 具体的にはインセプションデッキを書いた」

これは入社2日目でした.

色々と読んだり聞いたりして,

「断片的に理解した, だが全体像(ry」

という感じだったので次のアクションを,

  • ひとまず体系化されたドキュメントにする
  • テンプレに埋めるだけかつ, あまりお絵かきとかしなくていいもの*8
  • 途中段階でも議論できるもの, つまり完成を目指さない

という所に目標を置きました.

「埋めるだけのテンプレかつ, アジャイルな状態に持っていくことができて完成を目指さなくてもいい感じにまとまる」ドキュメントといえばインセプションデッキやろ!

と思いついたのですぐ書きました.

github.com

インセプションデッキ自体は, ずっと昔から使っていて書き慣れているので, 割とすぐ書けました&物事を説明するのに,「エレベーターピッチ」のスライドがいい感じの「一枚の絵(見ればわかるだろというスライド)」になるので実にやりやすかったです.

これを元に, チームメンバー・関係者とMTGしたりSlack上で議論したりといい感じに成熟しました.

なお, 今回は「データ基盤としてDWHとかWorkflowを何とかする」とゴールがある程度決まっていたので使いませんでしたが, もっとフワッとしたお題目だった場合は,

  • ビジネスモデルキャンバス
  • リーンキャンバス

などで状況を整理するつもりでした.*9

3. そろそろキックオフを...

やったこと:「議論した結果を閉じる. インセプションデッキをブラッシュアップ&関連ドキュメントを作る」

これは入社3, 4日目くらい.

議論したり新たに出たイシューを,

  • 技術調査したり
  • ちょっとお試ししたり(プロトタイピングなど)

しながら一個ずつ解くような事をしていました.

ただ, これをずっとやってるといつまで経ってもキックオフができないので,

  • インセプション・デッキのフワッとした箇所をクローズする
  • チームのOKRとインセプションデッキをつなぐ*10
  • インセプションデッキでは足りない箇所を補足するドキュメントを書く

といったことをしました.

これで試すことをやりつつも, データ基盤としての開発をスタートしました.

4. 具体化しないと開発できないよ

やったこと:「コードを書ける, すなわち開発できる状態にする. ざっくりなデザインドックを書く」

開発可能な状態(これは入社数週間ですね)になった時点で,

  • プロダクトの構成図だったりなんの技術使うかを言語化する
  • 試してOKな事, NGだったこと整理
  • そもそも仕様を書かないと(ry

というわけで,

  • 必要な項目のみに絞ったデザインドックを書く*11
  • 完成したもの, トライアルで動かせるものから順に仕様書を書く(例:テーブル定義など).

を作り, 順次公開しました.

この当たりのコツは, 「必要になったら作りはじめる, 早すぎる最適化はしない」といったあたりで,

  • 変更の可能性があるところはギリギリまで「言語化作業」の着手を遅らせる
  • 時が来たら, テンプレを決めて(作って)言語化

最後の数日でまとめてやったジャスト・イン・タイムで出していきました.*12

5. データ活用に向けて - ローンチから計測へ

これからやる:「監視可能な指標を提示し常に可視化できる状態をつくる」

お察しの通り, 今と未来のはなし, です笑

先月ようやっとデータ基盤がいい感じに使える状態になりました.

この先は,

  • プロダクト・サービスなど, 分析・監視対象に対してメトリクスを定義する
  • メトリクスを常に見れる状態にする
  • メトリクスを元に対話し, 変化に順応する

状態をデータ基盤を中心に作っていくことになります.

これらの打ち手はまだふわっとしていますが,

  • 既存のダッシュボード(Redash)をいい感じに使うとか
  • もっと別の可視化・言語化手段を活用するとか
  • 可視化やデータ分析をいい感じに楽しくやる習慣を作る

がメインになっていくかなと思っています.

一言で言うと,

計測可能なメトリクスをファクトとしていい感じに回せる世界

を作っていけたらと思います, 今もできてる所ありますがまだまだストレッチできるかなと.

この辺は未来の話なのでワクワクと苦労両方あると思いますがやっていきたいお気持ちです.

まとめ

というわけで, 自分がやってきたこと・これからやることを言語化しました.

この先も色んな状況やワクワクが待っていると思いますが,

「Be Agile(アジャイルな状態)」を目指す価値とし, 言語化の手段・プラクティスを状況によって変えていく, そして計測可能であることが重要.

という価値観・想いを元にやっていきたいと思います.

最後までお読みいただきありがとうございました.

次の方へ

次は@YAMITZKYさんの「Apollo、Prisma」あたりの話です.

【Appendix】参考文献

これらの思想の・施策の元になったものをいくつかご紹介します.

*1:同時に, 「DevLOVE Advent Calendar 2019」3日目でもあります.

*2:SRE(インフラ)やデータ基盤といった, サービス・プロダクトに紐付かない全社的な機能をサポートする横断的な部門です.

*3:自分のことはだいたいこの辺に書いてます.

*4:データ基盤やサイエンスの話は後日別エントリーで数本書く予定です, そちらを乞うご期待.

*5:この後いくつか引用も出てきますが, 「Lean Baseball」というブログでこの辺を色々と書いてます.

*6:これは実は前職から心がけていることで, 一つの例として昨年こんな発表をしました.

*7:これがホントに大ファインプレーでした, 一緒にやったチームメンバーに感謝しかない.

*8:余談ですが私はスライドを作るのが死ぬほど嫌いなので, なるべく手抜きしたい・コード書きたいという気持ちもありました.

*9:社内基盤だとユーザーが社員もしくは関係者, 解くべき課題も大体明確なのでインセプションデッキがいきなり作れそう, と判断しました.

*10:JX通信社では全社的にOKRを採用しています.

*11:文脈的には過去にやった仕事を参考にしました.

*12:必要になるまで読まれないのは辛いし何より「作ってから変更」が入ると, コードを書くのとドキュメント更新するのと二重苦を味わうので勘どころ大事です. なお誤解を招かないように言うと「ドキュメントはいらない」という意味ではありません.

Scala で書いたマイクロサービスを Go で書き直した話

この記事はJX通信社 Advent Calendar 2019 2日目の記事です。
昨日は、たっちさんの「Kubernetes Admission Webhookでリソース作成を自在にコントロールする」でした。

f:id:nsmr_jx:20191129095710p:plain

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 今回は長年動かしてた Scala のマイクロサービスのリビルドを行った話をしようと思います。

TL;DR

  • 新しい言語を投入するのにマイクロサービスは便利
  • Scala で感じていた問題点を解消しつつ Go へ移行できた
  • 消費メモリが大きく減って安定稼働できるようになった 

予防線を貼っておきますと、Scala より Go のほうがいいよね、といった本旨ではありません。

Scala で書いたマイクロサービス

弊社のマイクロサービスの一つにカテゴリ分類専用のサービスが存在します。 カテゴリやキーワードを登録しておくとルールベースでカテゴリ分類を行うもので、自然言語やMLを使うほどでもない大雑把な分類を低コストで実現するためのサービスです。

持ってる機能としては

  • カテゴリやキーワードを REST 形式で CRUD 操作できる
  • テキストを分類エンドポイントへ POST するとカテゴリ分類が実行される

というシンプルなものです。2015年4月に作り始めたのでもう4年以上稼働しているシステムになります。

このシステムは最初 Scala で作り始めました。当時社内で Scala 勉強会というのが行われており、そこで学んだ知識の実践のために Scala を使って実装をはじめました。 独立したサービスなので新しい言語を持ち込むには最適のタイミングだったと思います。 また計算的な処理も多いのでメインで使っている Python よりもパフォーマンス出せるのではないかという期待もありました。

いろいろあったものの (本筋ではないので省略) 無事サービスとしてリリースでき、今日まで稼働してくれました。 ただ4年間動かす上でインフラ周りの大きな変遷がありました。

インフラの変遷

f:id:nsmr_jx:20191129100411p:plain:w120 f:id:nsmr_jx:20191129100459p:plain:w100

当初は専用の EC2 を建てて、そこに Ansible でデプロイするという形式で動かしてました。インスタンス自体の管理をしなくてはならないものの、サーバーのリソースを専有して動かせるので安定して動かす事ができていました。

その後、Docker が全社的に流行りだし、このサービスも Docker の上で走るよう変更しました。ビルドした war ファイルを Docker Image に乗っけるだけだったので移行はスムーズでした。

Docker の運用も最初は ElasitcBeanstalk を使って専有インスタンスで稼働していましたが、会社の ECS の基盤が整ってからは共有のサーバーの上で動くようになりました。

共用サーバーになって顕在化してきたのが Scala のメモリ使用量の問題です。 1タスクをメモリの割当量が256MBでは安定して動かすことが出来ず OOM が頻発してしまいました。512MBや1024MB消費することもあったので実装上のメモリリークも疑わしかったのですが、そもそも最低専有量が大きくて共用サーバーでは扱いづらいサービスとなっていました。 Docker の上に JVM を載せてその上にアプリケーションを載せる2階建ての構造になっているので、どうしてもオーバーヘッドが大きくなってしまうみたいです。 (最近は GraalVM のようなものが出てきて事情が変わりつつあります。)

その他 Scala で発生していた問題

またメモリ以外に Scala を扱う上で発生していた問題がいくつか。

コンパイルが遅い

開発のタイミングで一番の課題はこちらでした。インクリメンタルビルドがあるものの、多くの場合コンパイル待ち・テスト待ちで手が止まってしまっていました。フルにビルドが走ってしまうと10分くらい待たされることもありました。普段はインタプリタの Python メインで書いていたのでなおさら待ち時間への戸惑いを感じました。

書き方の統一が難しい

Scala はとても高機能な言語です。オブジェクト指向でありながら関数型のパラダイムを取りこんでいる特徴を持ってます。 それ故にプログラマの Scala 熟練度によって書き方が変わってしまう問題がありました。 Better Java 的な書き方から、より Scala らしい書き方、関数型を意識した書き方などいろいろな表現方法があります。 プログラムを書く上で「こういう書き方あるよ」と教えてもらってスキルアップできるのはとても楽しいですが、システムを保守する上でネックになりがちでした。 全社的に Scala の知見があって、コーディングの方針が統一できていればよかったのかもしれませんがそこまでの体制は作れませんでした。

JVM の知見が少ない

先程のメモリの話にも共通することですが、会社として JVM のサーバー運用に対して知見が溜まってなかったというのもあります。メインで使っている Python であればサーバーを動かすための知見が溜まっていて安定的に動かすことができたのですが、JVM のシステムは社内初だったため不安定になったり、問題発生時に適切な対処が取りづらかったです。

一時期新サービスは Scala で書いていこうみたいな勢いがありましたが、これらの問題のためか結局 Python メインに戻ってしまいました。

Go でリビルド

Python は個人的にもしっくりきていてとても扱いやすい言語だと思っているのですが、プロジェクトの規模が大きくなってくるにつれ「型」の重要性が増してきました。静的な型付けがあると、それ自身がコードのテストになり実行時の予期せぬエラーを減らしたり、型の情報を生かして IDE による効率的な開発が出来たりします。 Python にも Type Hints と呼ばれる型のサポートがありますが、エディタによってサポートのばらつきがあったり、間違った型付けをしていても mypy を通さない限り気づかなかったりしてイマイチです。

そこで目をつけたのが Go でした。 静的型付け言語の候補はいくつもあるのですが、コンパイルが速く Docker とも相性がよい言語を探してるうちに Go がよさそうなのでは、と思うようになってきました。 言語仕様もシンプルで書き手に左右されづらかったりと Scala のときに感じていた課題点を解消できそうな点もよさそうでした。あとコンパイルの速さとかコンパイルの速さなんかも大事ですね。 最終的に Go やろうと決め手になったのは社内で Go を利用しているプロジェクトがすでにある点でした。社内にすでに触れる人が複数名いるというのは非常に心強いです。

Tour of Go をこなして基本を覚えたあと、言語を覚えるには実践が一番ということで Go でかけそうなシステムを探してました。 そこで出てきたのが例の Scala のマイクロサービスです。独立したサービスで改修しやすく、APIの仕様も固まっており規模もそんなに大きくないという理由から、カテゴリ分類サービスを書き直してみることにしました。

やったこと

仕様が固まっていたので BDD (振る舞い駆動開発) を採用してテストコードを充実させながら進めていきました。

BDD のフレームワークを使うと

    シナリオ: カテゴリ一覧がみれる
    前提 insert_categories.sqlの中身がDBにセットされている
    前提 メソッドがGET
    もし /v1/categoriesへアクセスする
    ならば ステータス200が返ってくる
    かつ レスポンスのカテゴリが1件入っている

みたいな形で振る舞いを記述することができます。 この形式自体は Gherkin と呼ばれる形式で、プログラムの実装言語によらず共通して定義することができます。 日本語のドキュメントのような形なので非エンジニアでも何をしたいのかが明確になるメリットがあります。

Go の場合 DATA-DOG/godog gucumber/gucumber といった BDD のテストフレームワークが存在します( Python だと behave というものがあります)。 今回はDATA-DOG/godog を利用してテストを書いていきました。

実際テストを書くときは、上記のシナリオの 1 行 1 行(=step) に対応するコードを準備します。

たとえば「ステータス200が返ってくる」に対応するコードだと以下のようになります。

func (c *Context) CheckStatus(status int) error {
    if c.resp.Code != status {
        return fmt.Errorf("status_code %d is not %d", c.resp.Code, status)
    }
    return nil
}

200 の部分はパラメータとして利用できるので汎用的なテストコードを実現することができます。 BDD のフレームワークを利用すると嬉しいのが、振る舞いの記述を追加してもその分だけテストコードが増えるわけではないという点ですね。同じ名前の step は同じ関数が再利用できるので、開発が進むほどテストコードに割く時間が減ってくる特徴があります。

Web のフレームワークは Echo を使ってみました。Go の場合フレームワークを使わなくてもいいみたいな話を聞きましたが、REST API の場合、GET や POST, PUT... といったメソッドレベルでのルーティングが必要となってくるため、フレームワークを利用して見通しをよくするようにしました。

Go を書いていて思うのはやはりコンパイルが速くて快適ということですね。テスト込みでも数秒で終わってしまうので TDD、BDD といった手法と相性がいいです。 あとは Go 言語は筋肉が要求されるというのが実感できました。例外処理もないので、愚直にエラーをチェックして返り値にセットするみたいなのを頻繁に書く必要があります。 ただ慣れてくると返り値を多値にして error を返すという仕様がとても自然に思えてくるようになってきました。 例外で横道から抜けられてしまうよりはちゃんと関数の結果として返ってくるほうが考慮漏れずに記述できる感じがします。 記述が長くなってしまうことについては「筋肉、筋肉…」と思うことで納得しています。

サービスリプレース

業務の合間の気分転換にちょっとづつ進めること約半年、ついにサービスが完成しました。 DB まわりのテストどうするか悩んだりきれいな設計になるようリファクタしてたりしてたら結構かかってしまいました。

Docker 化

FROM golang:latest as builder

ENV CGO_ENABLED=0 \
    GOOS=linux \
    GOARCH=amd64 \
    GO111MODULE=on

WORKDIR /opt/app
COPY . /opt/app
RUN go build

# runtime image
FROM alpine
COPY --from=builder /opt/app /opt/app

CMD /opt/app/basic_classifier

ECS に載せるため、Docker のイメージを作ったのですが Dockerfile がとてもシンプルでした。 バイナリを生成できてしまうと強いですね…。最終的なイメージサイズもかなり軽量です。 ECS だと起動のタイミングでイメージの Pull が走るので、イメージが軽いことは起動の高速化にも繋がります。

デプロイ

いよいよ実戦投入です。ECS で別タスクとして作っておき簡単にロールバックできるようにして切り替えました。

問題なく動いてる…と思いきや大きな問題が。 カテゴリ分類サービスを利用してるシステムの一部で予期せぬ挙動が起こってしまいました。 原因を精査してみたところ、API の仕様が変化しているのが元凶でした。 パラメータ名がスネークケースではなくキャメルケースという…。 サービス作成時に策定した仕様書に沿って実装していたのですが、Scala 版の実装のほうが間違っていて実装しており、利用側も実装を正に進めていたため、切り替えのタイミングでトラブルが起きてしまってました。

切り戻してシステムを改修したところ無事動くようになりました。

パフォーマンスの変化

実際 Scala から Go に置き換えてみてメモリ消費量がどうなったかというと

f:id:nsmr_jx:20191129101331p:plain

のようにリリースを境に大きくメモリ消費量を減らすことが出来ました。
(Scala 版右肩上がりなのでメモリリークも疑わしいです…) クラスタに余裕ができたので台数減らしたり、その分他のサービスを載せることができそうです。

一応 Apache Bench を使って簡易的なベンチマークもとってみました。

f:id:nsmr_jx:20191129101309p:plain

軽めのテキストの分類
Scala: Requests per second:    97.24 [#/sec] (mean)
Go: Requests per second:    118.89 [#/sec] (mean)

重めのテキストの分類 
Scala: Requests per second:    20.37 [#/sec] (mean)
Go: Requests per second:    29.34 [#/sec] (mean)

Go 書くときパフォーマンスはあまり意識せず書いていたので、Scala より性能が上がるのは嬉しい誤算でした。 分類結果は一致しているので大きくロジックを変えたつもりはないですが、コード的に完全移植したわけではないのでこのベンチマークは参考程度に見てもらえると。 Scala 強い人が書いたら十分逆転もありえそうです。

おわりに

1機能が独立して動くマイクロサービスみたいな構造だとこういった置き換えが気軽にできていいですね。 サービスまるまる実装してみて Go への理解がだいぶ進んだように思います。 パフォーマンスや使い勝手も悪くないのでこれから Go どんどん触っていきたいです。

明日は@shinyorkeさんの「データをいい感じに活用するためのアジャイルな言語化とその取り組み」です。