10
12

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.

ReactAdvent Calendar 2019

Day 23

Reactでgprc-webを使った簡易チャットを作成してみた

Posted at

作ったもの

Reactとgrpc-webでこのような簡易チャットを作成してみました。

HcFeXRcpqs.gif

作成してみたコード↓


以前やってみた、こちらの投稿で開発環境を作成しています

gRPC-Web Hello World Guideをやってみた


全体

simplechat.proto
syntax = "proto3";

package simplechat;

service ChatCaller {
  rpc AddChat (ChatDataRequest) returns (SuccessReply);
  rpc RepeatChat(RepeatChatRequest) returns (stream ChatListReply);
  rpc TypingChat(TypingUserRequest) returns (SuccessReply);
}

message ChatData {
  int32 user_id = 1;
  string text = 3;
}

message ChatDataRequest {
  ChatData chat_data = 1;
}

message SuccessReply {
  bool result = 1;
}

message RepeatChatRequest {
}

message ChatListReply {
  repeated ChatData chat_data = 1;
  repeated int32 typing_user_id = 2;
}

message TypingUserRequest {
  int32 user_id = 1;
}
server.js
const PROTO_PATH = __dirname + "/simplechat.proto";

const grpc = require("grpc");
const protoLoader = require("@grpc/proto-loader");
const packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
const simplechat = protoDescriptor.simplechat;

/**
 * チャットデータ
 *
 * @typedef {Object} chatData
 *
 * @property {number} userId
 * @property {string} text
 * @property {Date} postDate
 */
/**
 * @type {chatData[]} chatDataList
 */
let chatDataList = [];
/**
 * 新しく追加されたチャットデータ
 * 
 * @type {chatData[]} latestChatDataList
 */
let latestChatDataList = [];
/**
 * 入力中のユーザーのID
 * 
 * @type {Set<number>} typingUserIds
 */
let typingUserIds = new Set;

(async function repeat() {
  while (true) {
    let lastGetDate = new Date;
    await new Promise(resolve => setTimeout(resolve, 2000));

    // 最新のチャットのみを保存しておく
    latestChatDataList = [];
    for (const { userId, text, postDate } of chatDataList) {
      if (lastGetDate.getTime() <= postDate.getTime()) {
        latestChatDataList.push({ user_id: userId, text });
        continue;
      }
      break;
    }

    // 入力中のユーザー初期化
    typingUserIds = new Set;
  }
})();

// チャット追加
function doAddChat(call, callback) {
  const { chat_data: { user_id: userId, text } } = call.request;
  chatDataList = [ 
    { userId, text, postDate: new Date },
    ...chatDataList, 
  ];
  callback(null, { result: true });
}

// チャット取得
async function doRepeatChat(call) {
  // 接続開始時に全てのチャットの内容を取得
  call.write({
    chat_data: [...chatDataList].reverse()
      .map(({ channel, userId: user_id, text }) => ({ channel, user_id, text })),
  });

  // 接続している間はループ、接続が切れたらループを抜ける
  while (!call.cancelled) {
    await new Promise(resolve => setTimeout(resolve, 2000));
    call.write({ chat_data: [...latestChatDataList].reverse(), typing_user_id: [...typingUserIds] });
  }
}

// 入力しているユーザー追加
function doTypingChat(call, callback) {
  typingUserIds.add(call.request.user_id);
  callback(null, { result: true });
}

function getServer() {
  const server = new grpc.Server();
  server.addService(simplechat.ChatCaller.service, {
    addChat: doAddChat,
    repeatChat: doRepeatChat,
    typingChat: doTypingChat,
  });
  return server;
}

if (require.main === module) {
  const server = getServer();
  server.bind("0.0.0.0:9090", grpc.ServerCredentials.createInsecure());
  server.start();
}

exports.getServer = getServer;
App.jsx
import React, { useState, useMemo, useCallback, useEffect, Fragment, useRef } from "react";
import "./index.css";
const { ChatData, ChatDataRequest, RepeatChatRequest, TypingUserRequest } = require("../generate/simplechat_pb.js");
const { ChatCallerClient } = require("../generate/simplechat_grpc_web_pb.js");

