Expo ReactNativeのStorybookを0から入れ直す

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

本日は、ExpoでのStorybookの導入について書いていきます。

StorybookのサイトにReactNativeでの導入方法が記載されていますが、それ通りだけでは上手く動かなかったので、ExpoでStorybookを使いたい人の参考になればと思います。

なぜStorybookを入れようと思ったのか

現在カミナシのデザインチームでは、ブランドガイドラインを固めてくれています。それを基に新たに0からデザインシステムを作り、現行デザインをリプレイスする予定です。

デザインシステムができてからエンジニアが動き出しては、時間がなくて殴り書きのコードができあがってしまうのが、予想できます。。

せっかくデザインシステムを作ったのに、それが守られなかったりデザインシステムに振り回されては本末転倒なので、今のうちから準備しておこうというのと、Storybook使ったことなかったので勉強のために導入してみました。

さよなら古のStorybook・・・😭

以前にも導入した形跡がありましたが、1年以上前からメンテナンスが止まっています。

どうやらまだサービスがローンチされる前に、モックとしてお客様にお見せするために使っていたようです。

コードの中身も大分変わってしまい動かなくなっていたので、Storybookをまるっと入れ直したいと思います。

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

ようこそNew Storybook

ベースはこちらドキュメントに沿って、導入していきます。

https://storybook.js.org/tutorials/intro-to-storybook/react-native/en/get-started/

npx -p @storybook/cli sb init --type react_native

早速パッケージをインストールしようとしたら、エラーが発生。

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

yarnのバージョンが1.22.4だとエラーが起こっているぽかったので、1.19.0に落としてインストールを進める。

yarn policies set-version 1.19.0

npx -p @storybook/cli sb init --type react_native

こちらのバージョンでは問題なくインストールできました。

yarn policiesで追加された変更(.yarn/releases.yarnrc内のyarn-path)を削除して、元の1.22.4のバージョンでに戻して、再度パッケージをインストールする。

rm -f .yarn/releases
rm -f .yarnrc
yarn

無事サンプルのStoryBookが追加されました。

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

サンプルが動くか確認するため起動しようとしたが、エラー発生。

yarn storybook

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

https://github.com/storybookjs/storybook/issues/5919と似ていたので、@storybook/theming をインストールする。

バージョンも他の依存パッケージと合わせる。

yarn add -D @storybook/theming@^5.3

改めて起動。

yarn storybook

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

無事起動したかと思いきや、、、なにかおかしい。

サイドメニューが表示されない。

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

アプリも起動しないといけないようでした。

yarn start

f:id:kaminashi-developer:20210406090432p:plain
メニューは表示されたが、右側の表示が切り替わらない。。

スマホに依存しているパッケージが入っていてExpo Webで起動できないのが原因で、ブラウザでは確認できないのかも。

アプリでStorybookを確認する

どうやらアプリ側のApp.tsxで、Storybookの自動生成されたコンポーネント./storybook/index.jsを呼び出すと、アプリで確認が可能になるようなので試してみます。

環境変数でStorybookを起動するかアプリを起動するかを切り替えられるようにする↓

STORYBOOK_ENABLED=1 yarn start --ios

app.config.js

export default {
  extra: {
    storybookEnabled: process.env.STORYBOOK_ENABLED,
  },
}

App.tsx

import Storybook from './storybook'
...
export default Constants.manifest.extra.storybookEnabled ? Storybook : App

さらに、Expoを使っているので./storybook/index.jsのコメントに書いてある通り少し編集します。

2箇所、Expoでは不要なコードがあるのでコメントアウトしておく。

// if you use expo remove this line
// import { AppRegistry } from 'react-native';

// If you are using React Native vanilla and after installation you don't see your app name here, write it manually.
// If you use Expo you should remove this line.
// AppRegistry.registerComponent('%APP_NAME%', () => StorybookUIRoot);

これでStorybookを起動。

STORYBOOK_ENABLED=1 yarn start

無事シミュレータ内で起動しました!

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

welcomeページから切り替わらないよ、という場合はエディタで適当な*.stories.jsを開いて保存すると表示されるようになるかと思います。

また、ブラウザで開かれている方は相変わらず表示されませんが、サイドメニューを操作するとアプリ側にも同期されるので、プロパティの変更などには使えます。

f:id:kaminashi-developer:20210406090500p:plain
ブラウザでHello KAMINASHI !と変更したものがシミュレータに反映された

最後に

今回は一旦起動まで。

storyディレクトリに*.stories.js があると絶対にメンテナンスされなくなるので、コンポーネントが置かれている場所に置いて管理するのと、StoryShotsで自動スナップショットテストの導入などもやりたかったです。

yarnのバージョンによってはインストールできなかったり、start-storybookが動かずドキュメント通りにはいかなかったり、想定よりも導入に時間がかかってしまいました。

今度改めて、より使いやすいStorybookを目指して改良していこうと思います。

(書いてる途中で思ったけど、Expoのフォルダ内で管理するよりも、WebUIとアプリUI用のリポジトリを作成して、そこにStorybookを導入した方が良いかも。)

React3Dチュートリアル

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

カミナシの浦岡です。

この記事ではreact-three-fiberというライブラリを使って、reactで3Dアプリケーションを作成してみます。

github.com

このライブラリを使うことでアプリの開発者からすると、 通常のreactアプリ(DOMにレンダリングするもの)を作るのと同じ実装方法で、3Dレンダリングするアプリケーションを作成することができます。

