たけのこ養殖場

マイコンプログラミングなどなど

『Firebase によるサーバーレスシングルページアプリケーション』を読んだ(前編):React class components を function components + hooks に置き換えながら写経(?)する

本の紹介

『Firebase によるサーバーレスシングルページアプリケーション』を読みました.
nextpublishing.jp

React は触ったことがあって Firebase は初めてな人(=僕)に向けた,React + Firebase のチュートリアル的な本です.
Firebase の Authentication・Firestore・Hosting・Storage・Functions を使って,ユーザー認証&mp4形式へのトランスコード処理付きの動画シェアサイトを作ります.

f:id:tankenta:20210531222333p:plain
完成するウェブアプリのスクリーンショット

さて,この本はもともと技術書典5(2018年10月)に出ていた同人誌で,僕が買ったのはそれを底本にして出版された商業版(?)だったようです.
techbookfest.org

この記事の執筆時点で本の出版から2年半が経っているため,コードが class components で書かれているなど,本の内容はやや古くなってしまっています.
React hooks の正式リリースは2019年2月なので,本の執筆の後だったんですね.
github.com

また,いくらか説明が端折られていたりサンプルコードが動かなかったりする部分があります.
とはいえ元が同人誌であることを知っているので「本を出してくれる方がいるだけでありがたい〜!」と五体投地で感謝しながら読ませていただきました.

この記事の位置付け

上記のような事情があり,Amazon レビューで「サンプルコードとか,誤修正とかどっかに出てれば良いんだけど」というコメントを見かけたこともあり,せっかくなので自分が読んで写経していて詰まった点とその対処をブログ記事に残すことにしました.
また,写経は class components で書かれたサンプルコードを funcion components + hooks に置き換えながら行いました(書き換えた場合も写経と言うのでしょうか?).
コンテキストやカスタムフックを用いた整理はしておらず,元コードからの差分最小限の移植になります.
本の著者によるオリジナルのコードはこちらに
github.com 今回僕が書いたコードはこちらに公開されています. github.com 後者は本の章が終わるごとに git のタグ chapter* を打っているので参考にしてください.

この本は8章から成り,それぞれの章で扱う内容は次のようになっています.

  1. Firebase について
  2. アプリケーションのセットアップとデプロイ
  3. Authentication で認証を組む
  4. Storage で動画ファイルを管理する
  5. Firestore でユーザー・動画メタデータ管理
  6. Functions でmp4形式への動画トランスコード
  7. Firestore のセキュリティールール
  8. Redux (& react-redux-firebase) の導入

この記事では1〜7章までを扱い,次のことをやったりやらなかったりします.
やる:

  • 生の Firebase SDK + React class components のサンプルコードを,生の Firebase SDK + React funcion components + hooks に置き換える
  • 写経を進めていて詰まった点とその対処を書く
  • その他ほんのちょっとだけいい感じにする

やらない:

  • Firebase や React の機能説明
  • react-firebase-hooks や ReactFire の導入
  • TypeScript 化
  • テストの導入

「やらない」に「react-firebase-hooks や ReactFire の導入」と書きましたが,後ほど8章相当としてこれをやり,後編記事として公開できたらなと思っています.

ではやっていきます.
今更ですが,基本的に前編は本が手元にある人向けに,書籍との差分の説明や誤修正を淡々と行っていく内容となります.
タイトルに「function components + hooks に置き換え」と入れており,事実置き換えを行ったコードを GitHub に置いてはいますが,この記事内では hooks の話はあまりしないかもしれません.

1章 Firebase

Firebase の紹介なので,本記事では特に言及しません.

2章 アプリケーションの構築

Firebase の CLI ツールをインストールする

インストールした firebase-tools のバージョンは,書籍と手元環境でそれぞれ次のようになりました.

firebase --version
  • 書籍: 4.2.1
  • 本記事執筆時の手元環境: 9.9.0

使用する機能の選択

firebase init

書籍中のスクショに見られる Database, Firestore, Functions, Hosting, Storage 以外に,Emulators, Remote Config が追加されていました.
本で指示された Firestore, Functions, Hosting, Storage のみチェックを入れて進めます.

