8
4

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.

CYBIRDAdvent Calendar 2021

Day 15

Amplify+React+Node.jsを使ってWebSocket通信の簡単なチャットアプリを作ってみる

Last updated at Posted at 2021-12-14

こんにちは。 CYBIRD Advent Calendar 2021 15日目担当の @utamakura です。
14日目は @cy-n_ao さんによる「Heroku&Phaserでかんたん環境構築」でした。
Heroku、お手軽に環境構築できていいですね!
料金について気になったので少し調べてみましたが、無料枠を超えても利用できなくなるだけで追加課金は発生しないとのことで、そこも個人開発に向いていると思いました。
個人でクラウドサービスを使っていると、基本無料だけど実際使ってみたら細かいところで追加課金されて総額でそれなりに請求されてしまった…みたいなことになるのが一番怖いですからね。:cold_sweat:

はじめに

今回、Amplifyの使い方と、Amplify+React+Node.jsを使ったWebSocket通信の実装方法をご紹介します。
これを読めば、Amplify+Reactで簡単にWebアプリを開発する方法を学ぶことができます。

ちなみに、AmplifyやReactは最近知ったばっかりのまだまだ初心者ですので、
それを前提で読んでいただけますと幸いです。
また、何か間違い等ございましたらご指摘いただければと思います。

経緯

Amplify+Reactを使ってSPA(Single Page Application)開発する機会がありました。
AmplifyやReactは初めて触ったのですが、さっくり作れて楽しかったです。

この楽しさを他の人にも知ってもらいたいと思い、今回簡単に開発環境を整えられるAmplifyプラットフォームの導入からアプリケーション公開までの手順をご紹介します。
また、実際に実装したものがあった方がイメージ付きやすいと思うので、Amplifyの環境構築を行った後は、WebSocket通信の簡単なチャットアプリを実装して解説していきます。

WebSocket通信を選んだのは、今まで使ったことがなく個人的興味があったからです。
今回はDBを使わずに実装してみました。
※DBを使ったWebSocket通信を実装したい場合には、AmplifyではAppSync+DynamoDBを使ったWebSocketによる双方通信ができます。もし興味があれば調べてみてください。

かなり長めの記事となってしまいましたが、お付き合いいただければ幸いです。
環境構築よりもチャットアプリの実装の方が気になる!という方は、 第3章: WebSocket通信のチャットアプリを作ってみる まで飛ばして読んでいただければと思います。

動作確認環境

  • OS
    • Windows 10 (nodeやamplifyコマンドが主なので、Mac環境でもほぼ変わらないと思います)
  • ブラウザ
    • Chrome
  • Node.js
    • v12.18.4
  • Amplify CLI
    • v7.5.3

サンプルコード

本記事でご紹介する、チャットアプリのサンプルコードです。
https://github.com/utamakura/ChatAppSampleOnWebSocket

目次

第1章: Amplify+React環境を構築してみる
第2章: 複数ページを実装しながらReactアプリケーションを理解してみる
第3章: WebSocket通信のチャットアプリを作ってみる
第4章: チャットアプリをサーバで動かしてみる
あとがき

第1章: Amplify+React環境を構築してみる

1. AWS Amplifyとは?

Amplifyとは、簡単に言うと、サーバレスアプリケーションを公開するまでの環境を、直感的に構築できるプラットフォームです。インフラ領域のサーバ環境構築をAmplifyに任せられるので、エンジニアはアプリケーション開発により注力できます。
サービスの分類としては、mBaaS(mobile backend as a Service)に当たります。
AWS以外だと、Firebase、ニフクラ mobile backendなどが該当します。

mBaaSの良い所は、直感的にバックエンド環境を構築できるというだけではなく、スケールしやすいコンポーネントで構築されていますので、最初はスモールスタートで、規模の拡大に合わせてスケールアウトしていくプロジェクトに向いていると思います。

また、認証が必要が機能を作りたい場合にはAmplifyとCognitoを組み合わせてセキュアなサービスを構築できます。AmplifyとPinpointを組み合わせてユーザのエンゲージメントを可視化することもできるようです。

では、早速環境構築に取り掛かります。


2. 前準備

ローカル環境にNode.jsがインストールされているか確認します。
Node.jsはv12.x、npmはv6.x以降が推奨されています。

$ npm -v
6.14.6

$ node -v
v12.18.4

まだインストールされていなければ、公式サイトよりインストールしてください。

▼Node.jsダウンロード
https://nodejs.org/ja/download/

もしAmplify Mockを使うならjavaもインストールします。(本記事では使いません)
JavaはJDK(Javaプログラムの開発に必要)とJRE(Javaプログラムを実行するのに必要)がありますが、JREがインストールされていれば大丈夫です。

▼Javaダウンロード
https://java.com/ja/download/


3. Amplify CLIのインストール

AmplifyはAmplify CLIで環境構築を行いますので、まずはAmplify CLIをインストールします。

$ npm install -g @aws-amplify/cli

AmplifyとIAMユーザーの紐づけも行います。

$ amplify configure

自動でブラウザが開かれて、AWSのログインを求められるのでログインします。

コンソールに戻り、リージョン、IAMユーザ名を設定します。

? region:     # リージョン
? user name:  # IAMユーザ名

自動でブラウザが開かれて、IAMの新規作成を求められますので作成します。
※作成するにはIAMロールの作成権限が必要です。
※Amplify CLIを実行するためのIAMを作ることが目的なので、「アクセスキー・プログラムによるアクセス」がチェックされているIAMが別途用意できていれば、無視してコンソールに戻って大丈夫のはず。

