LoginSignup
30
39

More than 5 years have passed since last update.

【実践】SPA+Firebaseで爆速で作るウェブアプリケーション開発

Last updated at Posted at 2018-12-20

備忘録も兼ねて最速で環境構築して開発してデプロイ
Homebrew, yarn, nodebrew, parcelなどの導入は略

構成

モダンなJS環境の構築を最速でやるにはparcelを使う。
webpackのような煩雑な設定が不要、細かい設定はwebpackでないとできないこともあるが
大体の場合、裏側で上手いこといい感じにbabelのES2015/2017トランスパイルもしてくれる。

GitHub環境構築テンプレート

・Parcel
・SPA(React、Vue)
・Firebase

多分これが一番はやいと思います。

Parcelのリソースパスの指定

画像などのアセットファイルパスはビルド時にファイル名が変換されてしまうため、require指定でパスを指定する必要があります。

<img src={require('../img/logo.png')} width={100} height={100} />

eslint

文法チェック、最近はないと逆に開発速度が落ちる。
未使用の変数とか、非推奨な文法とかのチェックもできる。
VSCodeの場合、エラーは赤字、warningは緑字の波線で表示されて可視化されるので把握しやすい。
また、設定のeslint.autoFixOnSaveをtrueにしておくとファイル保存時にeslint --fixを走らせることができて便利。

settings.json
{
    "eslint.autoFixOnSave": true
}

eslint系のプラグインの導入

$ yarn add --dev eslint babel-eslint eslint-loader eslint-plugin-react

個人的によく使うlint設定(.eslintrc.js)

eslintrc.js
module.exports = {
  'parser': 'babel-eslint',
  'env': {
    'browser': true, // ブラウザ
    'es6': true, // ES6
    'node': true, // NodeJS
  },
  // reactプラグイン
  'extends': ['eslint:recommended', 'plugin:react/recommended'],
  'parserOptions': {
    'ecmaFeatures': {
      'experimentalObjectRestSpread': true,
      'jsx': true, // JSX文法有効
      'legacyDecorators': true
    },
    'sourceType': 'module'
  },
  'settings': { 
    'react': { 'version' : '16.6.3' }
  },
  // reactプラグイン使用
  'plugins': [
    'react'
  ],
  'globals': {
  },
  'rules': {
    // インデントルール
    'indent': [
      'error',
      2,
      { 'SwitchCase': 1 }
    ],
    // 改行コード
    'linebreak-style': [
      'error',
      'unix'
    ],
    // シングルクォートチェック
    'quotes': [
      'error',
      'single'
    ],
    // 末尾セミコロンチェック
    'semi': [
      'error',
      'never'
    ],
    // マルチライン末尾コンマ必須
    'comma-dangle': [
      'error',
      'always-multiline'
    ],
    // 末尾スペースチェック
    'no-trailing-spaces': [
      'error'
    ],
    // 単語間スペースチェック
    'keyword-spacing': [
      'error',
      { 'before': true, 'after': true }
    ],
    // オブジェクトコロンのスペースチェック
    'key-spacing': [
      'error',
      { 'mode': 'minimum' }
    ],
    // コンマ後スペースチェック
    'comma-spacing': [
      'error',
      { 'before': false, 'after': true }
    ],
    // ブロック前スペースチェック
    'space-before-blocks': [
      'error'
    ],
    // アロー関数スペースチェック
    'arrow-spacing': [
      'error',
      { "before": true, "after": true }
    ],
    // 括弧内のスペースチェック
    'space-in-parens': [
      'error',
      'never'
    ],
    // オブジェクトのdot記法強制
    'dot-notation': [
      'error'
    ],
    // ブロックを不要に改行しない
    'brace-style': [
      'error',
      '1tbs'
    ],
    // elseでreturnさせない
    'no-else-return': [
      'error'
    ],
    // 未使用変数チェック
    'no-unused-vars': [
      'warn',
      { 'ignoreRestSiblings': true }
    ],
    'no-console': 'off',
    // reactのprop-typesチェックをしない
    'react/prop-types': 'off',
    // reactのコンポーネント名チェックをしない
    'react/display-name': 'off',
    // stateless functional componentを優先させる
    'react/prefer-stateless-function': [
      2,
      { 'ignorePureComponents': true }, // PureComponentsは除く
    ],
    // 静的クラスのプロパティとライフサイクルメソッドを宣言する際に、大文字と小文字の区別がないようにする
    'react/no-typos': 'error',
    // 未使用propsはエラー
    'react/no-unused-prop-types': 'error',
    // 未使用stateはエラー
    'react/no-unused-state': 'error',
    // 中身が空のタグはself closingをさせる
    'react/self-closing-comp': 'error',
  }
}