使用する Firebase プロジェクトを指定する

プロジェクトを選択したらエラー

Error: It looks like you haven't used Cloud Firestore in this project before. Go to https://console.firebase.google.com/project/react-firebase-example-560cf/firestore to create your Cloud Firestore database.

が出たので,URL をクリックしてプロジェクト内で Firestore をセットアップ.
Firestore を扱う書籍5章を先に覗いてみると,本番ではなくテストモード(誰もがフルアクセス可能)にしろとあるので従います.
リージョンの指定画面が出たので asia-northeast1 を選びました.

? Set up automatic builds and deploys with GitHub? (y/N)

この機能は気になりますが,デフォルトの No のまま進めました.

3章 認証

Google アカウントによる認証を有効化する

「ウェブアプリに Firebase を追加」をクリックした際,「このアプリの Firebase Hosting も設定します」にチェックを入れました.

  • Firebase SDK の追加
  • Firebase CLI のインストール
  • Firebase Hosting へのデプロイ

は何もいじらず.

書籍で触れられている「各設定情報」は,「プロジェクトの設定>全般>マイアプリ>Firebase SDK snippet」に並ぶ「自動・CDN・構成」のうち構成を選択すると現れます.
本と比べると databaseURL がなく,appId, measurementId が増えていますが,気にせず進めます.

手元では本のように src/config/firebase-config.js とする代わりに src/plugins/firebase.js として,コンフィグ変数の宣言と firebase.initializeApp() 他を同一ファイル内で行うことにしました.
process.env.XXX については後述.

import firebase from 'firebase/app';
import 'firebase/firestore';

// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const config = {
  apiKey: process.env.REACT_APP_API_KEY,
  authDomain: process.env.REACT_APP_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_APP_ID,
  measurementId: process.env.REACT_APP_MEASUREMENT_ID
};

firebase.initializeApp(config);
firebase.firestore().settings({ timestampsInSnapshots: true });

これを src/index.js で読み込んでいます.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
import reportWebVitals from './reportWebVitals';
import './plugins/firebase';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

reportWebVitals();

src/components/App.js はこれだけ.

import React from 'react';

function App() {
  return (<div>Hello React</div>);
}

export default App;

コンポーネント側のコードで firebase クライアントが欲しいときは import firebase from 'firebase/app'; をしてあげれば初期化済みのクライアントが使えます.
このあたりは src/plugins/firebase.js で初期化したクライアントを export しておいてコンポーネント側で firebase/app の代わりに import する,あるいはコンテキストで渡しておくなど他の書き方もありそうです.
後編で紹介しようと思っているライブラリ ReactFire では,自作コンポーネント<FirebaseAppProvider firebaseConfig={firebaseConfig}> </FirebaseAppProvider> で包むだけでクライアントの初期化を肩代わりしてくれて,自作コンポーネントから useFirestore() などの便利 hooks を使えるという仕組みになっています.
github.com 他には,複数回初期化の防御を気にしなくていいようにシングルトンで用意しておくという例もありました. blog.ojisan.io が,本記事では紹介にとどめて単純な方法にとどめておきます.

process.env.XXX について.
設定値は src/plugins/firebase.js にベタ書きせず, .env に書いたものを読ませることにしました.
.env はプロジェクトトップ(src ディレクトリと同じ階層)に置き,GitHub に上がってしまわないように .gitignore に追加しておきます.
React のプロジェクトで何も気にせず process.env.XXX として値を読むにはキー名を REACT_APP_ プレフィックスで始める必要がある点に注意です.

REACT_APP_API_KEY=xxx
REACT_APP_AUTH_DOMAIN=react-firebase-example-560cf.firebaseapp.com
REACT_APP_PROJECT_ID=react-firebase-example-560cf
REACT_APP_STORAGE_BUCKET=react-firebase-example-560cf.appspot.com
REACT_APP_MESSAGING_SENDER_ID=xxx
REACT_APP_APP_ID=xxx
REACT_APP_MEASUREMENT_ID=xxx

ヘッダー画面の作成

