作ったもの
Reactとgrpc-webでこのような簡易チャットを作成してみました。
作成してみたコード↓
以前やってみた、こちらの投稿で開発環境を作成しています
gRPC-Web Hello World Guideをやってみた
全体
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;
}
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;
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. チャットに追加する部分
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
の配列にチャット内容を追加していきます。
/**
* チャットデータ
*
* @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);
)
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. チャットを取得する部分
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
// ...
/**
* @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
でサーバーからのデータをリッスンするようにしました。
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が入力中です
」と表示させる部分
service ChatCaller {
// ...
rpc TypingChat(TypingUserRequest) returns (SuccessReply);
}
// ...
message TypingUserRequest {
int32 user_id = 1;
}
入力中のユーザーIDをSetで保存しておきます
// ...
/**
* 入力中のユーザーの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 });
}
// ...
ユーザーが入力中に一定間隔でサーバーに通信して入力中のユーザー情報を送信します。
/**
* @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