RELATIONS Developers Blog

RELATIONS株式会社の開発ブログです。

最新のExpo SDK を使ってジオフェンスとプッシュ通知を組み合わせてみました

こんにちは、RELATIONS株式会社の大川です。
最近はWistant のUI開発とRedashを使ったデータ分析をしています。

これまで、2回に分けてExpoとFirebaseを組み合わせたプッシュ通知配信の検証をしていました。

今回は年明けに案内されたExpo SDK v32 で新たにサポートされた「バックグラウンドでの位置情報取得機能」と「ジオフェンス機能」が気になっていたので、検証のスピンオフとして位置情報をもとにしたプッシュ通知の表示を試していきます。

新たにサポートされたバックグラウンド位置情報取得とジオフェンスについて

バックグラウンドでの位置情報取得機能は、アプリの画面を開いていなくても位置情報が取得できる機能です。
(これまでのExpo SDKではアプリ画面を開いている状態での位置情報取得だけがサポートされていました。)
この機能のおかげで、例えばジョギングアプリで画面をOFFにしたまま自分が走ったコースを記録する、といったことが可能になりました。

また、ジオフェンス(特定の場所に対する仮想的な境界線)を設定し、端末がそのジオフェンスへ出入りしたときにイベントを発生させることができるようになりました。(ジオフェンス機能も、アプリのバックグラウンド状態をサポートしています。)

ジオフェンスのイメージ図
ジオフェンスのイメージ図

ネイティブアプリ開発用には既にジオフェンス機能が提供されており、iOSにデフォルトで付属しているリマインダーアプリのように場所でタスクを通知するために利用されていたり、O2O(Online to Offline)アプリで特定の場所に近い端末にのみ情報を配信する(たとえばスーパーのお得情報など)ことが可能になっていました。

この記事では特定のエリアに近づいた(=設定したジオフェンスに入った)端末にLocal Notification(スマートフォンがサーバーと通信せずに単体で配信されるプッシュ通知)を配信してみます。

ジオフェンス機能を利用したプッシュ通知の表示を検証する

(準備: Expo CLIの更新と新規プロジェクト作成)

古いバージョンのExpo CLIをインストールしたまま、 expo init コマンドで新規プロジェクトを作成すると古いExpo SDKがインストールされてしまいます。
npm install -g expo-cli で Expo CLIを更新してからプロジェクトを作成します。

パーミッションの取得

ルートコンポーネントのcomponentDidMountで Permissions.askAsync を呼び出して、iOS向けにパーミッション取得のための許可画面を表示します。
(スタンドアローンのアプリには別途設定が必要なので、Locationのドキュメントもご覧ください。)

// NOTE: iOSシミュレーター用に例外処理を省いています
async componentDidMount() {
  // (iOS向け)パーミッションを取得
  await Permissions.askAsync(Permissions.NOTIFICATIONS, Permissions.LOCATION);
  // プッシュ通知を開いた時のイベントハンドラーを登録
  Notifications.addListener(this.handleNotification);
}

上のメソッドはiOSシミュレーターでプッシュ通知のパーミッションを取得できないので 例外処理などを省いていますが、自分が実際にアプリに組み込む場合は以下のような実装になると思います。

async componentDidMount() {
  // 既存のパーミッションを取得
  const { permissions } = await Permissions.getAsync(Permissions.NOTIFICATIONS, Permissions.LOCATION);
  const currentNotificationPermission = permissions[Permissions.NOTIFICATIONS];
  const currentLocationPermission = permissions[Permissions.LOCATION];

  if (currentNotificationPermission.status !== 'granted') {
    // (iOS向け) プッシュ通知の許可をユーザーに求める
    const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
    if (status !== 'granted') {
      console.log('notification permission is denied');
      return;
    }
  }

  // プッシュ通知を開いた時のイベントハンドラーを登録
  Notifications.addListener(this.handleNotification);

  if (currentLocationPermission !== 'granted') {
    // (iOS向け) 位置情報利用の許可をユーザーに求める
    await Permissions.askAsync(Permissions.LOCATION);
  }
}

ジオフェンスを設定する

ジオフェンスを設定するには、Location.startGeofencingAsync メソッドを呼び出します。

// ボタンが押されたときにジオフェンスを設定する
onPressStartGeofencing() {
  Location.startGeofencingAsync(GEOFENCING_ON_ENTER, [{
    latitude: 35.661561,
    longitude: 139.707883,
    radius: 50,
    notifyOnEnter: true,
    notifyOnExit: false,
  }]);
}