書籍のサンプルのままだと googleLogin が未定義ですと怒られるので,この時点では空の関数を定義しておきます.
また,material-ui の theme.spacing.unit は古い書き方で警告が出るので theme.spacing(1) にしておきます.
Typography の variant="title" も警告が出たので適当に variant="h5" にしておきました.
サイズは手元環境でちょうどいい大きさになるものを雑に選んだので,適切ではないかも.
このあたりは responsiveFontSizes() ヘルパーというものを使うとビューポートに従って大きさを調節してくれるようです.手元では使いませんでしたが. material-ui.com

ログインボタンをクリックして,Google アカウントでログインする

componentDidMount() に入っていた firebase.auth().onAuthStateChanged() のコードは useEffect() に移植します.
後から調べていて知ったので本記事と GitHub のコードには反映できていないのですが,onAuthStateChanged() は unsubscribe 用関数を返すので,それを useEffect() のクリーンアップ処理に追加しておくといいかもしれません. firebase.google.com

その他好みの問題で,書籍のように {this.state.isLogin ? this.renderLoginedComponent(classes) : this.renderLoginComponent(classes)} とはせずに,それぞれコンポーネントとして宣言していたりします.
3章での最終的な src/components/App.jssrc/components/Header.js は次のようになりました.

import React from 'react';
import Header from './Header';

function App() {
  return (
    <>
      <Header />
      <div>Hello React</div>
    </>
  );
}

export default App;
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Button from '@material-ui/core/Button';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography';
import firebase from 'firebase/app';
import 'firebase/auth';

const styles = theme => ({
  root: {
    flexGrow: 1,
  },
  flex: {
    flexGrow: 1,
  },
  button: {
    margin: theme.spacing(1),
  },
  rightIcon: {
    marginLeft: theme.spacing(1),
  },
  avatar: {
    margin: 10,
    backgroundColor: 'white',
  },
});

function Header({ classes }) {
  const [isLogin, setIsLogin] = useState(false);
  const [userProfile, setUserProfile] = useState({ name: '', picUrl: '' });

  useEffect(() => {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        setIsLogin(true);
        setUserProfile({ name: user.displayName, picUrl: user.photoURL });
      } else {
        setIsLogin(false);
        setUserProfile({ name: '', picUrl: '' });
      }
    });
  }, []);

  const googleLogin = () => {
    const provider = new firebase.auth.GoogleAuthProvider();
    firebase.auth().signInWithRedirect(provider);
  }

  const googleSignOut = () => {
    firebase.auth().signOut();
  }

  const LoginComponent = ({ classes }) => (
    <Button color="inherit" className={classes.button} onClick={googleLogin}>
      Login with Google
    </Button>
  );

  const LoginedComponent = ({ classes, userProfile }) => (
    <>
      <Button color="inherit" className={classes.button}>
        <Avatar alt="profile image" src={userProfile.picUrl} className={classes.avatar} />
        {userProfile.name}
      </Button>
      <Button color="inherit" className={classes.button} onClick={googleSignOut}>
        Sign Out
      </Button>
    </>
  );

  return (
    <div className={classes.root}>
      <AppBar position="static" color="primary">
        <Toolbar>
          <Typography variant="h5" color="inherit" className={classes.flex}>
            Firebase Videos
          </Typography>
          {
            isLogin
            ? <LoginedComponent classes={classes} userProfile={userProfile} />
            : <LoginComponent classes={classes} />
          }
        </Toolbar>
      </AppBar>
    </div>
  );
}

Header.propTypes = {
  classes: PropTypes.object.isRequired,
};

export default withStyles(styles)(Header);

4章 Cloud Storage によるコンテンツの管理

Firebase のコンソール画面から,Cloud Storage を開始する

セキュリティルールの設定は求められず,デフォルトで本と同じ設定になっていました.

動画ファイルをアップロードして,Cloud Storage に保存する

VideoUpload コンポーネントUpload に名前変更されているので注意.
手元では VideoUpload の名前のままにして,以降の名前を読み替えます.

4章終わり時点での src/components/VideoUpload.js を載せておきます.
他は GitHubchapter4 タグを見てください.
イベントハンドラーたちがコンポーネントの外で定義されていますが気にしないでください.

