RIT Tech Blog

株式会社RITのエンジニアが知見を共有する技術ブログです。

React Navigationで遷移元に応じて戻る先を変える

React Navigationで、ネストされたStackの遷移元によって戻る先を出し分けたい場合があったので、その対処法を記事にしました。

環境

  • Expo
  • TypeScript
  • ReactNavigation

セットアップ

型安全なプロジェクトを作成します

  • expoプロジェクトを作成
$ expo init navigation-test
  • テンプレートで、blank (Typescript)を選択
  • 必要なパッケージをインストール
$ yarn add @react-navigation/native @react-navigation/native-stack
$ expo install react-native-screens react-native-safe-area-context
  • 公式を参考に以下を実装
// App.tsx
import { NavigationContainer, useNavigation } from '@react-navigation/native';
import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack';
import React from 'react';
import { Button, Text, View } from 'react-native';
import { RootStackParamList } from './types';

const RootStack = createNativeStackNavigator<RootStackParamList>();

export default function App() {
  return (
    <NavigationContainer>
      <RootStack.Navigator initialRouteName="A">
        <RootStack.Screen name="A" component={AScreen}/>
        <RootStack.Screen name="B" component={BScreen}/>
        <RootStack.Screen name="C" component={CScreen}/>
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

const AScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>A Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}
const BScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>B Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}
const CScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>C Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}

// types.ts
export type RootStackParamList = {
  A: undefined;
  B: undefined;
  C: undefined;
}

簡単なコードの解説

const RootStack = createNativeStackNavigator<RootStackParamList>();
  • 公式にならって、createNativeStackNavigatorのジェネリック型にStackが扱うScreenのパラメータの型を渡して、Stackを定義しています
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  • Stackの型を渡して、rootNavigationを定義しています。rootNavigationをconsole.logすると、画面遷移に関係する様々なメソッドが取れます↓
Object {
  "addListener": [Function addListener],
  "canGoBack": [Function canGoBack],
  "dispatch": [Function dispatch],
  "getId": [Function getId],
  "getParent": [Function getParent],
  "getState": [Function anonymous],
  "goBack": [Function anonymous],
  "isFocused": [Function isFocused],
  "navigate": [Function anonymous],
  "pop": [Function anonymous],
  "popToTop": [Function anonymous],
  "push": [Function anonymous],
  "removeListener": [Function removeListener],
  "replace": [Function anonymous],
  "reset": [Function anonymous],
  "setOptions": [Function setOptions],
  "setParams": [Function anonymous],
}
  • この中のgetStateメソッドのroutesで、過去に積まれたscreenの履歴を見れるので、Screen A、B、Cを作成して、そこで積まれているroutesを画面に表示しています
  • 各画面のナビゲーション先は、一旦全てBにしています

    基本的なnavigationの挙動確認

AからBに画面遷移する

A→B→A

  • AからB遷移時に、routesに新しいscreeen Bが積まれる
  • 戻るをタップすると、一つ前の画面Aに戻り、screeen Bが削除される
  • 内部的には、navigation.goBack()が発火していて、routesに積まれている一つ前のscreen Aにnavigate(’A’)している

BからBに遷移する

B→B

  • routesにすでにBが積まれていて、現在Bにいるため動かない

Bから新しいBに遷移する

  • ただし、Bへの遷移をnavigateではなく、pushメソッドを使うことで新たなscreenをroutesに登録、画面遷移できます
// B Screen
    <View>
      <Text>B Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To New B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.push('B')} 
      />
    </View>

B→NewB→B

積まれていないrouteに戻る

  • ここでBから、routesに積まれていないCに戻ったらどうなるでしょうか?
  • BScreenに以下のコードを追記して、戻るボタンをカスタマイズします
// B Screen
  useFocusEffect(
    useCallback(() => {
      rootNavigation.setOptions({
        headerLeft: () => (
          <Button 
            title="Go Back C Screen" 
            onPress={() => rootNavigation.navigate('C')} 
          />
        ),
      });
    }, []),
  );

A→B→NewB→C

  • routeにCが存在しないので、新たなScreenCがroutesに積まれて画面遷移するのがわかります

遷移元により戻る先を変動させる

  • 長くなりましたがここから本題です。これらのStackはネストすることができるのですが、異なるStack間の画面遷移時に意図した通りのroutesが積まれず、navigationに用意されているメソッドでは理想とする画面遷移を実現できないケースが出てきました。
  • 例えば以下の構成で、HelpからHelpDetailに画面遷移した後はHelpに戻りたい一方、UserからHelpDetailに遷移した場合はUserに戻りたい場合、UserとHelpDetailは異なるStackに属するのでgoBackができないなど問題が発生しました。

  • RootStack

    • Home
      • TabNavigator
        • HelpStack
          • Help
          • HelpDetail
        • XXXStack
    • User
    • XXX
  • 対処法としては、HelpDetailにパラメータを渡して、そのパラメータによって戻り先を変更することができます。

Aからの遷移だった場合のみCに遷移させる

  • これまでの例で見ていきます。
  • BにAからの遷移であるかを示すパラメータisFromAを渡します。
// types.ts
export type RootStackParamList = {
  A: undefined;
  // B: undefined;
  B: { isFromA: boolean };
  C: undefined;
}

// AScreen
    <View>
      <Text>A Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.navigate('B', { isFromA: true })} 
      />
    </View>
  • useRouteを使って、Bに渡されるパラメータを取得できます
