CodePipelineを用いたLambdaのデプロイについての所感

JX通信社Advent Calendar 2019」7 日目の記事です。 こんにちは。2019年9月からJX通信社のエンジニアとなった鈴木(泰)です。趣味は映画観賞です。

はじめに

JX通信社では AWS の Lambda Layer、Lambda 関数を使った Serverless なアプリケーションの開発に従事しています。

私が初めて Lambda 関数に触れたのは2019年の9月です。 3ヶ月のあいだ業務で扱ってきたこともあり、現在では Lambda 関数をサクサク作れるようになりました。 また、複数の Lambda 関数を連携させて1つのアプリケーションを組んでみたり、共通する処理を Layer として切り出したりと、少しずつ複雑なこともできるようになりました。

最近の問題は、増えてきた Lambda 関数の管理です。 特に、Lambda 関数のデプロイにかかる手間の大きさが問題(詳細は後述します)でした。

本記事では、AWS CodePipeline を使用してアプリケーションのデプロイを自動化したという話、CodePipelineを使用しての所感を、忙しい人向けに(あまり長くならないように)紹介したいと思います。

TL;DR

  • Lambda 関数のデプロイを手作業でやるのがダルい・・・
  • CodePipeline を使って、デプロイを自動化しました!
  • せっかくなので、CodePipeline 使ってみた感想をシェアさせてください。

CodePipelineとは何か?(ざっくりと)

本題に入る前に、少しだけCodePipelineに触れておきます。

CodePipelineとは、CDを構築するための仕組みです。 こちらの図にある通り、Sourceからプロダクション環境へのデプロイまでのパイプラインを構築できます。

より厳密には、CodePipelineはStageを繋げてパイプラインを構築するための仕組みです。 Stageとは1つ以上のActionを含む、Actionのまとまりです。 Actionが具体的な処理の最小単位です。 StageのなかのActionの実行順番は、直列にも並列にもできます(詳しくはこちら)。

CodePipeline を用いてデプロイを自動化しました

私が所属するチームでは、複数の Lambda 関数と 1 つの Layer を用いた社内アプリケーションを運用しています。 Layer は、Lambda 関数の中で共通して使われる処理を含みます。

デプロイ作業の手間が大きかった

これまでは、このアプリケーションを手作業でデプロイしていました。 次のようなデプロイ手順(開発者が変更を push した後)です。

  1. Git 上のソースコードを自分の MacBook Pro へ clone し、Lambda 関数デプロイ用の zip アーカイヴに固めます。
  2. 手順 1 にて作成したアーカイヴを AWS 上へアップロードします(この作業を AWS の UI から行なっていました)。
  3. Layer にアップデートがあった場合、各 Lambda 関数が使用している Layer のバージョンを更新します(この作業を AWS の UI から行なっていました)。

上の作業にかかる時間が大体10分ぐらいでしょうか。 確かに、1度だけであれば手間はかからないです。

しかしながら、 この社内アプリケーションは未だ発展途上の段階にあるため、修正が高頻度で発生します。 そのため、毎日デプロイ作業が発生し、作業頻度が増えれば増えるほど作業ミスも多くなり、とても大きな時間が削り取られていました笑。

構築したデプロイパイプライン

私たちが目指したのは

  • ソースコードを Git へプッシュするだけ。デプロイまでは自動で完了する。

という世界観でした。 この社内アプリケーションの要件と構造自体はシンプルなものであったため、上の世界観を目標とすることは十分に現実的でした。

次のようなデプロイのパイプラインができました。

図1 f:id:taisuzuk:20191205103812j:plain

パイプライン構築後のデプロイ手順(開発者が変更を push した後)

なんとこれだけです。

  1. 「Lambda 関数を Deploy して良いですか?」というリクエストメールを管理者が受け取り、「リクエストを承認」をクリックする(図 1 の Mannual Approval)。

パイプライン(図1)の解説

  • GitLab のマスターブランチ へ push すると、GitLab から CodeCommit へレポジトリがミラーリングされます。弊社では、ソースコードレポジトリとして GitLab エンタープライズを使用しているため、CodePipeline からソースコードを直接取得できません。そのため、CodeCommit へミラーリングしています。
  • 1 つ目の CodeBuild と CloudFormation は、Layer をビルド・デプロイするためのものです。CodeBuild は、Layer が格納されている zip と SAM パッケージを生成し、成果物として S3 へ保存します。CloudFormation は S3 上に保存されている成果物を Serverless 実行環境へ反映します。
  • 2 つ目の CodeBuild と CloudFormation は、Lambda 関数をビルド・デプロイするためのものです。CodeBuild は、Lambda 関数が格納されている zip と SAM パッケージを生成し、成果物として S3 へ保存します。CloudFormation は S3 上に保存されている成果物を Serverless 実行環境へ反映します。
  • Mannual Approval は、Lambda 関数のデプロイを開始するかどうか?を、ソースコード管理者へ確認するためのものです(なぜこの確認が必要となるのか?については長くなるので省きます笑)。確認には Slack を用いています。具体的には、次のようなメッセージが Slack へ通知されます。f:id:taisuzuk:20191205113451p:plain
  • Mannual Approval は、CodePipeline の Mannual Approval Action を用いて作りました。この機能は(ざっくりと説明)、承認・拒否のどちらかのボタンをクリックするまで、パイプラインを一時的に停止するためのものです。