SPA

個人的にReactが好きなのでReactを使う。(別にvueとかでも良い)

$ yarn add --dev react react-dom react-hot-loader

bebelトランスパイルプラグインで追加の文法を変換

$ yarn add --dev @babel/core @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators @babel/plugin-syntax-dynamic-import @babel/preset-env @babel/preset-react

.babelrcにプラグイン追加

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }], 
    ["@babel/plugin-proposal-class-properties", { "loose": true }], 
    ["@babel/plugin-proposal-pipeline-operator", { "proposal": "minimal" }],
    "@babel/plugin-proposal-optional-chaining",
    "@babel/plugin-syntax-dynamic-import",
  ]
}

アプリケーション状態管理

Redux、React-Redux、非同期処理

$ yarn add --dev redux react-redux redux-thunk axios

ページ遷移

react-router系

$ yarn add --dev react-router react-router-dom connected-react-router

UI&フォーム

マテリアルUIのライブラリ導入(MUI)

$ yarn add --dev @material-ui/core @material-ui/icons

redux-formでページのフォーム入力情報をreduxで管理

$ yarn add --dev redux-form

バックエンドとインフラ

Firebaseに必要最低限なものが全部揃っている
カスタムドメインの設定も可能

  • Authentication: 認証
  • Database(RealTime Database, Cloud Firestore): データベース
  • Storage: ファイルストレージ
  • Function: Rest API
  • Hosting: publicフォルダ(SPA)

ドキュメント:
Firebase のガイド
Firebase API Reference
Cloud Functions for Firebase Sample Library

firebase-tools導入後、アカウントのアクティベートのため、ローカルからログインする

$ firebase login

Cloud Functionsのローカルエミュレータのダウンロード
Node v8に対応したいのでオプションでエラー回避

$ yarn global add @google-cloud/functions-emulator --ignore-engines

開発用と本番用でDBを2つ以上作りたくなるが、1つのプロジェクトで複数DBは課金が必要・・・

StorageのCORS設定ファイルをアップロードするために必要(ファイルダウンロードの実装に必要になる)
gsutil をインストールする

$ curl https://sdk.cloud.google.com | bash
$ source ~/.bash_profile
$ gcloud init

実践

プロジェクトをtemplateフォルダに作成すると仮定する

$ mkdir template && cd test
$ touch README.md

package.jsonの作成

$ yarn init -y

parcel導入、eslint導入、React導入、Redux導入、React Router導入
babel系文法プラグイン導入

$ yarn add --dev parcel-bundler eslint babel-eslint eslint-loader eslint-plugin-react react react-dom react-hot-loader @babel/core @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators @babel/plugin-syntax-dynamic-import @babel/preset-env @babel/preset-react redux react-redux react-router react-router-dom connected-react-router @material-ui/core @material-ui/icons redux-form

Firebase導入

$ yarn add firebase

scriptsに
parcelの起動コマンドの"start": "parcel src/index.html -d public"
を追加

package.jsonはこんな感じ

package.json
{
  "name": "test",
  "version": "1.0.0",
  "main": "index.js",
  "license": "UNLICENSED",
  "scripts": {
    "start": "parcel src/index.html -d public",
  },
  "devDependencies": {
    "@babel/core": "^7.2.2",
    "@babel/plugin-proposal-class-properties": "^7.2.1",
    "@babel/plugin-proposal-decorators": "^7.2.2",
    "@babel/plugin-proposal-optional-chaining": "^7.2.0",
    "@babel/plugin-proposal-pipeline-operator": "^7.2.0",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/preset-env": "^7.2.0",
    "@babel/preset-react": "^7.0.0",
    "@material-ui/core": "^3.6.2",
    "@material-ui/icons": "^3.0.1",
    "axios": "^0.18.0",
    "babel-eslint": "^10.0.1",
    "connected-react-router": "^6.0.0",
    "eslint": "^5.10.0",
    "eslint-loader": "^2.1.1",
    "eslint-plugin-react": "^7.11.1",
    "parcel-bundler": "^1.10.3",
    "react": "^16.6.3",
    "react-dom": "^16.6.3",
    "react-hot-loader": "^4.6.0",
    "react-redux": "^6.0.0",
    "react-router": "^4.3.1",
    "react-router-dom": "^4.3.1",
    "redux": "^4.0.1",
    "redux-form": "^8.0.4",
    "redux-thunk": "^2.3.0"
  },
  "dependencies": {
    "firebase": "^5.7.0"
  }
}

