uwu

プログラミングの備忘録を書いています。誰かの為になれば幸いです

【環境構築】Laravel9 + Vite + Inertia.js + React + TypeScript

Laravel9 + Vite + Inertia.js + React + TypeScriptで環境構築した際の
手順を忘れないうちに残して置きます。

◆環境

npm 8.5.5
node v16.15.0

Laravelプロジェクトの作成

composer create-project laravel/laravel example-app


example-appの部分は任意のもので大丈夫です。

Inertiaのインストール


作成したディレクトリに移動して以下のコマンドを実行します。

composer require inertiajs/inertia-laravel

blade.phpファイルの編集


resources/views/welcome.blade.php
app.blade.phpに名前変更して以下のコードに書き換えます。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Inertia</title>

    @viteReactRefresh
    @vite('resources/js/app.tsx')
    @inertiaHead
</head>

<body>
    @inertia
</body>

</html>

Inertiaミドルウェアの追加

php artisan inertia:middleware


次に、App\Http\Kernelのroute middleware groupの'web'の最後尾に
\App\Http\Middleware\HandleInertiaRequests::class,を追加します。

protected $middlewareGroups = [
    'web' => [
        // ...
        \App\Http\Middleware\HandleInertiaRequests::class, // 一番最後に追加
    ],

Inertiaライブラリの追加

npm install @inertiajs/inertia @inertiajs/inertia-react

ReactとTypeScriptの追加

npm install -D typescript @types/node @types/react @types/react-dom 

app.jsの編集

resources\js\app.jsの拡張子を.tsxに変更し、
以下に書き換えます。


React18~の場合

import React from "react";
import { createRoot } from "react-dom/client";
import { createInertiaApp } from "@inertiajs/inertia-react";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";

createInertiaApp({
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob("./pages/**/*.tsx")
        ),
    setup({ el, App, props }) {
        createRoot(document.getElementById("app")!).render(<App {...props} />);
    },
});


React18より前

import { render } from "react-dom";
import { createInertiaApp } from "@inertiajs/inertia-react";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";

createInertiaApp({
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob("./pages/**/*.tsx")
        ),
    setup({ el, App, props }) {
        return render(<App {...props} />, el);
    },
});

Viteの設定

npm install -D @vitejs/plugin-react


vite.config.jsから拡張子を.tsに変更します。


vite.condig.tsを以下のように書き換えます。

import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import react from "@vitejs/plugin-react";

export default defineConfig({
    plugins: [
        react(),
        laravel({
            input: "resources/js/app.tsx",
            refresh: true,
        }),
    ],
});

vite-env.d.tsファイルを作成


vite-env.d.tsという名前のファイルをresources\js\app.tsx
同じ階層に作成して以下を記述します。

interface ImportMeta {
    readonly glob: any;
}
declare var route: any;

tsconfig.jsonの作成


下記のコマンドを入力するとtsconfig.jsonが作成されます。

tsc --init


※TypeScriptをグローバルインストールしていない場合は以下のコマンドを打ちます。

npx tsc --init


作成されたtsconfig.jsonを以下のように書き換えます。

{
    "compilerOptions": {
        "baseUrl": ".",
        "target": "ESNext",
        "useDefineForClassFields": true,
        "lib": ["DOM", "DOM.Iterable", "ESNext"],
        "allowJs": false,
        "skipLibCheck": true,
        "esModuleInterop": false,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "ESNext",
        "moduleResolution": "Node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react-jsx",
        "paths": {
            "@/*": ["resources/js/*"]
        }
    },
    "include": ["resources/js/**/*"],
    "references": [{ "path": "./tsconfig.node.json" }]
}

tsconfig.node.jsonの作成


tsconfig.jsonと同じ階層にtsconfig.node.jsonを作成して、
下記のコードを記述します。

{
    "compilerOptions": {
        "composite": true,
        "module": "esnext",
        "moduleResolution": "node"
    },
    "include": ["vite.config.ts"]
}

表示するページを作成


resources\js\配下に
pagesというディレクトリを作成し、
その中にindex.tsxを作成します。
中身は適当に記述します。(表示を確かめる為なのでなんでもよし。)

export default function index() {
    return <h2>Inertia.js with React &#128518;</h2>;
}

web.phpの書き換え


routes\web.phpを以下のように変えます。
これでhttp://localhostにアクセスした時に先ほど作成した
resources\js\pages\index.tsxが読み込まれます。

<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return Inertia::render('index');
});

全ての準備が整ったので
LaravelとViteのサーバーを立ち上げます。

php artisan serve
npm run dev


php artisan serveはアセットをビルドしないので
ViteとLaravel両方のサーバーを実行する必要があります。


私はコマンドプロンプトphp artisan serveして
VS codeでnpm run devをしています。


http://localhostにアクセスすると表示を確認できました!



TypeScriptもちゃんと動いてるようです。



今回は以上になります。お疲れ様でした!

React + MUI + TS + React-hook-formで作るログインフォーム

今回作ったもの



デザインはMUIのテンプレお借りしました。Tyvm💛
9+ Free React Templates - Material UI


もちろん、会員登録ページなどにもお使いいただけます。


指定したバリデーションに引っかかるとこんな感じになります。





今回はvalidationライブラリを使っていません。
ちなみに緑の鍵マークは使っているセキュリティソフトのアイコンです。


環境

Windows11
React18
TypeScript4.7
MUI5.1

React-hook-formのインストール方法
// npm を使う場合
npm install react-hook-form

