gRPC-WebをKotlinバックエンドで試した時のメモ - 4. Webフロントエンド編

# About

このテーマの連載、Webフロントエンド編です。

https://qiita.com/hosopy/items/d7afd9cf85bd6e155815

# はじめに

おさらいになりますが、Webフロントエンドは[gRPC-Web](https://github.com/grpc/grpc-web)を利用して、ReverseProxy経由でバックエンドのgRPCサービスとやり取りします。

<img width="752" alt="architecture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1489/9c0ce2a0-2258-0143-d3ca-458eff313af8.png">

今回のサンプルでは、Webフロントエンドは下記の構成としました。

* TypeScript
* Nuxt.js 2.9.2 : SPAモード

Nuxt.jsのTypeScriptサポートですが、[2.9からそれなりの規模の変更が入っている模様](https://typescript.nuxtjs.org/migration.html)なので、2.8以前を使っている場合はサンプルの書き換えが必要になるケースがあるのでご注意下さい。

また、[gRPC-Web](https://github.com/grpc/grpc-web)自体、まだSSRには対応していないようです。今回はSPAモードで検証しましたが、SSRで使いたい場合は別の選択肢を検討する必要がありそうです。

# 構成

サンプルの[Web](https://github.com/hosopy/grpc-web-kotlin-sample/tree/master/web)ディレクトリに、Webフロントエンドのプロジェクトが入っています。

## プロジェクト作成

[公式チュートリアル](https://typescript.nuxtjs.org/)を参考に、Nuxtプロジェクトを作成しました。

## package.json

主要なdependenciesを掲載しておきます。

```json:web/package.json
  "dependencies": {
    ...
    "google-protobuf": "^3.10.0-rc.1",
    "grpc-web": "^1.0.6",
    "vue-property-decorator": "^8.2.2"
  },
  "devDependencies": {
    "@nuxt/typescript-build": "^0.2.6",
    ...
    "@types/google-protobuf": "^3.7.1",
    ...
  }
```

自分で追加したのは、

* google-protobuf
* grpc-web
* vue-property-decorator

の3つです。

## gRPCクライアントの実装

serviceディレクトリを作成し、その配下にgRPCサービス(`GreeterService`)を呼び出すためのクライアント実装を配置しました。


```web/service
service
├── GreeterService.ts
└── grpc ← protocで生成したファイル
    ├── Greeter_grpc_web_pb.d.ts
    ├── Greeter_grpc_web_pb.js
    ├── Greeter_pb.d.ts
    └── Greeter_pb.js
```

個別に見てみます。

### protocによるファイル生成

ようやく本題のgRPC-Webが登場します。
[GitHubのREADME](https://github.com/grpc/grpc-web)を参考に、protocのプラグインとして、`protoc-gen-grpc-web`をインストールします。

```sh:protoc-gen-grpc-webのインストール
$ curl -o /tmp/protoc-gen-grpc-web-1.0.6-darwin-x86_64 \
          https://github.com/grpc/grpc-web/releases/download/1.0.6/protoc-gen-grpc-web-1.0.6-darwin-x86_64
$ sudo mv /tmp/protoc-gen-grpc-web-1.0.6-darwin-x86_64 \
          /usr/local/bin/protoc-gen-grpc-web
$ chmod +x /usr/local/bin/protoc-gen-grpc-web
```

これで`protoc`コマンドで`--grpc-web_out`オプションが使えるようになるので、`Greeter.proto`からコードを生成します。

```sh
protoc -I=service/src/main/proto Greeter.proto \
       --js_out=import_style=commonjs:web/service/grpc \
       --grpc-web_out=import_style=commonjs+dts,mode=grpcwebtext:web/service/grpc
```

`import_style`ですが、今回は`commonjs+dts`を指定しました。

Experimentalですが`typescript`も指定できるので試してみたものの、下記の点で実用にはまだ早そうな印象を受けたので、今回は採用しませんでした。

* PromiseベースのClient関数が提供されない (コールバックベースのClient関数のみ)
* `d.ts`をimportしようとしてコンパイルエラーが発生する
  * TypeScript不勉強につき解決に至らず

### GreeterService.tsの実装

protocで生成したファイルを元に、`GreeterService`に対するClientを実装しました。

```typescript:web/service/GreeterService.ts
import { GreeterPromiseClient } from './grpc/Greeter_grpc_web_pb'
import { HelloRequest } from './grpc/Greeter_pb'

export default class GreeterService {
  constructor(private readonly hostname: string) {
    this.client = new GreeterPromiseClient(hostname, null, null)
  }

  private readonly client: GreeterPromiseClient

  public async sayHello(name: string): Promise<{message: string; nameLength: number}> {
    const request = new HelloRequest()
    request.setName(name)
    const response = await this.client.sayHello(request)
    return Promise.resolve({
      message: response.getMessage(),
      nameLength: response.getNamelength()
    })
  }
}
```

## ClientをVueインスタンスに注入

実装したClientをVueインスタンスに注入するためにプラグインを定義します。

```typescript:web/plugins/greeterService.ts
import GreeterService from '~/service/GreeterService'
import { Context } from '@nuxt/types'

declare module 'vue/types/vue' {
  interface Vue {
    readonly $greeterService: GreeterService
  }
}

export default (ctx: Context, inject: (key: string, value: any) => void) => {
  const greeterService = new GreeterService(ctx.env['GRPC_HOST'])
  inject('greeterService', greeterService)
}
```

定義したプラグインを、`nuxt.config.js`で読み込みます。

```javascript:web/nuxt.config.js
export default {
  mode: 'spa',
  ...
  /*
   ** Plugins to load before mounting the App
   */
  plugins: [
    { src: '~plugins/greeterService.ts', ssr: false }
  ],
  env: {
    GRPC_HOST: process.env.GRPC_HOST || 'http://localhost:8080'
  }
}
```

## Component

プラグインでVueインスタンスに`$greeterService`が注入されるので、画面のComponentから呼び出してみます。

```vue:web/pages/index.vue
<template>
  <section class="section">
    <div class="container">
      <h1 class="title">gRPC(gRPC-Web) Sample</h1>
      <div class="field">
        <label class="label">Name</label>
        <div class="control">
          <input v-model="name" class="input" type="text" placeholder="Name" />
        </div>
      </div>
      <div class="control">
        <button class="button is-primary" @click="send">Send</button>
      </div>

      <div
        v-if="message && nameLength"
        class="content"
        style="margin-top: 0.75rem;"
      >
        <blockquote>{{ message }} (nameLength={{ nameLength }})</blockquote>
      </div>
    </div>
  </section>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'

@Component({ name: 'IndexPage' })
class IndexPage extends Vue {
  name: string = ''

  message: string | null = null

  nameLength: number | null = null

  async send() {
    // Vueインスタンスに注入された$greeterService
    const { message, nameLength } = await this.$greeterService.sayHello(
      this.name
    )
    this.message = message
    this.nameLength = nameLength
  }
}

export default IndexPage
</script>
```