🤟

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

2020/10/05に公開

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

全体の流れ

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

コードを書いてみましょう

1. Protoファイルの作成

ProtoファイルはgRPCの心臓と呼ばれる部分、ここでRequestとResponseとサービスを定義することによって、後でStubsファイルを自動的に生成することができます。
Protoファイルの構成は大体四つの部分に分けている。

  1. Protoのバージョンを定義する
  2. Packageの名前
  3. サービス定義
  4. メッセージ定義
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
npm init -y
npm i uuid grpc @grpc/proto-loader
npm i -D nodemon

yarn:

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

startのnodeをnodemonに書き換える:

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"
  }
}

サーバーのコード内容:

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
FROM envoyproxy/envoy:v1.14-latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml

Envoyの設定ファイル

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

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

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

bash
vue create client

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

  • todo_pb.js // メッセージのType定義
  • todo_grpc_web_pb.js // gRPCクライアント
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に接続します。

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

6. 動かしてみましょう

Back-Endを立ち上げて:

bash
$ cd ./server
$ npm start

enovy proxy:

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

Front-End:

bash
$ cd ./client
$ yarn dev

成功すればこんな感じです:
giphy.gif

7. Docker化

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

./serverフォルダ

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" ]
.dockerignore
.git
.gitignore

node_modules

README.md

./clientフォルダ

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" ]
.dockerignore
.git
.gitignore

node_modules

README.md

./docker-composer.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の設定ファイルのサーバーアドレス修正します

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

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

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

最後

Zennで初めての投稿です、よろしくお願いします。
Githubでソースコードを公開しています、
なんが詰まったところがあれば参考してください:Github

Discussion