// yarn を使う場合
yarn install react-hook-form
コード

まずは完成形のコードを貼っていきます。


フォームの使いまわしができるように
コンポーネントに分けました。


コンポーネント:

import { memo } from 'react';
// React-hook-form
import { useForm, SubmitHandler } from 'react-hook-form';

// MUI
import {
  Avatar,
  CssBaseline,
  Box,
  Typography,
  Container,
  Button,
} from '@mui/material';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';

// Components
import Input from '../Atoms/FormInputText';

// Input form type
export type FormValues = {
  email: string;
  password: string;
};

const LoginForm = memo(() => {
  const { handleSubmit, control } = useForm<FormValues>();

  // Validation rules for login form
  const validationRules = {
    email: {
      required: 'Email is required',
      pattern: {
        value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
        message: 'Please enter a valid email',
      },
      maxLength: { value: 254, message: 'Email is too long' },
    },
    password: {
      required: 'Password is required',
      minLength: { value: 6, message: 'Password is too short' },
    },
  };

  const onSubmit: SubmitHandler<FormValues> = (data: FormValues) => {
    console.log(`data: ${JSON.stringify(data)}`);
  };

  return (
    <Container component='main' maxWidth='xs'>
      <CssBaseline />
      <Box
        sx={{
          marginTop: 8,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
        }}
      >
        <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component='h1' variant='h5'>
          Log in
        </Typography>
        <Box component='form' noValidate sx={{ mt: 1 }}>
          {/* Input for email */}
          <Input
            name={'email'}
            rules={validationRules.email}
            required={true}
            id={'email'}
            type={'email'}
            label={'Email Address'}
            autoComplete={'email'}
            autoFocus={true}
            control={control}
          />
          {/* Input for password */}
          <Input
            name={'password'}
            rules={validationRules.password}
            required={true}
            id={'password'}
            type={'password'}
            label={'Password'}
            autoComplete={'password'}
            autoFocus={true}
            control={control}
          />
          {/* Submit button */}
          <Button
            onClick={handleSubmit(onSubmit)}
            fullWidth
            variant='contained'
            sx={{ mt: 3, mb: 2 }}
          >
            LOG IN
          </Button>
        </Box>
      </Box>
    </Container>
  );
});

export default LoginForm;


コンポーネント:

import { FC, memo } from 'react';
import { Controller } from 'react-hook-form';

// MUI
import TextField from '@material-ui/core/TextField';

// Form type
import { FormInputProps } from '../Types/Form/FormInputProps';

const FormInputTmp: FC<FormInputProps> = memo((props) => {
  const {
    name,
    control,
    rules,
    required,
    type,
    id,
    label,
    autoComplete,
    autoFocus,
  } = props;

  return (
    <Controller
      name={name}
      control={control}
      defaultValue={''}
      rules={rules}
      render={({
        field: { onChange, value },
        fieldState: { error },
        formState,
      }) => (
        <TextField
          margin='normal'
          fullWidth
          variant='outlined'
          required={required}
          type={type}
          id={id}
          label={label}
          name={name}
          autoComplete={autoComplete}
          autoFocus={autoFocus}
          error={!!error}
          onChange={onChange}
          value={value}
          helperText={error ? error.message : null}
        />
      )}
    />
  );
});

export default FormInputTmp;


型宣言したファイル:
any警察に捕まりませんように@@

type InputFiledType = 'text' | 'password' | 'email';

export interface FormInputProps {
  name: string;
  control: any;
  label: string;
  setValue?: any;
  required?: boolean;
  type?: InputFiledType;
  id?: string;
  autoComplete?: string;
  autoFocus?: boolean;
  error?: boolean;
  helperText?: string | undefined;
  rules: any;
}
コンポーネントの説明


まず、react-hook-formからuseFormSubmitHandlerをインポートします。


import { useForm, SubmitHandler } from 'react-hook-form';


useFormは「インプット、セレクト要素のデータをreact-hook-formに登録」したり、
「入力されたデータをリセット」したりしてくれる
便利なメソッドが入ったオブジェクトを返します。



メソッドの種類はこちらから:
useForm | React Hook Form - Simple React forms validation



SubmitHandlerはTypeScript で書く場合に必要な型定義です。 
submit イベントに関連して実行する関数の型宣言に使います。



次に、入力値の型宣言をします。


export type FormValues = {
  email: string;
  password: string;
};

次に、useFromから、handleSubmit, controlを定義します。
useForm フックの直後に、フォームの入力値についての型を宣言します。


 const { handleSubmit, control } = useForm<FormValues>();

handleSubmitは、
ログインボタンを押した時、フォーム データを受け取るためのもの、
controlにはコンポーネント
React Hook Formに登録するためのメソッドが入っています。


次に、バリデーションのルールを定義します。
今回はemailには入力必須、emailの形式で、254文字までというルール、
passwordには入力必須、6文字以上というルールをつけています。


// Validation rules for login form
  const validationRules = {
    email: {
      required: 'Email is required',
      pattern: {
        value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
        message: 'Please enter a valid email',
      },
      maxLength: { value: 254, message: 'Email is too long' },
    },
    password: {
      required: 'Password is required',
      minLength: { value: 6, message: 'Password is too short' },
    },
  };

利用できるバリデーションルールは以下です。


  • required
  • min
  • max
  • minLength
  • maxLength
  • pattern
  • validate

validateを使うとカスタムルールを設定することができます。