とは言え、 3Dの基本的な用語を知らないと思うように実装を進められないと思います。今回簡単なアプリの作成を通して3Dの基本に入門しましょう。

これから作る物

Reactの公式チュートリアル三目並べゲーム (tic-tac-toe) 」を3D化することをゴールにします。

gyazo.com

最終完成形

さっそく、完成形は以下になります。

codesandbox.io

Reactのチュートリアルと同じく、大きく以下3つのコンポーネントで構成しています。

  1. Game(ゲーム全体)
  2. Board(盤面)
  3. Square(正方形のマス目)

以下、1つずつ見てみましょう。

1. Gameコンポーネント

Gameコンポーネントは主に「三目並べゲーム」のロジックですが、Canvasタグの部分が3Dレンダリングのための実装になります。

      <Canvas camera={{ fov: 45, position: [0, 8, 12] }}>
        <CameraControls />
        <ambientLight />
        <pointLight position={[0, 10, 10]} />
        <Board
          squares={history[stepNumber].squares}
          handleClick={handleClick}
        />
      </Canvas>
カメラ
<Canvas camera={{ fov: 45, position: [0, 8, 12] }}><Canvas>

Canvasタグではカメラの設定を行うことができ、positionに三次元の座標を指定します。 中心座標を少し上から覗きこむような配置にしています。

gyazo.com

CameraControls
<CameraControls />

CameraControlsは、マウスのドラック操作などに合わせてカメラの位置を動かします。

今回扱う3Dオブジェクトはただの板なので、グリグリしても何も面白くありませんが 3Dキャラクターなどを「いろいろな角度から見たい」って時に重宝します。

ライティング
<ambientLight />
<pointLight position={[0, 10, 10]} />

もしライトを配置しないと、光のない世界になってしまうので、下の画像のようにただ黒い状態になります。

ライトはいくつか用意されていて、それによって色合いや影のつき方などが変わるので 合わせて選ぶ必要があります。ThreeJSのドキュメントを参照してください

2. Boadコンポーネント

Boadコンポーネントは、9つのSquareをマス状に配置するために座標を指定します。

import React from 'react'
import { BoardProps } from '../types'

import Square from './Square'

const positions: [x: number, y: number, z: number][] = [
  [-3, 3, 0],
  [0, 3, 0],
  [3, 3, 0],

  [-3, 0, 0],
  [0, 0, 0],
  [3, 0, 0],

  [-3, -3, 0],
  [0, -3, 0],
  [3, -3, 0],
]

const Board: React.FC<BoardProps> = (props) => {
  return (
    <>
      {props.squares.map((square, index) => {
        return (
          <Square key={index} position={positions[index]} value={square} handleClick={() => props.handleClick(index)} />
        )
      })}
    </>
  )
}

export default Board

3. Squareコンポーネント

Squareは1つずつ、meshとして定義します。

    <mesh
        {...props}
        ref={mesh}
        onClick={(_) => props.handleClick()}
        onPointerOver={(_) => {
          setHover(true);
        }}
        onPointerOut={(_) => {
          setHover(false);
        }}
      >
        <boxBufferGeometry args={[3, 3, 0]} />
        <meshStandardMaterial color={getColor(props.value, hovered)} />
      </mesh>
mesh

meshタグの子要素にある、Geometry(3D形状)とMaterial(素材)を基に、3Dオブジェクトが表示されるようになります。

<boxBufferGeometry args={[3, 3, 0]} />
<meshStandardMaterial color={getColor(props.value, hovered)} 
マウスイベント

マス目を選択できるようにするために、クリックとホバーイベントを検知するようにします。

ThreeJSでこれらのイベントを実装する場合、レイキャストを使って当たり判定から行う必要があるのですが、react-three-fiberでは、その処理がラップされていて、 meshタグの属性のonClick、onPointerOver、onPointerOutでイベント時の処理を実装できます。

◯×表示

マス目が選択状態になった場合、◯or×をマス目に表示します。 textGeometryで立体的な文字としてOXが表示されます。 (そもそも題材が3D向きではなかったなぁ💦)

    {props.value !== null && (
        <Suspense fallback={null}>
          <Text
            size={1}
            text={props.value}
            color="gray"
            position={props.position}
          />
        </Suspense>
      )}

まとめ

Reactのチュートリアルをベースにレンダリング部分だけ置き換える形で3D化することができました!

今回触れませんでしたが、EffectComposerを利用して見た目を凝ることもできます。3Dの世界奥が深いので興味持っていただいた方がいれば、ぜひ試してみてください!

gyazo.com

【React Native + Expo】Push通知を試してみた

f:id:kaminashi-developer:20210331103402j:plain こんにちは、株式会社カミナシのエンジニア @ImuKnskです。

はじめに

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

と、いうことでPush通知の話です!

カミナシではReact Native(Expo)でアプリケーションを開発しています。

Push通知の実装って意外と難しいイメージありませんか? アプリケーション側での監視、通知内容を保存しておくサーバー、どういうタイミングで通知するかのパターン等を考えると、簡単なパッケージがあれば楽なのに…と思ってしまいます。

Expoには便利なPackageがいくつかあって、通知のPackageもあります! どれくらい簡単に実装できるか、ご紹介出来ればと思います。