// B Screen
  const { params } = useRoute<RouteProp<RootStackParamList, 'B'>>()
  • isFromAがtrueの時のみ、戻り先をCに固定します
  useFocusEffect(
    useCallback(() => {
      rootNavigation.setOptions({
        // headerLeft: () => (
        //   <Button 
        //     title="Go Back C Screen" 
        //     onPress={() => rootNavigation.navigate('C')} 
        //   />
        // ),
        headerLeft: () => (
          params.isFromA ? (
            <Button 
              title="Go Back C Screen" 
              onPress={() => rootNavigation.navigate('C')} 
            />
          ) : (
            <Button 
              title="Go Back" 
              onPress={() => rootNavigation.goBack()} 
            />
          )
        )
      });
    }, []),
  );

挙動確認

A→B→C

  • routesにCが存在しないため、新たなscreenCが積まれて画面遷移してはいますが、遷移元がAのとき正しくCに戻れています

  • 最後にCからBに遷移したときの挙動確認です。初期表示するScreenを設定できるinitailRoutesをCに変更して検証します。

// App.tsx
export default function App() {
  return (
    <NavigationContainer>
      // <RootStack.Navigator initialRouteName="A">
      <RootStack.Navigator initialRouteName="C">
        <RootStack.Screen name="A" component={AScreen}/>
        <RootStack.Screen name="B" component={BScreen}/>
        <RootStack.Screen name="C" component={CScreen}/>
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

// C Screen
    <View>
      <Text>C Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.navigate('B', { isFromA: false })} 
      />
    </View>
  )

C→B→C

  • 遷移元がCのときは正しくCに戻れることが確認できました!

参考

React NavigationをTypescriptと一緒に使う際につまづいたところ - kmgk's blog

https://reactnavigation.org/docs/typescript/

インターフェース分離の原則とは何か

エンジニアの前田です。

インターフェース分離の原則とは

オブジェクト指向で用いられる五つの原則の頭字語である、SOLIDのうちIの部分です。
不要なインターフェースに依存することを避けるべきという原則です。

不要なインターフェイスとは?

例えば以下のようなインターフェイスがあったとします。

// アクション全ての複合クラス
interface Action {
  run: () => void;
  stop: () => void;
  fly: () => void;
}

class Car impletements Action {
  run() {
    console.log('走る');
  }
  stop() {
    console.log('止まる')
  }
  fly() {
    console.log('車は飛べません')
  }
}

class Plane impletements Action {
  run() {
    console.log('走る');
  }
  stop() {
    console.log('止まる');
  }
  fly() {
    console.log('飛ぶ');
  }
}

ActionインターフェイスはCarクラスやPlaneクラスなどの乗り物ができるアクションのインターフェイスです。 上の例を見ていただければ分かるように、Carクラスは本来持つべきでないflyメソッドを実装することを余儀なくされています。 もし、Carクラスがflyという不要なメソッドを実装していることを知らない人がCarクラスのflyメソッドを使ってしまうと問題が起きる可能性があります。

以上のことから、

  • 不要な実装をしなければならない <- Carクラスのflyメソッド

  • フログラム上の不具合の原因 <- Carクラスはflyメソッドを使えないはずなのに使えてしまう

の2つの問題が発生していることがわかります。

インターフェイスを分離する

Actionインターフェイスを以下のように乗り物ごとに分離しましょう。

// 車のアクション
interface CarAction {
  run: () => void;
  stop: () => void;
}

// 飛行機のアクション
interface PlaneAction {
  run: () => void;
  stop: () => void;
  fly: () => void;
}

class Car impletements CarAction {
  run() {
    console.log('走る');
  }
  stop() {
    console.log('止まる')
  }
}

class Plane impletements PlaneAction {
  run() {
    console.log('走る');
  }
  stop() {
    console.log('止まる');
  }
  fly() {
    console.log('飛ぶ');
  }
}

乗り物ごとにActionのインターフェイスを分けることで、Carクラスがflyメソッドを実装する必要がなくなりました。

基底クラスを用意する

インターフェイスを分けることで、CarクラスとPlaneクラスを同一のクラスとして扱うことができなくなり困ることがありそうです。 そんな時は以下のように基底クラスを用意しましょう。

// 乗り物のインターフェイスの基底クラス
class NorimonoAction {
  run: () => void;
  stop: () => void;
}

// 車のアクション
interface CarAction extends NorimonoAction  {}

// 飛行機のアクション
interface PlaneAction extends NorimonoAction {
  fly: () => void;
}

function eachRun(norimonos: Norimono[]) {
  norimonos.forEach(norimono => {
    norimono.run();
    norimono.stop();
  })
  console.log("finish all run");
}

以上のように基底クラスを作ることで、CarとPlaneを安全に同一のものとしても扱うことができるようになりましたね

メソッドの引数の場合のインターフェイスの分離の必要性

メソッドの引数についても分離しておいたほうがいいよという話も簡単に説明しておきます。 メソッドの引数を分離していない場合の例を以下に示します。

interface Post {
  message: string;
  userId: number;
}

function displayMessage(post: Post) {
  document.getElementById("area1").innerText = post.message;
}

displayMessageメソッドは特定の文字列を画面に表示するシンプルなメソッドです。 displayMessageの引数としてPostオブジェクトをそのまま渡しています。 以下の問題が発生しそうです。

  • postオブジェクトの構造が変わった時に、displayMessageメソッドにも変更が波及する

  • postオブジェクトのmessageプロパティ以外を画面に表示させたい時

そんなときはインターフェイスを分離させましょう

function displayMessage(message: string) {
  document.getElementById("area1").innerText = message;
}

displayMessage(post.message);

例外

以下のように、オブジェクトに密接したメソッドの場合、無理に分離させるのではなくそのまま使ったほうがいい場合もあります