次に、子コンポーネント(フォーム)に必要な情報を渡します。

        {/* Input for email */}
          <Input
            name={'email'}
            rules={validationRules.email}
            required={true}
            id={'email'}
            type={'email'}
            label={'Email Address'}
            autoComplete={'email'}
            autoFocus={true}
            control={control}
          />
          {/* Input for password */}
          <Input
            name={'password'}
            rules={validationRules.password}
            required={true}
            id={'password'}
            type={'password'}
            label={'Password'}
            autoComplete={'password'}
            autoFocus={true}
            control={control}
          />
          {/* Submit button */}
          <Button
            onClick={handleSubmit(onSubmit)}
            fullWidth
            variant='contained'
            sx={{ mt: 3, mb: 2 }}
          >
            LOG IN
          </Button>

ボタンをクリックすると、handleSubmit()が実行され
バリデーションokだったら自分で定義したonSubmit関数が呼ばれます。


handleSubmit は、第 1引数の関数を「バリデーションokだった場合」に実行します。
「バリデーション🙅‍♀️」だった場合、第2引数にダメだった時の関数を渡すことができるみたいです。


呼ばれる関数は以下です。
SubmitHandlerに続けて、フォーム入力値の型を宣言しています。
引数のdataには、フォーム入力値のデータがオブジェクトとして格納されています。便利。


  const onSubmit: SubmitHandler<FormValues> = (data: FormValues) => {
    console.log(`data: ${JSON.stringify(data)}`);
  };
コンポーネント


Controllerをreact-hook-formからインポートします。

import { Controller } from 'react-hook-form';

ControllerはMUIなどの為のラッパーコンポーネントです。
これで要素をラップしてあげることで要素を制御できます。


ちなみに、reactt-hook-formで要素を制御するには2つの方法があって、
ControllerRegisterというものがあります。


Controllerを使うとフォーム入力時に再レンダリングするので
基本的にはregisterの使用がおすすめされています。


ただ、MUIを使用する場合すでにMUIによって要素が制御されてるので
react-hook-formのControllerを使うことになります。



Controllerに関するDoc:
Controller | React Hook Form - Simple React forms validation



コードの説明に戻ります。
コンポーネントから必要な情報をpropsで受け取って、
それぞれのpropを渡しています。


   <Controller
      name={name}
      control={control}
      defaultValue={''}
      rules={rules}
      render={({ field: { onChange, value }, fieldState: { error } }) => (
        <TextField
          margin='normal'
          fullWidth
          variant='outlined'
          required={required}
          type={type}
          id={id}
          label={label}
          name={name}
          autoComplete={autoComplete}
          autoFocus={autoFocus}
          error={!!error}
          onChange={onChange}
          value={value}
          helperText={error ? error.message : null}
        />
      )}

nameは必須です。これがないと判別してくれません。
rules={rules}は、親コンポーネントで定義したバリデーションルール
control={control}react-hook-formにコンポーネントを登録するメソッド
defaultValue={''}で、初期レンダーの際に呼ばれる初期値を設定できます。
render={}


これはイベントと値をに渡してくれるrender propです。
この関数は、field、fieldState、formStateの3つを持っています。



fieldStateのerrorにはバリデーションがダメだった時のメッセージが格納されます。



バリデーションがダメだった時はのerrorでエラーの状態を切り替え、
helperTextにエラーメッセージを入れることでエラーを表示しています。


     error={!!error}
     helperText={error ? error.message : null}

コードの説明は以上となります。
お付き合いいただきありがとうございました!

Firestore Cloud firestoreの公式動画まとめ7

前回の記事の続きです。
今回はページネーションについてのお話になります!



www.youtube.com
※この記事で使われている全ての画像はこの動画の切り抜きになります



後に続く動画、



・8:トランザクションについて
・9:オフラインサポートについて
・10:リアルタイムについて



は自分が今作りたいアプリで必要ないものだと思うので
9~10の動画のまとめは一旦飛ばしたいと思います😥😥
また機会があればまとめていきます!!



では、ページネーションについてみていきましょう!


ページネーションとは?


データベースの結果を複数の小さな塊に分割して、
一度に全て取得しないようにする方法のことです。



例えば、DBの中に5万件の日本食レストランのドキュメントがあるとします。
ユーザーが世界中の日本食レストランを見たいと思っても、
5万件全てのレストランのドキュメントを画面に表示することはあまりないですよね。
一度に表示する分だけの情報をクライエントに送れば十分です。



ページネーションのやり方

limit()



limit(to:)を使うと、to:の後に指定した件数のみ取得することでできます。



使用例↓






start()



limit()は件数を指定して情報を取得する関数でしたが、
start()を使用すると、その後20件の情報を取得する、ということができます。



使用例↓





start(after: ["Tokyo", "Tempura" , 4.9])



afterパラメータに正しい順番で配列を渡すことで
Firestoreは次の20件のドキュメントを取得してきます。





この方法だとクエリを追跡しなければいけないのが大変なのと、
もし評価4.9レストランがたくさんある場合いくつかのレストランの取得を
スキップしてしまうかもしれません。



この対策として、Firestoreではafterパラメータにドキュメントを渡すことができます。






Firestoreはそのドキュメントを分析し、
どのような値を検索すべきかを正確に把握します。
もし、重複したドキュメントを見つけた場合は重複した
ドキュメントの次のドキュメントからデータを取得します。


ただしこれらのページネーションをする時は、
データが挿入、更新、削除されたりしていつも動いていることを意識しなければいけません。