コンソールに戻り、作成した(または別途用意した)IAMのアクセスキーID、シークレットアクセスキーを設定します。

? accessKeyId:      # アクセスキーIDを入力
? secretAccessKey:  # シークレットアクセスキーを入力
? Profile Name:     # プロフィール名を入力

設定したアカウント情報は、下記でも確認することができます。

~/.aws/credentials
[profile プロフィール名]
region=リージョン
output=json
~/.aws/credentials
[プロフィール名]
aws_access_key_id=アクセスキーID
aws_secret_access_key=シークレットアクセスキー

4. Reactアプリケーションの作成

Amplifyの設定が終わりましたので、次に create-react-app を使ってReactアプリケーションを作成します。

$ npx create-react-app test_project

Reactアプリケーションをローカルサーバで起動してみます。
起動すると、自動でブラウザが開かれます。

$ cd test_project
$ npm start

React初期画面が表示されていれば成功です!

▼Reactアプリケーションの構築チュートリアル
https://aws.amazon.com/jp/getting-started/hands-on/build-react-app-amplify-graphql/


5. Amplifyプロジェクトのホスティング

では、このプロジェクトをAmplifyでホスティングしてみます。
Amplifyバックエンドを構築する為には、プロジェクトの初期設定が必要です。

$ amplify init
? Enter a name for the project                 # プロジェクト名を入力(任意)
? Initialize the project with the above configuration?  # 1度設定していると、2度目以降は設定を引き継ぎ可能(今回は「N」を選択しました)
? Enter a name for the environment             # 環境名入力(任意)
? Choose your default editor:                  # デフォルトエディタ選択(任意)
? Choose the type of app that you're building  # 言語を選択(「javascript」を選択しました)
? What javascript framework are you using      # フレームワークを選択(「react」を選択しました)
? Source Directory Path:                       # ソースディレクトリパス(デフォルトの「src」にしました)
? Distribution Directory Path: build           # 配布ディレクトリパス(デフォルトの「build」にしました)
? Build Command:                               # ビルドコマンド(「npm build」にしました)
? Start Command:                               # 開始コマンド(「npm start」にしました)
? Select the authentication method you want to use  # 認証方法選択
? Please choose the profile you want to use    # プロファイル選択(amplify configureで作成したプロフィールを入力します)

設定したAmplifyプロジェクトをデプロイ&ホスティングします。

$ amplify publish
? Select the plugin module to execute # プラグインモジュール選択(「Hosting with Amplify Console」を選択します)
? Choose a type # タイプ選択(「Manual deployment」を選択します)

成功時、AmplifyのURLが表示されるので、ブラウザでアクセスして確認してください。
React初期画面が表示されていればホスティング完了です。

第2章: 複数ページを実装しながら、Reactアプリケーションを理解してみる

バックエンド環境が構築できたので、軽くReactプロジェクトを説明します。
生成されているコードを修正しながら、挙動の変化を見ていきます。

1. ディレクトリ構成

ディレクトリ構成は下記のようになっています。

test_project/
 ├ amplify/  # Amplify関連
 ├ public/   # 静的ファイル
 ├ src/      # ソースファイル
 │ ├ App.css
 │ ├ App.js
 │ ├ App.test.js
 │ ├ index.css
 │ ├ index.js
 │ ├ logo.svg
 │ └ setupTests.js
 ├ .gitignore
 ├ package-lock.json
 ├ package.json
 └ README.md

package.jsonも最初から用意してくれています。
必要なパッケージがあればnpm(Node.jsのパッケージ管理ツール)コマンドでインストールしていきます。

