Vue 3.0とgRPCを使ってTodoListを作ってみた

# gRPCとは？

gRPCはオープンソース、RPCフレームワークをベースとして、最初はGoogleが開発されました。 
インターフェース記述言語としてProtocol Buffersを使用し、protobufは構造化データをシリアル化するためのメカニズムです。
protoファイルでサービスとそのデータ構造を定義するだけで、gRPCがさまざまな言語でプラットフォームのクライアントとサーバーのStubsを自動的に生成します。 
profobufを使用すると、JSONではなくバイナリを使用して資料を転送しています。
これにより、gRPCがはるかに高速で信頼性の高いものになります。 
gRPCの他の主要な機能のいくつかは、双方向ストリーミングとフロー制御、BlockingまたはNonBlockingバインディング、および認証機能です。 
gRPCはHTTP/2を使用して、シングルTCPコネクションの中で複数のストリームを開始することができます。
gRPCの詳細については、こちらをご覧ください：https://grpc.io/

# gRPC V.S. REST

| Feature        | gRPC     | REST     |
| :------------- | :----------: | :-----------: |
| Portocol　| HTTP/2 (早い) | HTTP/1.1 (遅い) |
| Payload　| Protobuf (バイナリ、小さい) | JSON (テキスト、大きい) |
| API構造　| 厳格、必要 （.proto） | ゆるい、選択 |
| Code生成　| 内蔵 (protoc) | 他のツール （Swagger） |
| 安全性　| TLS/SSL | TLS/SSL |
| ストリーミング　| 双方向ストリーミング | クライアント -> サーバーリクエストだけ |
| ブラウザのサポート | 制限あり （grpc-webは必要） | ほぼ全部 |

Protobuf と gRPC を使えば、REST API の GET、PUT やヘッダーなどを気にする必要はありません、そしてgRPCフレームワークによって生成されたStubsにはデータモデル用の記述が全部書いてるので、直接引用するだけで使えます。

# 開発環境とツール

- Protoc v3.12.4 -- Protobuf コンパイラー Stubs を生成する為に使ます。
- Node.js v14.2.0 -- バックエンドとVueのビルドに使います。
- Docker v19.03.12 -- envoyを動かす為に使ます。
- envoy v1.14 -- 普通WebからのHTTP/1.1をHTTP/2に変換する為のプロキシ。
- Vue.js 3.0.0-rc.5 -- 今回は Vue 3 を使ってフロントエンドを作成します。
- Docker Compose v1.26.2 -- 全部をDocker化する為に使います、なくでも動けます。

# フォルダ構成

![dya2g02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642924/4d6f580d-5203-8ca9-41cb-96d716b82589.png)

# 全体の流れ

1. Protoファイルの作成
- Node.jsでバックエンド作成
- Envoy proxyの設定
- Client stubsの生成
- Clientの作成
- 動かしてみましょう
- Docker化

# コードを書いてみましょう
## 1. Protoファイルの作成

ProtoファイルはgRPCの心臓と呼ばれる部分、ここでRequestとResponseとサービスを定義することによって、後でStubsファイルを自動的に生成することができます。
Protoファイルの構成は大体四つの部分に分けている。
1. Protoのバージョンを定義する
2. Packageの名前
3. サービス定義
4. メッセージ定義

```protobuf:todo.proto
syntax = "proto3";

package todo;

service todoService {
  rpc addTodo(addTodoParams) returns (todoObject) {}
  rpc deleteTodo(deleteTodoParams) returns (deleteResponse) {}
  rpc getTodos(getTodoParams) returns (todoResponse) {}
}
// Request
message getTodoParams{}

message addTodoParams {
  string task = 1;
}

message deleteTodoParams {
  string id = 1;
}
// Response
message todoObject {
  string id = 1;
  string task = 2;
}

message todoResponse {
  repeated todoObject todos = 1; //ここはArrayの中身にtodoObjectが複数ありますのこと。
}

message deleteResponse {
  string message = 1;
}
```

## 2. Node.jsでバックエンド作成

### 環境設定：
npm：

```bash:bash
npm init -y
npm i uuid grpc @grpc/proto-loader
npm i -D nodemon
```
yarn:

```bash:bash
yarn init -y
yarn add uuid grpc @grpc/proto-loader
yarn add -D nodemon
```