start()を使わずに、ボタンクリックなどをトリガーに
limitの数を増やしてページネーションしていく方法もあります。





この方法はシンプルで、データが動いていることを意識する必要はありません。
ただし、この方法では前の20件や40件のデータも読み込むことになるのでその分料金が発生します。


offset()


ドキュメントの40行目から60行目を取得したい場合、
offset()のようなメソッドが使えるが、この場合60回分ドキュメント読み取りが発生するため
こちらのメソッドは推奨しないそうです。




ページネーションの仕方を決めるのはどれくらいリアルタイム性が必要かで決めるのがいいです。



冒頭でも説明した通り、次は動画11へ続きます。(いつか😥)

FirestoreのDB設計を考えてみる

FirestoreのDB設計の仕方、セキュリティルールなどを学習したので
実際に作りたいアプリのDB設計を考えていきます!


作りたいサイト


英語圏の人に向けて、日本語の俗語、
特にサブカルチャーから作られた俗語に焦点を当てた
辞書サイトを作りたいです。


サブカルチャーとは、マンガやアニメのことです。


日本語の俗語が見れるサイトは存在するのですが
サブカルチャーに焦点をあてたものはありませんでした。

作ろうと思った背景


WoWというMMORPGゲームをプレイしていて、
たまたまゲーム内でぶらついてた時に招待された
トルコ系オーストラリア人達のギルドに所属しているのですが
何故か日本のサブカルチャー好きな人が多く
よく「草ってどういう意味?」とか聞かれるので🤣
彼らの助けになればいいなと思い作成することを決めました。


それと、サイトを作成するために調査を行っていたところ、
とある調査では半数以上の日本語学習者が日本語を学習しようと思ったきっかけは
サブカルチャーへの興味」と答えた、ということを知り、
そういう方の為にもなればなーと思っています。

フェーズ


最初はとりあえず検索フォームから単語を探せたり、
カテゴリー別に単語をAtoZで表示するといったシンプルな機能をつけたいです。


単語が増えてきたら、管理画面を作り、
そこからCRUD操作を行ったり、管理者を増やしたりなどの機能を増やすことも考えています。


サイトを表示するのに必要な情報の洗い出し


・単語名(日本語)
・単語のふりがな
・単語の読み方(ローマ字)
 →AtoZの並び替えはこの読み方を参照するのがよさそう?
・単語の意味(英語)
・単語を説明するのに必要であれば画像(1枚)
・単語を使った例文
・例文の読み方(ローマ字)
・カテゴリー


現状必要な情報といえばこれくらいです。



今のところ画像はFirestorageに保存して
パスをFirestoreに保存して参照する方法で考えています。
AWSのS3の方が慣れてますが折角なのでFirestorage使いたいと思います🙂



今後の拡張を考えて



・単語を登録した日
 →新着順に並べたくなった時に必要
・単語を登録した人のID


管理者画面を作った時のために事前にユーザー情報もいれたいです。


ユーザー情報としては、
・名前
・email
・パスワード
・役職


あたりが必要な情報かなと思います。

必要な情報を元にDB構造を考える

単語の前方一致検索を導入したいと考えているのでそれも念頭に置いておかねばなりません。



単語コレクションとユーザーコレクションを作る



こんな感じです。


words(トップレベルコレクション): {
  wordドキュメント 1: {  
    name: "草",
    kana: "くさ",
    nameInEng: "kusa",
    meaning: "Something funny. You can use it just like lol",
    imgPath: "/images/kusa.jpg",
    Example: "マジで草",
    ExampleInEng: ”maji de kusa”,
 category: ["General"],
    authorRef: /user/user1,
    createdTime: 2022-08-27~,
  },
  wordドキュメント 2: {
   name: "推し",
   kana: "おし",
   nameInEng: "oshi",
   meaning: "Someone you love. Usually used for anime characters, Vtubers, and idols",
   imgPath: "/images/oshi.jpg",
   Example: "これが私の推しです",
   ExampleInEng: "kore ga watashi no oshi desu",
   category: ["Anime", "Vtuber", "Idle"],
   authorRef: /user/user/1,
   createdTime: 2022-08-27~,
  },
  ...
}

users(トップレベルコレクション): {
  userドキュメント 1: {  
    name: "bluh bluh",
    email: "bluhbluh@bluhbluh.com",
    password: "***********",
    role: "Owner",
  },
  ...
}


考えられるメリット:
・サイトの表示に必要なデータは全てwordsコレクションに入っているのでread/writeが簡単
・読み取り回数が少ないので、もし利用者が増えて無料枠を出た時も安心。



考えられるデメリット:
・カテゴリー別に単語をatoz、リスト形式で表示する画面で
「単語名、単語のふりがな、単語の読み方」のみが必要なところを
不要な「単語の意味や例文、画像パス」などの情報も読み込んでしまう


正直、小規模なサイトですし、単語の数が数万にも及ぶなんてことも
考えられないのでそこまでのデメリットではないかなーと思います。


もしこの単語の情報をサブコレクションに保存するとなると、
不要な情報を読み取らなくて済む分、読み取り回数が増えるというデメリットが発生します。


カテゴリーコレクションを作ることも考えたのですが、
管理者画面から単語を登録できるようにしたときのための
選択フォームに表示する用くらいしか用途が思いつかないのでこちらは後から作っても問題なさそうです。



とりあえずはこれで作っていってみようと思います!🙂

Firestore Cloud firestoreの公式動画まとめ6

前回の記事の続きです。
今回はセキュリティルールについてのお話です!!