package.json
{
  "name": "test_project",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-scripts": "4.0.3",
    "web-vitals": "^1.1.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

2. パッケージのインストール

さっくり実装するため、UIフレームワークを導入してみます。
今回は「React-Bootstrap」をインストールしました。

では、package.jsonがあるプロジェクトルートディレクトリに移動して、npmでインストールしてみましょう。

$ cd test_project
$ npm install react-bootstrap bootstrap@5.1.3

package.jsonを確認すると、 react-bootstrap が追記されていることが確認できると思います。

package.json
    "react-bootstrap": "^2.0.2",

これで、BootstrapライブラリをJSXで扱うことができるようになりました。
JSXについては、 4. 要素のレンダリングの仕組み で解説します。

▼React-Bootstrap
https://react-bootstrap.github.io/

ルーティング設定のため、react-router-domもインストールしておきましょう。

$ npm install react-router-dom

3. App.jsを修正してみる

では、試しに App.js を下記のように修正してみてください。

App.js
import './App.css';
import React from "react";

import {
    Container,
    Card,
} from 'react-bootstrap';

function App() {
    return (
        <div className="App">
            <Container>
                <p className="h3">Home</p>
                <Card>
                    <Card.Body>Homeだよ</Card.Body>
                </Card>
            </Container>
        </div>
    );
}

export default App;

修正が終わりましたら、ブラウザで確認してみます。
画面が下記のように変更されたと思います。

このように、ReactではJavaScript側でビューを実装します。


4. 要素のレンダリングの仕組み

Reactでは、HTMLっぽく書ける構文を書きます。この構文のことを JSX と呼びます。
JSXはHTMLっぽく書けるので誤解されやすいのですが、直接HTMLに変換する訳ではなく、まずJavaScriptに変換(React.createElement)されます。
その後に、ReactDOM.render関数を通してレンダリングされて画面に描写されます。

変換の流れ(App.jsの例)

①Reactコード

function App() {
    return (
        <div className="App">
            <Container>
                <p className="h3">Home</p>
                <Card>
                    <Card.Body>Homeだよ</Card.Body>
                </Card>
            </Container>
        </div>
    );
}


②JavaScript

function App() {
  return React.createElement(
    "div", { className: "App" },
    React.createElement(
      Container, null,
      React.createElement(
        "p", { className: "h3" },
        "Home"
      ),
      React.createElement(
        Card, null,
        React.createElement(
          Card.Body, null,
          "Homeだよ"
        ))));
}


③ReactDOM.render関数でレンダリング

④画面描写


5. コンポーネントの仕組み

Reactでは、UIを再利用できる部品に分割できる、コンポーネントの概念があります。
今回修正したApp.jsだと、 <Container></Container><Card></Card> がコンポーネント呼び出しに該当します。
Reactはコンポーネントを使う前提で設計されており、これを利用することで再利用性・拡張性を高めています。

また、コンポーネントは クラスコンポーネント関数コンポーネント から選べ、どちらを選ぶかでコードの書式も変わります。
その名の通り、コンポーネントをクラスで書くか、関数で書くかの違いです。
最近の主流は、コードを簡潔に書ける関数コンポーネントみたいです。今回は関数コンポーネントで実装します。


6. 複数ページを実装してみる

では、複数ページを実装して挙動をみてみましょう。
ルーティング設定は react-router-dom を使います。
設定することで、リクエストされたURLとコンポーネントを、少ない記述で紐づけてくれます。

今回は、「Home」「News」「Chat」という3つの画面を作り、それぞれコンポーネント化して実装します。
ついでに「Header」「Footer」もコンポーネント化して、各コンポーネントから呼び出すようにします。

components/Home.js
import React from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
    Card,
} from 'react-bootstrap';

function Home() {
    return (
        <>
            <Header />

            <Container>
                <p className="h3">Home</p>
                <Card>
                    <Card.Body>Homeだよ</Card.Body>
                </Card>
            </Container>

            <Footer />
        </>
    );
}

export default Home;
components/News.js
import React from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
    Card,
} from 'react-bootstrap';

function News() {
    return (
        <>
            <Header />

            <Container>
                <p className="h3">News</p>
                <Card>
                    <Card.Body>Newsだよ</Card.Body>
                </Card>
            </Container>

            <Footer />
        </>
    );
}

export default News;
components/Chat.js
import React from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
    Card,
} from 'react-bootstrap';

function Chat() {
    return (
        <>
            <Header />

            <Container>
                <p className="h3">Chat</p>
                <Card>
                    <Card.Body>Chatだよ</Card.Body>
                </Card>
            </Container>

            <Footer />
        </>
    );
}

export default Chat;
components/Header.js
import React from 'react';

import {
    Container,
    Nav,
    Navbar
} from "react-bootstrap";

function Header() {
    return (
        <>
            <Navbar className="mb-3" bg="light" expand="md">
                <Container>
                    <Navbar.Brand>TEST_PROJECT</Navbar.Brand>
                    <Navbar.Toggle aria-controls="basic-navbar-nav" />
                    <Navbar.Collapse id="basic-navbar-nav">
                        <Nav className="me-auto">
                            <Nav.Link>Home</Nav.Link>
                            <Nav.Link>News</Nav.Link>
                            <Nav.Link>Chat</Nav.Link>
                        </Nav>
                    </Navbar.Collapse>
                </Container>
            </Navbar>
        </>
    );
}

export default Header;
components/Footer.js
import React from 'react';

function Footer() {
    return (
        <>
            <footer className="text-center mt-3" bg="light">
                <small>
                    © Copyright TEST_PROJECT
                </small>
            </footer>
        </>
    );
}

export default Footer;

App.jsを書き換えて、ルーティング設定します。
pathで該当したリクエストがあった場合、elementで指定したコンポーネントを画面表示してくれるようになります。

App.js
import './App.css';
import React from "react";
import {
    BrowserRouter as Router,
    Routes,
    Route,
} from 'react-router-dom';

// スタイルシート
import 'bootstrap/dist/css/bootstrap.min.css';

import Home from "./components/Home";
import News from "./components/News";
import Chat from "./components/Chat";

function App() {
    return (
        <Router>
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/News" element={<News />} />
                <Route path="/Chat" element={<Chat />} />
            </Routes>
        </Router>
    );
}

export default App;

ただ、このままでは各ページのリンクが存在しませんので、ヘッダーにリンクを設定します。
今回React-Bootstrapのコンポーネントで生成されたDOMに対してリンクを設定したいので、Nav.Link にonClickイベントを設定してみます。

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

import {
    Container,
    Nav,
    Navbar
} from "react-bootstrap";
import { useNavigate } from "react-router-dom";

function Header() {
    const navigate = useNavigate();

    return (
        <>
            <Navbar className="mb-3" bg="light" expand="md">
                <Container>
                    <Navbar.Brand>TEST_PROJECT</Navbar.Brand>
                    <Navbar.Toggle aria-controls="basic-navbar-nav" />
                    <Navbar.Collapse id="basic-navbar-nav">
                        <Nav className="me-auto">
                            <Nav.Link onClick={() => navigate('/')}>Home</Nav.Link>
                            <Nav.Link onClick={() => navigate('/News')}>News</Nav.Link>
                            <Nav.Link onClick={() => navigate('/Chat')}>Chat</Nav.Link>
                        </Nav>
                    </Navbar.Collapse>
                </Container>
            </Navbar>
        </>
    );
}