import React from 'react';
import firebase from 'firebase/app';
import 'firebase/storage';

const handleChange = (event, setVideo) => {
  event.preventDefault();
  const video = event.target.files[0];
  setVideo(video);
}

const handleSubmit = (event, video) => {
  event.preventDefault();
  fileUpload(video);
}

const fileUpload = async video => {
  try {
    const userUid = firebase.auth().currentUser.uid;
    const filePath = `videos/${userUid}/${video.name}`;
    const videoStorageRef = firebase.storage().ref(filePath);
    const fileSnapshot = await videoStorageRef.put(video);
    console.log(fileSnapshot);
  } catch (error) {
    console.log(error);
  }
}

const VideoUpload = () => {
  const [video, setVideo] = React.useState(null);

  return (
    <form onSubmit={e => handleSubmit(e, video)}>
      <h2>Video Upload</h2>
      <input type="file" accept="video/*" onChange={e => handleChange(e, setVideo)} />
      <button type="submit">Upload Video</button>
    </form>
  );
}

export default VideoUpload;

5章 Firestore によるデータベース管理

本アプリケーションのDB設計

書籍ではユーザーを横断して全アップロード動画の一覧を得られるようにするために users/:user/videos/:videovideos/:video を用意しています.
たしかに書籍出版時点では上位階層を横断してのサブコレクション取得はできなかったようなのですが,現在ではそれを可能とするコレクショングループという機能もあるようです.
firebase.google.com qiita.com Firestore では一つのコレクションへの書き込み速度が最大500回/秒に制限されるとのことなので,全ユーザーのビデオメタデータをルートコレクションで一元管理するのは,サービスの規模が大きい場合は良くないかもしれませんね.
firebase.google.com この記事ではコレクショングループを考慮したDB設計変更はせず書籍のDB設計のまま話を進めますが,参考までに.

Firestore のセキュリティールールを設定

2章の操作で設定画面が出たため,既に設定済みです.

動画のメタデータを Firestore に保存する

handleChange メソッドが handleFileSelect に名前変更されているので注意.
これは手元でも handleFileSelect に変更しました.こちらの方が名前が良いと思ったため.

lodash をインポートしていますが,インストールの指示が省略されているためやっておきます.

npm i -S lodash

ユーティリティライブラリ lodash が提供する機能は ES5, ES6 以降の機能で簡単に書けることが多いため,むやみに lodash 使ってバンドルサイズ大きくするのはやめようね?という話もあるようです. github.com この本では lodash を omitByisEmpty の一行でしか使ってなさげなので,lodash を避けて当該行を書き換えてもいいかもしれませんね.こんな感じ?

const isEmpty = obj => [Object, Array].includes((obj || {}).constructor) && !Object.entries((obj || {})).length;
const omitEmpty = srcObj => Object.entries(srcObj)
        .filter(([k, v]) => !isEmpty(v))
        .reduce((dstObj, [k, v]) => (dstObj[k] = v, dstObj), {});
const metadataForFirestore = { ...omitEmpty(fileSnapshot.metadata), downloadURL };

というところまで考えたものの,自分は現時点で fileSnapshot.metadata にどんな値が渡ってくるのか知らないのに大丈夫だろうか?そもそも firebase 入れてる時点でバンドルサイズは……と考え始めて面倒になり,脱 lodash は見送りました()

saveVideoMetadata には async をつける必要があります.

アップロード時にローディングを表示する

react-loading-overlay を入れようとしたら,create-react-app で入った手元 React がバージョン17だった関係で npm install 時に ERESOLVE unable to resolve dependency tree が出ました.
代わりに material-ui の circular とか使うのもありな気もしますが,コンポーネント構造がお手本とずれてくるのはちょっとなーと思い,react-loading-overlay のまま雑に進めました.

npm i -S --legacy-peer-deps react-loading-overlay

書籍では Cloud Storage のビデオ保存パスのトップが videos から origin-videos にひっそりと変わっているため注意です.
後に mp4 以外のビデオがアップロードされた際に mp4 に変換して transcoded-video 下に保存する機能を実装するため,区別のため名前変更をしたものと思われます.
手元では videos のままにしました.