プロジェクトのフォルダ構成

├── .babelrc
├── .eslintrc
├── .gitignore
├── README.md
├── package.json
├── public
├── src
│   ├── index.html
│   └── js
│       └── index.js
└── yarn.lock

index.htmlテンプレ

index.html
<!DOCTYPE html>
<html>
 <head>
  <meta charset="UTF-8">
  <title>テンプレート</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 </head>
 <body>
  <div id="root"></div>
  <script src="js/index.js"></script>
 </body>
</html>

index.js、とりあえず最低限のセットアップ
・Reduxストアの作成
・ページ遷移
・MUIの初期化
・React Hot Loaderの設定

index.js
/*globals module: false process: false */
import React  from 'react'
import ReactDOM from 'react-dom'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import { Provider } from 'react-redux'
import createHistory from 'history/createBrowserHistory'
import thunk from 'redux-thunk'
import axios from 'axios'
import { reducer as formReducer } from 'redux-form'
import { Route, Switch } from 'react-router-dom'
import { ConnectedRouter, connectRouter, routerMiddleware } from 'connected-react-router'
import { MuiThemeProvider } from '@material-ui/core/styles'
import { createMuiTheme } from '@material-ui/core/styles'

// mui theme
const theme = createMuiTheme({
  typography: {
    useNextVariants: true,
  }}
)

const client = axios.create()

// redux-devtoolの設定
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
// ブラウザ履歴保存用のストレージを作成
const history = createHistory()
// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)

// reducer
const reducer = combineReducers({
  router: connectRouter(history),
  form: formReducer,
})

// redux-thunkをミドルウェアに適用、historyをミドルウェアに追加
const store = createStore(connectRouter(history)(reducer), {}, composeEnhancers(applyMiddleware(routerMiddleware(history), thunkWithClient)))

const TopPage = () => <div>Hello World!!</div>
const NotFound = () => <div>Not found</div>


const App = ({history}) => (
  <ConnectedRouter history={history}>
    <Switch>
      <Route exact path='/' component={TopPage} />
      <Route component={NotFound} />
    </Switch>
  </ConnectedRouter>
)

ReactDOM.render(
  <MuiThemeProvider theme={theme}>
    <Provider store={store}>
      <App history={history} />
    </Provider>
  </MuiThemeProvider>,
  document.getElementById('root'),
)

firebaseのプロジェクトを作成する

$ firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /Users/daikiterai/Desktop/private/test

? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, the
n Enter to confirm your choices. (Press <space> to select)
❯◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules

使いたいサービスにスペースキーでチェックを入れる
・Database:RealTime Database
・Functions:API
・Hosting:SPAホスティング
・Storage:ファイルサービス
にチェックを入れる

以下のfirebase関連のファイルが作成されます。

  • functionsフォルダ
  • database.rules.json
  • storage.rules
  • firebase.json

async/awaitを使いたいので
functions/package.jsonにNode v8の設定を追加(デフォルトだとNode v6になっている)

functions/package.json
  "engines": { "node": "8" },

functions/package.jsonにexpress関連のパッケージを追加

$ yarn add express cors

functions/package.jsonはこんな感じ

functions/package.json
{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "serve": "firebase serve --only functions",
    "shell": "firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.16.4",
    "firebase-admin": "~6.0.0",
    "firebase-functions": "^2.1.0"
  },
  "engines": {
    "node": "8"
  },
  "private": true
}

バックエンドの処理をfunctions/index.jsに書きます。
expressでAPIのルーティングと認証をさせることができます。

functions/index.js
'use strict'

const functions = require('firebase-functions')
const admin = require('firebase-admin')
const serviceAccount = require('admin認証情報.json')
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: `https://${serviceAccount.project_id}.firebaseio.com/`,
  storageBucket: `${serviceAccount.project_id}.appspot.com`,
})
const database = admin.database()
const storage = admin.storage()
const bucket = storage.bucket()

const express = require('express')
const cors = require('cors')({origin: true})
const app = express()