『カミナシ』のアプリケーションにはまだ実装されていないのですが、これくらい簡単であれば入れたいな…。

環境構築

ExpoCLIのインストール
npm install -g expo-cli 

インストールが終わったらひな形を作成しましょう。 今回はManaged workflowのblank(TypeScript)を利用します。

expo init notification
通知のパッケージをインストール

公式ドキュメントに従って通知のパッケージをインストールします。 BareWorkflowを使った場合はこちらを参照してください。

expo install expo-notifications

Expoのサーバーを利用してPush通知が可能になります。 自前でサーバーを用意することがないので非常に楽ちんです。

docs.expo.io

早速使ってみよう
大まかな流れは以下の通りです
  1. 通知の権限、Pushトークンの取得
  2. 通知したい内容を登録する
  3. アプリケーション側で通知内容を受け取る
通知の権限、Pushトークンの取得
if (Constants.isDevice) {
  // 通知の権限を確認します
  const { status: existingStatus } = await Notifications.getPermissionsAsync()
  let finalStatus = existingStatus
  if (existingStatus !== 'granted') {
    // 通知の権限がない場合は、再度通知の権限を確認します
    const { status } = await Notifications.requestPermissionsAsync()
    finalStatus = status
  }
  if (finalStatus !== 'granted') {
    alert('Failed to get push token for push notification!')
  } else {
    // Pushトークンの取得 
    const token = (await Notifications.getExpoPushTokenAsync()).data
  }
} else {
  // Push通知はSimulatorでは確認できない
  alert('Must use physical device for Push Notifications')
}
通知したい内容を登録する
// 通知したい内容をRequestBodyに設定する
const pushMessage = {
  // Push通知のトークン
  to: expoPushToken,
  // Push通知の音を設定
  sound: 'default',
  // Push通知のタイトル
  title: title,
  // Push通知の内容
  body: body,
  // dataは実際にPush通知には表示されないがアプリケーション側で参照することが出来るので、アプリケーション側になにかさせる場合は設定すると良さそう
  data: { addData: data, sample: 'test' }
}

await fetch('https://exp.host/--/api/v2/push/send', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Accept-encoding': 'gzip, deflate',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(pushMessage)
})

fetch関数を呼び出しているだけなので、コマンドラインからcurlを実行しても良いです。

% curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" \                                                                                                    (git)-[main]
> -d '{"to":"ExponentPushToken[Pushしたい先のトークン]","title":"kaminashi","body":"blog"}'
{"data":{"status":"ok","id":"hoge"}}%

他にもスケジュール通知もあるので詳しくはこちらを確認してみてください。

docs.expo.io

アプリケーション側で通知内容を受け取る
// アプリがフォアグラウンドの状態で通知を受信したときに起動
notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
  // notificationには通知内容が含まれています
  setNotification(notification)
})

// ユーザーが通知をタップまたは操作したときに発生します
// (アプリがフォアグラウンド、バックグラウンド、またはキルされたときに動作します)
responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
  console.log(response)
})
最終的なコード

公式サイトのサンプルを元に、画面から入力した内容でPush通知できるように変更したものがこちらです。

import Constants from 'expo-constants'
import * as Notifications from 'expo-notifications'
import React, { useState, useEffect, useRef } from 'react'
import { Text, View, Button, Platform, TextInput, StyleSheet, TouchableHighlight } from 'react-native'
import { Subscription } from '@unimodules/core'

export default function App() {
  const [expoPushToken, setExpoPushToken] = useState('')
  const [notification, setNotification] = useState<Notifications.Notification>()
  const notificationListener = useRef<Subscription>()
  const responseListener = useRef<Subscription>()
  const [title, onChangeTitle] = useState<string>('')
  const [body, onChangeBody] = useState<string>('')
  const [data, onChangeData] = useState<string>('')

  useEffect(() => {
    // 通知を受信した時の振る舞いを設定
    Notifications.setNotificationHandler({
      handleNotification: async () => ({
        shouldShowAlert: true,
        shouldPlaySound: false,
        shouldSetBadge: false
      })
    })

    // Expo Pushトークンを取得
    registerForPushNotificationsAsync().then(token => token && setExpoPushToken(token))

    // アプリがフォアグラウンドの状態で通知を受信したときに起動
    notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
      setNotification(notification)
    })

    // ユーザーが通知をタップまたは操作したときに発生します
    // (アプリがフォアグラウンド、バックグラウンド、またはキルされたときに動作します)
    responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
      alert('ユーザーが通知をタップしました')
      console.log(response)
    })

    // アンマウント時にリスナーを削除
    return () => {
      const notification = notificationListener.current
      notification && Notifications.removeNotificationSubscription(notification)
      const response = responseListener.current
      response && Notifications.removeNotificationSubscription(response)
    }
  }, [])

  return (
    <View style={styles.container}>
      <Text style={styles.title}>プッシュ通知を試してみよう🙌</Text>
      <View style={styles.pushView}>
        <Text style={styles.title}>プッシュする内容を入力📤</Text>
        <TextInput
          style={styles.input}
          placeholder='タイトルを入力してください'
          value={title}
          onChangeText={onChangeTitle}
        />
        <TextInput
          style={styles.input}
          placeholder='メッセージを入力してください'
          value={body}
          onChangeText={onChangeBody}
        />
        <TextInput
          style={styles.input}
          placeholder='追加メッセージ(通知には表示されない)'
          value={data}
          onChangeText={onChangeData}
        />
      </View>
      <View style={styles.pushView}>
        <Text style={styles.title}>プッシュした内容を表示📥</Text>
        <View style={styles.output}>
          <Text style={styles.outputText}>タイトル: {notification && notification.request.content.title}</Text>
          <Text style={styles.outputText}>メッセージ: {notification && notification.request.content.body}</Text>
          <Text style={styles.outputText}>
            追加メッセージ: {notification && JSON.stringify(notification.request.content.data)}
          </Text>
        </View>
      </View>
      <View style={styles.buttonView}>
        <TouchableHighlight style={styles.button}>
          <Button
            title='プッシュ通知'
            color='white'
            onPress={async () => {
              await sendPushNotification(expoPushToken, title, body, data)
            }}
          />
        </TouchableHighlight>
        <TouchableHighlight style={styles.button}>
          <Button
            title='スケジュールのプッシュ通知'
            color='white'
            onPress={async () => {
              await schedulePushNotification()
            }}
          />
        </TouchableHighlight>
      </View>
    </View>
  )
}