www.youtube.com
※この記事で使われている全ての画像はこの動画内から切り抜いたものです。


セキュリティルールについて

もし、あなたがクライエントにDBへ接続するための
アクセス制限のないAPIキーをあげていたとしたら
悪意のあるユーザーは簡単にあなたのDBへ攻撃することができます。



セキュリティルールを使用すると、データベース内のドキュメント
およびコレクションへのアクセスを制御できます。
Firestoreではこのセキュリティルールの設定が重要となります。


クライエントとセキュリティ

クライエントからのリクエストがいつも正しいと信じるべきではない。



あなたがとても良くバリデーションをしたクライエントサイドのコードを
書いていて、テストを行っていたとしてもクライエントは
簡単にクライエントリクエストを変えたり虚偽のリクエストを送ることができます。



データを守るためには、クライエントが正しい行動を
とらないことを前提として考える必要があります。



セキュリティルールはどのように動くのか


Firestoreに送られてくるリクエストは、CRUDのいずれかの操作を行うか、
コレクションから大量のドキュメントを取得しようとしているリクエストです。
リクエストが送られてきたとき、
Firestoreは、指定されたドキュメントに適用される一連のセキュリティルールを探します。



そして、開発者が設定したルールに従ってテストを行い、
このリクエストを許可するかどうかを判断します。


セキュリティールール設定方法

すべてのドキュメントは、データベースのパスと、ドキュメントへの実際のパスの下にあります。


例えば、restaurants/hotdogというドキュメントがあった場合、
そのドキュメントまでのパスは/databases//documents/restaurants/hotdogです。



基本的な読み取り・書き込みルール


以下はデフォルトのセキュリティルールです。



service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
パスのネスト

Firestoreでは階層になったデータを扱うことになります。
その度に、フルパスを書いていては疲れるしエラーの原因にもなるかもしれません。



そこでFirestoreではパスをネストさせることが可能です。


service cloud.firestore {
  match /databases/{database}/documents {
      match /restaurants/{restaurantID}{
      }
      match /restaurants/{restaurantID}/reviews/{reviewID} {
      }  
   }
}

コレクションのみを指定したい場合


service cloud.firestore {
  match /databases/{database}/documents {
      match /restaurants/ {
      // ワイルドカードなしでコレクションのみを指定したこれでも動くが、、
      }  
      match /restaurants/{restaurantID} {
      // こっちを使うほうがいい
      }
   }
}
ワイルドカード

ワイルドカードは2種類あります。


{}で囲むワイルドカードは、
単一のパス要素(例えば、ドキュメントやコレクション)にマッチします。





{=**}で囲むワイルドカードはパスの残りをマッチしてこの変数に格納します。



下の画像でいうと、usersコレクション以下の
サブコレクション・ドキュメント全て指定ということになります。






{=**}を使って「users」コレクション全てにreadを許可した場合、
後から「users」コレクションのサブコレクション「privateData」のドキュメントに
アクセス権を指定しようとしてもきちんと動かないことに注意が必要です。




また、以下の5種類のアクションごとにBoolean値を提供することで
送られてきたリクエストを受け入れるかどうかを設定することもできます。



get→ユーザーが特定のドキュメントを取得するリクエストを送ったとき
list→ユーザーがドキュメントを返すクエリを実行した時
create→新しいドキュメントを作成
delete→ドキュメントを削除
update→ 既に存在するドキュメントを更新



getとlistは一つのアクション、readとして分類されます。
createとupdate, deleteはwriteとして分類されます。



allow read: if true;またはallow read;で誰でもreadができるようになります。






allow write: if false;でwriteを拒否できます。



実際には、ログインしているユーザーならreadを許可する、ユーザーIDが〇〇ならwriteを許可する、
というような条件と一緒に使われることが多いです。
よって、基本的には以下のデータを元に条件を作成していくことになります。



1. Request data



これはリクエストオブジェクトと呼ばれます。
このリクエストオブジェクトには主に以下の2種類があります。





authオブジェクト→Firebase Authなどでログインしたユーザーの情報が含まれている



以下はログイン済みのユーザーなら読み込みを許可する、
というセキュリティルールです。





request.resourceオブジェクト→ユーザーがwriteしようとしている
全てのフィールドのドキュメントの情報が含まれている。
このドキュメントのデータに基づいて動的にリクエストを許可・拒否することができます。



このオブジェクトは、いくつかのメタデータフィールドを持っていますが、
実際のコンテンツのほとんどはrequest.resource.dataプロパティにあります。



例えば、scoreフィールドにアクセスしたい場合、
以下のようにすることでアクセスができます。


request.resource.data.score
request.resource.data[“score”]

Firebaseはスキーマレスですが、セキュリティルールを使用して以下のように、
DBに保存するデータの型や数値に制限をかけることもできます。







2. Resource Object



request.resourceオブジェクトに似ていますが、request.resourceは
「書き込もうとするドキュメントを表す」のに対して、
Resourceオブジェクトはデータベースに「既に存在するドキュメント、
つまり読み込もうとしているドキュメントや更新しようとしているドキュメントを表す」ことです。



他のドキュメント (データベース内の任意のドキュメント) へのアクセス

get() 関数と exists() 関数を使用すると、
受信したリクエストをデータベース内の他のドキュメントと比較して評価することができます。




get() 関数→リソースオブジェクトに似たオブジェクトを返す
exist() 関数→特定のものが存在するかどうかをチェックする




どういうときに使うか?