export default Header;

画面上部のナビゲーションバーをクリックして、各ページで画面遷移ができるようになりました!

localhost:3000/

localhost:3000/News

ちなみに、ただのリンク要素を生成したいだけなら、Linkコンポーネントを使うことができます。
下記のように実装するだけでよいです。

components/Link.js
import { Link } from 'react-router-dom';

中略

    <Link to="/">Home</Link>
    <Link to="/News">News</Link>
    <Link to="/Chat">Chat</Link>

7. Amplifyプロジェクトのデプロイ

ローカルの検証が終わったら、サーバにデプロイしてみましょう。
Amplify構築時にデプロイ&ホスティング開始時に実行した、amplify publish コマンドを実行するだけでデプロイが完了します。簡単ですね。

$ amplify publish

8. ルート以外のリクエストで「Access Denied」エラーにならないようにする

ローカル環境では問題ないのですが、サーバに上げてみるとルート以外に直でリクエストしたりブラウザリロードすると「Access Denied」エラーとなってしまいます。
ルート以外のリクエストに書き換えてリダイレクトすることでこの事象を回避することができます。AWSマネジメントコンソールで設定を行いましょう。

  1. AWSマネジメントコンソールでAmplifyを開く
  2. アプリの設定>書き換えてリダイレクトを選択する
  3. 編集を選択する
  4. 下記ルールを設定して保存する
送信元アドレス:</^[^.]+$|\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|woff2|ttf|map|json)$)([^.]+$)/>
ターゲットアドレス:index.html
入力:200(書き換え)

第3章: WebSocket通信のチャットアプリを作ってみる

ここまで2章に掛けて、バックエンドの環境構築を行ってきました。
ここからは、チャットアプリの実装の方に入ります。

今回WebSocket通信を実装するに当たり、下記記事を参考にさせていただきました。

▼ReactとSocket.ioを使用してリアルタイムアプリケーションを構築する
https://ichi.pro/react-to-socket-io-o-shiyoshite-riarutaimu-apurike-shon-o-kochikusuru-269122676139351

1. WebSocket通信とは

WebサーバとWebブラウザの間で双方向通信できるようにする技術仕様のことです。
HTTPは通常、クライアントからの要求にサーバが応答する形で通信を行いますが、WebSocketでは一旦接続が確立していれば、双方向でメッセージのやり取りを行うことができます。

▼WebSocket
https://e-words.jp/w/WebSocket.html


2. チャットアプリの処理の流れ

クライアントとサーバのやり取りを分かりやすくするため、シーケンス図を作成してみました。
なんとなくやりたいことが理解していただけるかと思います。


3. パッケージのインストール

まずはクライアント側から実装します。

チャット画面のUIは chat-ui-kit-react パッケージが使いやすそうだったので採用しました。

$ npm install @chatscope/chat-ui-kit-react
$ npm install @chatscope/chat-ui-kit-styles

▼chat-ui-kit-react
https://github.com/chatscope/chat-ui-kit-react

今回は機能の一部しか使いませんが、これを使えばチャットアプリの一通りのUIは整えられそうです。

下記スクショはchat-ui-kit-reactの公式ドキュメントより。

メッセージの送受信は、WebSocket通信で行いたいので Socket.IO を採用します。
クライアントには socket.io-client パッケージをインストールします。

$ npm install socket.io-client

▼socket.io
https://socket.io/


4. チャット画面のUI実装

前項で作成したChat.jsを修正して、チャットの画面を作ってみます。

components/Chat.js
import React, { Component, useRef, useState, useEffect } from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
} from 'react-bootstrap';

// chat-ui-kit-react
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
    MainContainer,
    ChatContainer,
    MessageList,
    Message,
    MessageInput,
    Avatar
} from "@chatscope/chat-ui-kit-react";

// 現在の表示メッセージ
let currentMessages = [];

function Chat() {

    const [messages, setMessages] = useState([]);

    const sendMessage = (innerHtml, textContent, innerText, nodes) => {
        console.log('Chat : sendMessage()');
        console.log('innerHtml : ' + innerHtml + ' : textContent : ' + textContent + ' : innerText : ' + textContent + ' : nodes : ' + JSON.stringify(nodes));
    };

    return (
        <>
            <Header />

            <Container className="chat_container">

                <p className="h3">Chat</p>

                <MainContainer>
                    <ChatContainer>
                        <MessageList>
                            {messages}
                        </MessageList>
                        <MessageInput id="message_input" placeholder="メッセージを入力" attachButton={false} onSend={sendMessage} />
                    </ChatContainer>
                </MainContainer>

            </Container>

            <Footer />
        </>
    );
}

export default Chat;
index.css
html {
  width: 100%;
  height: 100%;
}

body {
  width: 100%;
  height: 100%;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

#root {
  width: 100%;
  height: 100%;
}

