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

# 作ったもの

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

![HcFeXRcpqs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/3af46c56-c7c9-98f5-dfba-99aa7d3e1f88.gif)

作成してみたコード↓

<a href="https://github.com/okumurakengo/react-grpc-web-chat"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/okumurakengo/react-grpc-web-chat.png" width="460px"></a>

---

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

[gRPC-Web Hello World Guideをやってみた](https://qiita.com/okumurakengo/items/cc696cdf28850d54775d)

---

# 全体

```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`の第２引数に`[]`を指定して、最初にレンダリングされた時だけと指定できるので、そこで`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