所感

「イケてるところ」と「工夫を要する点(a.k.a 微妙な点)」についてまとめてみました。

イケてるところ

認証・認可周りの設定管理が楽

(当たり前ですが)デプロイするまでのパイプラインがAWS上で全て完結します。 サードパーティ製のサービスを選択しなくて良いことによる恩恵は、ユーザーの権限管理、サービスの学習や事前調査(AWSの実行環境との親和性を調べたり)をしなくて良いことです。

自由度が高い

(これも当たり前かもしれませんが)CodePipeline は幅広くカスタマイズ可能です。 サードパーティが提供する CI/CD サービスでできることであれば、大体のことはサポートされているように思います。 パイプラインの中に CodeBuild(CircleCIのようなビルドパイプライン)を挿れることにより、殆どのことは実現可能かと思います。

自由度への制約

1つ上で「自由度が高い」を称賛しましたが、CodePipeline は上手い具合に自由度を制限します。 より正確に言えば、ある特定の目的を達成するために、AWS は唯一の方法を提供してくれます。

論理的に考察した場合、CodePipeline を選択する決め手となりうるのは、この標準化にあるのではないか?と思います。

これがどういうことかを説明するために、少しだけパイプラインの仕組みについて述べます。

CodePipeline とは Stage を繋げたものであり、Stage は1つ以上の Action 繋げたものです。 Action が具体的な処理の最小単位となります。

f:id:taisuzuk:20191202162921j:plain

Action に設定できる処理は、AWS により予め決められています。 要するに、AWS は「XXの目的のときはYYを使うと良い」ということを提案してくれているのです。 例えば、Lambda 環境へのデプロイを実現する方法として、CodeBuild(S3 に SAM をアップロードし、この SAM を適用するコマンドを buildspec.yml に羅列し、ゴニョゴニョゴニョ・・・)を用いることで実現可能です。 しかし、CodePipeline では、Lambda 環境へのデプロイをするためには、CloudFormation を使用した方が良いということを明言します。

工夫を要する点(a.k.a 微妙な点)

ドキュメント

これは私だけかもしれないのですが、率直に言ってドキュメントが読み難い・・・というよりもどこから読んで理解したら良いのか分からない・・・というのがありました。

この理由は、CodePipeline に関わるサービスが幅広いからなのかな、と思っています。 私が参考にした CodePipeline 構築チュートリアルは、CodeCommit、CodeBuild、CodeDeploy、CloudFormation、の3つサービスを組み合わせてパイプラインを作っていました。 私は3つの全てについて無知だったため、最初何が何やら訳の分からない状態で、とりあえず動くものを作っていました笑。

私の場合、まずこのチュートリアルを見ながらとりあえず動くものを作り、その後でドキュメントを熟読しました。 時間がない場合、この方法でも問題ないと思います。 言うまでもなく、CodePipeline を習得するための良い方法は、CodePipeline で使われている各々のサービスを1つずつ理解していくことだと思います。

パイプラインの実行結果のSlack通知を自前で作らなければならない

実行結果を Slack へ通知したい場合、SNS を通して Lambda 関数から Slack へ通知しなければなりません。 つまり、イベントを受けて Slack へポストするための Lambda 関数を自前で作成しなければならないのです。

昨今のサードパーティ製のサービスであれば、Slack 連携は容易にできることが当たり前です。 今後、AWS に改善していただきたい点の1つだと思います。

私が知らないだけで簡単にできる方法あるのかな???

まとめ

「所感」では微妙な点も述べましたが、これらは「慣れ」によって十分に解消可能だと思っています。 実際のところ、私自身は CodePipeline に触れてから3週間以上経ち、当初微妙な点であると感じていたことが気にならなくなっています。 今ではサクサク CD を作れるようになりました。

これから AWS でアプリケーションを構築する方は、選択肢の1つとして検討してみては如何でしょうか? それではありがとうございました。

Appendix

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