const users = [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
    { id: 3, name: "Carol" },
];

/**
 * @see {@link https://github.com/bhaskarGyan/use-throttle}
 */
function useThrottle(value, limit) {
    const [throttledValue, setThrottledValue] = useState(value);
    const lastRan = useRef(Date.now());

    useEffect(() => {
        const handler = setTimeout(function () {
            if (Date.now() - lastRan.current >= limit) {
                setThrottledValue(value);
                lastRan.current = Date.now();
            }
        }, limit - (Date.now() - lastRan.current));

        return () => {
            clearTimeout(handler);
        };
    }, [value, limit]);

    return throttledValue;
}

const App = () => {
    const [userId, setUserId] = useState(1);
    const [text, setText] = useState("");

    const [chatDataList, setChatDataList] = useState([]);
    const chatEl = useRef(null);

    const [typingUserIds, setTypingUserIds] = useState([]);

    const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []);

    // チャットに新規投稿する
    const callAddChatData = useCallback(async postText => {
        const chatData = new ChatData;
        const request = new ChatDataRequest;

        chatData.setUserId(userId);
        chatData.setText(postText)
        request.setChatData(chatData);

        await new Promise(
            resolve => client.addChat(request, {}, (err, response) => {
                resolve(response.getResult());
            })
        );
        setText("")
    }, [userId]);

    // 新規チャットがあれば取得するためストリームを開く
    useEffect(() => {
        const stream = client.repeatChat(new RepeatChatRequest, {});
        stream.on("data", response => {
            setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]);
            setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList());
        });

        const handleBeforeunload = () => stream.cancel();
        window.addEventListener("beforeunload", handleBeforeunload);
        return () => {
            window.removeEventListener("beforeunload", handleBeforeunload)
        };
    }, []);

    // チャットに新着があれば下にスクロール
    useEffect(() => {
        chatEl.current.scrollTop = chatEl.current.scrollHeight
    }, [chatDataList.length])

    // 入力しているユーザーの情報を追加
    const throttleText = useThrottle(text, 700)
    useEffect(() => {
        const request = new TypingUserRequest;
        request.setUserId(userId);
        if (throttleText.trim()) {
            client.typingChat(request, {}, () => {});
        }
    }, [throttleText]);

    const typingUserInfo = useMemo(() => {
        const typingOtherUserIds = typingUserIds.filter(typingUserId => typingUserId !== userId)
        if (typingOtherUserIds.length === 0) {
            return '';
        }
        if (typingOtherUserIds.length === 1) {
            const user = users.find(user => user.id === typingOtherUserIds[0]);
            return `${user.name}が入力しています`;
        }
        if (typingOtherUserIds.length > 1) {
            return '複数人が入力しています';
        }
    }, [userId, typingUserIds]);

    return (
        <>
            <h1>Simple Chat</h1>
            <div className="flex">
                <div className="left">
                    <select value={userId} onChange={e => setUserId(Number(e.target.value))}>
                        {users.map(({ id, name }) => <option key={id} value={id}>{name}</option>)}
                    </select>
                </div>
                <div className="right" ref={chatEl}>
                    {chatDataList.map((chatData, i) => (
                        <Fragment key={i}>
                            <p>
                                <strong>
                                    {users.find(user => user.id === chatData.getUserId()).name}
                                </strong>
                            </p>
                            <p>{chatData.getText()}</p>
                            <hr />
                        </Fragment>
                    ))}
                </div>
            </div>
            <form onSubmit={e => {
                e.preventDefault();
                callAddChatData(text);
            }}>
                <input value={text} onChange={e => setText(e.target.value)} />
                <button type="submit">送信</button>
                {typingUserInfo}
            </form>
        </>
    );
};

export default App;

1. チャットに追加する部分

simplechat.proto
service ChatCaller {
  rpc AddChat (ChatDataRequest) returns (SuccessReply);
  // ...
}

message ChatData {
  int32 user_id = 1;
  string text = 3;
}

message ChatDataRequest {
  ChatData chat_data = 1;
}

message SuccessReply {
  bool result = 1;
}

簡単にチャットを作るだけなので、トップレベルに宣言した変数のchatDataListの配列にチャット内容を追加していきます。