.chat_container {
  height: calc(100% - 170px);
  max-width: 600px;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

詳細については省略しますが、基本的にはchat-ui-kit-reactの公式ドキュメントに則って実装したものです。
これだけでも、だいぶチャットっぽい感じに近づいてきましたね。

試しにメッセージを送信してみましょう。
入力ボックスに適当なメッセージを入力して送信ボタンを押します。
デベロッパーツールのConsoleにログが残っていれば、正しくイベントハンドラが設定されています。

では、コードの解説をします。

state

components/Chat.js
const [messages, setMessages] = useState([]);

中略

<MessageList>
    {messages}
</MessageList>

Reactには、クラス・関数内でデータを保持して動的に変えられる「state」という機能があります。
これをより簡単な記述で書けるフック(hook)と組み合わせたものが useState です。
useStateで初期化しておき、 messages をJSXで書くことで、messagesの内容がレンダリングされます。また、setMessages()を更新することでmessagesの内容が再レンダリングされます。
あとから再レンダリングする予定のある要素は「state」で定義しておきましょう。

props

components/Chat.js
    const sendMessage = (innerHtml, textContent, innerText, nodes) => {
        console.log('Chat : sendMessage()');
    };

中略

<MessageInput id="message_input" placeholder="メッセージを入力" attachButton={false} onSend={sendMessage} />

Reactには、コンポーネント間でデータの受け渡しを行って処理内容を動的に変えられる「props」という機能があります。
MessageInputコンポーネントの場合、id, placeholder, attachButton, onSendといったプロパティが設定されていますね。

今回の例だと、MessageInputコンポーネント側はid, placeholder, attachButton, onSendの値が受け取れるので、これらのデータを使ってメッセージ入力ボックス+送信ボタンの要素を返却してくれます。
もう少し具体的に説明するため、MessageInputコンポーネントのonSendについて注目してみましょう。
MessageInputコンポーネントはchat-ui-kit-reactパッケージのものですので、公式ドキュメントを読むと下記のように記載されています。

Name Description Default
onSend onSend handler
(innerHtml: String, textContent: String, innerText: String, nodes: NodeList)
noop

▼chat-ui-kit-reactの公式ドキュメント
https://chatscope.io/storybook/react/?path=/story/documentation-introduction--page

つまり、onSendでイベントハンドラを設定してくれれば、送信ボタンクリック時に指定した関数が呼び出されて、その関数では送信メッセージが受け取れますよ、ということになります。


5. クライアント→サーバのSocket.IO実装

クライアント側

入力したメッセージをサーバ側に送るよう実装してみます。
Chat.jsに、WebSocket通信ができるよう手を加えます。

components/Chat.js
import React, { useRef, useState, useEffect } from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
} from 'react-bootstrap';

// chat-ui-kit-react
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
    MainContainer,
    ChatContainer,
    MessageList,
    Message,
    MessageInput,
    Avatar
} from "@chatscope/chat-ui-kit-react";

// 現在の表示メッセージ
let currentMessages = [];

// socket.io-client
import { io } from "socket.io-client";
import userIconBlue from '../images/figure_one_blue.png';   // 適当なユーザアイコンを用意 
import userIconGreen from '../images/figure_one_green.png'; // 適当なユーザアイコンを用意 

const SERVER = "http://localhost:8080"; // WebSocket通信のサーバURL
let socket = null;
let sender = "default";

function Chat() {

    const [messages, setMessages] = useState([]);

    useEffect(() => {
        (async () => {
            console.log('Chat : useEffect()');

            socket = io(SERVER);
            socket.on('connection', () => {
                console.log('start connection. socket.id=' + socket.id);
                sender = socket.id;
            });
        })();
    }, []);
    
    const sendMessage = (innerHtml, textContent, innerText, nodes) => {
        console.log('Chat : sendMessage()');
        console.log('innerHtml : ' + innerHtml + ' : textContent : ' + textContent + ' : innerText : ' + textContent + ' : nodes : ' + JSON.stringify(nodes));

        socket.emit('sendMessage', {
            message: innerHtml,
            sentTime: new Date().getTime(),
            sender: socket.id,
        });
    };

    return (
        <>
            <Header />

            <Container className="chat_container">

                <p className="h3">Chat</p>

                <MainContainer>
                    <ChatContainer>
                        <MessageList>
                            {messages}
                        </MessageList>
                        <MessageInput id="message_input" placeholder="メッセージを入力" attachButton={false} onSend={sendMessage} />
                    </ChatContainer>
                </MainContainer>

            </Container>

            <Footer />
        </>
    );
}

export default Chat;

では、コードの解説をします。

useEffect

components/Chat.js
useEffect(() => {
 
}, []);

Reactはstate等の更新があった時はレンダリングが行われるので、初回レンダリング時のみ実行させたい場合はuseEffect関数を使用します。
第一引数で処理を書いて、第二引数を空の配列で指定します。

WebSocket通信の実装は1回のみレンダリングさせたいので、useEffect関数を使っています。

socket.on

components/Chat.js
socket = io(SERVER);
socket.on('connection', () => {
    console.log('start connection. socket.id=' + socket.id);
    sender = socket.id;
});

サーバとのWebSocket通信のコネクションを確立させる処理です。
成功したか確認したいのでデバッグログも書いています。

socket.emit

components/Chat.js
socket.emit('sendMessage', {
    message: innerHtml,
    sentTime: new Date().getTime(),
    sender: socket.id,
});

WebSocketで送信する処理です。
今回は、メッセージ内容、送信した日時、socketトークンを送っています。


サーバ側

次にサーバ側を実装します。クライアント側とは別のフォルダで管理してください。
自分は「test_server」フォルダを作成しました。

