1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【備忘録】日本一わかりやすいReact-Redux講座 実践編 #12後半 「トランザクションを使って商品を注文しよう(後半)」

Posted at

はじめに

概要

この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。

前回の講座で、カート購入画面のビューを実装しました。

今回は、Firestoreのトランザクション機能を利用して、購入処理を実装していきます。

※ 前回記事: 【備忘録】日本一わかりやすいReact-Redux講座 実践編 #12前半 「トランザクションを使って商品を注文しよう(前半)」

動画URL

トランザクションを使って商品を注文しよう(後半)【日本一わかりやすいReact-Redux講座 実践編#12】

要点

  • Firestore の batch()メソッドを用いることで、トランザクション処理を実装できる

完成系イメージ

http://localhost:3000/cart

image.png

すでに、カート内に商品が3つ入っています。Firebaseコンソールでは以下の通り。

image.png

「レジへ進む」をクリックすると、

http://localhost:3000/order/confirm

image.png

注文確認画面に遷移します。カートに入れた商品価格に応じて、合計金額などが表示されています。

「注文を確定する」をクリックすると、

http://localhost:3000/order/completes

image.png

注文完了画面に遷移します(ビューファイルは未定義)。

ヘッダーのカートアイコンバッジが 3 から 0(非表示) に切り替わっています。

Firebaseコンソールで確認すると、認証ユーザーのcartsサブコレクションが削除され、代わりにordersサブコレクションに、購入商品の情報が保存されています。

image.png

cartでは、1商品につき1ドキュメントでしたが、ordersでは、1回の購入につき1ドキュメントになります(同時に購入した商品情報は、ドキュメント内の配列で保存されます)。

メイン

ordersサブコレクションの設計

各ユーザーの購入商品情報を扱うため、usersコレクションのサブコレクションとして、ordersを用意します。

データ設計
productsコレクション
usersコレクション
  ├── cartsサブコレクション
  └── ordersサブコレクション

購入処理が完了すると、それまでcartsに入っていた商品情報が、ごそっとordersに移動してくるイメージです。

batch()によるトランザクション処理の実装

トランザクション処理とは**「分割することができない一連の処理または操作のかたまり」**のことです。

例えば、商品を購入する処理を実行するとき、「ユーザーが購入を決定する」という処理から、「店舗がその購入商品を発送準備にかける」という処理までの一連の流れは、必ず最後まで完了させなければなりません。

途中で何らかのエラーが発生して購入処理が止まってしまったとき、「購入ボタンは押しているからもうお金は払っている」にも関わらず「購入情報が店舗側に届いていないから、発送準備を行わない」という状態に陥ることは避けなければなりません。

他にも、ATMでお金を下ろすとき、ユーザーがお金を卸せているのに、その情報が銀行側に正確に送信されずに銀行残高が減らないまま、という状況が起きてしまえば、銀行側の損失は計り知れません。

トランザクション処理は、「一連の処理全てが正常が完了する」か、「全ての処理を無かったことし、元通りにする」ことのいずれかの結果を保証します。

この「元通りにする」ことをロールバックといいます。トランザクション処理においては処理実行前の状態を保持しておき、常にロールバックできるようにします。

そして、「一連の処理全てが正常が完了できたため、処理前の状態を破棄して結果を確定する」ことをコミットといいます。

Firestoreでは、batch()メソッドを用いることで比較的簡単にトランザクション処理を実装できます。

トランザクション処理は、src/reducks/products/operations.js内のorderProduct()関数として実装します。各カート商品に対して、

  • 該当商品情報を取得した上で、DB内の該当商品の在庫数を-1する
  • 該当商品をcartsから削除する
  • 該当商品をordersに追加する

という処理を、トランザクション処理として実装します。

ファイル実装

購入確認画面のビューは、

  • template: OrderConfirm.jsx
  • path: /order/confirm

として実装します。また、購入商品の合計金額等を表示させるコンポーネントとして、TextDetail.jsxをUIKitに追加します。

12-3.png

実装ファイル
1.src/templates/OrderConfirm.jsx
2.src/templates/index.js
3.src/components/UIkit/TextDetail.jsx
4.src/components/UIkit/index.js
5.src/Router.jsx
6.src/reducks/products/operations.js
7.firestore.rules
1.src/templates/OrderConfirm.jsx
import React, {useCallback, useMemo} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {getProductsInCart} from "../reducks/users/selectors";
import {makeStyles} from "@material-ui/core/styles";
import {CartListItem} from "../components/Products";
import List from "@material-ui/core/List";
import Divider from "@material-ui/core/Divider";
import {PrimaryButton, TextDetail} from "../components/UIkit";
import {orderProduct} from "../reducks/products/operations";