startGeofencingAsyncの第1引数はジオフェンスへの出入りがあったときに実行されるタスクの名前です。別途同じ名前でタスク定義を行うのでGEOFENCING_ON_ENTERという文字列定数を用意しています。 第2引数はリージョンオブジェクトの配列です。各リージョンオブジェクトは以下の内容を含みます。
(参考: Geofencing / Location

  • identifier: リージョンを識別するためのID(デフォルトは自動でUUIDベースの文字列が割り当てられる)
  • latitude: 緯度
  • longitude: 経度
  • radius: ジオフェンスの半径(メートル指定)
  • notifyOnEnter: ジオフェンス内に入った場合にタスクをトリガーするためのフラグ
  • notifyOnExit: ジオフェンスから出た場合にタスクをトリガーするためのフラグ

実行されるタスクの定義

ジオフェンスへの出入りがあったときに実行されるタスクは、アプリがバックグラウンド状態でも実行できるようにグローバルスコープでTaskManager.defineTaskを呼び出して定義しています。

タスクが実行されたときのコールバック関数には、eventTypeとトリガーになったリージョン情報が渡されます。
eventTypeを確認し、ジオフェンスに入った場合であればNotifications.presentLocalNotificationAsyncメソッドを呼び出してLocal Notificationを表示させています。

import React from 'react';
import { StyleSheet, Text, View, Button } from 'react-native';
import { Notifications, Permissions, Location, TaskManager } from 'expo';

// タスク名
const GEOFENCING_ON_ENTER = 'geofencingOnEnter';

// タスク定義
TaskManager.defineTask(GEOFENCING_ON_ENTER, ({ data: { eventType, region }, error }) => {
  if (error) {
    console.error(error.message);
    return;
  }
  // ジオフェンス内に入ったイベントであれば、プッシュ通知を表示
  if (eventType === Location.GeofencingEventType.Enter) {
    Notifications.presentLocalNotificationAsync({
      title: 'test geofence notification',
      body: 'geofence notification',
      data: {
        message: 'geofence notification message',
      },
    });
  }
});

//(以下、ルートコンポーネント)

iOSシミュレーターでのデバッグ

実装したExpoプロジェクトは、端末の移動をシミュレーションするためにiOSシミュレーターで実行します。

iOS シミュレーターのメニューから Debug > Location > Custom Location...を選択し、 端末の緯度・経度をジオフェンス内に指定することで、プッシュ通知が表示されるようになります。

iOS シミュレーターの位置情報カスタマイズ画面
iOS シミュレーターの位置情報カスタマイズ画面

iOSシミュレーターに配信されたプッシュ通知
iOSシミュレーターに配信されたプッシュ通知

まとめ

この記事では、Expo SDK v32で新たにサポートされたバックグラウンドでの位置情報取得機能とジオフェンス機能を利用して、特定のエリアに近づいた端末にプッシュ通知を表示する検証をしました。

Expoではネイティブ機能を利用したライブラリを組み込みにくいのですが、Expo SDKのアップデートによって色々なネイティブ機能がカバーされていくことで、そのデメリットが薄れていくんだろうなと感じました。 これからも気になる機能があればどんどん試していこうと思います。

Webエンジニアがスマホアプリをリリースするまでに学んだ32のコト(前編)

こんにちは。RELATIONS株式会社の久原です。
2019年も開発ブログをどうぞよろしくお願いいたします!

Webエンジニアだけどスマホアプリを作ることになった件

弊社では「ええ会社をつくる」というミッション実現のために、様々な新規事業の仮説検証を行っています。そしてこの度、とある検証のためのスマホアプリを開発することとなり、私が開発を担当をすることになりました。

私はWebのフロントエンドエンジニアではありますが、スマホアプリの開発経験はほぼゼロの人です。そこで今回は「自分のフロントエンドスキルセットを活かしつつ、最速でスマホアプリをリリースするためには、どうすればよいか?」を色々と試行錯誤した結果を綴ってみたいと思います。

PWA(Progressive Web Apps)じゃダメなの?

Web技術でスマホ対応と聞いて、Webエンジニアとして真っ先に思いつくのは、「PWAで実装できるんじゃない?」という話なのですが、今回の要件として「iOSでのリリース」「プッシュ通知が仮説検証のためのコア機能」という2点があり、アプリとしての実装が必須でした。

近い将来、プッシュ通知などを含めたPWAが実現できる環境が(特にiOS側に!)整っていると良いなぁ…と思っています。

結論:React NativeとExpoを採用

結果だけ先にお伝えしますと、React NativeとExpoを採用しました。これによって必要な知識の多くをWeb技術でまかなえたことから、素振り0.5ヶ月、フロント1ヶ月、バックエンドを含めても合計2ヶ月ほどで、ドッグフーディング版をリリースできました。

昨今、React NativeとExpoを使って、開発がとても早くできたよ!という事例も、数多く見かけるようになってきたと思います。今回はReact NativeとExpoを採用した場合、Webエンジニアとしては、どのあたりの知識が差分になり、どこを学習すれば良いか、という勘所を中心にお伝えできればと思います。

React NativeとExpo

選定基準

「最速で仮説検証するために、最小のコストでリリースできる」ことを技術の選定基準としました。私はWebのエンジニアなので、特に「可能な限りネイティブの知識を学ばずに済む」ようにすることがコストの削減につながると考えました。

ネイティブの知識とは、SwiftやKotlin、StoryboardやXML、XcodeやAndroid Studioなど。ここをWebの知識(HTML/CSS/JS)だけで済ませたい。

そこでWeb技術ベースで開発を行える環境をあたることになるのですが、著名な例としてはReact NativeやApache Cordovaなどの環境が挙げられるかと思います。いずれも長所がありますが、社内でReactを使っていることや、Expoという素晴らしいツールチェインの存在を知ったこともあり、React NativeとExpoを使用することを選択しました。

React Native・Expoの特徴と勘所

f:id:mkubara:20190204162952p:plain
React NativeとExpo

React Native(以下、RN)はFacebook製のフレームワークで、JavaScript(以下、JS)記述のみでiOSやAndroid向けのスマホアプリを開発することができるものです。ベースはReactであり、その主要知識であるJSX・state/props・ライフサイクルメソッド・イベントハンドリングなどは、RNでもReactと同じ知識で実装が可能です。

Webエンジニアとしては、Reactに関する知識があれば、ただ「ビルドターゲットをスマホに向けるだけ」という感覚で開発が可能になります。コードはすべてJS記述になるので、ロジック部分はそのまま流用でき、多くのコードベースを活かすことができます。

ExpoはRNの開発環境です。CLIとライブラリ群を提供しています。CLIとしてのExpoは、スケルトン生成から、ビルド、ライブリロード、デプロイ、実機テスト、リリースまでの開発のすべてのフローをサポートしてくれる、強力なツールチェインになっています。

Webエンジニアとしては、create-react-appのnative版+リリースツールだと認識いただければ、ビルドやリリースなどの部分をwebpackやcreate-react-appに任せて、自身はアプリの機能開発に集中できることが想像できると思います。

加えてライブラリとしてのExpoは、ネイティブUI、プッシュ通知などのラッパーを、JSのインターフェースにて提供しています。つまりこれひとつで多くのネイティブ機能がJS記述だけで追加できるという、非常に便利なライブラリになっています。

まとめると、RNとExpoを使えば、開発フロー全体はExpoに任せつつ、全体の記述をJSで、UIの記述をReactベースで行えることになります。かなり多くの知識をWeb技術から流用できそうです。

では、新しく学習が必要なところはどこでしょうか?

Webの知識で戦えるところ、戦えないところ

f:id:mkubara:20190205110436p:plain
知識マップ

Webの知識で戦えるところ

カテゴリ 学習済みの知識で戦えるところ
UIのコンポーネント化・イベント周り React
フロントエンドロジック Redux, Redux-Saga
フォームバリデーション Redux-Form
汎用ロジック moment, lodash, validator, normalizr, etc.
スタイル記述 (CSS in JS) styled-components
Linter/Formatter ESLint, Prettier

RNとExpoによって、ReactでのUI記述や、JS記述のロジックなど、多くの技術がそのままスマホアプリへ転用できることがわかります。心強いですね!

Webの知識だけでは戦えないため、学習したところ

カテゴリ 学習したところ
開発環境 React Native, Expo
ナビゲーション React Navigation・スタックベースナビゲーション
ネイティブUI NativeBase・レイアウト手法
メディア表現とプリロード ロード方法・CSSとの差の埋め方
ネイティブ機能 プッシュ通知
デバッグ react-native-debugger
ビルドとリリース Expo
フロントエンド以外の技術 Firebase

ネイティブ固有のUIやナビゲーション、メディアの活用、デバッグ手法など、Webの概念と微妙に異なる知識もあります。次章では、この知識の差分を埋めるために学習した、32のコトをご紹介していきます。

新たに学習した32のコト

開発環境 (2)

前述の通りです。インストールは非常に簡単で、npmコマンドだけで終わります。あとはスケルトンプロジェクトを expo init で作成するだけで、すぐに開発が開始できます。

留意点として、Expoはアカウント登録が必要になりますが、登録するとExpoサーバへデプロイが可能になり、実機テストやコードのパブリッシュも容易になりますので、メリットのほうが強いです。

ナビゲーション (3)

React Navigationは、アプリ内での遷移部分を担当します(react-routerのポジション)。URLリンクでの遷移ではなく、アクションを起動してスクリーン間を遷移させるような記述法になります。 onClick = id => { this.props.history.push({id}); } みたいな感じです。

遷移記述は「this.props.navigationからアクションを叩く」か「Redux Middlewareで紐つけておいてSagaなどから叩く」かになります。遷移先の指示はスクリーンにつけたunique名を使い、querystringみたいなものはparamsとして引き渡せますので、/users/:id や /posts?q=Expo みたいな指示が実質的に可能です。

handlePressNav = yearMonth => {
  this.props.navigation.navigate('List', { yearMonth });
}

ナビゲーションの種類としては、push遷移型のスタック(戻れる)と、replace遷移型のドロワー、タブ、スイッチなどがあり、それらをネストさせて組み上げることが可能です。

ナビゲーションの記述については、JSXではなく専用のデータモデルを使用しますが、React-RouterでSwitch/Routeだけのコンポーネントをページ単位で記述するような感覚で、Navigatorコンポーネントをexportできます

import { createSwitchNavigator, createStackNavigator } from 'react-navigation';

// アプリのメイン部分のナビゲーションスタック
const AppStack = createStackNavigator(
  {
    Main: MainTab, // タブナビゲーションを積む
    Quiz: QuizStack, // タブナビを使わないスタック
  },
  {
    initialRouteName: 'Main',
    headerMode: 'none',
  },
);

// 認証などを含む、最上位のナビゲーション
const RootNavigator = createSwitchNavigator(
  {
    Initialize, // 自動認証し、成功したらApp、失敗したらSignInへスイッチ
    SignUp,
    SignIn,
    App: AppStack, // Appへナビゲーションした場合は、AppStackがルーティングを担当
  },
  {
    initialRouteName: 'Initialize',
  },
);

export default RootNavigator;

ナビゲーション用のUIも提供されます。ドロワー・ヘッダ・タブなどがあり、Navigatorコンポーネントに自動付与されます。環境ごとに最適なUIが表示されるため、これらの実装を省略し、作りたいことに集中できるのは、React Navigationの非常に良い点でした。

import { createBottomTabNavigator } from 'react-navigation';
import SvgUri from 'react-native-svg-uri';

const homeIcon = require('./icon-nav-home.svg');
const homeIconCurrent = require('./icon-nav-home-on.svg');

const HomeIcon = ({ focused }) => (
  <SvgUri width="24" height="24" source={focused ? homeIconCurrent : homeIcon} />
);

const MainTab = createBottomTabNavigator(
  {
    Home: {
      screen: Home,
      navigationOptions: {
        title: 'ホーム',
        tabBarIcon: HomeIcon,
      },
    },
    Ranking: { ... },
    Setting: { ... },
  },
  {
    initialRouteName: 'Home',
    tabBarOptions: { ... },
  },
);

f:id:mkubara:20190204162742p:plain
下タブナビ

ネイティブUIの提供 (5)

NativeBaseは、ReactベースのUIフレームワークです。Material-UIやSemantic-UIをご存知であれば、同様の書き心地で記述ができます。環境ごとのネイティブUIを適切に使ってくれますし、テーマの設定や、styled-componentsによる個別上書きも可能でした。

import { Container, Button, Text } from 'native-base';

export const NativeBaseSample = () => (
  <Container>
    <Button>
      <Text>Button</Text>
    </Button>
  </Container>
);

スタイル記述はCSS in JSを使うことになります。styled-componentsがRNに正式対応しており、CSS的な記法でスタイリングが可能です。

レイアウトについてWebとアプリで考え方が異なる点は、Webの場合は溢れた要素を縦方向に流しますが、アプリの場合は一画面に収まるように要素全体をスケーリングする場合が多いことです。そこで、react-native-responsive-screen, react-native-responsive-fontsizeの2点を使って、画面幅基準の%指定によるレイアウトを行うようにしました。

import styled, { css } from 'styled-components/native';
import { Button as NbButton, Text as NbText } from 'native-base';
import { heightPercentageToDP as hp } from 'react-native-responsive-screen';
import rf from 'react-native-responsive-fontsize';

import { hasVariant } from '../../../utils/style';

export const Button = styled(NbButton)`
  display: flex;
  justify-content: center;
  align-items: center;
  align-self: center;

  background-color: ${props => (hasVariant(props.variant, 'primary') ? '#40356f' : 'white')};

  ${props =>
    hasVariant(props.variant, 'rounded') &&
    css`
      height: ${hp('6.6%')};
      border-radius: ${hp('3.3%')};
    `};
`;

export const Text = styled(NbText)`
  align-self: center;

  font-size: ${rf(2.4)};
  font-weight: bold;
  color: ${props => {
    if (hasVariant(props.variant, 'primary')) return 'white';
    if (hasVariant(props.variant, 'default')) return '#4facfe';
    return 'gray';
  }};
`;

f:id:mkubara:20190204162831p:plain
ログインボタン

モーダルについては、Webでは擬似的なものなので無限に重ねられますが、アプリの場合はシステムモーダルを使う関係で、RN側で1枚制限などがかかります。特に表示切り替え時にアニメーションを入れた場合は、切り替え後のモーダルが表示制限に引っかかって表示されず、waitが必要だった、なんていう問題が出たりしますので注意が必要でした。

メディア表現とプリロード (5)

画像やアイコン、フォントなどについては、Webと同じように遅延読み込みされます。気をつけたい点としては、Webの世界では初期レンダリング時にコンテンツが完全表示されないことは一般的な事象ですが、アプリの場合はアセットをすべてダウンロードしてから画面を表示することが多いでしょう。

そういった場合は、アセットの準備完了を同期するために、 Asset.fromModule, Image.prefetch, Font.loadAsync などのプリロードメソッドをそれぞれ使用し、await Promise.all([...]) で待ち受けてから表示する、というような実装が必要でした。

function cacheImages(images) {
  return images.map(image => {
    if (typeof image === 'string') {
      return Image.prefetch(image);
    }
    return Asset.fromModule(image).downloadAsync();
  });
}

const assetImages = cacheImages(images);
await Promise.all([...assetImages, ...others]);

グラデーションなどのエフェクトは、CSS in JSでは記述できず、ネイティブ機能に頼ることになります。ExpoがLinearGradientコンポーネントを提供していますので、私はそれを使用しました。アニメーションについてはLottieが推奨されています。

import { LinearGradient as ExLinearGradient } from 'expo';

const LinearGradient = styled(ExLinearGradient).attrs({
  colors: ['#cccaee', '#6c699b'],
})`
  flex: 1;
  width: 100%;
  height: 100%;
`;

export const MainContainer = ({ children }) => (
  <SafeAreaView>
    <Container>
      <LinearGradient>
        <Content>{children}</Content>
      </LinearGradient>
    </Container>
  </SafeAreaView>
);

続きは後編にて!

まだ半分ほどのご紹介ですが、続きは後編にてご紹介していきたいと思います。コンテンツは以下のものを予定しております。近日公開予定です!

  • ネイティブ機能
  • デバッグ
  • ビルドとリリース
  • フロントエンド以外の技術
  • 実際の開発の経過

※追記(2019.3.28):後編を公開しました!

developers.relationsgroup.co.jp

ExpoとFirebaseを使った、サーバーレスでのプッシュ通知配信の検証続き

あけましておめでとうございます!RELATIONS株式会社の大川です。

弊社では1月が新しい年度の始まりなので 今期目標やその達成に向けたアクションをチームメンバーで共有していました。 (その様子はこちらからご覧いただけます。) 自分はこの開発者ブログを毎月更新することを目標のひとつとして、 引き続き業務の中で得られたノウハウをどんどんオープンにしていければと思います。

前回のおさらい

前回の記事では、 Expo Push APIとFirebaseを組み合わせてサーバーレスでのプッシュ通知配信を検証していました。

投稿されたメッセージはCloud Firestoreに保存され、保存されたことをトリガーにしてCloud Functionsが起動し、Expo Push APIをリクエストしてプッシュ通知を配信します。
前回検証したプッシュ通知の配信フロー

当初は新しいデータがFirestoreに保存されると、登録されているすべての端末にプッシュ通知を配信していました。しかし今回は実際のアプリ運用を想定して、ユーザーを絞り込んで配信するように変更しました。

投稿されたメッセージはCloud Firestoreに保存され、それをトリガーにCloud Functionsが起動します。メッセージが投稿されたトピックを購読しているユーザーを検索し、そのユーザーがもつデバイストークンをもとにExpo Push APIをリクエストして、一部のユーザーにだけプッシュ通知を配信するようにします。
今回検証したプッシュ通知の配信フロー

ユーザーごとにデバイストークンを管理する

デバイストークンをFirestoreに保存する処理は、以下のsaveDeviceTokenというメソッドで実装しています。
前回はFirestore上のtokensコレクションにデバイストークンをただ保存しているだけだったので、
アプリ起動時にsaveDeviceTokenメソッドが呼び出されると、その度にデバイストークンのデータが増えていく実装になっていました。
(同じデバイストークンが何回も登録されており、その分だけ同じプッシュ通知が配信されてしまう状態でした。)

// 前回の記事で実装したデバイストークン保存処理
const saveDeviceToken = async (token) => {
  try {
    // Firestore にデバイストークンを保存
    const docRef = await db.collection("tokens").add({ token })
    console.log(`document written with ID:${docRef}`); // デバッグ用
  } catch (err) {
    console.error(`error: ${err}`);
  }
};

今回はFirebase Authenticationも組み合わせてユーザー情報をFirestoreで管理できるようにしているので、ログイン中のユーザーが保持しているデバイストークンをFirestoreから取得し、saveDeviceTokenに渡されたトークンがすでに登録されていないかを確認しています。

// ユーザー情報と紐づけて管理するように修正したデバイストークン保存処理
const saveDeviceToken = async (token) => {
  try {
    // token(ExponentPushToken[xxxxx]の形式)からランダム文字列そうなところだけ取り出す
    // firestoreのパスに記号が使えないため
    const matchResults = token.match(/ExponentPushToken\[(.*)\]/);
    const actualToken = matchResults[1];
    if (!actualToken) return;

    // Firestoreから保存済みのデバイストークンを取得
    const user = await firebase.auth().currentUser;
    const userRef = db.collection("users").doc(user.uid);
    const userDoc = await userRef.get();
    const userInfo = userDoc.data();
    const currentTokens = userInfo.tokens || [];

    // デバイストークンが保存済みかを確認
    if(!currentTokens[actualToken]) {
      // Firestore にデバイストークンを保存
      currentTokens[actualToken] = true;
      await userRef.update({ tokens: { ...currentTokens }});
    }
  } catch (err) {
    console.error(`device token save error: ${err}`);
  }
};

プッシュ通知を配信するユーザーを絞り込む

プッシュ通知配信をリクエストする処理は、FirebaseのCloud Functionsで実装しています。
前回の記事ではmessagesコレクションに新規メッセージが投稿されたことをトリガーにして、全デバイストークンを取得しExpo Push APIを利用してプッシュ通知を配信していました。

// トリガー設定とデバイストークン検索部分のみ抜き出したもの
// tokensコレクションのデータを全取得しています
exports.sendNotification = functions.firestore
  .document('messages/{messageId}')
  .onCreate(async () => {
    // デバイストークンを Firestore から全検索
    const query = db.collection('tokens').where('token', '>', '');
    const snapshot = await query.get();
    const tokens = snapshot.docs.map(doc => doc.get('token'));
(...以下略)

今回はメッセージが投稿されたトピックの購読ユーザーを検索し、そのユーザーが持っているデバイストークンの端末だけにプッシュ通知を配信をしていきます。

まずは、関数が起動するトリガー設定を変えてメッセージが投稿されたトピックのIDを取得します。
トピックIDは、ドキュメントパスでワイルドカード指定した部分のパラメーターをコンテキストから取得しています。

// トリガー指定とパラメーターの取得
exports.sendNotification = functions.firestore
  .document('/topics/{topicId}/messages/{messageId}')
  .onCreate(async (_, context) => {
    const { topicId } = context.params;  // ワイルドカード指定したパラメーターの値を取得
(...以下略)

取得したトピックIDをもとに、そのトピックを購読しているユーザーを検索します。
そして、購読ユーザーが保持しているExpo用のトークン文字列からデバイストークンを組み直して、tokens配列に追加しています。

// メッセージが投稿されたトピックを購読しているユーザーを検索
const usersSnapshot = await db
  .collection('users')
  .where(`topic.${topicId}`, '==', true)
  .get();
if (usersSnapshot.empty) {
  console.log('empty query result');
  return;
}

const usersDoc = usersSnapshot.docs;
// usersからデバイストークンを取得
const tokens = usersDoc.reduce((t, doc) => {
  const u = doc.data();
  if (!u || !u.tokens) return t;
  // アプリ側でトークンのランダム文字列だけをFirestoreに保存するような処理をしているので本来のデバイストークンに復元
  const userTokens = Object.keys(u.tokens).map(t => `ExponentPushToken[${t}]`);
  return t.concat(userTokens);
}, []);

Expo Push APIをリクエストする部分では、配信する通知メッセージにトリガーとなったトピック名を含めるようにしました。

// メッセージが投稿されたトピックのデータを取得
const topicDoc = await db
  .collection('topics')
  .doc(topicId)
  .get();
const topic = topicDoc.data();

// プッシュ通知用のメッセージオブジェクトを作成
const messages = [];
tokens.forEach(pushToken => {
  if (!Expo.isExpoPushToken(pushToken)) {
    console.error(`Push token ${pushToken} is not a valid Expo push token`);
  }

  messages.push({
    to: pushToken,
    sound: 'default',
    body: `${topic.name} が更新されました!!`,  // トピックの名前を通知内容に利用
    data: { withSome: 'data' },
  });
});
const chunks = expo.chunkPushNotifications(messages);
const tickets = [];
(async () => {
  // Expo Push API をリクエスト
  for (let chunk of chunks) {
    try {
      const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      console.log(ticketChunk);
      tickets.push(...ticketChunk);
    } catch (error) {
      console.error(error);
    }
  }
})();

(関数の全体はこちら からご覧いただけます。)

これで、各トピックを購読しているユーザーにだけ、新規メッセージが投稿されたときのプッシュ通知を配信することができるようになりました!

Expo Client Appで動作確認した端末にプッシュ通知が配信されました
配信されたプッシュ通知

Firestoreで配列を扱うためのTips

上記のサンプルコードでは、ユーザーが購読しているトピックやデバイストークンを配列形式ではなく、値のマップとして保存しています。

こうすることで、Firestoreからデータ検索をするためのクエリを書くときの条件指定が簡単になります。
(参考: 配列、リスト、セットの操作 | Firebase

// プッシュ通知配信をリクエストするCloud Functionsから抜粋

// メッセージが投稿されたトピックを購読しているユーザーを検索
const usersSnapshot = await db
  .collection('users')
  .where(`topic.${topicId}`, '==', true) // 購読しているトピックを {“トピックID”: true} の形式で保持しているので、whereの条件指定が簡単になる
  .get();

ただし、フィールド名に記号が使えないので、Expoのデバイストークンはランダム文字列部分だけを保存するようにしています。

// React Nativeアプリのデバイストークン保存処理から抜粋

// Expoのトークン(ExponentPushToken[xxxxx]の形式)からランダム文字列だけ取り出す
const matchResults = token.match(/ExponentPushToken\[(.*)\]/);
const actualToken = matchResults[1];

(...略...)

// Firestore にデバイストークンを保存
currentTokens[actualToken] = true; // トークンを {“トークン文字列”: true} 形式でセットしている
await userRef.update({ tokens: { ...currentTokens }});

デバイストークンの消し込み処理

iOS端末に対してプッシュ通知の配信を行うAPNsでは、無効になっているデバイストークンをフィードバックするサービスがあります。
これを利用することで不要なプッシュ通知配信をAPNsにリクエストせずに済みます。
(参考: APNsのフィードバックサービスについて

Expo Push APIは、プッシュ通知配信をリクエストしたときに無効になっているデバイストークンが指定されてもOKが返ります。
(Expo Push APIが、処理を完了したことを意味しているようです。)

// プッシュ通知を配信する関数をローカルで実行した結果
firebase > sendNotification({ text: 'test' }, { params: { topicId: 'MwaYLDF1PQQebt6Vuggu', messageId: 'testId' }})
'Successfully invoked function.'
firebase > info: User function triggered, starting execution
info: Execution took 3667 ms, user function completed successfully
info: [ { status: 'ok', id: '5cd87dce-d426-4865-8c9e-aa5fa1b36bb7' } ]

ただし、指定されたデバイストークンがAPNs側で無効になっている場合は、Expo側がAPNsからのフィードバックを処理しているので、その結果を確認することができます。

// プッシュ通知の処理結果を確認
$ curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/getReceipts" -d '{
>   "ids": ["5cd87dce-d426-4865-8c9e-aa5fa1b36bb7"]
> }'
{"data":{"5cd87dce-d426-4865-8c9e-aa5fa1b36bb7":{"status":"error","message":"The Apple Push Notification service failed to send the notification (reason: Unregistered, status code: 410). Read Apple's docs about \"Communicating with APNs\" to learn what this error means.","details":{"apns":{"reason":"Unregistered","statusCode":410},"error":"DeviceNotRegistered","sentAt":1547691632},"__debug":{}}}}

この仕組みを利用して不要なプッシュ通知配信をリクエストしないためには、いくつかの実装が必要になります。

まずExpo Push APIでプッシュ通知配信したときに、リクエストしたデバイストークンごとに割り振られるidをデバイストークンとセットで保存する必要があります。
フィードバックサービスの結果を確認するときには、そのidを指定して結果を確認し、APNs側で無効となっていればデバイストークンを削除していきます。
ここで重要なのは、APNs側でデバイストークンが無効になるまでにタイムラグがあるので、この処理をプッシュ通知配信のCloud Functionsとは別で日次のバッチ処理として実装するがあるということです。

今回のような検証やプロトタイピングの場合など、デバイストークンがそんなに増加しないのであればフィードバックサービスに対応する必要はなさそうですが、実際のアプリを運用していくタイミングで用意したほうがいいと思います。
(バッチを実装するよりはFirebase Cloud Messagingに対応したほうが楽かもしれないとも正直考えました。。。)

まとめ

今回の検証では実際のアプリ運用を想定して、ユーザーを絞り込んでプッシュ通知を配信してみました。

今後もExpo関連で気になったこと(年明けにリリースされたSDK v32の機能など)を検証していこうと思います。

マネジメント支援のSaaSプロダクト「Wistant」のバックエンド技術スタックを紹介します

こんにちは、RELATIONS奥宮です。

今年も残りあとわずか。平成最後の年末、いかがお過ごしですか? 自分は先日のクリスマス、人生で初めて妻と娘(ゼロ歳)と一緒に過ごしました。お風呂やら離乳食やら夜泣きやら、普段と変わらない1日でしたけどねー… ( ´ ▽ ` )

前回の久原さんのフロントエンド技術スタックの話に引き続き、今回は自分がバックエンドの実装に関して、マネジメント支援ツール「Wistant」の事例を引きながらお話したいと思います。

「Wistant」について

技術スタックに入る前に、Wistantがどういうプロダクトなのか、簡単に触れておきたいと思います。(プロダクトの特性が、技術やアーキテクチャの選定にも関わっていますので、前段として共有させてください)

f:id:occmee2:20181228131222p:plain
弊社で実際に使っているWistantの画面の一例

Wistantは、従業員が数百名規模の企業をメインターゲットに、ピープルマネジメントの運用改善や仕組み化を支援するサービスです。

具体的には、現在以下のような機能を提供しています(HPより抜粋)。

目標管理
  • メンバーが追っている目標を管理することができます。目標に対して振り返りをし、1on1を実施することでメンバーの達成を支援することができます。
1on1の運用・改善
  • 1on1のペア作成、アジェンダ設定、分析、など1on1の運用や改善を楽にする機能が揃っています。
パフォーマンス分析
  • 1on1後にメンバーは簡単な振り返りを行います。この結果から、組織のマネジメント状態を分析することができ、改善のための示唆が得られます。

これらのメイン機能以外に、一般的なSaaSに標準で必要な機能、あるいはBtoBツールならではの必須機能もあわせて実装しています。主だったのものは以下のとおりです。

アカウント管理
  • 入退会、メンバーの招待、ロール管理、プロフィール変更、などの機能
課金機能
  • サブスクリプション型の従量課金に対応した課金機能
閲覧権限制御
  • 「Aさんの目標は<本人と直属の上長>のみ閲覧可能」のように、データソースごとに権限を設定できる機能
外部連携
  • Slack等のチャットツールと連携し、通知をポストする機能

こうした一連の機能を、エンジニア総勢4名(「フロント:バックエンド」=「2:2」くらいの比率で稼働)で開発しています。

使用しているライブラリなど

構成については後述するとして、まずはバックエンドで使用している言語・フレームワーク・ライブラリについて、主だったところをざっとご紹介します。

Node

少人数の開発チームでいかに学習コストを抑えて開発効率をあげられるかを検討した結果、数年前から全社的にNodeを採用しています。

その狙いどおり、フロントとバックエンドの垣根なくJavaScriptに特化して開発を進められることで、相乗効果が生まれ、チーム全体としてノウハウやスキルレベルが向上したと思います。

技術的側面でいうと、Wistantではロジック層にReactive(RxJS)を採用したのですが、それができたのもNodeの特性(ノンブロッキング I/O、イベントループ)によるところが大きいです。

Koa

Webフレームワークについては、開発をはじめた2017年初頭時点で、Koa v2を選択しました。ExpressではなくKoaにした最大の理由は、async / awaitで非同期処理を書きたかったからです。

新規プロダクトなので下位互換などのしがらみ(笑)もなかったため、デファクトのExpressではなくモダンさを優先してKoaを選択した形です。後述するApollo ServerがKoa v2に対応していたのも大きかったですね。

Apollo Server (GraphQL)

APIには、RESTではなくGraphQLを採用しています。これも2017年初頭には意思決定しているので、プロダクトでの採用事例としては早い部類ではないかと思います。

Nodeで動くGraphQLサーバ、いまでこそPrismaとか便利なライブラリがありますが、当時はGraphcoolくらいしかなく(のはず)、自前サーバでGraphQLのバックエンドを動かすとしたらApollo Serverを立ててSchemaやResolverをゴリゴリ書いていくのみ…な感じでした。現在も、その実装をブラッシュアップしつつ引き継いでいます。

情報やツールの少ない中、導入にはもろもろ苦労がありましたが、ひとたび勘所を押さえてしまえばGraphQLは本当に便利!

とくにResolverの仕組みは秀逸で、複数のデータソース(DB, 外部 API, etc)を柔軟に取り扱えますし、type(データ型)に対してResolverを設定することでフィールド毎のアクセス制御も容易に実装できます。この利便性は、ちょっともう手放せない感じです。

GraphQL Subscriptionに関しても、Apollo Clientが正式対応した時点で導入しました。上記のメリットをソケットでもありがたく享受させていただいてます^^

Sequelize

ORMにはSequelizeを採用しています(DBはMySQL)。当初はAssociationなどもがっつり書いていたのですが、開発を進めるうちにORMのAssociationとGraphQLのResolverとの食い合わせが悪いことがわかり、Sequelize側からはAssociationなど外し、ごく薄く使う形に仕様変更しました。

現在は基本、マッピングとトランザクションとマイグレーションのみSequelizeが受け持っています。(それでも、DB スキーマとGraphQLスキーマの両方を書かなければいけないとか、まだイケてない面が残っていたり。Prisma…)

RxJS

「CQRS」なアーキテクチャ(後述)でロジック層を実装する目的で、RxJSを採用しました。

実装的には、GraphQLのMutationをトリガーにしてObservableを生成し、そのストリームに対して、認証認可・データ更新・各種通知(Email、Slack、WebPush、etc)などの処理をカスタムオペレータでつないでいく形で、ビジネスロジックを形成しています。

メリットとして、各カスタムオペレータごとに職責が分離しているので、コードの見通しがとてもよくなりますし、ロジック変更にも柔軟に対応が可能です。

コードを実際に見ていただいたほうがイメージ伝わると思いますので、下にサンプルコードを掲載します。

function entityCreating(source$, appContext) {
  const { log,Sequelize} = appContext;

  return (
    source$
      // イベントをフィルタ
      .typeOf('entityCreating')
      .log(log, 'info', '[entityCreating]', 'start')
      // リソースのロード
      .resource(util.loadResource(sequelize))
      // 認可
      .verify(spec.whenCreate, e => {
        const { args, actor, context: { resource } } = e;
        return [args, actor, { sequelize, resource }];
      })
      // データ生成
      .execute(async e => {
        const { args, actor } = e;
        const entity = await service.create(args, actor, appContext);
        return { entityId: entity.id };
      })
      // タイムラインに feed を流す
      .feed(util.feedActivity(FeedTypes.created.value))
      // web socket で publish
      .sideEffect('created', util.effected(appContext))
      .log(log, 'info', '[entityCreating]', 'end')
      .share()
  );
}

その他

  • ユーザ認証にはJWTを利用しています。
  • GraphQLのフェッチ層のキャッシュにはData Loaderを使用しています。
  • バッチ処理系は、LambdaのScheduled Eventで処理しています。
  • 各種SaaSも利用させていただいています(クレジットカード決済はStripe、チャットボットはDialogflow、など)。

アーキテクチャ

CQRS ( with RxJS )

Wistantは企業単位/チーム単位で利用されるプロダクトという特性上、1回の処理に対して複数の通知を投げなければならないユースケースが多くあります。

たとえば、チームのAさんが目標の登録を行なった場合に、以下のような通知を行うケースを想定します。

  • Aさんにメールで通知する
  • チームリーダーにWebPushで通知する
  • チームのSlackチャネルにフィードを流す
  • チームの(Wistant上の)タイムラインにフィードを流す

こちらに対応するビジネスロジックは、たとえば以下のようなダイアグラムで表すことができます。

f:id:occmee2:20181228131551p:plain

各タスクの中には非同期にできるものもあり、すべてのタスクが直列処理されるわけではありません。しかしながら、「目標を登録する」というユースケースに対するロジックとして一括りにするには、粒度が大きすぎる印象があります。

実際、当初Wistantはこのようなスタイル(Resolverにゴリゴリ処理を書いていく)で実装を進めていたのですが、複雑になっていくビジネスロジックに対応しているうちにコードの見通しがどんどん悪くなり、密結合でメンテナンス性も悪くなり...。

こうした状況を打開すべく、開発スタートから半年ほどのタイミングで「CQRS *1 」をベースとしたアーキテクチャに全面改修しました。

先ほどの例を、RxJSを利用してCQRS的に実装すると、以下のようなダイアグラムになります。

f:id:occmee2:20181228131637p:plain

改修ポイントは以下のとおり。

  • 書き込み系の処理と読み込み系の処理を明確に分ける(通知は読み込み系に分類)*2
  • 書き込みに関しても、目標の作成とフィードの作成は別のユースケースと考えストリームを分離し、イベントをdispatchすることで呼び出す
  • 各通知処理は、目標作成のストリームからイベントを受けとり、それぞれ個別に実行される(並行処理)

改修前と比較すると、タスクごとの職責が明確になり、フローの見通しが格段に良くなったかと思います。また、(これはCQRSというよりRxJSの功績になりますが、)各タスクが疎結合になったことで、コードのメンテナビリティもぐっと向上しました。

Wistantのバックエンドのビジネスロジックは現在、全般にわたって上記のようなCQRSベースのアーキテクチャを採用しています。

では次に、このアーキテクチャをAPIのインターフェイスとどう接続するかを見てみましょう。

with GraphQL

GraphQL(APIインターフェイス)も含めた、バックエンド全体の構成は以下のようになります。

f:id:occmee2:20181228131657p:plain

CQRSパターンは、ズバリ、GraphQLとも相性がとても良いです。 (そもそもGraphQLのMutation/Queryというフレーム自体、CQRSをベースにしているのかも。詳しい方いらっしゃったら是非教えてください…!!)

例えば、Queryに関して。 CQRSの利点のひとつに、Command(書き込み)とQuery(読み込み)の職責を分離してすることで「読み取り側ではクエリ用に最適化されたスキーマを使用し、書き込み側では更新用に最適化されたスキーマを使用できる *3 」ことがあげられます。

その点、GraphQLは、定義されたスキーマの範囲であればクライアント側が自由にクエリを書いてデータを取得することができるので(あるエンティティの必要なフィールドだけ、とか、複数エンティティを1クエリで、とか、自由に指定できる)、CQRSのQueryの実装として完璧にマッチします。

これは、書き込み後にレスポンスデータを返すケース(Mutation)も同様です。Command処理後、イベントとしてルートエンティティ情報をQueryに渡すだけで、あとはGraphQLがよろしくクエリをさばいてくれます。クエリ内容に関して、Command側が意識する必要は全くありません。

このように、GraphQLに「CQRS+Reactive」なロジック層を組み合わせることで、それぞれの特性が存分に発揮され、柔軟でメンテナンス性の高いバックエンド構成が実現できたかな、と思っています。

まとめ

以上、駆け足になりましたが、Wistantのバックエンドの技術スタックの概要をご紹介しました。

他の多くの言語だとフルスタックなフレームワークがデファクトで存在しますが、Nodeでは薄いWebフレームワークにお好みでミドルウェアを追加していくのが一般的です。そのぶん、ロジック層にプロダクトごと・実装者ごとのカラーが出やすいかなと思います。

Wistantの場合、ビジネスの業務フローに沿ったサービスを提供する上で、取り扱うドメインは多岐にわたりますし、ユーザロールごとの権限管理など要件も複雑になります。

必然、バックエンドのロジックも容易に肥大化する傾向にあります。したがって、CQRSのようなフレームやReactiveのような宣言的なコードスタイルを導入して、やや重厚ではありますがメンテナビリティを重視した構成を採用しています。

これがもし、もっと軽量なサービスのバックエンドでしたら、GraphQLを採用するにしてもロジックはResolverに素直に書くだけで足りるかもしれません。あるいは、いまならAppSyncなどのサーバレスバックエンドの利用を検討するかもしれません。

ことほど左様に、最適な手段は相手や目的に依るもの。「技術やフレームワークは適材適所で」というのが、しごく当たり前ではありますが自分が技術選定する上で指針としているところです。

今回はWistantの事例をご紹介しましたが、また機会がありましたら別の事例についても掘り下げてお伝えできればと思います。そしてそれらが、みなさんの開発のご参考になれば幸いです^^

(...なお、インフラ構成に関しては時間切れ(納期に間に合わなかった...)につき、後日追記にて簡単に補足させていただければと思っていますmm)

それではみなさん、よいお年を! 来年も、RELATIONSならびに本ブログを、どうぞよろしくお願いいたしますー。


現在RELATIONSでは開発メンバーを募集中です!興味がある方は、まずはお話をできればと思います! https://www.wantedly.com/companies/relations

*1:CQRSについて詳しく知りたい方は、「CQRS アーキテクチャのスタイル 」や「 CQRSとイベントソーシングの使用法、または「CRUDに何か問題でも?」 」あたりが参考になるかと思います。

*2:通知に関しては、CQRS的なQueryとは厳密にはいえないと思いますが、この例では構造を単純化するため「読み込み」側に含めて考えます。(寛大なお心で何卒ご理解ください...mm)

*3:CQRS アーキテクチャのスタイル | Microsoft Docs より

RELATIONSのフロントエンド技術スタックと、大事にしている選択基準

こんにちは、RELATIONS株式会社の久原です。主にフロントエンドを担当しているエンジニアです。

今回は、弊社で使用しているフロントエンド技術と、それを選ぶ際に大事にしている選択基準について、紹介していきたいと思います。

やや長文になってしまいましたので、さきに選択基準について抜粋いたします。(後半で詳述します)

  • 情報収集のしやすさ(実現すべきUXの開発に集中できること)
  • 分業のしやすさ(各々の得意を生かし完成度を高めること)
  • 入れやすさ/捨てやすさ(仮説の出し入れを素早く行えること)

フロントエンド技術スタックの紹介

リリース済のプロダクトである「Wistant」を例に、技術スタックを紹介したいと思います。Wistantのサービスとしての特性は以下の通りです。

  • B2BのSaaSプロダクト、マネジメント支援を行うWebサービス
  • SPAのWebアプリ、スマホ対応(レスポンシブ)
  • フロントエンドはReact/Redux、バックエンドはNode/GraphQL

UI/Design

  • design tools: Sketch, Zeplin
  • catalog: Storybook, StoryShots
  • design framework: Atomic Design

デザインとエンジニアリングの間のフローとしては、まずデザイナーにSketchを使ってZeplinでWeb上にデザインを配置してもらいます。それに対して、プロダクトオーナーやエンジニアが質問やツッコミをペタペタ貼り付けていくスタイルを取っています。

UIカタログツールとしては、Storybookを採用しています。利点は大きく3つです;

  1. マークアップ専門でも開発しやすい。バックエンドシステムやフロントロジックの用意が不要なので、分業して作業できるようになりました。
  2. パターンカタログができる。パターン漏れが把握しやすくなり、チェックの分業化も実現しました。
  3. デグレの検知がしやすくなる。Snapshot Testing (StoryShots)を連動させることで、チェックの自動化を実現しました。

最近のチャレンジとしては、Atomic Designに基づいたコンポーネント化を行っています。現時点でも、以下のようなメリットが出ています;

  1. エンジニアとデザイナーの共通言語が生まれる。両者でUIレイヤーの認識を揃えやすくなりました。
  2. コンポーネントの役割をレイヤで明確に分離できる。「このレイヤーの振る舞いじゃないよね」といった認識を揃えやすくなりました。
  3. UIとロジックもレイヤーで分離できる。具体例として、molecules以下とorganisms以上を分割点とし、organisms以上にのみアプリの状態への操作権を持たせると規定したことで、UIとロジックを分業した開発が可能になりました。

フロントエンド

  • language: ES6
  • transpiler: Babel
  • ui lib: React
  • stylesheet: Stylus
  • framework: Redux, Redux-Saga, normalizr, reselect, Apollo Client
  • api layer: GraphQL, graphql-subscription
  • build tool: webpack, Workbox
  • testing: Jest, StoryShots
  • linting: ESLint, Prettier

APIはGraphQLを採用しています。リクエストの変化(≒UX改善)に強く、フロント担当者だけでクエリを改変することもできました。加えてgraphql-subscriptionを採用しており、バックエンドのデータ更新をトリガーに、ソケット経由でのリアルタイム同期を実現できました。

最近のチャレンジとしては、モバイルUXを高める目的で、PWAへ順次対応しています。CodeSplittingを行ってダウンロードサイズを削る、AppShellモデルに対応してFirstPaintを早める、Workboxでプリキャッシュを行う、などの取り組みによって、実際にモバイル環境での初期化処理が大きく高速化されました。

ほかに特徴的なものとしては、normalizr/reselectを積極採用していることでしょうか。開発初期はAPIと画面を1対1で紐付けていたこともあったのですが、ソケット通信などで非同期に様々なデータが更新されるアプリへと進化していく過程で難しさが発生して…このあたりの知見は別途エントリにて公開していければと思います。

インフラ

  • IaaS: AWS
  • BaaS: Firebase
  • CI: Circle CI
  • Virtualization: Docker

特に試作的なリリースを行うときは、DBにRDBではなくFirebaseを使う場合もあります。バックエンドシステムの用意が不要なことや、データモデルがドキュメントベースのため、フロント担当者だけで改造を完結できることなどが利点です。

実例として、基盤システムにはRDBを使いつつ、試作機能にのみFirebaseを導入したことがあります。仮説検証の結果がダメだったときに、捨てやすいのも特徴です(実例です…)

ローカル環境の運用は、すべてDockerにて行っています。コマンド一発で簡単に環境構築ができることから、デザイナーやPOに対しても、ラフに動作確認が依頼できています。将来的にはインフラ含めて一貫して運用したいですね。

SaaS

  • communication: Intercom
  • onboarding: Appcues
  • error tracking: Sentry
  • payment: Stripe
  • mailing: SendGrid
  • analytics: Google Analytics, Amplitude w/Segment

カスタマーサクセス(CS)チームに喜ばれているのがIntercomの導入で、プロダクトにメッセンジャー機能やマニュアル掲示などのヘルプセンター機能を簡単に載せることができました。これによって顧客との距離が大幅に縮まっており、プロダクトの魅力をより伝えやすくなっています。

さらにオンボーディングツールのAppcuesを導入することで、プロダクトにチュートリアルやツールチップ機能を載せることができ、離脱率の減少に寄与することができました。シナリオ作成はSaaS経由で行えるため分業でき、その改善も容易です。

上述のような、プロダクトの本質的価値とはやや異なる部分については、自分たちで作り込まずにSaaSへと積極的に分業しています。それによって得られた時間で、プロダクトの本質的価値の向上に集中して取り組んでいます。

大事にしている選択基準

最も気にしている点は「作りたいものに、いかに集中できるか」です。

ベンチャー企業に属するエンジニアとしては、事業の仮説検証をいかにすばやく回転させられるかが大事だと考えています。そのなかでフロントエンドエンジニアとしては、仮説を実現するためのUXに注力し、ノイズのない仮説を顧客に提供することが求められます。

そのためには、「実現すべきUXの開発に集中しやすくすること」「各々の得意を生かして完成度を高めること」「必要に応じて仮説の出し入れをすばやく行える環境を整えること」、この3点が重要だと考えています。その理由と事例を交えてご紹介したいと思います。

情報収集のしやすさ

実現すべきUXの開発に集中し、少ないリソースで効率的に開発するためには、イケイケのエッジな技術を採用するよりも、情報流通性の高い、多少枯れ始めているくらいの技術のほうが良いと判断しています。

エッジな技術は魅力的ですが、仕様変更やバグも多く、思わぬところで躓いてしまうことが多いと経験上感じます。その対策ばかりに時間を取られるよりは、導入事例の多い「枯れはじめの技術」を使ったほうが、変に躓かずに作りたいものへ集中できると考えています。

ただそれだけでは進化が止まってしまいますので、エッジな技術のキャッチアップと素振りは常に行っています(e.g. React Hooks, Prisma)。キャッチアップを続けることで、枯れ始めたなと感じたときに採用しやすい土壌は整備しています(e.g. Expo, styled-components)。

分業のしやすさ

1人ひとりが持てる「得意」をぶつけて、上限値を高めることが、プロダクトの価値向上につながると考えています。得意を任せ、得意の集合体をリリースするために、できるだけ得意に集中してもらえるよう、積極的に分業をしています。

たとえば弊社ではマークアップのスペシャリストに業務委託としてジョインいただいていますが、AtomicDesignやStorybookを選択し、実装レイヤーが分割可能になったことによって、その得意にコミットしてもらいやすくなりました。

自分たちで作る必要のないものは、どんどんSaaSに頼るのも分業だと考えます。Wistantで多くのSaaSを使用しているのはこのためです。注力ポイントを明確化することで、注力したいUXに対してこだわり尽くしていく姿勢を常に取っていきます。

入れやすさ/捨てやすさ

プロダクトの仮説検証をしていく最中において、顧客や環境が変化することは当然あり得ます。そういった変化にすばやく対応するために、試作的なリリースを行ったり、不要になってしまった体験の除去を行ったりと、機能の出し入れが激しく行われることもあります。

顧客の目に直接触れるフロントエンドにおいては、それは顕著です。ですからフロント技術スタックにおいては、特に機能の入れやすさと捨てやすさが重要になることが多いと考えています。

たとえば外部サービスを活用して試作リリースを行い、結果がポジなら自社開発・ネガなら捨てる。価値の確定しきっていない箇所では敢えてDRY原則を外してダブらせて開発し、ポジなら基盤化・ネガなら捨てる。

機能を出し入れしやすい、ひいては改善しやすい開発体制を作ることによって、仮説をすばやくリリースし、また環境の変化にも追従できる。そんなフロントエンド体制を維持していくことが大事だと感じます。

まとめ

RELATIONSのフロントエンド技術スタックと、フロントエンドエンジニアとして大事にしている選択基準をご紹介しました。「作りたいものに集中できる」環境を整えておくことで、事業の仮説検証と、それを実現するUXにコミットしつづけていきたいと思います。

今回のご紹介では漏れてしまいましたが、他の開発事例として、ReactNative(Expo), react-navigation, native-base, styled-components, Firebaseなスマホアプリなども開発しています。逆にランディングページではバリバリjQueryを選択したり…。

その他の事例共有・失敗談・情報交換なども出来ると思いますので、ぜひランチなどいかがでしょうか! お気軽にどうぞ!

https://www.wantedly.com/companies/relations

ExpoとFirebaseを使って、サーバーレスで手軽にできるプッシュ通知配信を試しました

こんにちは、RELATIONS株式会社の大川です。
普段は Wistant の開発・運用をしているエンジニアです。

最近 Expo(React Nativeによるアプリ開発を支援するツール)とFirebase(DBやユーザー認証機能などアプリ開発でよくある機能を提供しているクラウドサービス)
を使い始めて、 Expoで作成したReact Nativeアプリのプッシュ通知を配信する仕組みを検証しています。

検証している仕組みの概要

検証用に、シンプルな掲示板アプリを作成しました。
ソースコードはこちらにあります。)
機能は以下の2つです。

  • メッセージを投稿できる
  • メッセージが投稿されると全員にプッシュ通知が届く

プッシュ通知を配信する仕組みは、Expoが提供している Expo Push API を利用しています。

Expo Push APIの特長は、iOS用の証明書設定や、Android用のサーバーキー設定を行わなくても、実際の端末でプッシュ通知の確認ができることです。

とても手軽ですが、デバイストークン(端末ごとにユニークなトークン)を管理するためのバックエンド開発が必要になります。

そこでメッセージとプッシュ通知を配信するためのデバイストークンの保存にCloud Firestore(Firebaseが提供しているデータベース。以下、Firestore)を利用し、メッセージが投稿されたタイミングで実行される処理を Cloud Functions for Firebase(クラウド上に保存したバックエンドのコードを実行できるサービス。以下、Cloud Functions)に実装しました。

メッセージとデバイストークンをCloud Firestoreに保存し、それをトリガーに起動されたCloud FunctionsでExpo Push APIをリクエストします
プッシュ通知配信のフロー

アプリの実装について

アプリでは、デバイストークンを端末から取得して Firestoreに保存するようにしています。

端末からデバイストークンを取得する部分は、ExpoのHPに公開されているドキュメントを参考に実装すると簡単です。

// デバイストークンを端末から取得して Firebase に保存する処理
const registerForPushNotificationsAsync = (navigation) => {
  return async () => {
    // プッシュ通知のパーミッションを取得
    const { status: existingStatus } = await Permissions.getAsync(
      Permissions.NOTIFICATIONS
    );
    let finalStatus = existingStatus;

    // すでにプッシュ通知が許可されていればなにもしない
    if (existingStatus !== "granted") {
      // (iOS向け) プッシュ通知の許可をユーザーに求める
      const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
      finalStatus = status;
    }

    // プッシュ通知が許可されなかった場合なにもしない
    if (finalStatus !== "granted") {
      return;
    }

    // Expo 用のデバイストークンを取得
    const token = await Notifications.getExpoPushTokenAsync();

    // Firebase にデバイストークンを保存するメソッドを呼び出す
    saveDeviceToken(token);

    // React Navigation で画面遷移
    navigation.navigate("Thread");
  };
};

途中で呼び出しているsaveDeviceTokenメソッドで、Firestoreのtokensコレクションにデバイストークンを保存しています。 (今回は検証目的なので、このメソッドが呼ばれる度にデバイストークンが追加されていく実装になっていますが、実際に運用するときは端末固有のIDやユーザー情報と紐づけた方が効率的です)

const saveDeviceToken = async (token) => {
  try {
    // Firestore にデバイストークンを保存
    const docRef = await db.collection("tokens").add({ token })
    console.log(`document written with ID:${docRef}`); // デバッグ用
  } catch (err) {
    console.error(`error: ${err}`);
  }
};

シミュレータで動作確認を行うと、プッシュ通知が許可されず処理されないので スマートフォンにExpoクライアントアプリをインストールしてから動作確認をしています。

うまく動作すれば、Firebase側にデバイストークンが保存されていることが確認できます。

FirebaseのWebコンソールでデバイストークンを確認
FirebaseのWebコンソールでデバイストークンを確認

Cloud Functionsの実装について

Cloud Functionsでは、データの保存をきっかけにして関数を実行できるので、メッセージが保存されたときにプッシュ通知を配信するようにしています。
(アプリとは別プロジェクトで、ソースコードはこちらにあります。)

const { Expo } = require('expo-server-sdk');
const functions = require('firebase-functions');
const admin = require('firebase-admin');

// Expo のインスタンスを用意
const expo = new Expo();

// Firestore のインスタンスを用意
admin.initializeApp();
const settings = { timestampsInSnapshots: true };
const db = admin.firestore();
db.settings(settings);

exports.sendNotification = functions.firestore
  .document('messages/{messageId}')
  .onCreate(async () => {
    // デバイストークンを Firestore から全検索
    const query = db.collection('tokens').where('token', '>', '');
    const snapshot = await query.get();
    const tokens = snapshot.docs.map(doc => doc.get('token'));

    // プッシュ通知用のメッセージオブジェクトを作成
    const messages = [];
    tokens.forEach(pushToken => {
      if (!Expo.isExpoPushToken(pushToken)) {
        console.error(`Push token ${pushToken} is not a valid Expo push token`);
      }

      messages.push({
        to: pushToken,
        sound: 'default',
        body: 'This is a test notification',
        data: { withSome: 'data' },
      });
    });
    const chunks = expo.chunkPushNotifications(messages);
    const tickets = [];
    (async () => {
      // Expo Push API をリクエスト
      for (let chunk of chunks) {
        try {
          const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
          console.log(ticketChunk);
          tickets.push(...ticketChunk);
        } catch (error) {
          console.error(error);
        }
      }
    })();
  });

Expo Push APIの呼び出し部分は、前述のドキュメント を参考にしています。

今回は async / awaitをCloud Functions内で利用しているので、
functions/package.json で実行環境の nodeバージョンを8に指定しています。

"engines": {
  "node": "8"
},

また、 Cloud Functionsで外部APIをリクエストするためには有料プランに申し込む必要があるので、従量制プランにしています。
(ローカルで動作確認すると実行するとうまくいくのに、デプロイして動かすとエラーになったので少しつまずきました。)

端末を2台用意すれば、片方でメッセージを投稿し、もう片方でプッシュ通知が受信されるか確認できます。

プッシュ通知の受信を確認
プッシュ通知の受信を確認

まとめ

Expo Push APIとFirebaseを利用してプッシュ通知配信の仕組みをつくると、 アプリとCloud Functionsのロジック実装のみに集中できて、とても手軽にプッシュ通知の配信ができるようになりました。

引き続き以下の実装を行い、検証を進めていく予定です。

  • デバイストークンをユーザー情報に紐付ける
  • 配信できなかったデバイストークンの削除処理を追加する
  • 特定のユーザーにプッシュ通知を配信できるようにする

今後もよろしくお願いいたします。


(追記 2019/1/23 12:00) 続きをこちらに投稿しました。

ついに、RELATIONSも開発ブログをはじめます

はじめまして、RELATIONSの奥宮@エンジニアです。

このたび弊社でも「開発ブログ」を始めることになりました。

今回は初投稿ということで、弊社のことを簡単に。

 

弊社が運営しているWebメディア「SELECK」のことをご存知の方の中には、「メディアの会社が開発ブログ? Why?」と思われる方もいらっしゃるかもしれません。どっこい、RELATIONSは、SELECKだけじゃないのです。

弊社は、「ええ会社をつくる」というミッションのもと、ビジネスメディアSELECK(600社1,000チームの改善事例を掲載)のほか、コスト改善コンサルティングの「Less is Plus」(支援社数600社以上)など、複数のサービスを運営・提供しています。

そして、これらのサービスを通じて得られたナレッジ =「各社の優良なビジネス改善事例」「現場の生の声」を「ええ会社」づくりに生かすべく、BtoB領域でのプロダクト開発も積極的に行っています。

 

その第1弾として、2018年3月には、組織のパフォーマンスを高めるマネジメント支援ツール「Wistant(ウィスタント)」をリリースしました。

(技術スタックについては別エントリに譲りますが、)その開発チームは現在、エンジニアとビジネスサイド(営業、CS)がシームレスに連携しながら、顧客によりよい体験を届けるためのプロダクト改善や、新機能の実装を鋭意おこなっています。

顧客フィードバックをダイレクトにいただきながら、すばやくUXを改善していく。これはSaaSプロダクトならではの醍醐味ですし、開発者冥利につきるなーと、個人的にも日々実感しております。

 

本ブログでは、そんな開発現場の日々の様子や、試行錯誤のなかで得た学び・ノウハウなど、開発にまつわるアレコレを公開していきます。

私たちのチームやプロダクトのことを知ってもらいたい、ということを主目的に気負わず書いていく予定ですが、あわよくば、技術的にもみなさんの開発のお役に立つような情報提供ができれば幸いです。

 

それでは、どうぞよろしくお願いいたします。