【React Native】表示速度が遅い問題のカイゼン

はじめに

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

今回は「React Nativeの表示速度が遅い問題のカイゼン」について書きたいと思います。
『カミナシ』は新しい機能を最速でリリースして、ビジネスの速度を上げる期間がありました。 そのときの技術的な負債が溜まってしまい、あるときお客様から表示速度が遅くて困っているという声をいただきました。
そこで表示速度が遅い原因の調査と実際に対応したことを書いてみたいと思います。
React Nativeの表示速度が遅くて困っている、どうにかしたいと思っている方の手助けになれば嬉しいです!

尚、React.memoのHooksは既に実装済みの状態での話になります。

開発環境

  • React Native (0.63.2)
  • native-base (2.13.14)
  • Expo SDK 39
  • TypeScript (3.9.6)

課題

  • 記録する画面で表示項目が大量にある場合、表示速度が遅くなってしまう
不要なレンダリングがされていないか確認しましょう

React Native(Web)だと、why-did-you-render を使って確認することが出来ますが、Mobile(Expo)だと利用できません。 そのため、Hooksを使った useWhyDidYouUpdate を使って確認することが出来ます!

useWhyDidYouUpdateにあるサンプルを元に、どのように確認できるか試してみます。

import React, { useState, useEffect, useRef } from 'react'
import { Text, View, Button, StyleSheet, StyleProp, TextStyle } from 'react-native'

interface CounterProps {
  count: number
  style: StyleProp<TextStyle>
}

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  counter: {
    alignSelf: 'center',
    color: 'red'
  }
})

const Counter = React.memo((props: CounterProps) => {
  useWhyDidYouUpdate('Counter', props)
  return <Text style={props.style}>{props.count}</Text>
})

const useWhyDidYouUpdate = (name: string, props: any) => {
  const previousProps = useRef<any>()
  useEffect(() => {
    if (previousProps.current) {
      const allKeys = Object.keys({ ...previousProps.current, ...props })
      const changesObj = {}
      allKeys.forEach(key => {
        if (previousProps.current[key] !== props[key]) {
          changesObj[key] = {
            from: previousProps.current[key],
            to: props[key]
          }
        }
      })
      if (Object.keys(changesObj).length) {
        console.log('[why-did-you-update]', name, changesObj)
      }
    }
    previousProps.current = props
  })
}

export default function App() {
  const [count, setCount] = useState(0)
  return (
    <View style={styles.view}>
      <Counter count={count} style={styles.counter} />
      <Button onPress={() => setCount(count + 1)} title="Increment"></Button>
    </View>
  )
}

gyazo.com

簡単なサンプルではありますが、なぜCounter Componentが更新されたのかが視覚的に分かると思います。 これを遅いComponentに入れ込むことで、不要なレンダリングがされていないかを確認することができるので是非試してもらいたいです!

この動画はコンソールログで確認していますが、ExpoClientの「Debug Remote JS」でも確認ができます。 f:id:kaminashi-developer:20201122231058p:plain

不要なレンダリングがされていないのに何故遅い?

理由は表示項目が画面外でも描画されているため、すべてレンダリングされた後に入力が開始できるようになっていたため、表示速度が遅くなっていました。

FlatListの「initialNumToRender」を使って初回のレンダリング数を決めて、遅延レンダリングしてみました。

import React, { useEffect, useState } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { FlatList } from 'react-native'

export default function App() {
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 100; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  return (
    <Content>
      <View>
        <FlatList
          data={sample}
          initialNumToRender={23}
          renderItem={({ item, index }) => (
            <Button key={index}>
              <Text key={index}>{item}</Text>
            </Button>
          )}
        />
      </View>
    </Content>
  )
}

一番簡単ですぐに解決出来ましたが、 VirtualizedLists should never be nested inside plain ScrollViews with the same orientation - use another VirtualizedList-backed container instead. のWarningが発生してしまい、Warningの状態で使い続けるのはダメだと思いやめました。

  • React.lazy, React.Suspenseを使ったComponentのロードを遅延する

初回はロードしている感があって良かったけど、2回目以降は同じ問題になってしまうため、根本的に違うなと思いやめました。

最終的にどうしたのか?

CSSを直接書いている箇所をStyleSheetに書き出した(多少なりカイゼンされるようです)

import React from 'react'
import { Text, View } from 'native-base'

export default function App() {
  return (
    <View style={{ backgroundColor: 'red' }}>
      <Text>sample</Text>
    </View>
  )
}import React from 'react'
import { Text, View } from 'native-base'
import { StyleSheet } from 'react-native'

const styles = StyleSheet.create({
  view: {
    backgroundColor: 'red'
  }
})

export default function App() {
  return (
    <View style={styles.view}>
      <Text>sample</Text>
    </View>
  )
}

②各Component単位のレンダリングは遅くないが表示項目が大量にある場合、画面外まで描画してしまうのをカイゼンしないといけないと思い、遅延レンダリングを独自実装しました。 (Mobile、Webで利用できます)

import React, { useEffect, ReactNode } from 'react'
import useSafeState from './useSafeState'
import useUnmountRef from './useUnmountRef'

interface DelayedProps {
  delayCount: number
  delayThreshold?: number
  children: ReactNode
}

export const Delayed: React.FC<DelayedProps> = props => {
  const unmountRef = useUnmountRef()
  const [visible, setVisible] = useSafeState(unmountRef, false)

  useEffect(() => {
    if (props.delayThreshold && props.delayCount < props.delayThreshold) {
      typeof setVisible === 'function' && setVisible(true)
    } else {
      setTimeout(() => {
        if (!unmountRef.current) {
          typeof setVisible === 'function' && setVisible(true)
        }
      }, (props.delayCount - props.delayThreshold) * 50)
    }
    return () => {
      typeof setVisible === 'function' && setVisible(false)
    }
  }, [])

  return <>{visible && props.children}</>
}
  • useUnmountRef.tsx
import { useRef, useEffect } from 'react'

const useUnmountRef = () => {
  const unmountRef = useRef(false)
  useEffect(
    () => () => {
      unmountRef.current = true
    },
    []
  )
  return unmountRef
}

export default useUnmountRef
  • useSafeState.tsx
import { useState, useCallback, MutableRefObject } from 'react'

const useSafeState = (unmountRef: unknown, defaultValue: unknown) => {
  const [state, changeState] = useState(defaultValue)
  const wrapChangeState = useCallback(
    (v: boolean) => {
      const ref = unmountRef as MutableRefObject<boolean>
      if (!ref.current) {
        changeState(v)
      }
    },
    [changeState, unmountRef]
  )

  return [state, wrapChangeState]
}