const useStyles = makeStyles((theme) => ({
  detailBox: {
    margin: '0 auto',
    [theme.breakpoints.down('sm')]: {
        width: 320
    },
    [theme.breakpoints.up('sm')]: {
        width: 512
    },
  },
  orderBox: {
    border: '1px solid rgba(0,0,0,0.2)',
    borderRadius: 4,
    boxShadow: '0 4px 2px 2px rgba(0,0,0,0.2)',
    height: 256,
    margin: '24px auto 16px auto',
    padding: 16,
    width: 288
  },
}));

const OrderConfirm = () => {
  const classes = useStyles();
  const dispatch = useDispatch();
  const selector = useSelector(state => state);
  const productsInCart = getProductsInCart(selector);

  const subtotal = useMemo(() => {
      return productsInCart.reduce((sum, product) => sum += product.price, 0)
  },[productsInCart])

  const shippingFee = (subtotal >= 10000) ? 0 : 210;
  const tax = (subtotal + shippingFee) * 0.1;
  const total = subtotal + shippingFee + tax;

  const order = useCallback(() => {
      dispatch(orderProduct(productsInCart, total))
  }, [productsInCart,total])

  return (
    <section className="c-section-wrapin">
      <h2 className="u-text__headline">注文の確認</h2>
      <div className="p-grid__row">
        <div className={classes.detailBox}>
          <List>
            {productsInCart.length > 0 && (
              productsInCart.map(product => <CartListItem product={product} key={product.cartId} />)
            )}
          </List>
        </div>
        <div className={classes.orderBox}>
          <TextDetail label={"商品合計"} value={"¥"+subtotal.toLocaleString()} />
          <TextDetail label={"消費税"} value={"¥"+tax.toLocaleString()} />
          <TextDetail label={"送料"} value={"¥"+shippingFee.toLocaleString()} />
          <Divider />
          <div className="module-spacer--extra-extra-small" />
          <TextDetail label={"合計(税込)"} value={"¥"+total.toLocaleString()} />
          <PrimaryButton label={"注文を確定する"} onClick={order} />
        </div>
      </div>
    </section>
  );
};

export default OrderConfirm;
  • カート内商品情報は、<List><CartListItem>およびmapメソッドによるイテレートで一覧表示します。
  • 「注文を確定する」ボタンをクリックしたときに、カート内商品情報をorderProduct()(後述)に渡し、DB上のordersへ保存します。
2.src/templates/index.js
export {default as CartList} from './CartList'
export {default as Home} from './Home'
export {default as OrderConfirm} from './OrderConfirm' //追記
export {default as ProductDetail} from './ProductDetail'
export {default as ProductEdit} from './ProductEdit'
export {default as ProductList} from './ProductList'
export {default as Reset} from './Reset'
export {default as SignIn} from './SignIn'
export {default as SignUp} from './SignUp'

3.src/components/UIkit/TextDetail.jsx
import React from 'react';
import {makeStyles} from "@material-ui/core/styles";

const useStyles = makeStyles({
  row: {
    display: 'flex',
    flexFlow: 'row wrap',
    marginBottom: 16
  },
  label: {
    marginLeft: 0,
    marginRight: 'auto'
  },
  value: {
    fontWeight: 600,
    marginLeft: 'auto',
    marginRight: 0
  }
})

const TextDetail = (props) => {
  const classes = useStyles();

  return (
    <div className={classes.row}>
      <div className={classes.label}>{props.label}</div>
      <div className={classes.value}>{props.value}</div>
    </div>
  );
};

export default TextDetail;
4.src/components/UIkit/index.js
export {default as GreyButton} from "./GreyButton"
export {default as PrimaryButton} from "./PrimaryButton"
export {default as SelectBox} from "./SelectBox"
export {default as TextDetail} from './TextDetail' //追記
export {default as TextInput} from "./TextInput"

「文字列+数値」を表現したい場面で、利用可能なコンポーネントです。

5.src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {CartList, OrderConfirm,ProductDetail,ProductEdit,ProductList,Reset,SignIn,SignUp} from "./templates";
import Auth from "./Auth"