async function sendPushNotification(expoPushToken: string, title: string, body: string, data: string) {
  const pushMessage = {
    to: expoPushToken,
    sound: 'default',
    title: title,
    body: body,
    data: { addData: data, sample: 'test' }
  }

  await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Accept-encoding': 'gzip, deflate',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(pushMessage)
  })
}

async function schedulePushNotification() {
  await Notifications.scheduleNotificationAsync({
    content: {
      title: 'スケジュール通知 📬',
      body: 'スケジュール通知されました!'
    },
    trigger: {
      seconds: 2
    }
  })
}

async function registerForPushNotificationsAsync() {
  let token: string = ''

  if (Constants.isDevice) {
    const { status: existingStatus } = await Notifications.getPermissionsAsync()
    let finalStatus = existingStatus
    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync()
      finalStatus = status
    }
    if (finalStatus !== 'granted') {
      alert('Failed to get push token for push notification!')
      return
    }
    token = (await Notifications.getExpoPushTokenAsync()).data
  } else {
    alert('Must use physical device for Push Notifications')
  }

  if (Platform.OS === 'android') {
    Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF231F7C'
    })
  }

  return token
}

export const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'space-around'
  },
  pushView: {
    width: '100%',
    alignItems: 'center',
    justifyContent: 'center'
  },
  title: {
    fontWeight: 'bold',
    fontSize: 34,
    lineHeight: 49
  },
  input: {
    width: '80%',
    height: 40,
    margin: 12,
    borderWidth: 1
  },
  output: {
    width: '100%',
    alignItems: 'center',
    justifyContent: 'center'
  },
  outputText: {
    width: '80%',
    fontWeight: 'bold',
    fontSize: 18,
    lineHeight: 40,
    margin: 12,
    borderWidth: 1
  },
  buttonView: {
    width: '100%',
    alignItems: 'center',
    justifyContent: 'center'
  },
  button: {
    height: 40,
    width: '50%',
    borderRadius: 12,
    backgroundColor: 'gray',
    marginBottom: 24
  }
}

iPadOS 14.4の実機で画面収録したものがこちらになります。 入力した内容でプッシュ通知を行い、その後スケジュール通知(ボタン押下から2秒後に通知される)を行っています。 gyazo.com

APNs(Apple Push Notification Service), FCM(Firebase Cloud Messaging)を利用した通知の紹介

APNs / FCM を理解していて、ExpoのPush通知では実現できない場合に検討するほうが良さそうです。

docs.expo.io

おわりに

expo-notificationsを使えば簡単にプッシュ通知が実装できました!Managed Workflowの場合は簡単に実装出来るので便利ですね。 次回は他のPackageで面白そうなものを紹介出来ればと思います。

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

open.talentio.com

Raspberry PiでKubernetesクラスタを作る〜準備編〜

f:id:kaminashi-developer:20210331224100j:plain 株式会社カミナシのエンジニア(まぁ当たり前か)の@nasum(id:Tomato-360)です。

以前個人でやってた活動で、Raspberry Pi 4でKubernetesクラスタを立てました。そのときは3台で、master 1台 worker 2台で構築しました。このときはKubernetesクラスタをただ動かしているだけで満足していました。

今回はより踏み込んで、masterを3台 workerも3台にして、Kubernetesをアップデートするときサービスを止めずに対応できる本格的なクラスタにチャレンジしていこうと思います。

Raspberry Piクラスタの組み立て

まずはハードウェアの選定です。選定したハードウェアは以下です

サーバー Raspberry Pi 3 3台
電源 Anker PowerPort Speed4 1台
hub BUFFALO LSW6-GT-5EPL 1台

後は必要なケーブル・SDカード等です。

今回使用するRaspberry Pi 3はmasterノードで使用します。masterノードはあまりリソースは消費しないため、比較的高性能なRaspberry Pi 4を使わず安価なRaspberry Pi 3で構築します。

workerノードは以前構築したクラスタを再利用します。

masterノードのクラスタを組み立てるとこんな感じになります。

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

黒いボディがかっこいいです。

Raspberry Pi に ArchLinuxをインストール

次にRaspberry piにさすmicro sdカードにOSをインストールします。

まずはfdiskでパーティションを切り、各パーティションをフォーマットしていきます。