では、まずはパッケージ管理のためpackage.jsonを用意します。

$ cd test_server
$ npm init

package name: (test_server) # パッケージ名(「test_server」を入力)
version: (1.0.0)            # バージョン(Enterでスキップ)
description:                # 説明(Enterでスキップ)
entry point: (index.js)     # エントリポイント(「server.js」を入力)
test command:               # テストコマンド(Enterでスキップ)
git repository:             # Gitリポジトリ(Enterでスキップ)
keywords:                   # キーワード(Enterでスキップ)
author:                     # キーワード(Enterでスキップ)
license: (ISC)              # ライセンス(Enterでスキップ)

パッケージのインストール

WebSocket通信のため socket.io パッケージをインストールします。

$ npm install socket.io

クライアントからリクエストを受け取るため express パッケージをインストールします。

$ npm install express

server.jsの実装

server.jsを作成して、下記のように実装します。

server.js
const app = require('express')();
const http = require('http').createServer(app);

const PORT = 8080;
const io = require('socket.io')(http, {
    cors: {
        methods: ['GET', 'POST'],
        origin: "*"
    }
});

http.listen(PORT, () => {
    console.log(`listening on *:${PORT}`);
});

io.on('connection', (socket) => {
    console.log('new client connected. socket.id=' + socket.id);
    socket.emit('connection', null);

    socket.on('sendMessage', (message) => {
        console.log('request sendMessage. message=' + JSON.stringify(message));
    });
});

require(socket.io)

server.js
const io = require('socket.io')(http, {
    cors: {
        methods: ['GET', 'POST'],
        origin: "*"
    }
});

socket.ioの読み込み部分。
socket.ioをrequireするだけでは、オリジンが異なるサーバへのアクセスが制限されてしまうのでCORS設定を行っている。

http.listen

server.js
http.listen(PORT, () => {
    console.log(`listening on *:${PORT}`);
});

該当ポート(8080)をリッスン開始。

io.on

server.js
io.on('connection', (socket) => {
    console.log('new client connected. socket.id=' + socket.id);
    socket.emit('connection', null);
});

クライアントとのコネクションを確立する処理。

socket.on

server.js
    socket.on('sendMessage', (message) => {
        console.log('request sendMessage. message=' + JSON.stringify(message));
    });

クライアントからsendMessageリクエストを受けた際の処理。


6. クライアント→サーバの動作確認

server.jsの実行

では、実際にメッセージが送信できるか確認してみます。
server.jsを実行します。

$ node server.js
listening on *:8080

8080ポートでリッスンが開始されました。

ブラウザからメッセージ送信してみる

クライアント

デベロッパーツール

サーバ

サーバにメッセージが届いていることが確認できました!


7. サーバ→クライアントのSocket.IO実装

サーバ側

受け取ったメッセージをクライアント側に返すよう、server.jsを改修します。
io.emit で、サーバにコネクション確立している全クライアントにメッセージを送信します。

server.js
socket.on('sendMessage', (message) => {
    console.log('request sendMessage. message=' + JSON.stringify(message));
    io.emit('message', message);
});

改修した後は、server.jsを一度止めて再実行しておきます。

$ node server.js

クライアント側

Chat.jsを修正して、サーバから送られたメッセージを受け取ってDOMに反映してみます。

components/Chat.js
    const [messages, setMessages] = useState([]);

    useEffect(() => {
        (async () => {
            console.log('Chat : useEffect()');

            socket = io(SERVER);
            socket.on('connection', () => {
                console.log('start connection. socket.id=' + socket.id);
                sender = socket.id;
            });

            socket.on('message', (res) => {
                console.log('receive message.');

                if (res) {
                    let object = isJSON(res) ? JSON.parse(res) : res;
                    console.log('Chat : useEffect() : message : object=' + JSON.stringify(object));
                    currentMessages.push(createMessageElement(object, currentMessages.length));
                    console.log('Chat : useEffect() : message : currentMessages=' + JSON.stringify(currentMessages));

                    // stateのコピー(配列のstateに対して追加削除を行う場合はコピーしてから反映させる)
                    const newMessages = Array.from(currentMessages);

                    console.log('Chat : useEffect() : message : newMessages=' + JSON.stringify(newMessages));
                    setMessages(newMessages);
                }
            });
        })();
    }, []);

    // メッセージの要素作成
    const createMessageElement = (item, index) => {
        return (
            <Message
                key={index}
                model={{
                    message: item.message,
                    sentTime: "" + item.sentTime,
                    sender: "" + item.sender,
                    direction: (item.sender === socket.id ? 'incoming' : 'outgoing')
                }}>
                <Avatar src={(item.sender === socket.id ? userIconBlue : userIconGreen)} name={item.sender} />
            </Message>
        );
    }

    // JSON書式かどうか
    const isJSON = (item) => {
        try {
            JSON.parse( item );
            return true;
        } catch (error) {
            return false;
        }
    };

8. サーバ→クライアントの動作確認

ブラウザでチャット画面を2窓で開いて、メッセージに送ってみてください。
双方向でチャットのやり取りができるようになっていると思います。

実際に動作させたデモも貼っておきます。(12秒)

第4章: チャットアプリをサーバで動かしてみる

ローカルでは問題なかったので、サーバに上げてみましょう。
だいぶ説明を省いていますがご了承ください。

1. サーバ用意