export default useSafeState
  • 使い方
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { Delayed } from 'shared/src/views/components/Delayed'

export default function App() {
  let delayCount = 0
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 10; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  return (
    <Content>
      <View>
        {sample.map((data: number, index: number) => {
          delayCount++
          return (
            <Delayed
              key={index}
              delayCount={delayCount}
              delayThreshold={5} // 5回目以降は遅延させてレンダリングする
              children={
                <Button key={index}>
                  <Text key={index}>{data}</Text>
                </Button>
              }
            />
          )
        })}
      </View>
    </Content>
  )
}
どれくらい変わるかをサンプルデータで見てみましょう
  • 1-200の数値を配列に格納した後、開始ボタンを押して表示速度を確認します。
  • カイゼン前はmapを使ってボタンを200個表示します。
  • カイゼン後はmapを使い、独自の遅延レンダリングComponentを使ってボタンを200個表示します。
カイゼン前の表示速度
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { StyleSheet } from 'react-native'

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  button: {
    margin: 12
  },
  text: {
    width: 100,
    textAlign: 'center'
  }
})

export default function App() {
  const [start, setStart] = useState<boolean>(false)
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 200; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  if (!start) {
    return (
      <View style={styles.view}>
        <Button style={styles.button} onPress={() => setStart(true)}>
          <Text style={styles.text}>開始</Text>
        </Button>
      </View>
    )
  }

  return (
    <Content>
      <View style={styles.view}>
        {sample.map((data: number, index: number) => {
          return (
            <Button style={styles.button} key={index}>
              <Text style={styles.text} key={index}>
                {data}
              </Text>
            </Button>
          )
        })}
      </View>
    </Content>
  )
}

gyazo.com

カイゼン後の表示速度
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { StyleSheet } from 'react-native'
import { Delayed } from './Delayed'

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  button: {
    margin: 12
  },
  text: {
    width: 100,
    textAlign: 'center'
  }
})

export default function App() {
  const [start, setStart] = useState<boolean>(false)
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 200; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  if (!start) {
    return (
      <View style={styles.view}>
        <Button style={styles.button} onPress={() => setStart(true)}>
          <Text style={styles.text}>開始</Text>
        </Button>
      </View>
    )
  }

  let delayCount = 0
  return (
    <Content>
      <View style={styles.view}>
        {sample.map((data: number, index: number) => {
          delayCount++
          return (
            <Delayed
              key={index}
              delayCount={delayCount}
              delayThreshold={16}
              children={
                <Button style={styles.button} key={index}>
                  <Text style={styles.text} key={index}>
                    {data}
                  </Text>
                </Button>
              }
            />
          )
        })}
      </View>
    </Content>
  )
}

gyazo.com

ただ、動画を見てもらうと分かる通り、意図的に遅延レンダリングしているため、高速でスクロールすると描画が追いつかなくて、若干引っかかるような感じになるので注意してください。(Delayed ComponentのsetTimeoutの時間調整したり、visibleがfalseの場合にローディングアイコンを挟むと良いかも知れません)

おわりに

いかがだったでしょうか。 表示速度のカイゼンって意外と単純そうで難しいなと個人的には感じています。きちんと問題に対して調査をした上で、どういったカイゼンのアプローチができるのかを模索して解決していくのはエンジニアとしての醍醐味でもありますし、それがお客様の体験を良くしていくとなると一段とやりがいを感じる部分だと思っています。

今回は独自のComponentを作成してカイゼンをしましたが、もっとこういう方法があるよ!という方が居れば、ぜひコメントください!よろしくおねがいします。

また、カミナシに少しでも興味を持っていただけて、話を聞いてみたい、応募したいという方はwantedlyからお待ちしております!

React Native と Flutterを比較してみた

こんにちは。株式会社カミナシの keinuma です。 今回はReact NativeとFlutterの比較についてまとめてみました。

弊社ではモバイルアプリ開発にReact Native + Expoを利用しています。ExpoはCLIを筆頭に便利な機能が多く、開発フローの基盤になっています。 そんな中、クロスプラットフォームのライブラリとして人気を博しているFlutterと比較すると何が違うのか知りたくてまとめてみました。

比較する上で以下の観点に絞ってみていきます。

  • 基本情報
  • 開発環境
  • UI
  • 状態管理
  • テスト

基本情報

FlutterとReact Nativeの言語、提供元でまとめてみました。 ここ1ヶ月ほどFlutterを調べてみた中でDart言語はJavaやJS, Pythonなどのいいところをあわせもっていて書きやすい言語だなと感じました。

これまで静的型付け言語をやっていれば学習コストは高くないと思います。

Flutter React Native
企業 Google Facebook
言語 Dart JavaScript
GitHubスター数 107k 91.3k

開発環境

React Native

開発環境の構築やローカル環境の起動はExpoが便利です。 Expo CLIでは主に以下の機能が提供されています。

  • 新しいプロジェクトの作成
  • ローカル環境の起動
    • シミュレータ起動
    • ログ実行
  • Publish
  • 各プラットフォーム向けのビルド

ローカル環境を起動すると以下の画面が表示されます。 ログの出力やシミュレータの起動、実機で確認する導線が1画面にまとまっていてとても便利です。

f:id:kaminashi-developer:20201124011021p:plain
expo local

LintやmonorepoなどはNPMで用意されているものを活用できます。 弊社でもESLint + Prettierによるコード整形とLernaによるmonorepo構成を採用しています。

Flutter

FlutterもCLIである Flutter CLIが提供されています。 機能はExpo CLIと同じようにローカル環境の起動や各プラットフォームのビルドなどがあり、開発に必要な物が用意されています。

Expoの場合はテストやコード整形は他のライブラリに任せていましたが、Flutterではテスト実行やコードフォーマット、ローカライゼーションなどをCLI上で実行することができます。

NPMほど成熟したライブラリがない分、Flutter上で用意されている機能が多い印象です。

UI<レンダリング編>

React Native

React Nativeはレンダリングのコア部分を react と共有していて、各プラットフォームごとのUIコンポーネント出力しています。(例:React Nativeの ViewAndroidandroid.view であり、iOSUIView と一致)

そのためプラットフォームのスタイルにそったUIを実装することができます。 一方でコアとなる機能数は多くないのでUIライブラリに依存して実装する必要が出てきます。

Flutter

f:id:kaminashi-developer:20201124013543p:plain
Flutter

Flutterは フレームワークAPIを担う flutter/flutter と低レイヤの flutter/engine に分かれています。 flutter/engineC++で書かれていて、グラフィックライブラリ Skia を使ってネイティブコンポーネントを利用せずレンダリングしています。