# パーティションを切る
$ sudo fdisk /dev/sdf
# 各パーティションをフォーマットする
$ sudo mkf.vfat /dev/sdf1
$ sudo mkf.ext4 /dev/sdf2

fdiskでパーティションを切るデバイスは自分の環境では /dev/sdf でした。各環境によってこの値は変わります。パーティション1は200MB分作りパーティション2は残り全ての領域を使います。パーティション1はvfat、パーティション2はext4でフォーマットします。

詳しくは公式のHPが詳しいです。

次に各パーティションをマウントし、Arch Linuxをインストールします。

# Arch Linuxをダウンロード
$ wget http://os.archlinuxarm.org/os/ArchLinuxARM-rpi-2-latest.tar.gz
# 各パーティションをマウント
$ mkdir boot
$ mkdir root
$ sudo mount /dev/sdf1 boot
$ sudo mount /dev/sdf2 root
# Arch Linuxをインストール
$ sudo bsdtar -xpf ArchLinuxARM-rpi-2-latest.tar.gz -C root 
# bootのためのファイルをboot用パーティションに移動
$ sudo mv root/boot/* boot

ファイルを解凍するだけでインストールが完了するのをみると、Linuxもあくまでファイルなんだなと当たり前のことを思ったりします。これでインストールはほぼ完了です。次にキーの配置とネットワークの設定を追記します。

# sshでログインできるように自分のPCのキーを配置
$ cp ~/.ssh/id_rsa.pub root/home/alarm/.ssh/
# IPを固定するためにイーサネットの設定を編集
$ emacs root/etc/systemd/network/eth0.network

イーサネットの設定は次のように編集します。今回は 192.168.1.19 に固定します。

[Match]
Name=eth0
  
[Network]
Address=192.168.1.19/24
Gateway=192.168.1.1
DNS=8.8.8.8

鍵の設置とネットワークの設定を終えたらSDカードをRaspberry Piに差し、LANケーブルと電源を差し起動します。問題がなければ次のコマンドでログインできるはずです。

$ ssh -l alarm -i ~/.ssh/id_rsa -p 22 192.168.1.19

ArchLinuxのデフォルトのユーザ名は alarm です。パスワードも alarm です。ログインが確認できたらパスワードを変更します。

$ passwd

これで1つmasterノードが完成しました。あとはmaster2台とworker3台のセットアップです。気が遠くなりますね。

次回に続きます!

AWS SAMを利用してAPI GatewayとLambda Authorizer(Golang)の構築をやってみた

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

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

前回、GolangでLambda Authorizer用関数をやってみましたが、 Golangはコードエディタが使えず、テストするには毎回zipで固めてアップするという一手間がありました。 kaminashi-developer.hatenablog.jp

そんな中、先日「AWS SAMを使うとAPI Gateway等とまとめて作成できるので便利だよ!」という話を伺ったので今回使ってみることとしました。

AWS SAMとは

aws.amazon.com

YAMLで記載したテンプレートを利用して、CLIにてサーバーレスのアプリケーションを構築できるオープンソースフレームワークです。

例として、以下のようなYAMLを作成することでサーバーレスのアプリケーションを構築できます。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-app

  Sample SAM Template for sam-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.7
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn

(チュートリアルsam initした際に作成されるもの)

利用するメリットとして、

  • サーバーレスのアプリケーションの一元管理
  • Lambdaに似た実行環境をローカルに構築することができるので、テストのために逐一アップロードするという手間が省ける

ということがあると思います。

CloudFormation の拡張機能であるとのことで、同じように記載ができるので、そちらに馴染みがある方は比較的簡単に利用できるのではないかと思われます(ただし、AWS SAMはJSONでの記載は不可でYAMLのみ)

詳細なテンプレートの記載方法については以下をご参照ください。

docs.aws.amazon.com

AWS SAM CLIのインストール

AWS SAMを利用するにはまずCLIをインストールする必要があります。

前提としてAWSアカウントや、ローカルで起動するためにはDockerのインストールが必要となりますので、以下を参照してご自身の環境に合わせてセットアップください。

docs.aws.amazon.com

前回やったサーバーレスアプリケーションをSAMで構築

続いて、早速ですが前回作成したものをSAMテンプレートを利用して作成していきます。

軽いおさらいとして、前回の構成は以下です。

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

今回はこの中から、

AWS SAMで構築していきます。

詳細は省きますが、チュートリアルや以下の公式ドキュメントを参考に設定しました、

※日本語版のドキュメントは作成されておらず自動翻訳となっているため、読んでてわかりづらい箇所は英語のまま読む方が良いと感じました。

今回実際に作成したテンプレートは以下です。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:  Sample API Gateway Lambda Authorizer by Golang.

Resources:
  # API Gatewayの作成
  SampleRestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      Description: "This is Sample Rest API"
      Cors:
        AllowMethods: "'GET, OPTIONS'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: MyLambdaAuthorizerFunction
        Authorizers:
          MyLambdaAuthorizerFunction:
            FunctionArn: !GetAtt LambdaAuthorizerFunction.Arn
  # API Gatewayのレスポンス作成
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        def handler(event, context):
            return {'body': 'Hello World!', 'statusCode': 200}
  # LambdaAuthorizer用の関数の作成
  LambdaAuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samAuth
      CodeUri: ./lambda_auth # buildされるが、configがアップされなかったので、`/build/関数名`のディレクトリに手動で配置
      Handler: app.lambda_handler
      Runtime: go1.x

# SAMでdeployした後、コンソールに表示するもの(URL表示したり)
Outputs:
  SampleRestApi:
    Description: "Sample API Gateway Lambda Authorizer by Golang."
    Value: !Sub "https://${SampleRestApi}.execute-api.${AWS::Region}.amazonaws.com/dev/"

前回とちょっと違う点として、API GatewayのエンドポイントにMockを指定する方法を見つけられなかったのでLambda Fanctionを利用して簡単なレスポンスを返す関数を置いてみました。

  # API Gatewayのレスポンス作成
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        def handler(event, context):
            return {'body': 'Hello World!', 'statusCode': 200}

チュートリアルのサンプルを元にしたものですが、SAMであればこれもサクッと追加できるのが良いですね。

ビルド & デプロイ

テンプレートの作成が完了しましたら、ビルド及びデプロイをやっていきます。

ビルド

ビルドは作成したテンプレートが存在するディレクトリで sam build コマンドを実行します。

-> % sam build
Building codeuri: /Users/takuya/dev/new_technology_learning/lambda/sam/api-gateway-authorizer-golang/lambda_auth runtime: go1.x metadata: {} functions: ['LambdaAuthorizerFunction']
Running GoModulesBuilder:Build

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Invoke Function: sam local invoke
[*] Deploy: sam deploy --guided

ハマったポイントとして、前回はLambdaAuthorizer用のファイルはzipでまとめてアップすれば良かったのですが、SAMですと設定が悪いのかビルドするディレクトリを指定してもその中のconfigがアップされませんでした。 (ビルド&デプロイはできるが、Lambda関数実行する際にconfigが指定箇所に存在しないエラーとなる)

  # LambdaAuthorizer用の関数の作成
  LambdaAuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samAuth
      CodeUri: ./lambda_auth # buildされるが、configがアップされなかったので、`/build/関数名`のディレクトリに手動で配置
      Handler: app.lambda_handler
      Runtime: go1.x

そのため今回は、デプロイ前にビルド後作成された .aws-sam/build ディレクトリ配下の、生成されたLambda関数のディレクトリに手動でconfigを配置することで対応しました。

-> % tree -a .
.
├── .aws-sam
│   ├── build
│   │   ├── LambdaAuthorizerFunction
│   │   │   ├── app.lambda_handler
│   │   │   └── config
│   │   │       └── jwt-lambda-auth-firebase-adminsdk-3sa6r-c2e0d97f93.json // ここに手動で配置
│   │   └── template.yaml
│   └── build.toml
├── lambda_auth
│   ├── config
│   │   └── jwt-lambda-auth-firebase-adminsdk-3sa6r-c2e0d97f93.json
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── samconfig.toml
├── template.yaml
└── web
    ├── js
    │   └── config.js
    ├── login.html
    └── pets.html

デプロイ

ビルドが完了しましたら同じディレクトリで sam deploy --guided コマンドを実行します。

 -> % sam deploy --guided


Configuring SAM deploy
======================

    Looking for config file [samconfig.toml] :  Found
    Reading default arguments  :  Success

    Setting default arguments for 'sam deploy'
    =========================================
    Stack Name [sample-auth]:
    AWS Region [ap-northeast-1]:
    #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
    Confirm changes before deploy [Y/n]: Y
    #SAM needs permission to be able to create roles to connect to the resources in your template
    Allow SAM CLI IAM role creation [Y/n]: Y
    Save arguments to configuration file [Y/n]: Y
    SAM configuration file [samconfig.toml]:
    SAM configuration environment [default]:

    Looking for resources needed for deployment: Found!
...

CloudFormation outputs from deployed stack
-------------------------------------------------------------------------------------------------
Outputs
-------------------------------------------------------------------------------------------------
Key                 SampleRestApi
Description         Sample API Gateway Lambda Authorizer by Golang.
Value               https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/
-------------------------------------------------------------------------------------------------

Successfully created/updated stack - sample-auth in ap-northeast-1

正常に終了した場合、AWS(API Gateway、CloudFormation、Lambda)を確認するとリソースが追加されているかと思います。

ローカルで起動

ローカルでテストしたい場合、ビルド完了後 sam local start-api コマンドでDockerを利用したシミュレーターを起動してテストすることができます。

 -> % sam local start-api
Mounting ApiFunction at http://127.0.0.1:3000/ [GET, OPTIONS]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-03-30 08:33:52  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)

テストをしながらLambda関数を作成する場合これは便利かと思います。

おわりに

AWS SAMを使ってみて、私自身まだLambda関数やCloudFormationに慣れていないこともあり、最初はテンプレート作成に時間がかかりましたが、慣れてしまうとまとめての構築やローカルでテストができるため便利なものかと思いました。

また、サーバーレスアプリケーションを構築するには、 Serverless Framework というツールもあるとこことのため、こちらも時間がある際に試してみたいと考えてます。

www.serverless.com

(Freeのオープンソース版と有料のPro版があり)

最後までご覧いただきありがとうございました。

不備やこうした方が良いよ!というのがあればコメント等いただけると幸いです。

BtoB SaaSスタートアップのインフラの失敗と選択の歴史を登壇の中で暴露してきました

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

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

先日行われた BtoB Startup Engineers Meetup 〜BtoB SaaSを支えるインフラ〜 - connpass にて「失敗しても前に倒れる。高速検証のための壊しやすいインフラ」というタイトルで登壇してきました。

登壇資料

登壇内容

スタートアップで事業を成長させていく過程では失敗も数多くあり、その中でも前に倒れてインフラを組み立てていく必要があります。
その様子はまさに飛行機を飛び降りながらインフラを組み立てていく感覚と近く、増えていくユーザーに迷惑かけずに形を作っていくのかがとても重要です。
その中で「壊しやすさ」は一つの目安であり、こんなインフラを作っていくのか。
これが一つ堅牢なインフラ構築の鍵なんじゃないか?と考えています。
そこで、トラブルと解決事例を交えながら壊しやすいインフラを如何に作ってきたのか、発表させていただきました。

登壇の中でお話ししきれなかった部分を掻い摘んでご紹介出来ればと思っています。

Deployの整理とコンテナ化の実施

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

移行への舵切りはミドルウェアとかのバージョンなど色々ありますが、結局一番怖かったのがダウンタイムが発生する恐怖でした。
リリースを行う度に数秒ほどシステムが止まる。
PoCから契約へ至ったお客さんがいる中で、このインフラは迷惑をかける事が何よりも嫌だし、これを成長の足枷にしたくないです。
そこで、正式リリースまでの期間が1.5ヶ月くらいと短い期間でしたが、えいやっと移行しました。

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

Fargate内のタスクの定義はsidecar構成で組み立て、メインとなるAPI Serverからログを各種コンテナへ流し込む。
FargateにはFirelensというlog routerというものがあり、これを利用する事でシームレスにコンテナとfluent bitを紐付ける事が出来ます。

docs.aws.amazon.com

他にもdatadogやX-Rayなども切り分けられて、起動しているミドルウェアも極力最小に保つ。
小さく、かつ各コンテナが責務以上の行動をしない。
そんなUNIXらしい設計を意識して作っています。

負荷テストの作成。運用の手間削減

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

実際の負荷テストにはK6 + Grafanaを利用した負荷シナリオテストツールを利用しています。 こちらは以前記事にしているので、詳しい操作方法等はこちらをご覧ください。

kaminashi-developer.hatenablog.jp

  • CLI Likeな設計の作り込み
  • ES2015/ES6のjsに対応、ローカル/リモートのモジュールを利用可能(importも使えるよ)
  • チェックとしきい値の記述も直感的なAPI

こそがK6のメリットと言えます。この手軽さと痒いところに手が届いている感じがいいですよね。

またシナリオ以外にも、Datadog Syntheticsを利用したAPIテストも作ってました。
全てのURL × HTTP Methodのパターンに対してワンパスを通せるので、移行時の動作を一定保証出来るのがいいところです。

docs.google.com

自前でコードを書くのもいいのですが、こういう選択肢もあると知っておくといざという時にさっと使えていいですよね。
実際インフラ移行までの期間が1ヶ月半と限られた中ではあったので、1週間くらいで環境を整えられたのもツールを駆使出来たからこそだと感じます。

クリスマスLock Waitボムの投下

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

クリスマスに投下されたLock Waitボムでしたが、垂直スケールとDead Lockの解消で血を止めました。
実は今回に限らず、これまでにもgolangを利用した負荷問題には頭を悩ませる事が多いです。
その中でも結構困ったAuroraとgolangの事例をご紹介します。

AuroraへのRead/Writeアクセスの切り替えで困った

AuroraはClusterを利用してRead/WriteのホストをURL単位でアクセス先を切り替える事が出来て、かつReadに関してはCPU/Connectionのボリュームによってスケールする事が可能です。
この特性を活用するため、golangを利用した接続先の切り替えを行う実装を追加しました。

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

gorm.Open によってDSNを直接指定するのではなく、sqlwrap を噛ませています。

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

gorm v1は第二引数で指定したオブジェクトは

  • DSNの場合は内部で sql.Open を直接呼び出し
  • SQLCommonのinterafaceを満たす場合は直接dbをオブジェクトのまま渡す

こんな実装となっています。

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

そこで、SQLCommonのメソッドを満たす、sqlwrap.DBを作成します。

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

ここで各接続先を切り替え、リクエスト先のRead/Writeの分岐を行っています。
Master/Slaveメソッドで呼び先のDSNを切り替え、そこで接続先ごとに接続先を切り替えるような実装を自前で行っています。
ただ現在はgorm公式でresolverを出していたりします。
こちらに乗り換えていくべきか、これから合わせて見ていく必要がありますね。

github.com

まとめ

今回はBtoB SaaSのインフラ的な観点というところで発表させていただきましたが、各社ステージごとにそれぞれ共通で持つ課題感がとても面白かったなと感じていました。
自分自身、登壇時のいろいろと反省点はありつつも発表自体はとてもいろんな経験が出来てまたとても新鮮な気持ちで終わる事が出来ました。
また他の場で再挑戦したい!という気持ちでいっぱいです😄

カミナシ自身、インフラ的な課題もより複雑度をましてきて、よりいいカオスな感じになってきました。 成長期のスタートアップならではの課題が増え始めた中で一緒に闘うメンバーを募集しています!

EM/アプリエンジニア/SREと幅広く募集しているので、気になった方は是非気軽にお話しましょう。

open.talentio.com open.talentio.com open.talentio.com

ScrapboxからNotionへの移行

概要

カミナシはこれまでドキュメント管理ツールにScrapboxを使っていました。 手軽にかけ、リアルタイムに複数人で編集できるのでチームで議論しやすかったです。一方でドキュメント管理ツール以外に使ってるツールが増えたり、ストックしたい情報が探しにくくなっていたので、全社的にNotionを利用することにしました。

今回はドキュメントツールの移行に伴ってScrapboxにあったページを全てNotionにコピーした流れを紹介していきます。

ScrapboxのデータをMarkdownに整形する

Scrapboxはプロジェクトの設定ページからJSONファイルとしてエクスポートできます。 JSONファイルは1行ずつ配列の要素になっています。

{
  "name": "kaminashi",
  "displayName": "kaminashi",
  "exported": 1616557445,
  "pages": [
    {
      "title": "2020/03/02 議事録",
      "created": 1556528208,
      "updated": 1556528208,
      "id": "XXXXXXXX",
      "lines": [
        "2020/03/02 議事録",
        "",
        "",
        "メモ",
        " カミナシ",
        "[** 見出し]",
        "[リンク https://example.com]"
    }
}

Pythonを使ってMarkdownになおす時はJSONファイルを辞書に直して1行ずつ正規表現で直しました。 変換する関数は以下になります。

def scrapbox_to_markdown(text: str) -> str:
    text = text.replace("\u3000", "\t")
    replace_patterns = [
        {'name': 'heading1', 'scrapbox': r'\t*\[#*\*{3,}#* (.+)]', 'markdown': r'# \1'},
        {'name': 'heading2', 'scrapbox': r'\t*\[#*\*{2}#* (.+)]', 'markdown': r'## \1'},
        {'name': 'heading3', 'scrapbox': r'\t*\[#*\*{1}#* (.+)]', 'markdown': r'### \1'},
        {'name': 'link', 'scrapbox': r'\[([^ ]*)[\t| ](http.+)\]', 'markdown': r'[\1](\2)'},
        {'name': 'list', 'scrapbox': r'^\t(\t*)(.*)', 'markdown': r'\1- \2'},
        {'name': 'italic', 'scrapbox': r'\[\/ ([^\]]+)]', 'markdown': r'_\1_'},
        {'name': 'strike', 'scrapbox': r'\[- ([^\]]+)]', 'markdown': r'~~\1~~'}
    ]
    for replace_pattern in replace_patterns:
        text = re.sub(
            replace_pattern['scrapbox'],
            replace_pattern['markdown'],
            text
        )
    return text

見出しはこれから説明するライブラリの都合上3つまでにしています。 リストの部分は出力によってタブだったり空白になっているので一括でタブに変換しています。

Notionライブラリ

Notionは公式APIがありますが、まだプライベートβ版のため内部APIを使用することにしました。 Notionへのアップロードには2つのライブラリを使用しています。

github.com

github.com

notion-py はページを作成したり、ブロックを追加したりできます。 md2notionMarkdownファイルや文字列からNotionのブロックを作成できます。

※注意

2021年3月25日現在は notion-py を使ってAPIを実行すると以下のエラーがでます。

[ERROR] HTTPError: Invalid input.

すでにGItHubのIssueにも起票されていますが、Limitのパラメータを変更することで解消できます。

notion/client.py

        data = {
            "query": search,
            "parentId": parent_id,
     -      "limit": 10000,
     +      "limit": 100,

ページの作成

事前にデータベースを作成して、データベース内のページとして作成していきます。

from notion.client import NotionClient

notion_client = NotionClient(token_v2=NOTION_TOKEN)
collection_view = notion_client.get_collection_view(COLLECTION_VIEW_URL)
new_page = collection_view.collection.add_row()
new_page.title = 'Scrapboxのタイトル'

COLLECTION_VIEW_URL はデータベースのURLです。URLはページIDとビューIDをもっています。

https://www.notion.so/{pageId}?v={viewId}

Collection Viewからページを作成し、タイトルやプロパティを変更できます。

Markdownをページにコピー

Markdownのテキストに変換した文字列をNotionのページにブロック形式で保存します。

from md2notion.upload import convert, uploadBlock

markdown_text = parse_scrapbox_lines(scrapbox_page_lines)
notion_block = convert(markdown_text)
for blockDescriptor in notion_block:
    uploadBlock(blockDescriptor, new_page, '')

ブロックの生成は md2notionconvert 関数を使います。 convert 関数はブロックの配列を生成するので、一個づつuploadBlock 関数を使ってアップロードします。 アップロードするときに先程生成したページを引数に渡すと、ページの内部にブロックがコピーされていきます。

並列実行

これらのブロック生成処理はある程度時間がかかるため、一つの関数にまとめて並列処理を行いました。

from concurrent.futures import ProcessPoolExecutor, wait

with ProcessPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(upload_markdown_block, scrapbox_page) for scrapbox_page in scrapbox_pages]
    done, not_done = wait(futures)
    [future.result() for future in futures]

def upload_markdown_block(scrapbox_page):
    # 1ページ移行する関数

感想

ScrapboxからNotionに移行するスクリプトPythonで作成しましたがScrapboxの記法が独特で変換するのが大変でした。

特に今回実装した正規表現ではGyazoの画像や内部リンクを移行できていないです。 内部リンクは一度全て移行した後にリンク先のページを検索して更新する必要があります。

次の機会があったらより正確にScrapboxMarkdownを変換できるようにしたいです。