EC2でサーバを建てます。
今回はEC2の t4g.micro インスタンスで建てました。

セキュリティグループで、SSH接続が通るようにしておきます。
WebSocket+SSLで使用するポートも開けておく必要があるので、8080ポートを開けておきます。

2. ユーザ作成

ユーザを作成します。

$ useradd ユーザ名
$ passwd パスワード

sudoが実行できるようにします。

$ sudo visudo

下記行を追加。
ユーザ名    ALL=(ALL)       NOPASSWD: ALL

鍵を作成します。

$ sudo su - ユーザ名
$ cd /home/ユーザ名
$ mkdir .ssh
$ cd .ssh
$ ssh-keygen -t rsa
$ mv id_rsa.pub authorized_keys

.ssh/id_rsa が秘密鍵なので、これをローカルに保存しておきます。
ローカルからSSH接続できたら成功です。

$ ssh -i 秘密鍵 ユーザ名@サーバIPアドレス

3. サーバ環境構築

では、サーバにSSH接続して、サーバ環境を構築します。
nvm(Node Version Manager)を使ってNode.jsをインストールします。

$ sudo yum update
$ sudo yum install git gcc-c++ make openssl-devel
$ git clone git://github.com/creationix/nvm.git .nvm
$ source ~/.nvm/nvm.sh
$ vi .bash_profile

次回ログイン時にも読み込まれるよう、下記テキストを追加。
# nvm
if [[ -s ~/.nvm/nvm.sh ]] ; then
        source ~/.nvm/nvm.sh ;
fi

Node.jsをインストールします。

$ nvm ls-remote

インストール可能なバージョン一覧が表示される。

       v0.1.14
       v0.1.15
       v0.1.16
         :
         :
       v12.22.4   (LTS: Erbium)
       v12.22.5   (LTS: Erbium)
       v12.22.6   (LTS: Erbium)
       v12.22.7   (Latest LTS: Erbium)
         :
         :
       v16.11.1
       v16.12.0
       v16.13.0   (LTS: Gallium)
       v16.13.1   (Latest LTS: Gallium)

ローカル開発ではv12を使っていたので、v12.22.7(Latest LTS)をインストールします。

$ nvm install v12.22.7
$ node -v
v12.22.7

Node.jsがインストールされました。

4. server.jsのサーバ実行

では、実際にサーバにコードをデプロイして実行してみます。
デプロイ方法は、scpでもGit経由でもなんでもよいです。
上げたら実行してみます。

$ node server.js
listening on *:8080

サーバで実行されました!

5. クライアントのデプロイ

クライアントに記載されているサーバURLを変更してみます。

Chat.js
// WebSocket通信のサーバURL
// const SERVER = "http://localhost:8080";
const SERVER = "http://サーバIPアドレス:8080"

デプロイして動作確認してみます。さあどうなる…!?

$ amplify publish

プラウザの開発者ツールのConsoleに下記のエラーが出てしまっていました。。
どうやら、クライアント側はHTTPSでセキュアな通信なのに、サーバ側へのリクエストがHTTP通信なのでセキュアではないとChromeでブロックされてしまっているようです。
仕方ないのでサーバ側のSSL化を行ってみます。

6. 自己署名証明書の作成

Node.js+ExpressでSSLが使えそうなので、こちらでやってみます。
秘密鍵とサーバ証明書が必要なようです。今回は自己署名証明書を作ってゴリ押ししてみます。
※Chromeで止められそうだなあと思いつつも、とりあえず動くことが確認できることを目標にします。。

test_serverフォルダに、certificateフォルダを新しく作成しておきます。

$ cd certificate
$ openssl req -x509 -sha256 -nodes -days 9999 -newkey rsa:2048 -keyout server_key.pem -out server_cert.pem

秘密鍵と自己署名証明書が作成できました。

▼秘密鍵、公開鍵、証明書、CSR生成のOpenSSLコマンドまとめ
https://blog.freedom-man.com/openssl-command

7. ローカル環境でSSL起動してみる

まずローカル環境で、Node.js+ExpressでSSL化ができるのか確認してみます。
ローカル環境のアプリ起動は npm start ですが、HTTPS=trueにするとSSL起動できるようです。

$ set HTTPS=true&&npm start

ただ、ブラウザ表示でプライバシー保護のエラーが。。
良くないのですが、ここではローカル環境のセキュリティ警告を無視して進めてみます。

8. SSL対応

コード修正します。

server.js

ポートはSSL化に伴い、8443ポートに変更してます。

server.js
const app = require('express')();

const fs = require('fs');
const SERVER_KEY = './certificate/server_key.pem';    # 秘密鍵
const SERVER_CERT = './certificate/server_cert.pem';  # サーバ証明書
const credentials = {
    key: fs.readFileSync(SERVER_KEY),
    cert: fs.readFileSync(SERVER_CERT),
};
const https = require('https').createServer(credentials, app);  # HTTPSサーバの起動
const PORT = 8443;
const io = require('socket.io')(https, {  # Socket.IOのやり取りをhttpsに変更
    cors: {
        methods: ['GET', 'POST'],
        origin: "*"
    }
});

https.listen(PORT, () => {  # 8443ポートでリッスン状態に
    console.log(`listening on *:${PORT}`);
});

io.on('connection', (socket) => {
    console.log('new client connected. socket.id=' + socket.id);
    socket.emit('connection', null);

    socket.on('sendMessage', (message) => {
        console.log('request sendMessage. message=' + JSON.stringify(message));
        io.emit('message', message);
    });
});