// JWTトークンの検証を行う(認証)
const validate = async (req, res, next) => {

  if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
      !(req.cookies && req.cookies.__session)) {
    return res.status(403).send('Unauthorized')
  }

  let idToken
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    // ID Tokenをthe Authorization headerから取得
    idToken = req.headers.authorization.split('Bearer ')[1]
  } else {
    // 認証失敗
    return res.status(403).send('Unauthorized')
  }

  try {
    const decodedIdToken = await admin.auth().verifyIdToken(idToken)
    console.log('ID Token correctly decoded', decodedIdToken)
    // ID Token decodedをreq.userに格納
    req.user = decodedIdToken
    return next()
  } catch (error) {
    console.error('Error while verifying Firebase ID token:', error)
    return res.status(403).send('Unauthorized')
  }
}

app.use(cors)

async function ping(req, res) {
  res.send('ok')
}

async function info(req, res) {
  const id = req.user.uid
  const msg = req.body.msg
  if (!msg) return res.status(400).json()
  // pathで格納される
  await database.ref(`/blog/${id}`).set(msg)
  // path以下の子がすべて取れる
  const result = await database.ref(`/blog/${id}`).once('value').then((snapshot) => snapshot.val())
  res.json(result)
}

async function addFilePermission(req, res) {
  const path = req.body.path
  const fileRef = bucket.file(path)
  let result = await fileRef.getMetadata()
  console.log(result)
  const data = {
    metadata: {},
  }
  data.metadata[req.user.uid] = true
  result = await fileRef.setMetadata(data)
  console.log(result)

  res.json({message: 'success'})
}

// 認証なしAPI
app.get('/', ping)

app.use(
  '/user',
  express.Router()
    // ユーザ認証付きAPI
    // `Authorization` HTTP headerに`Bearer <Firebase ID Token>`が必須
    .post('/info', validate, info)
    .post('/permission', validate, addFilePermission)
)

// それ以外
app.all('*', (req, res) => {
  res.status(404).send('not found')
})

exports.api = functions.https.onRequest(app)

admin認証情報.jsonの払い出しはプロジェクトの設定→サービスアカウントより生成できます。
このjsonファイルの設定値はあとで説明するアクセスルール関係なしにDBにアクセスできるため、Gitなどのリポジトリにはアップロードしてはいけません
スクリーンショット 2018-12-21 0.52.39.png

DBのURLはDatabaseの画面から確認できます。
スクリーンショット 2018-12-21 0.51.46.png

StorageのURLはStorage画面から確認できます
スクリーンショット 2018-12-21 0.51.25.png

プロジェクトの設定→ウェブアプリにFirebaseを追加を開く
スクリーンショット 2018-12-19 9.12.38.png

configオブジェクトのプロジェクト情報はアプリ単位に違うものを設定します。
スクリーンショット 2018-12-19 9.12.47.png

以下、クライアントサイドではsrc/lib/firebase.jsにてfirebaseライブラリの初期化を行います。

src/lib/firebase.js
import firebase from 'firebase'

// プロジェクト別に違う
const config = {
  apiKey: '<API Key>',
  authDomain: '<アプリのドメイン>',
  databaseURL: '<データベースURL>',
  projectId: '<プロジェクトID>',
  storageBucket: '<StorageURL>',
  messagingSenderId: '<Push通知用ID>',
}
firebase.initializeApp(config)

Functionsで認証付きのAPIを作る

クライアントサイドでauthライブラリを使うことでログインをすることができます。
この際、払い出されるJWTトークンをバックエンド側に送信し、バックエンド側で正当なJWTトークンか検証することで認証付きAPIの実装を行うことができます。

src/lib/firebase.js
firebase.initializeApp(config)
const auth = firebase.auth()

// apiのエンドポイント
export const apiEndpoint = document.location.hostname === 'localhost' ?
  `http://localhost:5001/${config.projectId}/us-central1` :
  `https://us-central1-${config.projectId}.cloudfunctions.net`


export function signIn() {
  // Googleログイン
  auth.signInWithPopup(new firebase.auth.GoogleAuthProvider())
}

export function signOut() {
  auth.signOut()
}

export function authChange(callback) {
  auth.onAuthStateChanged((user) => {
    if (user) {
      // ログイン時にAuthorization Headerにtokenを付与
      firebase.auth().currentUser.getIdToken().then((token) => {
        callback({uid: user.uid, token})
      })
    } else {
      // ログアウト
      callback({uid: null, token: null})
    }
  })
}

