会社のプロジェクトでNuxt.jsとgrpc-webを使用しており、その周りについてタイプセーフにすることを進めてきたのでハマったところも含めてその知見についての記事になります。
ちなみに、今回使用するのはgrpc organizationによるgrpc/grpc-webではなくimprobable-eng/grpc-webです。本家はgrpc/grpc-webになりますが、色々比較した際にimprobable-eng/grpc-webのほうがTypeScript型定義周りを始めとして色々な点において良いと感じたので(スターもこっちのほうが多い)こちらを使用しています(詳しく説明された記事がありました)
そのため、つい先日こちらでgRPC-Web + Typescriptについて詳しく説明された記事がありましたが、こちらはgrpc/grpc-webを使用されているので違う観点の記事として見ていただければと思います。
なお、プロジェクトではバックエンドはGolangを使用していますが、本記事はクライアントサイドのみに焦点を当てた内容となります。(リポジトリにはGolangによるバックエンドモックサーバーも含んでいます)
概要
ディレクトリ構成は以下のようになります。(Nuxt.jsのデフォルトで用意されているものは今回に関連あるもののみ記載しています)
nuxt-grpc-web
├── go
│ ├── Gopkg.lock
│ ├── Gopkg.toml
│ ├── _proto
│ │ └── src
│ │ └── user.pb.go # gRPC API(自動生成)
│ └── exampleserver
│ └──exampleserver.go # バックエンドモックサーバー用
├── pages
│ └── index.vue # Vue
├── store
│ └── index.ts # Store
├── ts
│ └── _proto
│ └── src
│ └── user_pb_service.ts # gRPC API(自動生成)
│ └── user_pb.d.ts # gRPC API(自動生成)
│ └── user_pb.ts # gRPC API(自動生成)
├── src
│ └── user.proto # protobufファイル
├── get_go_deps.sh # Go依存ライブラリインストール用シェル
└── protoggen.sh # protoc実行用シェル
.protoファイルは以下のようなものを用意しました。
syntax = "proto3";
package helloworld;
message User {
message Info {
string description = 1;
string birthday = 2;
}
string name = 1;
repeated int32 follower_ids = 2;
Info info = 3;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
int32 id = 1;
}
message GetUserResponse {
User user = 1;
}
リクエストしたら定義されたUserを返すというものです(返されるデータはモックなのでリクエストパラメータのidは関係ありませんが)
今回はVuex周りのコード実装の部分に焦点を当てているため、実際に動かす際のセットアップはこちらのREADMEをご覧ください。
grpc-web通信部分
Before(型定義無し)
今回は主にvuexでの動きを確認する形になりますので、grpc-webを使用してサーバー側からのレスポンスをstateに反映させるところまでを書いてみました
import { grpc } from 'grpc-web-client';
import { UserService } from '../src/user_pb_service'
import { GetUserRequest } from '../src/user_pb'
const defaultUser = {
name: 'hoge',
followerIds: [],
info: {
description: '',
birthday: '1990-01-01'
}
}
export const state = () => ({
user: defaultUser
});
export const mutations = {
setUser(state, payload) {
state.user = payload.user
}
};
export const actions = {
getUser({ commit }, payload) {
const client = grpc.client(UserService.GetUser, {
host: 'http://localhost:9090',
});
const param = generateRequestParam(payload.id);
client.start();
client.send(param);
client.onMessage((message) => {
const pbUser = message.getUser();
const user = pbUser.toObject();
commit('setUser', { user });
});
}
};
const generateRequestParam = (id) => {
const req = new GetUserRequest();
req.setId(id);
return req;
}
grpcの通信の順序としては、
- hostとrpcを指定してclientを作成する
- rpcのリクエストパラメータの作成
- client start
- client send(リクエスト)
- client onMessage(レスポンス)
- responseからuserを取得
- この時点でprotobufのUser classなのでobject化
- mutationsへのコミット
といった具合です。.protoの内容に基づいてUserService
やGetUserRequest
など必要なobjectやclassがts-protoc-genによって生成されたファイルに用意されているのでそれを使用する形になります。
TypeScript化(Before)
上記ではtypescriptなしで書いていましたが、次はtypescriptで型定義を行って書いてみます。ts-protoc-genは当然定義ファイルも生成するのでそれを使用した形で書いてみます。
import { grpc } from 'grpc-web-client';
import { UserService } from '../ts/_proto/src/user_pb_service'
import { GetUserRequest } from '../ts/_proto/src/user_pb'
import {
GetUserRequest as _GetUserRequest,
GetUserResponse as _GetUserResponse,
User as _User
} from '../ts/_proto/src/user_pb.d'
const defaultUser: _User.AsObject = {
name: 'hoge',
followerIds: [],
info: {
description: '',
birthday: '1990-01-01'
}
}
type State = {
user: _User.AsObject;
};
export const state: () => State = () => ({
user: defaultUser
});
export const mutations = {
setUser(state, payload: { user: _User.AsObject }) {
state.user = payload.user
}
};
export const actions = {
getUser({ commit }, payload: { id: number }) {
const client = grpc.client(UserService.GetUser, {
host: 'http://localhost:9090',
});
const param = generateRequestParam(payload.id);
client.start();
client.send(param);
client.onMessage((message: _GetUserResponse) => {
const pbUser = message.getUser();
const user = pbUser.toObject()
commit('setUser', { user });
});
}
};
const generateRequestParam = (id: number): _GetUserRequest => {
const req = new GetUserRequest();
req.setId(id);
return req;
}
肝になるのが_User.AsObjectです、こちらが.protoで記述したUserの型をほぼそのまま定義してくれています。
ところがこのコードは誤りです、エディターでは以下のように怒られます
const stateのuser: defaultUser
の部分
[ts]
Type '{ name: string; followerIds: never[]; status: number; }' is not assignable to type 'AsObject'.
Property 'followerIdsList' is missing in type '{ name: string; followerIds: never[]; status: number; }'.
const user = pbUser.toObject();
の部分
[ts] Object is possibly 'undefined'
stateのdefault値として当てているdefaultUserが型を満たしていない、pbUserはundefinedになり得るということなので型定義ファイルを確認すると、
export namespace User {
export type AsObject = {
name: string,
followerIdsList: Array<number>,
info?: User.Info.AsObject,
}
// 中略
export namespace GetUserResponse {
export type AsObject = {
user?: User.AsObject,
}
}
となっています、followerIdsがfollowerIdsListとなり、infoがoptionalになっています。
こちらは現在のts-protoc-genの仕様のようで、.protoファイルでmessage内のmessageはoptionalに、repeatedのキーはsuffix List
ががついた形で出力されます。(optionalの方に関しては.protoにオプションとして用意されているrequiredをつけて対処できないかと思いましたが、requiredはproto3からは使えなくなっていたため断念)
個人的にはこのプロジェクト内においてVuexはそういった部分も含めて正しくタイプセーフにしたく、optionalはどうしても許容したくなかったのと、_User.AsObjectをカスタマイズした型を定義するならsuffix List
もどうにかして対処したかったので、カスタマイズした型を定義するのとレスポンスをその型にマッピングする方向で対応することにしました。
TypeScript化(After)
その結果が以下のようになりました。
import { grpc } from 'grpc-web-client';
import { UserService } from '../ts/_proto/src/user_pb_service'
import { GetUserRequest } from '../ts/_proto/src/user_pb'
import {
GetUserRequest as _GetUserRequest,
GetUserResponse as _GetUserResponse,
User as _User
} from '../ts/_proto/src/user_pb.d'
type UserType = Pick<
Required<_User.AsObject>,
Exclude<keyof Required<_User.AsObject>, 'followerIdsList'>
> & {
followerIds: number[];
};
const defaultUser: UserType = {
name: 'hoge',
followerIds: [],
info: {
description: '',
birthday: '1990-01-01'
}
}
type State = {
user: UserType;
};
export const state: () => State = () => ({
user: defaultUser
});
export const mutations = {
setUser(state, payload: { user: UserType }) {
state.user = payload.user
}
};
export const actions = {
getUser({ commit }, payload: { id: number }) {
const client = grpc.client(UserService.GetUser, {
host: 'http://localhost:9090',
});
const param = generateRequestParam(payload.id);
client.start();
client.send(param);
client.onMessage((message: _GetUserResponse) => {
const pbUser = message.getUser();
if (pbUser === undefined) {
return;
}
const user = mapPbUserToUser(pbUser)
if (user === false) {
return;
}
commit('setUser', { user });
});
}
};
const generateRequestParam = (id: number): _GetUserRequest => {
const req = new GetUserRequest();
req.setId(id);
return req;
}
const mapPbUserToUser = (user: _User): UserType | false => {
const info = user.getInfo();
if (info === undefined) {
return false
}
return {
name: user.getName(),
followerIds: user.getFollowerIdsList(),
info: info.toObject()
}
}
UserTypeの定義について説明します。Requiredはoptional型をrequiredにconvertします。Pickは第一引数のtypeから第二引数にあるキーのみのtypeを生成します。Excludeは第一引数から第二引数のものを除いたtypeを返すので、Pick部分は
Pick<
Required<_User.AsObject>,
'name' | 'info'
>;
// ↓
Pick<
{ name: string, followerIdsList: number[], info: User.Info.AsObject },
'name' | 'info'
>
// ↓
{
name: string,
info: _User.Info
}
となります。
&演算子でtypeをマージすることができるので、
type UserType = Pick<
{ name: string, followerIdsList: number[], info: User.Info.AsObject },
'name' | 'info'
> & {
followerIds: number[];
};
// ↓
type UserType = {
name: string,
followerIds: number[],
info: _User.Info
}
となります。これでrepeatedのsuffix List問題とmessage optional問題を解決することができました。
続いてmapperは以下になります。
const mapPbUserToUser = (user: _User): UserType | false => {
const info = user.getInfo();
if (info === undefined) {
return false
}
return {
name: user.getName(),
followerIds: user.getFollowerIdsList(),
info: info.toObject()
}
}
ts-protoc-genによって生成された定義ファイル上ではuserのinfoはoptionalなのでuser.getInfo()
のtypeも _User.Info | undefined
となります。そのためundefinedチェックのif文が入ってしまいますが、上記のようにしてマッピングすることができます。
grpc-webとtypescriptの部分に関しては上記のとおりですが、自分が前回投稿した記事で紹介させていただいた型定義と合わせることで、mutationsにコミットする際のpayloadの型、mutationsでstateに値を反映する際の型も保証することができ、レスポンスを受け取ってからmutationsの終了までの一連の流れをタイプセーフにすることができます。
また上記のようにレスポンスを受け取った直後にmapperで一括変換することで、今後grpc-web clientの型定義が修正されてListやoptionalの問題が解消された場合もコードの改変を最小限に抑えることができます。