動画を再生する

video-react を npm install しようとして同じく ERESOLVE が出ました.
React 16 に下げるべきだったかなぁと後悔しながら,同様に --legacy-peer-deps します.

<Grid>spacing に関して warning が出ていたため, spacing={10} としました.

Warning: Failed prop type: Invalid prop `spacing` of value `40` supplied to `ForwardRef(Grid)`, expected one of [0,1,2,3,4,5,6,7,8,9,10].

本では video ドキュメントのメタデータを次のように取得しています.

const datas = [];
const colection = await firebase.firestore()
        .collection('videos')
        .limit(50);
const querySnapshot = await collection.get();

await querySnapshot.forEach(doc => {
    datas.push(doc.data());
});

this.setState({ videos: datas });

ここでは await が3回出てきますが,実は本当に必要なのは1回だけな気がします.
await.then と違って,notPromiseValue につけたからといって困りはしないと思いますが.

Firestore Node.js client のドキュメントを見てみます.
まず, firebase.firestore().collection('videos') が何を返しているかというと CollectionReference オブジェクトです.
googleapis.dev ここに生えている limit() メソッドを呼ぶと Query オブジェクトが返ります. googleapis.dev googleapis.dev このとき Query オブジェクトは Promise でラップされずに返ってくるので,1個目の await は不要です.

一方で Query オブジェクトの get() メソッドは Promise.<QuerySnapshot> を返すので,2個目の await は必要です. googleapis.dev

無事 QuerySnapshot オブジェクトを手に入れると,こいつには forEach() メソッドが生えています. googleapis.dev forEach() に渡しておくコールバック関数には QueryDocumentSnapshot が渡ります.
3個目の await は不要です.
たとえコールバック関数に Promise が渡されていたとしても,3個目の await の書き方はうまくいかない気がしますね.

ところで forEach() は生えてるのに map() は生えていないのかいと探してみると, QuerySnapshot には docs メンバーが存在して,これが QueryDocumentSnapshot の配列になっているようです. googleapis.dev つまり map() が使えます.
ここまでに調べたことを利用すれば,元のコードを次のように書き換えることができます.
ついでに datas の変数名を変えたり, setState の代わりに hooks を使ったりしています.

const colection = firebase.firestore()
        .collection('videos')
        .limit(50);
const querySnapshot = await collection.get();
const videosMetadata = querySnapshot.docs.map(doc => doc.data());
setVideos(videosMetadata);

さて,実は5章のこのコードには大きな罠がありまして,現状 firestore().collection('videos') では videos コレクションが取得できません.
というのも,今のところ Firestore の構造は VideoUpload.saveVideoMetadata() で作成した /Users Collection/User Document/Videos Collection/Video Document のみである一方で,上記コードでは /Videos Collection/Video Document(s) の取得を試みているためです.
これは,「5.2 本アプリケーションのDB設計」で触れられている「第6章で紹介する Cloud Functions で Videos コレクション内に User ドキュメント内にある Video ドキュメントをコピー」を行った後に可能となる操作となっています.

6章まで待ちたくないならば,次のようにコード変更すればよさそうですが……

const userUid = firebase.auth().currentUser.uid;
const collection = await firebase.firestore().doc(`users/${userUid}`).collection('videos').limit(50);

残念ながらページをリロードすると次のエラーが出てしまいます.

Unhandled Rejection (TypeError): Cannot read property 'uid' of null

これはログインしている・いないに関わらず,ページロード直後は firebase.auth().currentUser が null であるためと考えています.
ヘッダーのログインアイコンに関してもリロード直後はサインアウト表示で,遅れてユーザー情報が出るあたり,同じことが起こっているものと思われます.
エラーを解決するには,ヘッダーの表示と同様に currentUser が null かそうでないかで VideoFeed の表示を切り分ければよいはずです.
が,自分は面倒になったので諦めて6章へ向かいました.

また,本では VideoFeed の propTypes と withStyles の追加が忘れられているようだったので,手元では追加しておきました.

VideoFeed.propTypes = {
  class: PropTypes.object.isRequired,
};