interface User {
  id: number;
  name: string;
  role: string;
  comment: string;
}

function displayUserInfo(user: User) {
  document.getElementById("area1").innerText = `id: ${user.id}, name: ${user.name}, comment: ${user.comment}`;
}

displayUserInfoの引数にuserをそのまま渡していますね。 これをインターフェイスで分離すると、id, name, commentをそれぞれ引数に分けて渡すことになって面倒そうですね。引数の順番を間違えたりエラーの原因にもなりそうです。 そもそも、Userオブジェクトの詳細を表示するメソッドなのでUserオブジェクト以外を渡す必要もなく、分離する必要がなさそうです。 時と場合で使い分けましょう。

以上です。ありがとうございました。

リスコフの置換原則とは何か

エンジニアの前田です。

SOLIDの原則のLにあたる「リスコフの置換原則」を調べてみました。

概要

リスコフの置換原則とは、 is a関係にあるクラスを定義する時に、サブタイプのオブジェクトはスーパータイプのオブジェクトの仕様に従わなければならない、という原則です。

守るべきこと

リスコフの置換原則では、以下の項目(事前条件・事後条件・不変条件)を順守すべきであるとされています。

事前条件

・事前条件を、サブクラスで強めることはできない。サブクラスでは同じか弱められる。

事前条件とはある操作をする際に満たさなければいけない条件のことです。

事後条件

・事後条件を、サブクラスで弱めることはできない。サブクラスでは同じか強められる。

事後条件とはある処理が実行された後に成立する条件のことを言います。

不変条件

・不変条件は、派生型でも保護されねばならない。派生型でそのまま維持される。

propaty Aのとりうる値が 1~100の間であれば、サブクラスでもそれを守らなければいけません。

原則に逆らうと何が起こるのか

リスコフの置換原則に逆らうと、サブクラスが使いづらくなってしまいます。 全てのサブクラスを同様に処理することがしにくいため、リスコフの置換原則に従っていないサブクラスを増やすごとに、サブクラスを使っている箇所で修正が必要になったりします。 結果的にオープンクローズドの原則にも反する実装になってしまいますね。オープンクローズドの原則はこちらの記事を参照お願いします。 オープンクローズドの原則とは、簡単に説明すると、機能追加や修正の際に対象の箇所以外の既存のソースコードを変更する必要がないプログラムにしましょうということです。

以下によくない例として疑似コードで例を示します。

class{
  private ingredients: 材料[];
  constructor(public ingredients: 材料[]) {
    this.ingredients = ingredients;
  }
  
  public boil() {
    this.ingredients.map(ingredient => {
      return ingredient.boil();
    })
  }
}

// スーパークラス
abstract class 材料 {
  abstract public boil(): void;
}

// サブクラス1
class 枝豆 extend 材料  {
    public boil() {
       // 色々な処理
    }
  }
}

// サブクラス2
class 人参 extend 材料 {
    private isPrepareBoid: boolean = false;
    public prepareBoid() {
      // boidする前に必要な処理

      this.isPrepareBoid = true
    }

    public boil() {
       if (!this.isPrepareBoid) throw new Error('茹でる前の準備がされていません');
       // 色々な処理
    }
  }
}

const edamame = new 枝豆();
const carrot = new 人参();
const pot = new([edamame, carrot]);
pot.boil(); // carrotでprepareBoidが実行されていないのでエラーになってしまう!

この例では、スーパークラスとして材料クラス、サブクラスとして枝豆クラスと人参クラスを実装しています 枝豆クラスは材料クラスと置換可能なように実装されているため問題ないですが、 人参クラスはboilメソッドの前に必要な処理として、材料クラスには存在しないメソッドであるprepareBoilを実装していて、boilの前にprepareBoilを実行しないとエラーになってしまうため、boidメソッドを実行した時にエラーになってしまっています。 人参クラスのboilメソッドの事前条件が材料クラスのboilメソッドと違い、prepareBoilの実行後を前提としているため発生しています。

鍋クラスのboilメソッド内でエラーが発生しないように実装するとしたらboilメソッドを以下のように修正するでしょうか

  public boil() {
    this.ingredients.map(ingredient => {
      if (instansof ingredient === '人参') {
        ingredient.prepareBoid();
      }
      return ingredient.boil();
    })
  }

ingredientのboilメソッドを実行する前に、ingredientが人参クラスのインスタンスの場合は、prepareBoilを実行するようにしました。 一応エラーは出なくなりましたが、人参クラスのようなboilメソッドの事前条件が材料クラスと違うサブクラスを作成するたびに鍋クラスを修正しなくてはいけなくなりましたね これは修正に対して閉じていないという点で、オープンクローズドの原則に違反しています。

良い例

材料クラスにprepareBoidメソッドを追加しましょう

abstract class 材料 {
  private isPrepareBoil: boolean = false;
  abstract public boil(): void;

  public prepareBoil() {
    this.isPrepareBoil = true;
  }
}

class 人参 extend 材料 {
    public prepareBoil() { // オーバーライドする
      ...
    }

    ...
  }
}

class{
  private ingredients: 材料[];
  constructor(public ingredients: 材料[]) {
    this.ingredients = ingredients;
  }
  
  public boil() {
    this.ingredients.map(ingredient => {
      ingredient.prepareBoil(); // 全てのIngredientインスタンスにprepareBoilが実装されているので場合分けしなくて良い
      return ingredient.boil();
    })
  }
}

const edamame = new 枝豆();
const carrot = new 人参();
const pot = new([edamame, carrot]);
pot.boil(); // 正常に実行が完了する