startのnodeを[nodemon](https://nodemon.io/)に書き換える：

```json:package.json
{
  "name": "server",
  "version": "1.0.0",
  "description": "A Node.js gRPC API Server",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon server.js"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "nodemon": "^2.0.4"
  },
  "dependencies": {
    "@grpc/proto-loader": "^0.5.5",
    "grpc": "^1.24.3",
    "uuid": "^8.3.0"
  }
}

```
### サーバーのコード内容：

```javascript:server.js
// proto ファイルのパス
const todoProtoPath = './todo.proto';
// npm packageを導入
const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');
const { v4: uuidv4 } = require('uuid');
// grpcの初期化
const packageDefinition = protoLoader.loadSync(
  todoProtoPath,
  {
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true,
  },
);
// packageを指定
const todoProto = grpc.loadPackageDefinition(packageDefinition).todo;

//　Todosの保存、リースダートしたら資料が消えます
let Todos = [];

const addTodo = (call, callback) => {
  const todoObject = {
    id: uuidv4(),
    task: call.request.task,
  };
  console.log(call.request);
  Todos.push(todoObject);
  console.log(`Todo: ${todoObject.id} added!`);
  callback(null, todoObject);
};

const getTodos = (call, callback) => {
  console.log('Get tasks');
  console.log(Todos);
  callback(null, { todos: Todos });
};

const deleteTodo = (call, callback) => {
  Todos = Todos.filter((todo) => todo.id !== call.request.id);
  console.log(`Todo: ${call.request.id} deleted`);
  callback(null, { message: 'Success' });
};

const getServer = () => {
  const server = new grpc.Server();
  // サービスを登録、名前はprotoファイルと同じなので省略できます
  server.addService(todoProto.todoService.service,
    { addTodo, getTodos, deleteTodo });
  return server;
};

if (require.main === module) {
  const server = getServer();
  server.bind('0.0.0.0:9090', grpc.ServerCredentials.createInsecure());
  server.start();
  console.log('Server running at port: 9090');
}

```

## 3. Envoy proxyの設定

Envoy proxyはサーバーとクライアントの中央にいるサービスです、主にはHTTP/1.1のコネクションをHTTP/2に変換するの役割です。

Dockerのイメージ設定ファイル

```dockerfile:Dockerfile
FROM envoyproxy/envoy:v1.14-latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml
```


Envoyの設定ファイル

```yaml:envoy.yaml
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 8080 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  cluster: todo_service
                  max_grpc_timeout: 0s
              cors:
                allow_origin_string_match:
                  - prefix: "*"
                allow_methods: GET, PUT, DELETE, POST, OPTIONS
                allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                max_age: "1728000"
                expose_headers: custom-header-1,grpc-status,grpc-message
          http_filters:
          - name: envoy.filters.http.grpc_web
          - name: envoy.filters.http.cors
          - name: envoy.filters.http.router
  clusters:
  - name: todo_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    load_assignment:
      cluster_name: cluster_0
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    # docker-composeを使うときserverに書き換えます
                    address: host.docker.internal
                    port_value: 9090
```

gRPCサービスは9090 portで動かして、Envoyは8080 portでWebからのHTTP/1.1をHTTP/2に変換して9090に送るそいう仕組みです。

## 4. Client stubsの生成

protocをインストール : [Protocol Buffer Compiler Installation](https://grpc.io/docs/protoc-installation/)

```bash:bash
# Linux
$ apt install -y protobuf-compiler
$ protoc --version
# MacOS using Homebrew
$ brew install protobuf
$ protoc --version
```

先にVueのProjectを作成します。
vue-cliを使います。

```bash:bash
vue create client
```

そしてStubsを作ります
このコメンドで ./client/src に二つのJSファイルを生成する

- todo_pb.js  // メッセージのType定義
- todo_grpc_web_pb.js  // gRPCクライアント

```bash:bash
protoc -I server todo.proto \
	--js_out=import_style=commonjs,binary:client/src \
	--grpc-web_out=import_style=commonjs,mode=grpcwebtext:client/src
```

## 5. Clientの作成

クライアントのTodoコンポーネントの中にtodo_pb.jsとtodo_grpc_web_pb.jsを導入して、todoServiceClient（）を使ってlocalhost:8080のEnvoy proxyに接続します。

```javascript:Todo.vue
import { ref } from 'vue'
// クライアントが使う部分だけを導入する
import { getTodoParams, addTodoParams, deleteTodoParams } from "../todo_pb.js";
import { todoServiceClient } from "../todo_grpc_web_pb.js";
import CloseIcon from './CloseIcon'
export default {
  components:{ CloseIcon },
  setup() {
    const todos = ref([])
    const inputField = ref('')
    // 新しクライアントのインスタンスを作成
    const client = new todoServiceClient("http://localhost:8080", null, null);

    const getTodos = () => {
      let getRequest = new getTodoParams();
      client.getTodos(getRequest, {}, (err, response) => {
        if (err) console.log(err);
        console.log(response.toObject());
        todos.value = response.toObject().todosList;
      });
    }

    getTodos()

    const addTodo = () => {
      let addRequest = new addTodoParams();
      addRequest.setTask(inputField.value);
      client.addTodo(addRequest, {}, (err) => {
        if (err) console.log(err);
        inputField.value = "";
        getTodos();
      });
    }
    const deleteTodo = (todo) => {
      let deleteRequest = new deleteTodoParams();
      deleteRequest.setId(todo.id);
      client.deleteTodo(deleteRequest, {}, (err, response) => {
        if (err) console.log(err);
        if (response.getMessage() === "Success") {
          getTodos();
        }
      });
    }

    return {
      todos,
      inputField,
      addTodo,
      deleteTodo
    }
  }
}
```

完成の参考 ： [Github](https://github.com/OVaaS/grpc-todolist-exmaple/tree/master/client)

## 6. 動かしてみましょう
Back-Endを立ち上げて：

```bash:bash
$ cd ./server
$ npm start
```
enovy proxy：

```bash:bash
$ docker build -t envoy:v1 ./enovy
$ docker run --rm -it -p 8080:8080 envoy:v1
```
Front-End:

```bash:bash
$ cd ./client
$ yarn dev
```

成功すればこんな感じです：
![giphy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642924/2d0ae937-8aa3-a109-a9f9-16ccd811566a.gif)


## 7. Docker化

Docker Compose一発で動かす為にDocker化します。
各フォルダにDockerfileと.dockerignore入れます。

### ./serverフォルダ
```dockerfile:Dockerfile
FROM node:lts-alpine

# make the 'app' folder the current working directory
WORKDIR /app

# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./

# install project dependencies
RUN npm install

# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .

EXPOSE 9090
CMD [ "node", "server.js" ]
```

```text:.dockerignore
.git
.gitignore

node_modules

README.md
```

### ./clientフォルダ

```dockerfile:Dockerfile
FROM node:lts-alpine

# install simple http server for serving static content
RUN npm install -g http-server

# make the 'app' folder the current working directory
WORKDIR /app

# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
COPY yarn.lock ./

# install project dependencies
RUN yarn install

# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .

# build app for production with minification
RUN yarn run build

EXPOSE 3000
CMD [ "http-server", "-p", "3000", "dist" ]
```

```text:.dockerignore
.git
.gitignore

node_modules

README.md
```

### ./docker-composer.ymlを作成します。

```yml:docker-compose.yml
version: '3'

services: 
  web:
    build: ./client
    image: todo-grpc-vue-client:v1
    ports: 
      - 3000:3000
    restart: unless-stopped
    networks:
      - grpc-todolist
  proxy:
    build: ./envoy
    image: todo-grpc-envoy:v1
    ports: 
      - 8080:8080
    restart: unless-stopped
    networks: 
      - grpc-todolist
  server:
    build: ./server
    image: todo-grpc-server:v1
    restart: unless-stopped
    networks:
      - grpc-todolist
networks:
  grpc-todolist:
    driver: bridge
```

Envoyの設定ファイルのサーバーアドレス修正します

```yaml:envoy.yaml
endpoints:
  - lb_endpoints:
      - endpoint:
          address:
          socket_address:
          # host.docker.internalをserverに書き換える
          address: server
          port_value: 9090
```

そしてビルドして立ち上げます。

```bash:bash
# イメージをビルド
$ docker-compose build
# 特定のイメージをリビルドします
$ docker-compose build --no-cache [service]
# 立ち上げる
$ docker-compose up
# 背景で立ち上げる
$ docker-compose up -d
# ログを確認
$ docker-compose logs
```

Front-Endに入ります：[http://localhost:3000](http://localhost:3000)

# 最後

Qiitaで初めての投稿です、よろしくお願いします。
Githubでソースコードを公開しています、
なんが詰まったところがあれば参考してください：[Github](https://github.com/OVaaS/grpc-todolist-exmaple)