export default withStyles(styles)(VideoFeed);

5章ではコードが長くなってきた&必要な部分はスニペットを示したのでファイルの内容全体を記事に貼ることはしません.
GitHubchapter5 タグを参照してください.
6章以降も同様です.

6章 Cloud Functions によるサーバーレスなバックエンド処理

デプロイ

firebase deploy --only functions を実行したところ,次のエラーが出ました.
プランのアップグレードが必要なようです.

Error: Your project react-firebase-example-560cf must be on the Blaze (pay-as-you-go) plan to complete this command. Required API cloudbuild.googleapis.com can't be enabled until the upgrade is complete. To upgrade, visit the following URL:

Firebase と支払い情報(クレジットカード)を紐付けて,無料枠を使い切ったら課金が発生する Blaze プランにアップグレードしました.

関数のランタイムを変更

手元の環境ではランタイムが Node.js 12系だったので,特にバージョン変更はしませんでした.

新規登録ユーザーの情報を保存する関数の実装

本での databaseURL: "https://fir-reacty-videos.firebaseio.com" における fir-reacty-videos の部分は,自分の Firebase プロジェクト ID に差し替えました.
なお,ここで作った saveUser.js は,たとえデプロイコマンドを走らせたとしても現時点では使われません.
後に6.6.1章で Functions のファイル切り分けと index.js からの読み込みを学びます.

トランスコード処理の実装

前と同様に fir-reacty-videos は自分の Firebase プロジェクト ID に差し替えました.

Error: Cannot find ffmpeg が出たため,雑に ffmpeg-static@ffmpeg-installer/ffmpeg に差し替えました. www.npmjs.com

npm i -S @ffmpeg-installer/ffmpeg
const ffmpegBin = require('@ffmpeg-installer/ffmpeg');
// ---
const command = ffmpeg(tempFilePath)
    .setFfmpegPath(ffmpegBin.path)
    .format('mp4')
    .output(targetTempFilePath);

これで当該エラーは消えて ffmpeg 自体は動くようになりましたが,本にも書かれている通りメモリ不足が厳しく,すぐに Error: memory limit exceeded. Function invocation was interrupted. が出てしまいます笑
僕の場合は,手元のスマホで適当に1秒程度のビデオを撮影してアップロード・動作確認を行いました.

Functions のデプロイ時に, saveUser ファンクションが過去プロジェクトにはあり新ソースコードにはないが削除してもいいか?と確認されたので,削除を実行します.
これは,最初に index.js に実装した Hello world を返す関数の名前を saveUser にしていたため,そしてそれを書き換えて transcodeVideo を作ったためのようです.

メタデータをコピーする関数の実装

index.js を分割し transcodeVideo.js を作成する際に,firebase-admin の初期化コードを try 節に入れるよう更新されていることに注意します.
また,saveUser.js ではメール・パスワード認証でのアカウント作成に対応するためのコード?が削られています.
ここまでの手順ではメール・パスワード認証を有効にしていないためそのコードがなくて困ることはありませんが,手元では残したままにしました.
新たに作成する copyVideoMetadata.js でも,他のファイルと同様に fir-reacty-videos を自分の Firebase プロジェクト ID に差し替えます.

デプロイを行うとメタデータコピーを含めきちんと動き,5章で一旦諦めた VideoFeed も正しく動画を表示できるようになります.
ただし Functions のファイル分割による多重 admin.initializeApp() 問題を try-catch の力技で対処しているため,Functions のログが多数のエラーで汚れます笑

FirebaseAppError: The default Firebase app already exists. This means you called initializeApp() more than once without providing an app name as the second argument.

7章 セキュリティールール

セキュリティールールのシミュレーション

「シミュレータ」は名前が変わったようで,現在は「Cloud Firestore > ルール > ルールプレイグラウンド」からセキュリティールールのシミュレーションができます.

すべてのコレクションを自由に読み書きできる「管理人ユーザー」は /admins/uid を自分で作っておく必要があるようです.
僕の手元では試しませんでした.
Admin ユーザー管理に関しては,カスタムクレームという仕組みを使う方法もあるようです. firebase.google.cn

前編は以上です!