Chat.js

Chat.js
import React, { useRef, useState, useEffect } from "react";

import Header from './Header';
import Footer from './Footer';
import {
    Container,
} from 'react-bootstrap';

// socket.io-client
import { io } from "socket.io-client";

// chat-ui-kit-react
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
    MainContainer,
    ChatContainer,
    MessageList,
    Message,
    MessageInput,
    Avatar
} from "@chatscope/chat-ui-kit-react";
import userIconBlue from '../images/figure_one_blue.png';
import userIconGreen from '../images/figure_one_green.png';

// 現在の表示メッセージ
let currentMessages = [];

// WebSocket通信のサーバURL
const SERVER = "https://localhost:8443";  # 8443ポートに接続するよう変更
// const SERVER = "https://サーバIPアドレス:8443"
let socket = null;
let sender = "default";

function Chat() {

    const [messages, setMessages] = useState([]);

    useEffect(() => {
        (async () => {
            console.log('Chat : useEffect()');

            socket = io(SERVER);
            socket.on('connection', () => {
                console.log('start connection. socket.id=' + socket.id);
                sender = socket.id;
            });

            socket.on('message', (res) => {
                console.log('receive message.');

                if (res) {
                    let object = isJSON(res) ? JSON.parse(res) : res;
                    console.log('Chat : useEffect() : message : object=' + JSON.stringify(object));
                    currentMessages.push(createMessageElement(object, currentMessages.length));
                    console.log('Chat : useEffect() : message : currentMessages=' + JSON.stringify(currentMessages));

                    // stateのコピー(配列のstateに対して追加削除を行う場合はコピーしてから反映させる)
                    const newMessages = Array.from(currentMessages);

                    console.log('Chat : useEffect() : message : newMessages=' + JSON.stringify(newMessages));
                    setMessages(newMessages);
                }
            });
        })();
    }, []);

    // メッセージの要素作成
    const createMessageElement = (item, index) => {
        return (
            <Message
                key={index}
                model={{
                    message: item.message,
                    sentTime: "" + item.sentTime,
                    sender: "" + item.sender,
                    direction: (item.sender === socket.id ? 'incoming' : 'outgoing')
                }}>
                <Avatar src={(item.sender === socket.id ? userIconBlue : userIconGreen)} name={item.sender} />
            </Message>
        );
    };

    // JSON書式かどうか
    const isJSON = (item) => {
        try {
            JSON.parse( item );
            return true;
        } catch (error) {
            return false;
        }
    };

    const sendMessage = (innerHtml, textContent, innerText, nodes) => {
        console.log('Chat : sendMessage()');
        console.log('innerHtml : ' + innerHtml + ' : textContent : ' + textContent + ' : innerText : ' + textContent + ' : nodes : ' + JSON.stringify(nodes));

        socket.emit('sendMessage', {
            message: innerHtml,
            sentTime: new Date().getTime(),
            sender: socket.id,
        });
    };

    return (
        <>
            <Header />

            <Container className="chat_container">

                <p className="h3">Chat</p>

                <MainContainer>
                    <ChatContainer>
                        <MessageList>
                            {messages}
                        </MessageList>
                        <MessageInput id="message_input" placeholder="メッセージを入力" attachButton={false} onSend={sendMessage} />
                    </ChatContainer>
                </MainContainer>

            </Container>

            <Footer />
        </>
    );
}

export default Chat;

問題なく動いています!(Chrome先生のお叱りを無視しつつ)

9. サーバ環境の最新化

Chat.jsの向き先をサーバに変更。

// const SERVER = "https://localhost:8443";
const SERVER = "https://サーバIPアドレス:8443"

あとは、クライアント側とサーバ側のコードをそれぞれデプロイして、開けていたポートも8443に変更して、動作確認してみます。
サーバでも同様に、ブラウザ表示でプライバシー保護のエラーが出てしまいました。。
それも、自己署名証明書が原因でブラウザに怒られてしまい通してくれません…ちゃんとSSL証明書作らないと駄目っぽい…
その後、(Chrome先生に怒られながらも)試行錯誤試して、サーバ環境で動かすことはできたので、こちらで今回の成果とさせていただきます。

最終的な動作デモです!(20秒)

【非推奨】無理やり通すために一部修正した名残…

server.js
const credentials = {
    key: fs.readFileSync(SERVER_KEY),
    cert: fs.readFileSync(SERVER_CERT),
    rejectUnauthorized: false,
}
Chat.js
socket = io(SERVER, {
    secure: true,
    rejectUnauthorized: false
});

あとがき

今回、Amplifyの使い方と、Amplify+React+Node.jsを使ったWebSocket通信の実装方法をご紹介してみましたがいかがでしたでしょうか?
AmplifyやReactを上手く活用できていたかは怪しいのですが、個人的にはバックエンドにそこまで注力することなく、さくっとSPAを作ってWebSocketの技術を試せたので楽しかったです。
これを機に、今までAmplifyやReact、Node.jsに触ったことのない人も興味を持っていただければ嬉しく思います。

CYBIRD Advent Calendar 2021 16日目は @kyukkyu81 さんの「VoiceUI的な何か」です。
VoiceUIはここ数年で一気に普及しましたが、自分はほとんど使えておらず詳しくないので、勉強させていただこうと思います!
皆さんも是非ご覧ください!

参考文献

8
4
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
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?