prepareBoilを材料クラスに追加し、材料クラスのサブクラスで共通で実行することに変更した結果、サブクラスごとに場合分けをしなくてもよくなりました。  リスコフの置換原則に従った効果です!

Prisma × PlanetScale × Netlify で PlanetScale入門

エンジニアの岸本です。 現在、総額1億500万ドル(約120億円)を調達したことで話題になった、「PlanetScale」というサーバーレスデータベースを皆さんご存知ですか? docs.planetscale.com

今回は簡易掲示板を実際に作りながら、PlanetScaleの導入から使用方法を共有したいと思います。 ※アカウント作成等の手順は、以下記事を参考にすることをおすすめします。 qiita.com

構成

  • ビルドツール
    • Vite
  • DB
    • PlanetScale
  • server
    • Netlify
  • アプリケーション

※ preact の代わりにNext.jsを使っても手順に大差はないので、適宜ご自身の仕様と比較しながら読み進めてもらえればと思います。

始める前に結論と所感

  • 無料枠が大きいので個人開発で遊ぶのにも十分だと思う
  • UXがとても良い。ほとんどのプログラマーgithubユーザーだと思うので、直感的に操作することが可能
  • mysqlが抱えていた問題(本番DBと開発DBの情報に差分が発生して意図しないバグを生じてしまう)を上手く解決してくれそう(上記に記載したように、githubのようなユーザー体験が提供されているためschema変更のrevartなんかも可能)

始める前の準備

PlanetScaleNetlifyはそれぞれcliが用意されているのでインストールします

$ npm install netlify-cli -g
$ netlify // インストールされているか確認

⬥ Netlify CLI
Read the docs: https://www.netlify.com/docs/cli
Support and bugs: https://github.com/netlify/cli/issues

VERSION
  netlify-cli/9.16.6 darwin-x64 node-v14.17.0


$ npm i scale -g
$ which sclae

Vite プロジェクト作成

適当なディレクトリを作成します

$ mkdir ディレクトリ名
$ cd ディレクトリ名
$ npm init vite@latest .

プロジェクト名等、何を利用するか問われるので以下の項目を入力・選択します

$ npm init vite@latest
npx: 6個のパッケージを3.977秒でインストールしました。
✔ Project name: … プロジェクト名
✔ Select a framework: › preact
✔ Select a variant: › preact-ts

$ cd プロジェクト名
$ npm i

プロジェクト作成完了後、ローカルサーバを走らせます

$ netlify dev

◈ Netlify Dev ◈
◈ Ignored general context env var: LANG (defined in process)
◈ Starting Netlify Dev with Vite

   ┌─────────────────────────────────────────────────┐
   │                                                 │
   │   ◈ Server now ready on http://localhost:8888   │
   │                                                 │
   └─────────────────────────────────────────────────┘

補足:) netlify devコマンドに関しては以下を参照ください

www.netlify.com

http://localhost:8888でプロジェクトが正常に立ち上がり以下の画面が確認できるはずです。

ブラウザ画面
viteプロジェクト初期画面

PlanetScale の設定

ターミナルからPlanetScaleloginします

$ pscale auth login
 Confirmation Code: ここに認証用コードが表示される

ブラウザでplanetscale認証ページが自動で開かれるので、ターミナル上に出力される認証用コードと一致しているか確認してください。 問題なければブラウザ上に表示されている、Confirme codeボタンをクリックします

次に、開発用のブランチを作成します

$ pscale branch create データベース名 dev // devは任意のブランチ名

※ ブランチ機能があり、GitのようにDBを管理できる所が良いですよね

shadowブランチを作成する ※ このブランチはPrismaのmigrations用に使われる

$ pscale branch create データベース名 shadow // devは任意のブランチ名

ローカルからデータベースへ接続して、正常に上記設定が行われているか確認します

$ pscale connect データベース名 dev[ブランチ名] --port 3309
$ pscale connect データベース名 shadow[ブランチ名] --port 3310

エディタで本プロジェクトコードを展開して.envファイルを追加して以下を記述します

DATABASE_URL="mysql://root@127.0.0.1:3309/データベース名"
SHADOW_DATABASE_URL="mysql://root@127.0.0.1:3310/データベース名"

Prismaの設定

プロジェクトにprismaを追加します

npm i -D prisma
npm i @prisma/client

schemaファイルを生成します

npx prisma init

schema.prismaを以下のように修正

generator client {
  provider = "prisma-client-js"
  previewFeatures = ["referentialIntegrity"]
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
  shadowDatabaseUrl      = env("SHADOW_DATABASE_URL")
  referentialIntegrity = "prisma"
}

/* 以下、投稿情報用モデルです */
model Post {
  id Int @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now())
  title String @db.VarChar(255)
  content String
}

上記で定義した schema を基にテーブルを作成します

$ npx prisma migrate dev

無事に作成できたらprisma studioを使用して検証用データを作成します

$ npx prisma studio

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555 /* ブラウザでhttp://localhost:5555を開く */

prisma studio 画像
prisma_studio

schemaファイルを基に prisma client を生成します npx prisma generateコマンドはスキーマを取得し、typescript定義を持つprismaクライアントを作成して、データベースとのやり取りに必要なものを自動で準備してくれるコマンドです

$ npx prisma generate

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma

✔ Generated Prisma Client (3.12.0 | library) to ./node_modules/@prisma/client in 70ms
You can now start using Prisma Client in your code. Reference: https://pris.ly/d/client

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

serverless functionを定義

