はじめに
概要
この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
前回の講座で、カート購入画面のビューを実装しました。
今回は、Firestoreのトランザクション機能を利用して、購入処理を実装していきます。
※ 前回記事: 【備忘録】日本一わかりやすいReact-Redux講座 実践編 #12前半 「トランザクションを使って商品を注文しよう(前半)」
動画URL
トランザクションを使って商品を注文しよう(後半)【日本一わかりやすいReact-Redux講座 実践編#12】
要点
- Firestore の
batch()
メソッドを用いることで、トランザクション処理を実装できる
完成系イメージ
http://localhost:3000/cart
すでに、カート内に商品が3つ入っています。Firebaseコンソールでは以下の通り。
「レジへ進む」をクリックすると、
http://localhost:3000/order/confirm
注文確認画面に遷移します。カートに入れた商品価格に応じて、合計金額などが表示されています。
「注文を確定する」をクリックすると、
http://localhost:3000/order/completes
注文完了画面に遷移します(ビューファイルは未定義)。
ヘッダーのカートアイコンバッジが 3 から 0(非表示) に切り替わっています。
Firebaseコンソールで確認すると、認証ユーザーのcarts
サブコレクションが削除され、代わりにorders
サブコレクションに、購入商品の情報が保存されています。
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に追加します。
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
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
へ保存します。
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'
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;
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"
「文字列+数値」を表現したい場面で、利用可能なコンポーネントです。
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
ルーティングを定義します。
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()
が実行されます。
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
サブコレクションの読み書きを許可します。
$ firebase deploy --only firestore:rules
さいごに
今回の要点をおさらいすると、
- Firestore の
batch()
メソッドを用いることで、トランザクション処理を実装できる
以上です!Firebaseでは、トランザクション処理も簡単に実行できますね!
このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。