一番よくあるユースケースは、特定のドキュメントにアクセス
できるユーザーのリストを保存したい場合です。
そして、このセキュリティルールを使って、後でそのリストを評価することができます。



例えば、レストランの情報が入ったドキュメントを編集できるのは、
プライベートなドキュメントに「編集者」として登録されている人のみ、
というルールを設定したいとします。



このような感じです。






その方法は以下の通りです。


service cloud.firstore {
 match /database/{database}/documents {
   match /restaurants/{restaurantID} {
    allow update: If get(/database/$(database)/documents/restaurants/$(restaurantID)/private_data/private).data.roles[request.auth.uid] == "editor"
  }

ワイルドカードでレストランIDを指定しています。
get関数がrestaurantIDという変数を使うことを認識できるように、変数に${}を付けなければならないことに注意が必要です。
${}を使わない場合、get関数はレストランIDを文字列だと判断してしまいます。



get関数()を使いget(/database/$(database)/documents/restaurants/$(restaurantID)/private_data/private)
プライベートデータを取得しています。
そして、その取得したプライベートデータのrolesフィールド内で、リクエストで送られてきたユーザーIDがeditorという値を持っているかチェックしています。



「オーナーと編集者」がレストランの情報を編集できる、という設定をしたい場合このように書きます。

roles[request.auth.uid] in ["editor", "owener"]

また、アプリの管理者など全ての情報を編集できるグローバルなアクセス権を持つ人がいる場合、
カスタムクレームを使うといいです。




カスタムクレームは、サーバーサイドライブラリや
クラウドファンクションを使って特定のユーザーに対して設定するカスタム変数です。




カスタムクレームを使うと
このように、管理者ならどこでも書き込みができる、という設定ができます。







カスタムクレームについての公式Doc:
Control Access with Custom Claims and Security Rules  |  Firebase Authentication


カスタム関数

セキュリティルールは、システムが大きくなったり複雑になったりすると、少しごちゃごちゃして冗長になり始めます。
セキュリティルールはこの問題を解決するための良い方法を持っています。



それは、カスタム関数を書くことです。
ほとんどの場合、これらの関数は1つの戻り値のステートメントを持つだけの関数です。
しかし、冗長なコードを避けるためと、セキュリティルールに実際に何をさせたいかを明確にするために、非常に役に立ちます。
例えば、ユーザーが Google のアカウントを持っているかどうかをチェックするカスタム関数を次のように書くことができます。






セキュリティルールは非常に強力で、カスタムルール関数を使用すれば、
かなり高度で複雑なルールセットを追加することができます。


このようなコードを





関数名に役割を持たせ何をするコードなのか一目でみてわかるようにすることができます。





もっと可読性を良くするために、さらに細かくわけることももちろん可能です。







カスタム関数の欠点は?


特に本番環境では、リクエストが拒否された場合、その理由についての説明が一切ありません。
何が原因で拒否されているのかがわからないのはフラストレーションが溜まります。



この問題を解決するために、Firestoreにはルールシミュレータが用意されており、
セキュリティルールに対するあらゆる操作をテストすることができるようになっています。
コンソールは、これらの呼び出しが成功したとき、どのルールがそれを許可したか、
または失敗したとき、どのルールがそれをブロックしたかを教えてくれます。


ルールはフィルターではない



データを保護し、クエリを書き始めたら、セキュリティルールはフィルタではないことに留意してください。
コレクション内のすべてのドキュメントに対するクエリを書いても、
現在のクライエントがアクセスする権限を持っているドキュメントだけをFirestoreが返してくれるとは限りません。


セキュリティルールは、各クエリをその結果に対して評価し、
クライアントが読む権限を持っていないドキュメントを返す可能性がある場合、
そのリクエストを失敗させます。クエリは、セキュリティルールで設定された制約に従わなければなりません。



7に続きます。

Firestore Cloud firestoreの公式動画まとめ5

前回の続きです!
前回の動画ではデータ構造の心構えや、Map、Array、サブコレクションの違いを見ていきました。
今回は「どのようにデータを構造すればよいか」についてのお話です。


www.youtube.com
※この記事で使われている全ての画像はこの動画内から切り抜いたものです。


また、レストランのレビューアプリを想定してお話していきます。



そのレストランレビューアプリでは検索ページや、
ボストンにあるTOP20の日本食レストランが検索できます。


そして、検索結果をクリックするとレストランの詳細が見れます。


レストランの詳細ページでは、そのレストランの情報や最新のレビュー数件が見れます。
そして、レビューをクリックするとレビューの詳細を見ることができ、他のレビューをもっと見るボタンを押すと全てのレビューが見れます。



こんな感じです。




データをどう管理するのがいいか?

今回の例ではレビューデータをどこに保管するべきかに焦点を当てています。
いくつかの方法を見ていきましょう。


方法1.レビューデータをマップや配列で管理する

レストランコレクションドキュメントの中にマップや配列を使ってレビューを保管する。





この方法では、ユーザーがレストランの詳細ページをクリックした時点で
全てのドキュメントのデータが読み込まれるのですぐにレビューデータを渡すことができます。
そして、サブコレクションに分けていないので読み取り回数も増えません。



この方法の問題は、レストランのドキュメントにレビューをマップで保存すると
もしユーザーがTOP20の日本食レストランを探したいときに、レストランの情報だけではなく
不必要なレビューの情報も同時に取得することになることです。



そして、もしレストランのレビューが増えすぎて、
1メガを超えたor2000フィールドのリミットに達した時に困ります。


さらに、最新10件のレビューを取得したいときにクエリでフィルターをかけることが
できないのでクライエントで処理する必要があります。


以上の問題点から、この方法はあまり良い案ではないかもしれません。

方法2.レビューデータをコレクションとして管理する

レストランコレクションと同じtop-levelのコレクションとしてレビューを保管する。





この方法では、レビューのデータをルートコレクションに置くことで「特定のレストランのレビューの取得」だけではなく、
「特定のユーザーが書いたレビュー一覧を取得する」みたいなことも簡単にできます。
そしてどれだけデータ量があったとしてもクエリが高速に動いて取得してくれます。


この方法の問題は、レビューを最新順に並べて取得したり、レビューの評価順に並べたいときに
複合クエリが必要になることです。





もしサブコレクションにそれぞれのレストランに対するレビューを保存していれば、複合クエリは必要ありません。



方法3.レビューデータをサブコレクションとして管理する



この方法の問題は、読み取り回数が増えることです。
これを回避するための案として、レストラン詳細ページでよく読み込まれるであろう
「最新10件のレビューの一部」などをレストランドキュメントに保存するのもよさそうです。


もしユーザーがあるレビューを詳しくみたい、全文を読みたいと思い
そのレビューをクリックしたときには、レビュードキュメントを読み込めばいいだけです。



このようなユースケースやアプリのコアバリューも考慮しながら、
どのような DB 設計をするか、Cloud Functions をどう使うかなどを、
開発者である皆さん自身が考えて決めていってください。



真偽値の保管方法

例えば、レストランが予約可能か?団体での飲利用は可能か?
などの真偽値はどのように保管すべきかを見ていきましょう。




ドキュメントにその値を直接保存する






Mapフィールドに項目名と真偽値のkey:value;ペアとして保存する





ドキュメントの真偽値フィールド(attributes)に配列として保存する





ただし、複数のarray_containsでクエリを発行できないので、
「子どもも楽しめて、予約 OK のレストランを探す」といった必要があるなら、フィールドを分ける方がいいです。


ユーザーのアクセス権限を保管する


例えば、レストランレビューアプリに「編集者」がいて、
編集者はレストランのドキュメントを編集できるとします。


このような場合には、「誰にその編集の権限を与えているか」を
userIDなどで管理・保存する必要があります。





セキュリティルールの詳しい説明は次の動画でしていきますが、この動画で知ってほしいのは
セキュリティルールによって、特定のドキュメントにアクセスできるユーザーを制御できるということです。



このように編集できる人をeditorsという配列にユーザーIDをいれて管理することができます。




もっといい例としては、マップを用いることです。
rolesというマップに、ユーザーID: ”役職”という形でデータを保存することで





役職が○○の時にのみ編集を許可する、といったセキュリティールールをつけることが可能です。





この良い例にも少し問題がありまして、Firestoreは情報の一部だけを読み取ることができないので
「どのユーザー ID がどのような権限をもっているのか」を検証するたびに全てのユーザーIDと役職を読み込んでしまうことです。



ユーザーIDはかなり不透明で、これをリバースエンジニアリングしても本当のユーザーが誰なのか、
解析することはできないですし、ユーザーIDはアプリごとにユニークですが
なんだかユーザー情報が漏洩しそうな気がしてセキュリティ面で不安が残ります。



そんなときは次の2通りのことをするといいです。


方法1.

編集権限をもつユーザーを、レストランドキュメントのサブコレクションの中に、
ドキュメントIDをそれぞれのユーザーIDに一致させて保存し、そのドキュメントの中にそのユーザーがもつ権限を記述しておく





方法2.

「プライベートデータ」とでも名付けたサブコレクションを作っておいて、
ここに「誰にどの権限があるか」という情報を持っておき、
このデータはパブリックにしないようにします。
このようなパブリックには公開しないコレクションは、
社内の営業チームだけが参照するような想定のデータを保存したり、
もしくは Cloud Functions がトリガーとして各種の API を実行するための
データとして使用するような場面で役に立ちます。




お気に入り機能

最後に「ユーザーがお気に入りのレストランを保存しておく機能はどうするか?」
という例をみていきましょう。



この場合、多対多のリレーションでありFirestore のような
NoSQLのDBでは度々問題になることがあります。



ひとつのアイディアとして、ユーザードキュメントの中に配列として
お気に入りのレストランを保存するとします。








この方法は、お気に入りのレストランを登録/外すといった操作が簡単に行えます。
アプリを起動するときに、このお気に入りリストに保存されたレストランIDの
一覧をメモリに保存しておけば、
レストラン一覧の中からレストラン ID が一致するものに、
ハートマークやスターマークを付けて、お気に入りのレストランを区別することもできそうです。



しかし、この方法だと、お気に入りのレストラン一覧をクエリで表示したいときに
お気に入りリストに保存されたレストランIDの一つ一つに対して、
別々にレストラン情報を読み込むクエリを発行する必要があり大変です。



お気に入りリストを頻繁に呼び出すわけではないならこの方法もありでしょう。



もし、お気に入りリストを頻繁に呼び出すなら「非正規化データ」を使うといいです。



つまり、単にお気に入りのレストランIDをユーザードキュメントに保存するだけではなく、
「お気に入りのレストラン一覧」を表示するために必要なデータを全てマップの中にいれてしまうということです。






そして、もしユーザーがレストランの情報をもっと詳細に見たいときには
そのレストランIDに対してクエリを発行する、という流れです。



この方法で注意しなければいけないことは、
もし以前お気に入り登録していたレストランが違うレストランになってしまった場合、
そのレストランをお気に入り登録していた全てのユーザーに対して
新しいレストランの情報を上書き保存しなければいけないことです。



その場合、こういったクエリを発行して全てのユーザーを抽出します。


全てのユーザーの中から、お気に入りフィールドID: rest_4215の
レストランデータの値が空文字 "" より大きいというクエリ





いろいろ説明してきましたが、もっとも良い例としては、
「favoriteRestaurans コレクションを作り、全ユーザーのお気に入りのレストランをそこに保存していく」
という方法です。




特定のユーザーがお気に入りにしたレストランを取得するクエリ

collection("favoriteRestaurans").where("user_id", "==", "user-1")


レストランで絞り込むクエリ

collection("favoriteRestaurans").where("rest_id", "==", "rest-1")

このようにクエリがとても簡単です。
また、レストラン情報が変更された時も、上のようなクエリを発行して抽出された
ドキュメントに対して、簡単に一括更新ができそうです。




6に続きます。

Firestore Cloud firestoreの公式動画まとめ4

前回の記事の続きです。
今回はマップ、配列、サブコレクションについてのお話です。
早速みていきましょう!!


www.youtube.com
※この記事で使われている全ての画像はこの動画内から切り抜いたものです。


はじめに

マップ、配列、サブコレクションの違いについて知る前に、
Firestoreでベストなデータ構造を決定するための、データ構造の心構えを知っていきましょう。




心構え1.ドキュメントにはリミットがある




ドキュメントにはどのくらいデータを入れれるかのリミットがあります。




・1ドキュメント1メガまで
これはハイクオリティの画像データを保存するには少ないが、文字列や数値を保存するには十分な数値です。
・2万個以上のフィールドを持つことはできない
マップ内のフィールドも1つのフィールドとしてカウントされます。
以下はフィールド数7とカウントされます。






この制限の理由はFirestoreがドキュメントのすべてのフィールド(マップ含む)にインデックスを作成するため




・同一のドキュメントに対しては、通常1秒間に1回の書き込みに制限される
・多くのクライアントが同じドキュメントに一斉に書き込みをしようとすると、書き込みに失敗することがある(コレクション内の異なるドキュメントに書き込むことは問題ない)




心構え2.ドキュメントを部分的に取得することはできない




ドキュメントには莫大なデータを置くことができるが、おすすめはしない。
なぜなら、Firestoreがドキュメントを取得する時、全てのデータも取得するからです。
そして、部分的に文字を取り出すといったことはできません。




読み取る必要がない巨大なフィールドを格納していると、
不要な読み取りが発生し、モバイルのバッテリー容量の消費やパフォーマンスの低下につながります。
また、ドキュメントの一部にのみセキュリティルールを適用することはできません。




心構え3.クエリは浅い




・ドキュメントを取得するとき、サブコレクションの中のデータは取得されない


dickens_booksのタイトルを取得したい場合、サブコレクションであるchaptersは取得されません。





心構え4.読み書きの回数によって料金が発生する



もしドキュメントの中のサブコレクションをわけた場合、
サブコレクションの中のドキュメントが欲しいなら読み取り回数が増えることになる







心構え5.配列が変



配列はFirestoreで時に上手く機能しません。

// 下記の配列をFirebaseに送ったら
['a', 'b', 'c', 'd', 'e']
// Firebaseは下記のようにデータを保存します
{0: 'a', 1: 'b', 2: 'c', 3: 'd', 4: 'e'}

// keyが一連の数字であるため
// クエリを行うと下記のデータが返ってきます
['a', 'b', 'c', 'd', 'e']

// しかしa, b, dを削除したら
// 配列のkeyが一連の数字ではなくなります
// この為Firebaseは下記を返し、配列を返さなくなります
{2: 'c', 4: 'e'}


もっと詳しい説明はこちらから:
Best Practices: Arrays in Firebase




そして、たくさんのクライエントが同じ配列の操作を行う場合、容易く混乱が起きます。




まずはマップの例を見ていきましょう。
このようにマップのフィールドを違う人がそれぞれ更新したり削除したりするのは問題なく行えます。





しかし、配列を違う人がそれぞれ更新したり削除したりすると予期せぬデータが返ってきたり
エラーになったりします。これらの命令をどの順番で受け取るかによって、結果は大きく異なります。





このようにkeyを指定して値を変更したり削除したりすることもできません。





こうなってくると、配列が必要ないかのように見えますがそうではありません。




以下の本のDBの例を見てみましょう。
キーワードがdramaになっている本を探したいとします。






Firestoreの中ではこの配列をこのようなマップに置換しています。





マップにはインデックがつくので、keywords.drama == trueでdramaというキーワードの本が探せる、という仕組みです。


結局どうするのがいいの?


・大きくしすぎると不要な読み取りが発生し、小さくサブコレクションでわけすぎると読み取り回数が増えるので、ちょうどいいサイズを目指すこと。


・もしいつも取得したいデータがある場合は
データをトップレベルのドキュメントに全て保存するといい。


・データをサブコレクションに入れるときは
それぞれのデータを検索したいとき、またはデータ(key:value;)が今後肥大しそうな時にするといい。


・コレクションの中のデータをベースに検索したいならマップを使うといい。


・マップはデータを整理整頓をしたい時にも使うといい。
マップにすることで今後命名が競合するリスクも防げる。


・flag管理するためには配列を使うといい。







5へ続きます!