プロジェクトのルートディレクトリにnetlify/functions/posts.tsを作成します. ファイル内は以下のような構成にします. 処理の内容は post した情報をDBから取得して返すだけのシンプルなものになってます.

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export async function handler() {
  try {
    const posts = await prisma.post.findMany();
    return {
      statusCode: 200,
      header: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(posts),
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(error),
    };
  }
}

ちなみに、Netlify の Netlify function とは Netlify が提供しているアドオンの1つで、サーバーサイドの機能を簡単に公開できるサービスです。AWS Lambda をラップして使いやすいように提供されており、 AWSにアカウントを用意する必要もありません。NetlifyのプロジェクトとGitリポジトリを連携するだけでOK。Netlifyが代わりにデプロイをしてくれる上に、HTTPで呼び出せる機能がすぐに用意できる代物です。

上記コードが正常に機能するかテストしましょう. ローカルサーバを起動します(もし起動していなければ).

$ netlify dev
◈ Netlify Dev ◈
◈ Ignored build settings env var: DATABASE_URL (defined in .env file)
◈ Injected .env file env var: DATABASE_URL
◈ Ignored general context env var: LANG (defined in process)
◈ Injected .env file env var: SHADOW_DATABASE_URL
◈ Loaded function post.
◈ Loaded function posts.
◈ Functions server is listening on 62987
◈ Starting Netlify Dev with Vite

> prisma-serverless@0.0.0 dev /Users/ryoma_kishimoto/Desktop/prisma-serverless-planetscale-netlify/prisma-serverless
> vite


  vite v2.9.5 dev server running at:

  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 540ms.


   ┌─────────────────────────────────────────────────┐
   │                                                 │
   │   ◈ Server now ready on http://localhost:8888   │
   │                                                 │
   └─────────────────────────────────────────────────┘

posts を返すか確認します.

http://localhost:8888/.netlify/functions/posts

レスポンスが確認できたらOKです.

次は実際にデータを post する処理をプロジェクトのルートディレクトリ(netlify/functions/post.ts)に作成しましょう.

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export async function handler(event) {
  const { title, content } = JSON.parse(event.body);
  try {
    await prisma.post.create({
      data: { title, content },
    });
    return {
      statusCode: 200,
      body: 'post created',
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(error),
    };
  }
}

正常に起動するか確認します. VSCode拡張機能でThunder Clientという優れものがあるので、検証機能としておすすめです. https://www.thunderclient.com/

簡単にですが使い方を紹介します.

ブラウザからのPOST機能実装

以下のコードをそのままsrc/app.tsxへcopy & pasteしちゃいましょう.

import { useState, useEffect } from 'preact/hooks';

type Post = {
  id: number;
  title: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
};

export function App() {
  const [loadPosts, setLoadPost] = useState(true);
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function load() {
      if (!loadPosts) {
        return;
      }
      const allPosts = await fetch('/.netlify/functions/posts').then((res) =>
        res.json()
      );
      setPosts(allPosts);
      setLoadPost(false);
    }
    load();
  }, [loadPosts]);

  async function handleSubmit(event: any) {
    event.preventDefault();

    await fetch('/.netlify/functions/post', {
      method: 'POST',
      body: JSON.stringify({ title, content }),
    });
    setTitle('');
    setContent('');
    setLoadPost(true);
  }

  return (
    <>
      <h1>投稿画面</h1>
      <ul>
        {posts.map((post: Post) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>Created {new Date(post.createdAt).toLocaleString()}</p>
            <p>Updated {new Date(post.updatedAt).toLocaleString()}</p>
          </li>
        ))}
      </ul>
      <h2>投稿しよう</h2>
      <form onSubmit={handleSubmit}>
        <label htmlFor='title'>Title</label>
        <input
          type='text'
          id='title'
          name='title'
          value={title}
          onChange={(e) => setTitle((e.target as HTMLInputElement).value)}
        />

        <label htmlFor='content'>content</label>
        <input
          type='content'
          id='content'
          name='content'
          value={content}
          onChange={(e) => setContent((e.target as HTMLInputElement).value)}
        />

        <button type='submit'>Save</button>
      </form>
    </>
  );
}

一応、cssも記述します.

html,
body {
  font-family: 'Helvetica Neue', arial, sans-serif;
}
button,
label {
  display: block;
  margin-top: 1rem;
}

以下画面がブラウザ上で確認できるはずなので、正常に「投稿→投稿されたPOSTが表示」されるか挙動チェックしましょう.

PlanetScaleのProduction環境DBの準備

PlanetScale のコンソール画面上からmainブランチを選択します.

mainブランチをproduction用としてpromote

任意のブランチをmainブランチにdeployするために、deploy requestを上げます.(gitのpull requestに近いイメージ) ターミナルから以下コマンドを入力

pscale deploy-request create データベース名 dev // devは任意のブランチ名

PlanetScaleのコンソール画面上にある、「Deploy requests」を選択すると、deploy requestが確認できます.

summaryタブから「Add changes to deploy queue」ボタンをクリックします.

Netlifyへアプリケーションをデプロイ

丁寧にまとめられている、以下記事を参考にして下さい. https://qiita.com/suin/items/743fe6252ad8af425c5e

「Site settings」をクリックし、画面左に表示されている「Build & deploy」タブを選択します.

「Build settings」項目で以下のように設定し、下部の「save」ボタンをクリックします.

PlanetScaleからDBへのURLを取得します. 次にコンソール画面上の「overview」タブを選択し、画面右の「connect」ボタンをクリックします.

「connect with」ドロップダウンメニューから「Prisma」を選択し、画面右のコピーアイコンをクリックしたら適当なメモ帳等にペーストしておきましょう.