ログイン方法はメジャーなものはauthライブラリ側で実装されています。
今回の例ではAuthentication→ログイン方法でGoogle OAuth認証を有効にします。

スクリーンショット 2018-12-21 1.11.33.png

クライアントサイドで認証を行い、tokenをreduxに保存します。
以降、functionsのAPIをコールする際にtokenを付与するようにします。

src/index.js
import { connect } from 'react-redux'
import { signIn, signOut, authChange, apiEndpoint } from './lib/firebase'

const LOGIN = 'user/LOGIN'
const LOGOUT = 'user/LOGOUT'
const INFO = 'user/INFO'

function userReducer(state = {uid: null, token: null, info: ''}, action = {}) {
  switch (action.type) {
    case LOGIN:
      return {
        ...state,
        uid: action.uid,
        token: action.token,
      }
    case LOGOUT:
      return {
        ...state,
        uid: null,
        token: null,
      }
    case INFO:
      return {
        ...state,
        info: action.info,
      }
    default:
      return state
  }
}

// reducer
const reducer = combineReducers({
  router: connectRouter(history),
  form: formReducer,
  userReducer,
})

// 認証状態の変更があった場合に呼ばれる(ログイン、ログアウト)
authChange(({uid, token}) => {
  if (token) {
    store.dispatch({type: LOGIN, uid, token})
  } else {
    store.dispatch({type: LOGOUT})
  }
})

client.interceptors.request.use(req => {
  const token = store.getState().userReducer.token

  // ログイン後はtokenを使ってリクエスト
  if (token) {
    // ieのリクエストキャッシュ対策
    document.execCommand && document.execCommand('ClearAuthenticationCache', 'false')
    req.url += (req.url.indexOf('?') == -1 ? '?' : '&') + '_=' + Date.now()
    // ユーザ認証トークン付与
    req.headers.Authorization = `Bearer ${token}`
  }
  return req
}, err => Promise.reject(err))

client.interceptors.response.use(res => res, err => {
  if (axios.isCancel(err)) {
    return Promise.reject({code: 999, message: 'cancel'})
  }
  return Promise.reject(err.response || {})
})

function sendInfo(msg) {
  return (dispatch, getState, client) => {
    return client
      .post(apiEndpoint + '/api/user/info', msg)
      .then(res => res.data)
      .then(info => {
        dispatch({type: INFO, info})
        return info
      })
  }
}

const TopPage = connect(
  state => ({
    uid: state.userReducer.uid,
    info: state.userReducer.info,
  }),
  { sendInfo }
)(({uid, info, chats, sendInfo, sendChat, getFilePermission}) => (
  <div>
    {uid === null ?
      <button onClick={() => signIn()}>Sign in with Google</button> :
      <button onClick={() => signOut()}>Sign out</button>
    }
    {uid && <button onClick={() => sendInfo({msg: 'hoge'})}>info送信</button>}
  </div>
))

functions側で認証付きAPIはJWTトークンの検証を行います。
認証したいAPIに関しては、validate関数を手前に挟むことでログイン認証をかけることができます。

functions/index.js
// JWTトークンの検証を行う(認証)
const validate = async (req, res, next) => {

  if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
      !(req.cookies && req.cookies.__session)) {
    return res.status(403).send('Unauthorized')
  }

  let idToken
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    // ID Tokenをthe Authorization headerから取得
    idToken = req.headers.authorization.split('Bearer ')[1]
  } else {
    // 認証失敗
    return res.status(403).send('Unauthorized')
  }

  try {
    const decodedIdToken = await admin.auth().verifyIdToken(idToken)
    console.log('ID Token correctly decoded', decodedIdToken)
    // ID Token decodedをreq.userに格納
    req.user = decodedIdToken
    return next()
  } catch (error) {
    console.error('Error while verifying Firebase ID token:', error)
    return res.status(403).send('Unauthorized')
  }
}

// よくある認証APIとDBアクセス(バックエンドのみ許可)を組み合わせてDB操作を行う
async function info(req, res) {
  const id = req.user.uid
  const msg = req.body.msg
  if (!msg) return res.status(400).json()
  // pathで格納される
  await database.ref(`/blog/${id}`).set(msg)
  // path以下の子がすべて取れる
  const result = await database.ref(`/blog/${id}`).once('value').then((snapshot) => snapshot.val())
  res.json(result)
}