const Router = () => {
  return (
    <Switch>
      <Route exact path={"/signup"} component={SignUp} />
      <Route exact path={"/signin"} component={SignIn} />
      <Route exact path={"/signin/reset"} component={Reset} />

      <Auth>
        <Route exact path={"(/)?"} component={ProductList} />
        <Route exact path={"/product/:id"} component={ProductDetail} />
        <Route path={"/product/edit(/:id)?"} component={ProductEdit} />
        <Route extct path={"/cart"} component={CartList} />
        <Route extct path={"/order/confirm"} component={OrderConfirm} /> {*追記}
      </Auth>
    </Switch>
  );
};

export default Router

ルーティングを定義します。

6.src/reducks/products/operations.js
import { fetchProductsAction,deleteProductAction } from "./actions";
import {db, FirebaseTimestamp} from "../../firebase";
import { push } from "connected-react-router";

const productsRef = db.collection("products")
.
.
.
export const orderProduct = (productsInCart, amount) => {
  return async (dispatch,getState) => {
    const uid = getState().users.uid;
    const userRef = db.collection("users").doc(uid);
    const timestamp = FirebaseTimestamp.now();

    let products = [],
        soldOutProducts = [];

    const batch = db.batch();

    for (const product of productsInCart) {
      const snapshot = await productsRef.doc(product.productId).get()
      const sizes = snapshot.data().sizes;

      const updatedSizes = sizes.map(size => {
        if (size.size === product.size) {
          if (size.quantity === 0) {
            soldOutProducts.push(product.name)
            return size
          }
          return {
            size: size.size,
            quantity: size.quantity - 1
          }
        } else {
          return size
        }
      });

      products.push( {
        id: product.productId,
        images: product.images,
        name: product.name,
        price: product.price,
        size: product.size
      });

      batch.update(
        productsRef.doc(product.productId),
        {sizes: updatedSizes}
      );

      batch.delete(
        userRef.collection("cart").doc(product.cartId)
      );
    }

    if (soldOutProducts.length > 0) {
      const errorMessage =  (soldOutProducts.length > 1) ?
                            soldOutProducts.join("") :
                            soldOutProducts[0];

      alert("大変申し訳ありません。" + errorMessage + "が在庫切れとなったため、注文処理を中断しました。")
      return false
    } else {
      batch.commit()
      .then(() => {
        const orderRef = userRef.collection("orders").doc();
        const date = timestamp.toDate();
        const shippingDate = FirebaseTimestamp.fromDate(new Date(date.setDate(date.getDate() + 3)));

        const history = {
          amount: amount,
          created_at: timestamp,
          id: orderRef.id,
          products: products,
          shipping_date: shippingDate,
          updated_at: timestamp
        };

        orderRef.set(history)

        dispatch(push("/order/completes"))

      }).catch(() => {
        alert("注文処理に失敗しました。通信環境をご確認の上、もう一度お試しください。")
        return false
      })

    }
  }
}

今回の肝!

const batch = db.batch();で**バッチ(実行処理を事前にひとまとめにしておけるもの)**を定義し、

  • batch.update()を用いて、ordersに保存したい購入商品の情報をバッチに追加。
  • batch.delete()を用いて、cartsから購入商品を削除する処理をバッチに追加。

を行います。

その後、batch.commit()により、batchに追加した処理を実行し、

  • ordersに購入商品情報を保存

を実現します。

もし処理が途中で失敗してしまった場合、それまでの処理は全て無かったこと(ロールバック)された上で、.catch()が実行されます。

7.firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read: if request.auth.uid != null;
      allow create;
      allow update: if request.auth.uid == userId;
      allow delete: if request.auth.uid == userId;

      match /cart/{cartId} {
        allow read,write: if request.auth.uid == userId;
      }
      
      // 追記
      match /orders/{orderId} {
        allow read,write: if request.auth.uid == userId;
      }
      // 追記ここまで
    }
      
    match /products/{productId} {
      allow read: if request.auth.uid != null;
      allow write: if request.auth.uid != null;
    }
  }
}

ordersサブコレクションの読み書きを許可します。

firestore.rulesの反映
$ firebase deploy --only firestore:rules

さいごに

今回の要点をおさらいすると、

  • Firestore の batch()メソッドを用いることで、トランザクション処理を実装できる

以上です!Firebaseでは、トランザクション処理も簡単に実行できますね!

このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?