server.js
/**
 * チャットデータ
 *
 * @typedef {Object} chatData
 *
 * @property {number} userId
 * @property {string} text
 * @property {Date} postDate
 */
/**
 * @type {chatData[]} chatDataList
 */
let chatDataList = [];

// ...

// チャット追加
function doAddChat(call, callback) {
  const { chat_data: { user_id: userId, text } } = call.request;
  chatDataList = [ 
    { userId, text, postDate: new Date },
    ...chatDataList, 
  ];
  callback(null, { result: true });
}

フォームがサブミットされた時に、チャットを投稿します。
リクエストする内容はprotoで定義した変数名に合わせる形でセットしました。(protoでuser_idと定義した場合は chatData.setUserId(userId); )

App.jsx
const App = () => {
    const [userId, setUserId] = useState(1);
    const [text, setText] = useState("");

    // ...
    const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []);

    // チャットに新規投稿する
    const callAddChatData = useCallback(async postText => {
        const chatData = new ChatData;
        const request = new ChatDataRequest;

        chatData.setUserId(userId);
        chatData.setText(postText)
        request.setChatData(chatData);

        await new Promise(
            resolve => client.addChat(request, {}, (err, response) => {
                resolve(response.getResult());
            })
        );
        setText("")
    }, [userId]);

    // ...

    return (
        <>
            // ...
            <form onSubmit={e => {
                e.preventDefault();
                callAddChatData(text);
            }}>
                <input value={text} onChange={e => setText(e.target.value)} />
                <button type="submit">送信</button>
                {typingUserInfo}
            </form>
        </>
    );
};

2. チャットを取得する部分

simplechat.proto
service ChatCaller {
  // ...
  rpc RepeatChat(RepeatChatRequest) returns (stream ChatListReply);
  // ...
}

message RepeatChatRequest {
}

message ChatListReply {
  repeated ChatData chat_data = 1;
  repeated int32 typing_user_id = 2;
}

latestChatDataListに新しく更新されたチャットの内容だけを保存しておいて、ストリームで新しいのがあればクライアントに送信します。

ストリームでクライアントとの接続が切れているかどうかの場合の判定は、ServerWritableStream.cancelledで判定で切るようでした↓
https://github.com/grpc/grpc-node/blob/master/packages/grpc-native-core/index.d.ts

server.js
// ...

/**
 * @type {chatData[]} chatDataList
 */
let chatDataList = [];
/**
 * 新しく追加されたチャットデータ
 * 
 * @type {chatData[]} latestChatDataList
 */
let latestChatDataList = [];

// ...

(async function repeat() {
  while (true) {
    let lastGetDate = new Date;
    await new Promise(resolve => setTimeout(resolve, 2000));

    // 最新のチャットのみを保存しておく
    latestChatDataList = [];
    for (const { userId, text, postDate } of chatDataList) {
      if (lastGetDate.getTime() <= postDate.getTime()) {
        latestChatDataList.push({ user_id: userId, text });
        continue;
      }
      break;
    }

    // ...
  }
})();

// ...
// チャット取得
async function doRepeatChat(call) {
  // 接続開始時に全てのチャットの内容を取得
  call.write({
    chat_data: [...chatDataList].reverse()
      .map(({ channel, userId: user_id, text }) => ({ channel, user_id, text })),
  });

  // 接続している間はループ、接続が切れたらループを抜ける
  while (!call.cancelled) {
    await new Promise(resolve => setTimeout(resolve, 2000));
    call.write({ chat_data: [...latestChatDataList].reverse(), typing_user_id: [...typingUserIds] });
  }
}

useEffectの第2引数に[]を指定して、最初にレンダリングされた時だけと指定できるので、そこでstreamでサーバーからのデータをリッスンするようにしました。