Netlifyへ戻り、「Site settings」をクリック、画面左に表示されている「Build & deploy」タブを選択して、以下スクショのように設定しますvalueには先ほどコピペした、DATABASE_URL情報のここから→mysql://~をペーストする)

これで終了です. もし正常にdeployされていない場合は、「Deploy」タブから「Trigger deploy」ボタンをクリックして再度、deployを行なって下さい.

補足:) 以下スクショ内のリンクをクリックすると、Netlify にデプロイされたページへ遷移することができます.

ZENDESKのexternal_idをZENDESK上から変更できるアプリをつくる

こんにちは。RITの関です。

ZENDESKのアプリを作成したのでその方法を紹介します。

基本的には下記の公式ドキュメントと
https://developer.zendesk.com/documentation/apps/getting-started/overview/
下記のZENDESK公式ブログ
https://developerblog.zendesk.com/getting-started-zaf-next-js-4d1f83dae815

の通りにそのままやっているだけなのですが、何かの参考になれば幸いです。

完成イメージ

完成イメージ
組織のexternal_idを画面上から更新できるアプリを作成しました
ZAF initして作られるZENDESKアプリ側のコードは殆どいじっておらず、下記の様に作っています。
①ZENDESKアプリの読み込み先をNextjsで書かれたシステムのURLに指定(例: https://test-app/test_zendesk_app )
②Nextjs側でUIを作成(例: \pages\test_zendesk_app.tsx に色々書く)
③Nextjsでボタンを押された際にZENDESK APIにpatchリクエストをしてデータを更新

zat new する

Zendesk Apps Tools (ZAT)はrubyのgemで作られているので、ご自身のPCにrubyZAT gemを入れます Installing and using ZAT | Zendesk Developer Docs

その後に、適宜作業用のディレクトリを作って、その中でzat new --scaffoldを実行します Building your first Support app - Part 1: Laying the groundwork | Zendesk Developer Docs

zat new --scaffold
適宜emailなどを入力するとデフォルトのZENDESKアプリが出来ます。

ZENDESKアプリを動かしてみる

--scaffoldした場合、manifest.jsonは/srcの中に入っています。 なので、cd srcでsrcフォルダに移り、そこでzat serverをするとデフォルトアプリを動かすことが出来ます。

zat server

localhost:4567に接続するよう言われるので接続するとこんなのが出てくる l

localhost:4567

ZENDESKアプリを表示する

今回は下記を参考にZENDESKアプリを作成するので、編集するものはmanifest.jsonのみです。 localhostで作成したページを表示したいので、localhostの該当ページのURLを挿入し、ZENDESKの組織のページに表示したいのでlocationもorganization_sidebarに変更します。

manifest.json

そしてchange_organization_idのURLに該当する箇所に適当なコードを書いてみます

change_organization_id.tsx

import { NextPage } from 'next';
import React from 'react';

const ChangeOrganizationId: NextPage = () => {
    return <div>hogedayo</div>;
};

export default ChangeOrganizationId;

無事組織フィールドに表示されました!

組織フィールドに表示される

ZENDESKアプリからexternal_idを変更できるようにする

先程作成したchange_organization_idの中身を、external_idを差し替えできるようなフォームに書き換えたのが下記コードです

change_organization_id.tsx

import { NextPage } from 'next';
import React, { useEffect, useState } from 'react';
import { useZafClient } from '@/App/Service/ZafClient';
import { Button, Col, Container, Form, Row } from 'react-bootstrap';

interface ZendeskOrganization {
    organization: OrganizationObj;
}

interface OrganizationObj {
    id: number;
    tags: string[];
    name: string;
    domains: string;
    details: string;
    notes: string;
    externalId: string;
    sharedTickets: boolean;
    sharedComments: boolean;
}

const ChangeOrganiztionId: NextPage = () => {
    const client = useZafClient();
    const [newExternalId, setNewExternalId] = useState<string>('');
    const [organization, setOrganization] = useState<OrganizationObj>();

    useEffect(() => {
        if (!client) return;

        client.get('organization').then(function (res: ZendeskOrganization) {
            setOrganization(res.organization);
        });
    }, [client]);

    if (!client) return <p>読込中</p>;
    if (!organization) return <p>organizationがありません</p>;

    const options = {
        url: `/api/v2/organizations/${organization.id}`,
        data: JSON.stringify({
            'organization': {
                'external_id': newExternalId,
            },
        }),
        contentType: 'application/json',
        type: 'PUT',
    };

    const onSubmit: React.FormEventHandler = async e => {
        e.preventDefault();
        await client.request(options);
    };

    return (
        <Container className='bg-white' style={{ height: '220px' }}>
            <Form.Group as={Row} className='pt-3 align-items-center'>
                <Col xs={6}>
                    <h5>現在のexternal_id</h5>
                </Col>
                <Col className='text-right' xs={6}>
                    {organization.externalId}
                </Col>
            </Form.Group>
            <Form.Group as={Row} className='align-items-center'>
                <Col xs={6}>
                    <h5>新しいexternal_id</h5>
                </Col>
                <Col xs={6}>
                    <Form.Control
                        placeholder='123456'
                        className='mr-2'
                        type='text'
                        value={newExternalId}
                        onChange={e => setNewExternalId(e.target.value)}
                    />
                </Col>
            </Form.Group>
            <div className='text-right'>
                <Button variant='primary w-100' onClick={e => onSubmit(e)}>
                    更新する
                </Button>
            </div>
        </Container>
    );
};

export default ChangeOrganiztionId;

コードを貼り付けてハイ終わりだと不明点が多すぎると思うので、上から順番に解説していきます。

const client = useZafClient();は
ZENDESKから組織情報を取得したり、ZENDESKにPUTリクエストをするといった、ZENDESKが用意しているメソッドを使用するために必要なコードです。
ZafClientはScriptタグを読み込む必要があり、同じpage内だとScriptを読み込んでくれないので、app.tsxとかの中に

import Script from 'next/script';

<Script
    type='text/javascript'
    src=’https://static.zdassets.com/zendesk_app_framework_sdk/2.0/zaf_sdk.min.js’
    strategy='beforeInteractive'
/>

を入れることでScriptタグを読み込むようにする必要があります。
そして、ZafClient.tsxの中ではZafClientが読み込めるまで待って、読み込めたらsetClientするといった処理を書いています。

import { useEffect, useState } from 'react';

declare global {
    interface Window {
        ZAFClient: any;
    }
}

let zafClient: any = null;

export function useZafClient() {
    const [client, setClient] = useState(zafClient);

    useEffect(() => {
        if (!client && typeof window.ZAFClient !== 'undefined') {
            zafClient = window.ZAFClient.init();
            if (!zafClient) return;

            setClient(zafClient);
            zafClient.invoke('resize', { height: '220px' });
        }
    }, [client]);

    return client;
}

ZafClientを読み込むことで、下記の箇所でorganizationの情報をセット出来たり

    useEffect(() => {
        if (!client) return;

        client.get('organization').then(function (res: ZendeskOrganization) {
            setOrganization(res.organization);
        });
    }, [client]);

下記の箇所で該当組織へPUTリクエストを送れるようになっています。

    const options = {
        url: `/api/v2/organizations/${organization.id}`,
        data: JSON.stringify({
            'organization': {
                'external_id': newExternalId,
            },
        }),
        contentType: 'application/json',
        type: 'PUT',
    };

    const onSubmit: React.FormEventHandler = async e => {
        e.preventDefault();
        await client.request(options);
    };

仕組みは今まで説明したとおりなので、あとはreturn内に新しいexternal_idを入れるようなフォームを作成すれば完成です!
本当なら送信結果が返ってくるので、成功/失敗のtry catchや、フォームのバリデーションなどを入れるべきですが、今回は簡易な説明をするために省略しています。

改めて完成イメージです。external_idが更新されることを確認いただけると思います。
これにて説明は異常です。ZENDESKアプリを作成するときの役に立てると幸いです。ありがとうございました!

完成イメージ

バグと戦う

問題が発生しデバッグをする必要があるときに、今、自分にはどの選択肢があり、何をして、何を解決できるのか、体系的にまとめる試みです。

ただ今回は、バグを修正するよりも、問題の特定に焦点を当てて、まとめてみようと思います。

体系的にデバッグ手法をまとめることで、バグを落ち着いてデバッグを行うことができ、自信を持って対処することで、必要な指示を仰いだり、誰かに手伝ってもらったり、チームでのコミュニケーションを円滑に行うことができます。また、バグを解決する中でクライアントとも連絡をとることもあると思いますが、クライアントに不必要な不安を煽ることなく対処することができます。さらに、問題解決のスケージュールや、どのくらいで解決することができるかなどの見積もりすることも可能になるでしょう。

以下に、デバッグ時のフローチャートをまとめてみます。実際にデバッグを行うときには、必ずこの通りのフローになるということではなく、情報収集をして、仮説を立て、検証し、また情報収集に戻るというような、このフローを反復しながら、デバッグをすすめていきます。

バグと戦うフロー

情報収集

情報収集の目的には、上長や、クライアントに報告するための情報収集など、いろいろな目的があると思いますが、バグを修正するためには、問題の再現に必要な情報収集を行っていきます。

問題を再現するためには、誰が何をしたときに、何の問題がおきたのかを確認します。すべてのプログラマーが自然にできていることだと思いますが、注意すべきことは、「誰が」の部分は、なるべく一次情報をあたるようにしましょう。他の人を通して聞くと、その過程で勘違いがおきて、全く別のところを調べているということはよくある話だと思います。また、「何をしたとき」についても、難しいところで、技術に詳しくないクライアントとやり取りしているときにはよくあることですが、全く別のタイミングでおきた問題を指していたり、別のページについて話していたりと、正しい情報を得るのに苦労することがあります。クライアントに連絡を取る前に、勘違いしそうな部分などを調べておくと、スムーズに問題を特定することができることもあります。

バグの発生には、様々な要因で発生するので、一度の情報収集で、全ての情報を得ることは、難しいので、適宜情報収集をしながら、デバッグをすすめましょう。

問題の再現

得られた情報から自分の環境で再現するか検証していきます。問題を再現することができない場合は、解決は非常に困難になります。OSの種類や、バージョン、モジュールのアップデートのタイミング、外部サービスの状況、ログの確認など、情報収集を行い、問題を再現することに努めましょう。

ここからは、具体的な問題の特定方法をみていきます。

(方法1)googleで検索

特定のOSやモジュール、バージョンなどに問題がある場合、ほとんどの場合、すでに、報告と解決策が示されています。モジュールのアップデートのタイミングで問題が発生した場合など、モジュールの仕様変更や、バグなどが想定される場合、それを検索してみましょう。クライアントからの情報収集や、自分の環境でも問題再現をさせたときに出るダイアログや、コンソールにでるログなど、特徴的なエラーメッセージがある場合もそれを検索にかけてみると問題を特定できる場合があります。

(方法2)仮説を立てる

問題の挙動を元に仮説を立ててそれを検証していきます。

仮説をたて、その仮説が正しければ、出てくる特徴的な挙動をログや、実際にシステムを実行してみたり、テストコードを作成することで検証していきます。デバッグには、問題を切り分け、どこが今対処している問題と関係がないか調べるのは重要なので、仮説が正しければ、そのような挙動を示すことは無いなど、仮説を否定する検証も重要です。

この方法は、問題の再現に失敗している場合でも、適用できる方法ですが、仮説を立てるためには、問題がおきているシステム自体や、システムに使われているフレームワークやモジュールなどの根本的な動作原理などに熟知していないと、仮説を立てること自体が難しいです。

(方法3)ソースコードのトレース

ソースコードのトレースは個人的には、問題の特定に最も時間のかかる方法なので、できれば、他の方法で問題の特定を試みてダメだった場合にトレースしてみます。また、configファイルなどの設定に問題がある場合、ソースコードのトレースでは、解決が難しい問題になります。

まずはじめに、ソースコードをトレースするときは、頭から始めるのではなく、クラッシュしている場合には、ログなどから、クラッシュしている行を特定する。または、表示がおかしいのであれば、おかしいデータが確認できる行を特定します。

次に、問題の原因となっている行を特定します。クラッシュしている行が問題となっている場合は、難しいことではありませんが、多くの場合は、実際にクラッシュしている行とは別の行が問題になっていることが多々あります。

ぬるぽ」などのデバッグが難しい理由はここにあると思います。論理的には、そこの行でnullになることは無いはずなのに、どこかで適切な初期化が行われておらず、その変数にアクセスしたときに、はじめて問題が出てくるような場合です。

コールスタックのトレースや、ブレークポイントを設定、デバッガーをアタッチできない場合はprint文を挿入することで、どこで問題のある値が入れられるのかを探し、問題の原因の行を特定していきます。

エラーを握りつぶしている場合や、非同期処理のエラーをメインのスレッドに伝播させてない場合などは、トレースの作業が難しくなります。

自分が使う問題の原因の行を特定する方法は5つあります。

  • 仮説を立てる
  • 二分探索(コードの実行順序)
  • 二分探索(ソフトウェアのバージョン)
  • Reduced Test Cases
  • Characterization Test

仮説を立てる

仮説を立てられる場合は、その仮説が確認できる行の周辺にブレークポイントを設定して、検証していきます。

二分探索(コードの実行順序)

全く仮説をたてることができない場合は、問題のある変数の伝播をコールスタックをもとにソースコードを二分探索していきます。

二分探索(ソフトウェアのバージョン)

前は、正常に動作していたが、ある時点で問題が出始めた場合などは、ソフトウェアのバージョンを時間軸で並べて二分探索で、一つづつ実行して問題が発生するバージョンを探していきます。 具体的には、git bisectで、問題の出るコミットを探していきます。

Reduced Test Case

少しづつ関係ないと思われるコードを削除、または、コメントアウトすることで、 問題の出る最小の構成になるまで、動作確認とコードの排除を繰り返し行います。

Characterization Test

特性テスト(Characterization Test)の応用で、モジュールのソースコードが手に入らなかったり、レガシーなシステムで、全く処理の内容がわからない場合に、使う手法で、 とり得る値のすべての組み合わせを入力し、問題となっているものと同じ出力になるものを特定する方法です。

以上が、自分が実践している問題の原因の特定方法です。

最後に

今回、なんとなくやっていたバグに対する対処方法をまとめてみました。

やはり、どの方法を取るにしても、見つかる時間が立てば立つほど、コストが高くなっていくので、開発の段階で問題が見つかるように工夫するのがベストだなと思いました。その方法は、テストになると思いますが、ユニットテストは書きやすいけど、統合テストはコストがかかる。ユニットテストについて、最近読んだ本の中で、面白かったのが、「街灯の下で鍵を探す」(どこにあるかわからないが、暗いところでは探せないので、明るいところだけを探す)という言葉が強烈に印象深く残り、難しい問題だなと思いました。

Git の ブランチ管理は VS Code の Source Tree で GUI 管理すると楽チンになる

こんにちは。エンジニアの岸本です。 日々開発をしていると、git add .コマンドを叩いて意図しないファイルをステージングエリアに追加した経験は、エンジニアなら誰しも経験したことがあると思います。また、git commitでコメントを記述している最中に、文章が長過ぎてターミナルがクラッシュしてしまうと気持ちが萎えちゃうこともしばしば...。実はVS CodeでGitの操作を行うことで、そんな不便から解放されるはずなので騙されたと思って是非試してもらえればと思います。

※ 今回は基本的なブランチ作成 → ファイル作成 → 作成したファイルの編集 → 編集したファイルのadd・commit → ファイルのpushを紹介

ブランチを作成

VS Code左下にある、現在のブランチを表示している箇所をクリック

任意のブランチ名を入力してEnter

これでブランチの作成は完了(自動的に作成したブランチへの移動もしてくれている)

ファイルをステージングへ追加

適当なファイルを作成して以下スクショ内の矢印部分をクリック

赤丸で囲まれた+をクリック

「staged Changes」欄へ移動される

ファイルをcommit

任意のメッセージを入力

赤丸箇所をクリック

ファイルをpush

以下スクショの「push」をクリック

「ok」をクリック

github上にpushされたことが確認できました!これで以上です。

余談

VS Code上で編集箇所の差分が確認できるので、 消し忘れのコード・コメントがファイル上に残ったままの時にも事前に対応できるので とても有難いです。

また、git stashgit renameのような処理もVS Code上から行うことは可能ですが、個人的な使い方として基本的なgit管理はterminalから行うようにして、git add ~git commitだけはVS Code上から行うようにしてたりします。