ネイティブコンポーネントを利用していませんがAndroid向けにはMaterial, iOS向けにはCupertinoのテーマが用意されています。

UI<実装編>

React Native

React NativeのコンポーネントをJSXでUIを実装します。 スタイルも StyleSheet がReact Nativeに含まれているので、CSSライクに書くことができます。

黒背景に白文字のHello Worldを実装して比較します。

// React Native
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

export default class App extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text>Hello world!</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center'
  }
});

HTMLタグは使わずに全て react-native ライブラリに含まれてるコンポーネントで実装するのが特徴的です。

Flutter

Flutterは元々Reactにインスパイアされて開発されているライブラリなので宣言的にUIを記述する方針は一緒です。 JSXの代わりにWidgetを使ってUIを実装していきます。

先ほどReact Nativeで書いた同じUIをFlutterに書き換えてみます。 こちらのサンプルは Flutter for React Native developers に記載されている内容です。 

// Flutter
import 'package:flutter/material.dart';

void main() {
  runApp(
    Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

FlutterではデフォルトでMaterialのテーマを利用でき、その中にレイアウト専用のWidgetや状態管理のWidgetが用意されています。 また、フォントサイズや色などの基本情報も用意されていているのが特徴的です。

状態管理

React Native

React Nativeのローカルコンポーネントの状態管理では react-hooksが提供されています。 Reactでグローバルの状態管理を扱うライブラリは複数あります。

これらの中で多くのラリブラリが単一データフローを採用していて、データの更新フローを一つの流れにしています。

Flutter

Flutterでも公式ではないもののローカルでの状態管理にReact Hooksと同等の機能を使える Flutter Hooks があります。

グローバルの状態管理は調べたところ、いくつかライブラリがありました。

React同様にRxとReduxをFlutterで使えるライブラリ

Flutterで使えるライブラリ

中でも最後に書いてるRiverpodを使った状態管理の実装方法はmonoさんの記事を参考にしています。Riverpodはproviderの改良版になっていて、Freezedによってimmutableに状態を扱えるのでReduxに慣れていても安心感のある構造になっています。(コードを生成するのは独特だなと思いました)

テスト

React Native

React Nativeでテストする場合はNPMに用意されているライブラリを使うことになります。

Flutter

Flutterは公式に用意されているライブラリ(flutter_test, flutter_driver)に単体テストからWidgetテスト、E2Eまで含まれています。 ドキュメントに詳細がまとめられていてわかりやすいです。

まとめ

ここまでReact NativeとFlutterを比較してきました。調べ始めた当初はFlutterはまだ早いと思っていたのですが、開発する上でネックになることはないと感じました。開発環境もドキュメントも充実しているのでスムーズにアプリを開発できそうです。

一方でReact NativeのいいところはJavaScript資産を活用できることだと思います。弊社でもWebアプリをReactで開発しているのでモバイルアプリと共有しているコードは多いです。今後もどちらのメリットが大きいかを判断した上で技術選定できるようにしていきたいです。機会があればFlutterも使ってみたいと思います。

参考

プログラミング言語「KAMINASHI」〜ノーコードで作る現場管理アプリ〜

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

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

この記事では、そもそもカミナシってどんな製品なのかのを紹介したいと思います。

我々が開発しているカミナシの製品コンセプトは「ノーコードで現場管理アプリを作る」です。

ノーコード(ビジュアル・プログラミング言語)により JavaScriptSQLなどのテキスト・プログラミング言語と比較して圧倒的に低い学習コストで、業務アプリを作り始めることができます。

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

学ぶといっても、直感的な操作で試しながら始められるので、 「簡単なレクチャーを受けたその日の内に」とか「操作マニュアルを見ながらその場で」といったレベルです。

ジョブズのインタビュー画像は続きがあり、プログラム自体を学ぶことは重要ではなく、考え方を学ぶことに意味があるという内容です

現場管理業務をデジタル化する

弊社ではビジネスチームだけでなくエンジニチームのメンバーも ユーザー先の現場に訪問してヒアリングや見学をさせていただいています。

下のフロー図は、実際に見学に伺わせてもらったユーザーの工場内に貼られてあったものです。

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

この業務フロー図を前に職員さん同士が「作業が確実・効率的に行えているか」を議論する光景を見ることができました。必要があれば業務フローを修正してカイゼンする。

現場の意識の高さに驚かされる反面、紙や口頭といったアナログな方法で行う作業の非効率さも感じました。

現場管理業務の入り口は「現場の活きた情報をいかに早く・効率的に集めるか」なのかもしれません。

カミナシを使うことで、こういったノンデスクワーカーの現場の業務フローをデジタル化できます。

プログラミング言語「KAMINASHI」

カミナシはノーコードながら、一般的なプログラミング言語同様に以下のことができます。

  • 分岐、繰り返しで手続き的な業務フローをアプリ内に構築できる
  • 業務ルールや出力データの様式を宣言的に定義できる

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

こうやって作られた業務フローがそのまま、モバイル端末上で動作するアプリになります。

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

カミナシを使って現場アプリを作るユーザーはプログラマーと言えますし、

我々カミナシのエンジニアも、何か新しいプログラミング言語を作っているような感覚があります。

プログラミング言語で言うところの関数ライブラリに相当するようなパーツ機能も正式リリース以降も拡充していて利便性も向上しています。

ふわぁ〜と言いましたが、プロダクトの中身は結構複雑ですし、業界横断の汎用的なユースケースに対応するための仕掛け作りに日々邁進している状態です💦

この辺り技術的に掘り下げた内容をこのブログで発信していければと思います!

最後に

弊社代表の呟きを引用します(伸び代しかありません

サービスサイトをGatsby×Wordpress×NetlifyでJamstackなサイトにリニューアル

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

はじめまして、株式会社カミナシのエンジニア @tomiです。

カミナシは、2020年10月にサービスサイトをフルリニューアルしました。

kaminashi.jp

今回のサイトリニューアルでは、どのような構成で作ったのか、また技術選定で考慮した点をお伝えします。

Jamstackな静的サイト構成

Gatsby.jsとWordpressを使いJAMstack構成で作成しました。

最終的に以下の画像のような構成になりました。

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

利用した技術を並べると、

  • Gatsby.js
  • Typescript
  • StyledComponents
  • GraphQL
  • EsLint + Prettier
  • Wordpress + Gutenberg
  • Netlify

導入事例セミナー情報など、動的な情報は記事としてWordpressに登録し、Gatsby.js側で記事をGraphQL経由で取得して表示しています。

静的なファイルのビルド・デプロイ・ホスティングはすべてNetlifyにおまかせしており、Wordpressで記事を更新したときとコードを修正したときは、Webhookで自動ビルドするように設定しています。

なぜこの構成・技術にしたのか

①フルリニューアルするならJAMstack

そもそも、リニューアル以前は外注でWordpressで作成されていて、今いるメンバーが仕様を把握していなかったので、改修は考えず再構築することにしました。

また、Wordpressだとどうしても読み込みが遅いのでSEO的に不利なので、Wordpress以外で動かしたいというのは全員一致でした。

サイトの読み込みスピードも早くしてSEOを強くしたいとなると、SSRよりSSGでJAMstack構成が一番でしょう。

JAMstack構成を採用している事例も増えてきて情報もWeb上に溢れているので、特に悩むことなくJAMstackが採用されました。

(個人的に、ただ作成するよりJAMstack構成で作り甲斐があるからという理由が8割くらい占めていたかも🤫)

②SSGとしてGatsby.jsを採用しました

静的サイトジェネレーター(SSG)として検討されたのは、Next.js・Jekyll・Hugo・Gatsby.jsでした。

その中からGatsby.jsを採用した理由は複数あります。

カミナシアプリでもReactが使われていること

魅力的なフレームワークはたくさんあり、ついつい新しい技術に手を出したくなってしまいますが、複数のエンジニアが今後作業するとなると、すでに使っている技術を使った方が開発コストが下がるので、カミナシのアプリでも採用しているReactを使ったNext.jsかGatsby.jsが良いのではと考えました。

TypescriptとStyledComponentsを採用しているのも同様の理由です。

公式またはコミュニティのプラグインが豊富

これがGatsby.jsを採用した一番の理由です。

Gatsbyでは数多くのプラグインが、公式またはコミュニティから配布されています。

f:id:kaminashi-developer:20201014140910p:plain
公式サイトからプラグインを探せます

Googleタグマネージャの設定やサイトマップ、ウェブフォント、GraphQLなどの機能のカスタマイズ性が高く、開発も活発に行われているのが印象的です。

そして、特に嬉しいのがgatsby-plugin-sharpというプラグインで、ビルド時に画像を表示サイズに合わせて自動で縮小・切り抜いてくれるもので、画像が適切サイズで読み込まれればSEO対策にもなります。

さらに、Wordpress内で作成した記事に対してもこのプラグインが有効で、記事に間違って大きな画像を挿入してしまっても、画像が最適化されるので安心です。

やっぱりSSGといえばGatsby

2019年に仕様したSSGは?というアンケートを見つけました。 Gatsby.jsとNext.jsが僅差ではありますがGatsby.jsが最も使われています。

f:id:kaminashi-developer:20201014144037p:plain
参照:https://tsh.io/state-of-frontend/#jamstack

Gatsby.jsとNext.jsのGitHubで比較してみると、スター数はSSRでも人気のNext.jsの方が少し多いですが、Used byの数では、Gatsby.jsが2倍近く多くなっていました。(Gatsby.jsが2.5k、Next.jsが1.3k)

今年Next.jsはIncremental Static Regenerationという機能でIncremental SSGにも対応してもしかしたら逆転してしまうかもしれませんが、SSGとしての信頼は現時点ではGatsby.jsの方が上かなということで、Gatsby.jsを採用しました。

③Headless CMSはサイトの仕様を考慮した結果Wordpress

お知らせのページや導入事例、セミナー情報のページなど、動的に増える部分をCMS上で管理しています。

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

当初は、microCMSやContentful、Ghost、HubspotCMSなどどれを使うか悩むな〜と思ってましたが、サイトの仕様が固まりそれを実現できるCMSはどれか調査していった結果... Wordpress一択でした。

Wordpress以外のCMSだと実現が難しいことが特に表れているのが、セミナーのページになります。

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

こちらはセミナーの編集画面の一部で、登壇者の画像・会社・役職・名前・紹介文を複数名可変で登録できるようになっています。

このようなブロックの繰り返しを実現できるようなカスタマイズ性の高いCMSWordpress以外に見つかりませんでした。

Markdownで表現できるようなブログなどであれば、どのCMSを使うか迷って、料金や制限などをよく見て検討する必要がありますが、今回の仕様を考慮した結果Wordpressになりました。

ホスティングサービスにはNetlifyを採用

ホスティングに関してはじめは、GatsbyCloudでビルドしてAWSのS3+CloudFrontでホスティングさせる構成で考えていました。

AWSはカミナシアプリでも使っており、サービス管理を一箇所に集めるメリットがありましたが、GatsbyCloudでIncremental Buildsする場合は有料プランにする必要がありました。

調べていくと、Netlifyなら無料プランでIncremental Buildsができそうだったので試したら、驚くほどカンタンでした。

src/static/netlify.tomlファイルを以下のように作成して、

[[plugins]]
  package = "netlify-plugin-gatsby-cache"

NetlifyにGitHubリポジトリを接続するだけで、ビルド・デプロイ・ホスティングをしてくれました。

結果的に、ホスティングサービスにはNetlifyを採用することとなりました。

まとめ

社内の技術・拡張性の高さ・仕様との折り合い・価格面などを踏まえた結果、Gatsby×Wordpress×NetlifyでJamstackなサイトに仕上がりました。

構成・技術に関して今回はお伝えしましたが、作成していく中で多くの躓きがありましたので、それも今度まとめて紹介したいなと思っております。

【LT参加レポート】GoによるGraphQL実装

はじめまして。株式会社カミナシでアプリケーションエンジニアをやってる keinuma です。

カミナシではAPIの開発にGo言語を使用しています。自分はGraphQLが好きなのですがこれまでGoのライブラリを利用してGraphQLランタイムを実装したことがありませんでした。なのでGoのライブラリの一つであるgqlgenを利用してサンプルアプリケーションを実装してみました。

今回は勉強会で発表した内容を編集して書いていきます。

speakerdeck.com

※ただし書き
カミナシのプロダクトではGraphQLを使っていません。 サービスサイトではGatsbyを使っていてこちらについてのまとめは後日公開予定です。

GraphQLの実装手段

BaaS

GraphQLはクライアントの柔軟性が高い分、ランタイムが複雑になりがちです。 そのため実装するときはGraphQLランタイムをBackend as a Service(BaaS)に任せるか自らライブラリを利用して実装するか選択します。

現在主要なBaaSは以下になります。

  • AWS AppSync
    • AWSが提供しているGraphQLのマネージドサービス
    • バックエンドにDynamoDB, Lambda, HTTPなど複数のサービスを連携できる
  • Hasura
    • Herokuを利用したPostgresQLによるGraphQLマネージドサービス
    • データベースのテーブル定義に応じてGraphQL APIを生成できる

BaaSを利用する場合はGraphQLランタイムの実装コストを抑えられるためスキーマ定義がシンプルな場合は有効な手段です。 ただBaaSによってはFragmentなどの機能に対応していないためGraphQLの機能を十分に活用したいときは自前でランタイムを実装します。

ライブラリ

Goにおける主要なGraphQLライブラリはGraphQL公式ドキュメントにまとめられています。 ライブラリはGoコードにスキーマ情報を記述するコードファーストのライブラリとスキーマからコードを出力するスキーマファーストのライブラリに別れます。

コードファースト

スキーマファースト

今回はスキーマファーストのgqlgenを採用しました。

gqlgenの使い方

gqlgenはスキーマを定義してCLIを実行する流れで開発します。

CLIは設定ファイルをもとにコードを出力します。

schema:
  - ./*.graphql  # GraphQL Schemaファイルを指定

resolver:
  layout: follow-schema
  dir: graphql
  package: graphql
  filename_template: "{name}.resolvers.go" 

exec:
  filename: graphql/generated/generated.go
  package: generated

models:
  User:
    model: github.com/keinuma/hogehoge/model.User

CLIを実行すると3種類のファイルが生成されます。

  • model.go : スキーマに定義されているデータファイル
  • resolver.go: リクエストを受け取りレスポンスを整形するファイルの出力設定
  • generated.go: GraphQLオブジェクトからResolverへの変換を実装(編集しない)

modelは設定ファイルのUserのようにマッピング先を記述すると既存のGoファイルとの整合性チェックだけ行われます。 resolver.go は関数の定義だけ実装され中身はpanic が入っているので開発者が編集する必要があります。

gqlgenの採用理由

今回は2つの観点でgqlgenを採用しました。

  • GraphQLのサポート範囲が広い
  • 型安全

それぞれ紹介していきます。

GraphQLのサポート範囲が広い

gqlgenのドキュメントにてGoのGraphQLライブラリにおけるサポート一覧が紹介されています。

gqlgen.com

他のライブラリに比べてEnumやInputなどコード生成のサポート範囲が広く、昨年公開されたApollo Federationもサポートされています。 GraphQL自体新しい機能が追加されていくので、今後もgqlgenのサポート範囲が拡大していくことを期待できます。

型安全

CLI実行時にスキーマ情報からGoコードとマッピングします。そのため型が不明確な interface{} が発生しません。
さらにスキーマとコードに差分が発生する場合は、差分を埋めるためのコードが生成されます。

例えばスキーマとGoが以下のように定義されているとします。

# GraphQL
type Todo {
    id: ID!
    text: String!
    done: Boolean!
}      
// Go
type Todo struct {
  id: string
  text: string
}

GraphQLにはTODOの done プロパティが追加されていますが、Goのstructには含まれていません。 この場合CLIを実行すると以下のようなコードが出力されます。

func (t *TodoResolver) Done(ctx context.Context, *model.Todo) (bool, error) {
    // 編集する
    panic("panic")
}

structの記述漏れの場合はstructを編集してCLIを再実行することで上の出力はなくなり、structに done が含まれない場合は既存のプロパティをもとに出力された上のResolverに done を定義できます。

スキーマとGo間の整合性を担保できることでIDEのサポートも効くようになり開発しやすくなります。

gqlgenの実装例

今回実装したコードは以下のリポジトリにまとめています。

github.com

コンセプト

開発者のマッチングアプリを想定して開発しています。 Schema定義を一部抜粋します。

type Story {
    id: Int!
    title: String!
    user: User!
}

type Match {
    id: Int!
    story: Story!
    date: Time!
    attendees: [User!]!
}

type User {
    id: Int!
    uid: String!
    name: String!
    description: String
}

type Query {
    getStories: [Story!]!
    getMatches: [Match!]!
}

type Mutation {
    createStory(input: NewStory!): Story!
    createUser(input: NewUser!): User!
    createMatch(input: NewMatch!): Match!
}

ユーザーが Story というイベントを作成し気になったユーザーが参加することで Matching オブジェクトが生成される構造です。

データベースにMySQL + GORM、WebフレームワークにEchoを使いました。

設計思想

設計のベースにドメイン3層のアーキテクチャを採用しています。 リクエストからレスポンスまでの流れを図に記します。

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

ドメインの3層に加えてリクエストを受け取る controller, レスポンスのデータ整形を行う presenter, データベースの実行を担う infra を追加しました。 ドメインロジックは domain/modeldomain/service に寄せて、データ取得部分のインターフェースを domain/repositoryに定義して infra に実体を記述するようにしています。

gqlgenとの結合

gqlgenが生成するファイルを先ほどのアーキテクチャに以下のように組み込みました。

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

GraphQLのResolver部分を controller に、Typeを domain/model, Input Typeを presenter/input に出力しています。

Story 作成のResolverは以下のように記述しました。

func (r *mutationResolver) CreateStory(ctx context.Context, input request.NewStory) (*model.Story, error) {
    storyPresenter := presenter.NewStory(*service.NewStory(gateway.NewStory(ctx, r.DB)))
    story, err := storyPresenter.CreateStory(input)
    if err != nil {
        return nil, err
    }
    return story, nil
}

GraphQLのInput Typeを受け取り、各層を初期化・実行してレスポンスを返しています。 domain は以下のようになります。

# model
type Stories []*Story

type Story struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    User  *User  `json:"user"`
}
# service
import (
    "github.com/keinuma/tech-story/domain/model"
    "github.com/keinuma/tech-story/domain/repository"
)

type StoryService interface {
    GetStories(limit, offset int) (*model.Stories, error)
    CreateStory(story model.Story) (*model.Story, error)
}

type Story struct {
    storyRepository repository.StoryRepository
}

func NewStory(storyRepository repository.StoryRepository) *Story {
    return &Story{
        storyRepository: storyRepository,
    }
}

func (s *Story) CreateStory(input model.Story) (*model.Story, error) {
    story, err := s.storyRepository.CreateStory(input)
    if err != nil {
        return nil, err
    }
    return story, err
}
package repository

import "github.com/keinuma/tech-story/domain/model"

type StoryRepository interface {
    GetStories(limit, offset int) (*model.Stories, error)
    CreateStory(story model.Story) (*model.Story, error)
}

domain/model にてGraphQLのデータ型とマッピングさせつつ domain/service, domain/repository に外部処理を実装しています。

最後にdomain/repository の詳細を実装している infra は以下のようになります。

package gateway

import (
    "github.com/keinuma/tech-story/domain/model"
    "github.com/keinuma/tech-story/infra/database/dao"
)

type Story struct {
    ctx context.Context
    tx  *gorm.DB
}

func NewStory(ctx context.Context, tx *gorm.DB) *Story {
    return &Story{
        ctx: ctx,
        tx:  tx,
    }
}

func (s *Story) CreateStory(story model.Story) (*model.Story, error) {
    var daoStory dao.Story
    daoStory = daoStory.ToDAO(story)
    if err := s.tx.Create(&daoStory).Error; err != nil {
        return nil, errors.New("[gateway.CreateStory] failed create story")
    }
    entityStory, err := daoStory.ToEntity()
    if err != nil {
        return nil, err
    }
    return entityStory, nil
}

GraphQL Resolverから infra を利用することで依存を逆転させてます。

まとめ

gqlgenを使ったGraphQLランタイムの実装を紹介しました。 今回はgqlgenの生成物を設計に組み込むことで責務を分離してGraphQLを実装しています。 今後はResolverの実装が肥大化しやすいので、Resolverを分割しつつスケールしやすい設計を考えていきたいです。

【React Native + Expo】オフライン対応について振り返ってみた

はじめに

はじめまして、株式会社カミナシのエンジニア @MuiKnskです。

カミナシは3年目で最近はソフトウェアテスト、ソフトウェア品質に興味を持っています。
何故かと言うとバグはゼロに出来ないと思っていて、如何に減らしてサービスの品質を担保していくかに興味を持つようになりました。
この辺り知見を持ったエンジニアを個人的にジョインして欲しいな…なんて思ってます。

今回は『カミナシ』の課題「オフライン」について、何故オフラインで動作する必要があるのかと、これを解決するために選択した技術を振り返りながら書きたいと思います。
『カミナシ』はオフラインでどうやって動作を担保しているのか、悩んでいる方がいたら参考になれば嬉しいです。

開発環境

  • React Native (0.60.22) + Redux
  • Expo SDK 37
  • TypeScript (3.7.2)

課題

  • Wi-Fi環境がないお客様にサービスを提供する
  • オンラインと同等の機能をオフラインで実現する
なぜオフラインが必要なのか

カミナシは工場などのWi-Fi環境がないお客様にサービス提供しているケースが多々あります。
Wi-Fi環境があっても通信環境の問題で途切れたり、お問い合わせ頂いて打ち合わせしても、Wi-Fi環境ないので…。ちょっと無理ですね。ということがあります。
そうなるとサービスの良さを知ってもらう前に失注してしまうのでオフライン対応が必要になっています。
そのためカミナシでは創業当時からオフラインを想定したアプリを提供しています。『カミナシ』も初期段階から実装を行っています。

オフラインDBは何を使ったのか

選択肢の候補として上がったのが以下になります。

  • Realm
  • Expo内にあるSQLite
Realm

ピボットする前のプロダクトでRealmモバイルデータベースを使っていたので、その経験を生かしてReact Native (Expo)でも利用しようと思いました。

が…

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

「サポートされていない!」

Realmに対する知見があったのでどうにか利用したかったのですが残念ながら採用できませんでした。Expo対応を望まれるコメントはあったのですが…未対応ですね。 expo.canny.io

SQLite

Realmが利用できないので、SQLiteを採用した理由は以下の点になります。

  • Expo側が提供している
  • ドキュメントが豊富にある
  • クエリライクに書けるので学習コストが低い

docs.expo.io

振り返ってみるとSQLite一択だったのかも知れません。

オフラインDBを使って何を提供するのか

オフライン対応は必須で、技術的にSQLiteを使うところまで決まりました。
あとはオフラインと同等の機能ってどこまでを担保するべきかをビジネスサイドと話し合いながら、いつまでにどの機能が必要かをscrapboxを使って詰めていました。 カミナシではビジネス側と議論する場合、scrapboxというツールを使っています。 f:id:kaminashi-developer:20200921220811p:plain

実装する前に利用者の立場になって、操作フローを想定して書き出しを行ったり、記録をどう見せるべきか等を考察した上で実装を行っていました。
この辺りはいきなり実装しなくてscrapboxに思考をまとめたりすると、あとで第三者に意見をもらって整理できるので良いです。
思考を整理したりする時間って意外と楽しいですよね。

実装方法

実際に『カミナシ』で実装されているオフライン周りの実装について、以下の手順で解説します。

  • SQLiteのDB操作
  • ネットワーク状態、アプリの状態監視してオフラインデータの同期
  • Reduxでオフライン時のAction分岐
SQLiteのDB操作

まずはExpo SQLiteをインストールしましょう。

import * as SQLite from 'expo-sqlite'

docs.expo.io

SQLiteにcreate, selectの関数はないため、全て生のクエリを書いてDB操作を行いました。
基本的な処理は以下の通りです。

export const insert = () => {
  const DB = SQLite.openDatabase('hoge')
  return new Promise<any>((resolve, reject) => {
    DB.transaction(
      (tx: any) => {
        tx.executeSql(
          `insert into test values(?, ?);`, 
          [...values],
          () => {
            // 成功時の処理
          },
          () => {
            // エラー時はロールバックする
            return true
          }
        )
      },
      () => reject(new Error('[insert] transaction failed')),
      () => resolve(true)
    )
  })
}

SQLiteのDB操作については難しいことはしていませんが、オフラインデータのテーブル構造はすべて同じにしています。 理由はDB操作を共通化して、テーブルが増えたときに対応しやすいようにしておきたいということです。

ネットワーク状態、アプリの状態監視してオフラインデータの同期

オフラインデータ同期タイミングについては、以下の2つになります。

  • ネットワーク状態がオフラインからオンラインになったとき
  • アプリの状態がバックグラウンドからフォアグラウンドになったとき

これを実現するためにまず、ExpoのドキュメントにAppState, NetInfoを確認すると、どちらもaddEventListenerを使って状態を監視しています。その場合、Component単位で追加する必要があり、とても手間だと感じアプリ全体(グローバル)で監視できれば簡単になると思いました。

(この辺りはQuipperさんの記事がとても参考になりました!ありがとうございます!)

  • ネットワーク状態を監視してオフライン同期アクションを発火するようにしています。
export default (): StoreEnhancer => <S>(createStore: StoreEnhancerStoreCreator<S>): StoreEnhancerStoreCreator => (
  reducer,
  preloadedState
) => {
  const store = createStore(reducer, preloadedState)

  const handleConnectivityChange = () => {
    Network.getNetworkStateAsync().then((state: Network.NetworkState) => {
      if (state.isInternetReachable) {
        store.dispatch<any>('オフラインデータを同期するアクション')
      }
    })
  }

  NetInfo.addEventListener(handleConnectivityChange)
  return store
}
  • アプリの状態を監視してオフライン同期アクションを発火するようにしています。
export default (): StoreEnhancer => <S>(createStore: StoreEnhancerStoreCreator<S>): StoreEnhancerStoreCreator => (
  reducer,
  preloadedState
) => {
  const store = createStore(reducer, preloadedState)

  const handleAppStateChange = (nextState: AppStateStatus) => {
    if (nextState === 'active') {
      store.dispatch<any>('オフラインデータを同期するアクション')
    }
  }

  // アプリのActiveイベントをトリガーにバージョンチェック
  AppState.addEventListener('change', handleAppStateChange)

  return store
}

あとはcreateStore時にenhancerとして機能を追加すれば完成です。

  • store.ts
import { default as netInfoListener } from './netInfo'
import { default as appStateListener } from './appState'

const store = createStore(
  reducers,
  compose(netInfoListener(), appStateListener())
)

これでネットワーク状態、アプリの状態を監視してオフラインデータを同期するアクションが発火出来るようになりました。

Reduxでオフライン時のAction分岐

通常はReduxのActionを以下のように書くと思います。

  • action.ts
const requestHoge = (hoge: RequestHoge) => {
  return {
    type: 'REQUEST_HOGE',
    hoge
  }
}

これだとオフライン用のActionを用意する必要があり、とても無駄な処理が乱雑するので避けたい問題でした。 リクエストの度に毎回ネットワーク状況の確認をして、オフライン用のif文で処理を分岐させると思うと面倒ですよね。 そこで、Actionのパラメータにオフラインを判別するためのプロパティとActionを追加して、middlewareでオフライン状態をチェックする実装を追加しています。

  • action.ts
const requestHoge = (hoge: RequestHoge) => {
  return {
    type: 'REQUEST_HOGE',
    isOffline: {
      // isOfflineプロパティを追加して、オフラインのときに実行するActionを追加
      type: 'OFFLINE_REQUEST_HOGE', 
    },
    hoge
  }
}
  • store.ts
const offlineMiddleware: Middleware = ({ getState }: MiddlewareAPI) => (next: Dispatch) => async action => {
  if (action.isOffline) {
    const { netInfoState } = getState()
    if (netInfoState.netInfo.isConnected) {
      return next(action)
    } else {
      // ネットワーク状況がオフラインのとき、オンライン時に発火するアクションタイプをオフラインのアクションタイプに上書きしています
      return next({ ...action, type: action.isOffline.type })
    }
  }
  return next(action)
}

const middleware = applyMiddleware(offlineMiddleware)

const store = createStore(
  reducers,
  compose(middleware)
)

custom middlewareを追加することで、オフラインで利用したいActionに対して、isOfflineプロパティを追加するだけでオフライン用のActionを実行することが可能になり、スマートな実装になったと思います!

おわりに

当時のことを思い出しながら振り返ってみました。
オフラインでもオンライン同等の機能を提供するアプリは数少ないのではないでしょうか。
こういったカミナシならではのやりがいを感じれる部分がありますので、難しい課題に対してチャレンジできる環境で楽しんでいます!
私個人としては、誰もしないこと、実装面倒だなと思ってしまうことにやりがいを感じてしまうので、めちゃくちゃ良い環境です。

少しでも興味を持っていただけて、話を聞いてみたい、応募したいという方はwantedlyからお待ちしております!
TwitterのDMでも構いません。よろしくおねがいします!

Metabaseのグラフをslackへ通知するbotをServerless Framework + Puppeteerで作ってみた

f:id:kaminashi-developer:20201012002731p:plain こんにちは。株式会社KAMINASHIでPMをやっている@gtongy1です。
みなさんはMetabaseをご存知ですか?
見た目の良さによる直感的なダッシュボードの構築、クエリ記述時の補完機能、グラフの種類数の豊富さ、ツール自体は無料で使える等便利なBIダッシュボードツールです。
カミナシでもMetabaseをフルに活用して、日々分析時に利用しています。
そんなMetabaseですが唯一欠点があって、それはslackとの連携が難しい所です。
slackへ通知を行う時にグラフで表現できるのはまだ棒グラフだけとか、マルチバイトの文字列は文字化けしてしまうとか、実運用に回す時にはちょっと辛いなと感じるところがあります。
そこで、今回Metabaseのグラフをslackへ通知するbotをServerless Framework + Puppeteerで作ってみました。
最終的に以下のようにslack上でMetabaseのグラフを通知することが出来るようになります。

slack-notified
slackへの通知後の画面

作成したコード

今回作成したbotリポジトリです。

github.com

構成

f:id:kaminashi-developer:20201011001751p:plain
構成図

  • Serverless Frameworkを用いてAWS Lambdaの構成管理
  • AWS Lambda上でPuppeteerを起動し、Metabaseのダッシュボードに対してスクリーンショットを撮る
  • 撮影後の写真をslackへ投稿
  • CloudWatch Eventsにより、Lambda関数を定期実行

上記の流れで、今回のbotを実現させています。

Serverless Frameworkを用いてAWS Lambdaの構成管理

Serverless FrameworkはServerlessアプリの構成管理、デプロイを行うツールです。
以下のコマンド実行により、初期プロジェクトを作成出来ます。

$ npm install -g serverless
$ serverless create -t aws-nodejs-typescript --name project-name -p /path/to/project-name

一度作成してしまえば後は、serverless invoke local -f funcNameで関数を実行出来るようになります。
localでのLambda関数の実行だったり、構成管理のデプロイだったりコマンドにうまく吸収されていて、取っ掛かりのハードルが下がるのが良いですね。

また今回の構成では、CloudWatch Eventで平日に定期的に通知を行うような関数を作成しています。

import type { Serverless } from 'serverless/aws'

const serverlessConfiguration: Serverless = {
  // ...
  functions: {
    metabaseSlackNotify: {
      handler: 'handler.metabaseSlackNotify',
      events: [
        {
          schedule: 'cron(0 0 ? * MON-FRI *)'
        }
      ]
    }
  }
}
import { ScheduledHandler } from 'aws-lambda'
export const metabaseSlackNotify: ScheduledHandler = async () => {}

cron形式の記述によって平日に通知を行う設定を追加しています。
handler側では、特にイベントを引数から受け取ることはなく、ただ純粋に関数を実行するような形となっています。

MetabaseのダッシュボードURLの取得方法

Metabaseでは、Embeddingという機能が存在します。

www.metabase.com

この機能を利用することにより、作成したダッシュボードをアプリケーション内に埋め込むためのURLが発行することが出来ます。

このURLへのリンク先には、ダッシュボード全体の画面が表示されます。
表示されたブラウザの全体をスクリーンショットを撮り、この画像をslackに通知することで、今回のbotを実現させています。
ダッシュボードを公開し埋め込みのURLを取得するためには、管理者画面側からとダッシュボード画面側の両方から設定を行う必要があります。
詳細はマニュアルを確認ください。

metabase-setting-admin
管理者画面の設定
ダッシュボード共有設定
ダッシュボードの公開

設定を行った後に、

const payload = {
    resource: { dashboard: dashboard.id },
    params: {},
    exp: Math.round(Date.now() / 1000) + 10 * 60
}
const token = jwt.sign(payload, process.env.METABASE_SECRET_KEY)
const embedUrl = process.env.METABASE_SITE_URL + '/embed/dashboard/' + token + '#bordered=true&titled=true'

のような形で認証後のtokenを取得し、URL経由でダッシュボードを表示することが可能となります。

Puppeteerでスクリーンショットを撮って画像を作成

この後で、Puppeteerを使ってスクリーンショットを撮ります。

export const metabaseSlackNotify: ScheduledHandler = async () => {
    // ...
    // フォントの読み込み。
    // raw.githack.comを経由することで、ローカルのファイルを参照することなくgithub上に上がっているフォント読み込める
    const readFontProcess = chromeLambda.font(
      'https://raw.githack.com/minoryorg/Noto-Sans-CJK-JP/master/fonts/NotoSansCJKjp-Regular.ttf'
    )
    await readFontProcess
    const buffer = await screenshotMetabaseGraph(embedUrl)
}
export const screenshotMetabaseGraph = async (
  embedUrl: string,
  defaultViewport: { width: number; height: number } = { width: 2000, height: 100 }
): Promise<Buffer> => {
  // ...
  const page = await browser.newPage()
  await page.goto(embedUrl)
  // ダッシュボードが読み込みを完了するまで画面内で待機
  await page.waitFor(5000)
  // fullPageをtrueに設定して、画面内全ての項目を表示出来るように
  const buffer = await page.screenshot({ fullPage: true })
  await browser.close()
  return buffer
}

実装のうちで

  • 画面全体を撮影する方法
  • ファイルサイズ超過でLambdaへデプロイ出来ない
    • 純粋なPuppeteerを使うのではなく、chrome-aws-lambdaを利用。
      • ファイルサイズ250MBの制限があって、Puppeteerが250MB少し超えるサイズで使えないが、50MB以下まで下がった状態でPuppeteerを使える
  • Puppeteerから撮った後の画像が文字化けを起こす
    • chromeLambda.fontによってフォントを事前に読み込み
    • raw.githack.comを経由することでローカルのファイルを参照することなく読み込み可能

あたりに躓きました。PuppeteerとLambdaの組み合わせやフォント当たりは少し工夫が必要な感じですね。

serverless-layersを使って、node_modulesのlayer化

dev.classmethod.jp

const serverlessConfiguration: Serverless = {
  custom: {
    'serverless-layers': {
      layersDeploymentBucket: 'bucketName'
    }
  },
  plugins: ['serverless-layers'],
}

これで、node_modules自体のlayer化を行うことが出来ます。
chrome-aws-lambdaがサイズ大きくて気になっていたのですっきりしますね。

AWS Systems Managerを利用して環境変数の外出し

環境変数は外部から注入したいので、AWS Systems Managerを利用して環境変数を外出しします。

import type { Serverless } from 'serverless/aws'

const serverlessConfiguration: Serverless = {
  provider: {
    environment: {
      METABASE_SITE_URL: '${ssm:/metabase-slack-notify/METABASE_SITE_URL}',
      METABASE_SECRET_KEY: '${ssm:/metabase-slack-notify/METABASE_SECRET_KEY}',
      METABASE_SESSION_ID: '${ssm:/metabase-slack-notify/METABASE_SESSION_ID}',
      SLACK_TOKEN: '${ssm:/metabase-slack-notify/SLACK_TOKEN}',
      SLACK_CHANNELS: '${ssm:/metabase-slack-notify/SLACK_CHANNELS}'
    }
  },
}

SSMパラメータを設定

等の設定を行っておけば、外部に環境変数の依存を外出し出来ます。
ローカルでlambdaを実行する時は

$ serverless invoke local \ 
   -f metabaseSlackNotify \
   --region region-name \
   -e METABASE_SITE_URL="" \
   -e METABASE_SECRET_KEY="" \
   -e METABASE_SESSION_ID="" \
   -e SLACK_TOKEN="" \
   -e SLACK_CHANNELS=""

-e ENV_NAMEの指定により、環境変数を上書き出来ます。

デプロイ

いざ、デプロイです!以下コマンドを実行し、本番環境へのデプロイを行います。

$ serverless deploy -v --region region-name --stage production

上記の実行に成功するとCloudFormation側でスタックが生成され、リソースが新規作成されます。 またデプロイ後にLambda関数の実装だけ変更し、関数の中身のロジックを変更する場合には

$ serverless deploy function -v --region region-name --stage production -f function-name

を実行することで関数のみを更新することが出来ます。

実行結果

実際の実行結果です。

f:id:kaminashi-developer:20201011011006p:plain
CloudWatch上の実行ログ

9時頃に成功のログが出力されていて

f:id:kaminashi-developer:20201011011928p:plain
slackへの通知

正しくslack上でも通知されていますね!

終わりに

Metabaseのグラフをslackへ通知するbotをServerless Framework + Puppeteerで作ってみました。
今回初めてServerless Frameworkを使ってみましたが、特に癖も少なくすんなり使えた印象。周辺のエコシステムが充実しているあたりにサーバーレスの成長をひしひしと感じました。

またslackにMetabaseのグラフの通知を飛ばすというところで、今回データによる改善のまさに第一歩目を踏み出せました。
実際に使ってくれているユーザーの行動に対してバイアスをかけないためにも、Metabaseからこれからデータを拾い続け事業を前に進める施策や改善を最速で回していきます!

カミナシでは実際に現場へ赴き、ヒアリングを重ね必要な機能や体験を磨き込み、リリース後反応を分析することにより次の一手を常に考える体制です。
そんな環境で、チャレンジしてみたい熱量持ったエンジニアの方を募集しています。エントリーお待ちしております!!