App.jsx
const App = () => {
    // ...

    const [chatDataList, setChatDataList] = useState([]);
    const chatEl = useRef(null);

    const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []);

    // ...

    // 新規チャットがあれば取得するためストリームを開く
    useEffect(() => {
        const stream = client.repeatChat(new RepeatChatRequest, {});
        stream.on("data", response => {
            setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]);
            setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList());
        });

        const handleBeforeunload = () => stream.cancel();
        window.addEventListener("beforeunload", handleBeforeunload);
        return () => {
            window.removeEventListener("beforeunload", handleBeforeunload)
        };
    }, []);

    // チャットに新着があれば下にスクロール
    useEffect(() => {
        chatEl.current.scrollTop = chatEl.current.scrollHeight
    }, [chatDataList.length])

    // ...

    return (
        <>
            <h1>Simple Chat</h1>
            <div className="flex">
                <div className="right" ref={chatEl}>
                    {chatDataList.map((chatData, i) => (
                        <Fragment key={i}>
                            <p>
                                <strong>
                                    {users.find(user => user.id === chatData.getUserId()).name}
                                </strong>
                            </p>
                            <p>{chatData.getText()}</p>
                            <hr />
                        </Fragment>
                    ))}
                </div>
            </div>
            // ...
        </>
    );
};

3. 「Bobが入力中です」と表示させる部分

simplechat.proto
service ChatCaller {
  // ...
  rpc TypingChat(TypingUserRequest) returns (SuccessReply);
}

// ...

message TypingUserRequest {
  int32 user_id = 1;
}

入力中のユーザーIDをSetで保存しておきます

server.js
// ...

/**
 * 入力中のユーザーのID
 * 
 * @type {Set<number>} typingUserIds
 */
let typingUserIds = new Set;

// ...

(async function repeat() {
  while (true) {

    // ...

    // 入力中のユーザー初期化
    typingUserIds = new Set;
  }
})();

// ...

// 入力しているユーザー追加
function doTypingChat(call, callback) {
  typingUserIds.add(call.request.user_id);
  callback(null, { result: true });
}

// ...

ユーザーが入力中に一定間隔でサーバーに通信して入力中のユーザー情報を送信します。

App.jsx
/**
 * @see {@link https://github.com/bhaskarGyan/use-throttle}
 */
function useThrottle(value, limit) {
    const [throttledValue, setThrottledValue] = useState(value);
    const lastRan = useRef(Date.now());

    useEffect(() => {
        const handler = setTimeout(function () {
            if (Date.now() - lastRan.current >= limit) {
                setThrottledValue(value);
                lastRan.current = Date.now();
            }
        }, limit - (Date.now() - lastRan.current));

        return () => {
            clearTimeout(handler);
        };
    }, [value, limit]);

    return throttledValue;
}

const App = () => {
    // ...

    const [typingUserIds, setTypingUserIds] = useState([]);

    const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []);

    // ...

    // 新規チャットがあれば取得するためストリームを開く
    useEffect(() => {
        const stream = client.repeatChat(new RepeatChatRequest, {});
        stream.on("data", response => {
            setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]);
            setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList());
        });

        const handleBeforeunload = () => stream.cancel();
        window.addEventListener("beforeunload", handleBeforeunload);
        return () => {
            window.removeEventListener("beforeunload", handleBeforeunload)
        };
    }, []);

    // ...

    // 入力しているユーザーの情報を追加
    const throttleText = useThrottle(text, 700)
    useEffect(() => {
        const request = new TypingUserRequest;
        request.setUserId(userId);
        if (throttleText.trim()) {
            client.typingChat(request, {}, () => {});
        }
    }, [throttleText]);

    const typingUserInfo = useMemo(() => {
        const typingOtherUserIds = typingUserIds.filter(typingUserId => typingUserId !== userId)
        if (typingOtherUserIds.length === 0) {
            return '';
        }
        if (typingOtherUserIds.length === 1) {
            const user = users.find(user => user.id === typingOtherUserIds[0]);
            return `${user.name}が入力しています`;
        }
        if (typingOtherUserIds.length > 1) {
            return '複数人が入力しています';
        }
    }, [userId, typingUserIds]);

    return (
        <>
            // ...
            <form onSubmit={e => {
                e.preventDefault();
                callAddChatData(text);
            }}>
                <input value={text} onChange={e => setText(e.target.value)} />
                <button type="submit">送信</button>
                {typingUserInfo}
            </form>
        </>
    );
};

最後までみていただいてありがとうございました。m(_ _)m

10
12
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
10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?