app.use(
  '/user',
  express.Router()
    // ユーザ認証付きAPI
    // `Authorization` HTTP headerに`Bearer <Firebase ID Token>`が必須
    .post('/info', validate, info)
)

クライアントから直接DBを参照させない

database.rules.jsonにDBのアクセス権限を記入することができます。(このファイルをfirebase deployコマンドでデプロイします
クライアントサイドから直接アクセスさせずにfunctionsのみからアクセスさせたい場合はすべてfalseにします。
この場合、前述のfunctions側のfirebase.initializeAppにてadmin認証情報.jsonを指定しておく必要があります。

database.rules.json
{
  "rules": {
    ".read": false,
    ".write": false,
  }
}

もしくはfirebaseの管理画面にて直接ルールの編集やシミュレーションを行うこともできます。
スクリーンショット 2018-12-21 1.38.58.png

リアルタイムデータを更新・取得する

よくある例としてはチャットなどのデータをリアルタイムに反映したい場合です。
RealTime DBをクライアントサイドからDBに直接書き込み・閲覧できるようにすることとReduxを組み合わせることで簡単に実現可能になります。
database.rules.jsonのchatパス以下をログイン済みユーザであれば誰でも書き込み・閲覧できるようにします。

database.rules.json
{
  "rules": {
    ".read": false,
    ".write": false,
    "chat": {
      ".read": "auth !== null",
      ".write": "auth !== null"
    }
  }
}

src/lib/firebase.jsにDB操作のユーティリティ関数を作成しておきます。

src/lib/firebase.js
firebase.initializeApp(config)
const database = firebase.database()

export const setData = (path, data) => {
  database.ref(path).set(data)
}

export const pushData = (path, data) => {
  database.ref(path).push().set(data)
}

export const updateData = (path, data) => {
  database.ref(path).update(data)
}

export const removeData = (path) => {
  database.ref(path).remove()
}

export const receiveData = (path, callback) => {
  database.ref(path).on('value', (snapshot) => {
    callback(snapshot.val())
  })
}

src/index.jsにてチャットの読み取り、送信処理を追加します。
ポイントはapiサーバが不要(直接DB)書き込みしているのと、
データ更新された場合にdatabase.ref(path).onがコールバックされてreduxにデータが保存され、
各コンポーネントの表示が更新されます。

src/index.js
import { signIn, signOut, authChange, apiEndpoint, pushData, receiveData } from './lib/firebase'

const LOGIN = 'user/LOGIN'
const CHAT = 'user/CHAT'

function userReducer(state = {uid: null, token: null, chats: []}, action = {}) {
  switch (action.type) {
    case LOGIN:
      return {
        ...state,
        uid: action.data.uid,
        token: action.data.token,
      }
    case CHAT:
      return {
        ...state,
        chats: action.chats,
      }
    default:
      return state
  }
}

// 認証系の処理(上で説明してあるので略)

function sendChat(msg) {
  return () => {
    return new Promise(() => {
      pushData('/chat', msg)
    })
  }
}

receiveData('/chat', (chats) => {
  store.dispatch({type: CHAT, chats})
})

const TopPage = connect(
  state => ({
    uid: state.userReducer.uid,
    chats: state.userReducer.chats,
  }),
  { sendChat }
)(({uid, info, chats, sendInfo, sendChat, getFilePermission}) => (
  <div>
    {uid === null ?
      <button onClick={() => signIn()}>Sign in with Google</button> :
      <button onClick={() => signOut()}>Sign out</button>
    }
    <button onClick={() => sendChat({msg: 'ほげ'})}>チャット送信</button>
    {Object.keys(chats).map(key => <div key={key}>{chats[key].msg}</div>)}
  </div>
))

ファイルのアップロード・ダウンロード

storage.rulesにStoregeに置かれているファイルのアクセス管理を行うことができます。(このファイルをfirebase deployコマンドでデプロイします
今回は/public以下を公開フォルダにします。
allow read, write;の場合は特に制限がないため、だれでもファイルの改変、閲覧が可能です。

storage.rules
service firebase.storage {
  match /b/{bucket}/o {
    match /public {
      match /{allPaths=**} {
        allow read, write;
      }
    }
  }
}

もしくはFirebase Storegeの管理画面から直接ルールの編集、シミュレーションを行うこともできます。
スクリーンショット 2018-12-21 2.29.28.png

・Firebase Storageにファイルのアップロード
Fileオブジェクトとパスを指定すればそのままアップロードできる

src/lib/firebase.js
firebase.initializeApp(config)
const storage = firebase.storage()

export const upload = (file, path) => {
  // ルートフォルダの参照
  const rootRef = storage.ref()
  // パスへの参照
  const targetRef = rootRef.child(path)
  targetRef.put(file)
}

Fileオブジェクトをinputタグなどから取得する

src/index.js
import { upload } from './lib/firebase'

<input type="file" name="files[]" multiple onChange={(e) => {
  const files = e.target.files // FileList object
  for (let file of files) {
    upload(file, 'public/' + file.name)
  }
}}/>

・Firebase Storageからファイルのダウンロード
バケットのCORSの制限を変更します。(クロスドメインとなるため)

cors.json
[
  {
    "origin": ["*"],
    "method": ["GET"],
    "maxAgeSeconds": 3600
  }
]

gsutil cors set cors.json gs://<your-cloud-storage-bucket>を実行してcors.jsonをアップロードしてバケットのCORS設定を適用します。

src/lib/firebase.js
const firebaseApp = firebase.initializeApp(config)
const storage = firebaseApp.storage()

export const download = (path) => {
  // ルートフォルダの参照
  const rootRef = storage.ref()
  // パスへの参照
  const targetRef = rootRef.child(path)

  return targetRef.getDownloadURL().then((url) => {

    // データのBlob取得
    return fetch(new Request(url))
      .then((response) => response.blob())
      .then((blob) => ({url, blob}))

  }).catch((error) => {
    // 全エラーコードの一覧
    // https://firebase.google.com/docs/storage/web/handle-errors
    switch (error.code) {
      case 'storage/object-not-found':
        // ファイルがない
        break
      case 'storage/unauthorized':
        // ユーザのファイルへのアクセス権がない
        break
      case 'storage/canceled':
        // ユーザがアップロードをキャンセルした
        break
      case 'storage/unknown':
        // 不明なエラー、サーバ側の問題の可能性あり
        break
    }
  })
}

Blobオブジェクトはブラウザ間の差異があるので注意

src/index.js
import { download } from './lib/firebase'
const filename = 'public/hoge.img'

<a href="#" onClick={(e) => {
   e.preventDefault()
   download(filename).then(({url, blob}) => {

     if (window.navigator.msSaveBlob) {
       window.navigator.msSaveBlob(blob, filename)

       // msSaveOrOpenBlobの場合はファイルを保存せずに開ける
       window.navigator.msSaveOrOpenBlob(blob, filename)
     } else {
       const blobUrl = window.webkitURL.createObjectURL(blob)
       const link = document.createElement('a')
       link.href = url
       link.click()
       window.URL.revokeObjectURL(blobUrl)
     }
   )
  }}>
  ダウンロード
</a>

ファイルのセキュリティ(認可したユーザにのみダウンロードできるように制限する)

storage.rulesにprivate用のルールを追加します。

  • write: ファイルの作成者ユーザのみ
  • read: 認可を与えられた(uidがメタデータ付与された)ユーザのみ
storage.rules
service firebase.storage {
  match /b/{bucket}/o {
    match /public {
      match /{allPaths=**} {
        allow read, write;
      }
    }

    match /private {
      match /{userId}/{allPaths=**} {
        allow write: if request.auth.uid == userId; 
        allow read: if resource.metadata[request.auth.uid] != null || request.auth.uid == userId; 
      }
    }
  }
}

functionsでファイルのメタデータを付与するAPIを作成します。

functions/index.js

// ファイルのメタ情報に操作を認可するユーザuidを追加する
async function addFilePermission(req, res) {
  const path = req.body.path
  const fileRef = bucket.file(path)
  let result = await fileRef.getMetadata()
  console.log(result)
  const data = {
    metadata: {}, // カスタムメタデータ
  }
  // 認可するユーザのuidを追加する
  data.metadata[req.user.uid] = true
  result = await fileRef.setMetadata(data)
  console.log(result)

  res.json({message: 'success'})
}

app.use(
  '/user',
  express.Router()
    // ユーザ認証付きAPI
    // `Authorization` HTTP headerに`Bearer <Firebase ID Token>`が必須
    .post('/permission', validate, addFilePermission)
)

ローカル起動

// SPA起動
$ yarn start
// Functions APIサーバ
$ firebase serve

デプロイ

functions、ルールの設定ファイル、Firebase HostingにSPAフォルダのデプロイ

$ firebase deploy
30
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
39