19
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

gRPC-WebとGoとVue.jsで簡素なチャット

Last updated at Posted at 2020-12-17

はじめに

何だか良くわからないけどよく聞くgRPC-Webなるものを触りだけでも理解すべく辛うじてチャット呼べそうなものを作ってみました。

2020-12-18_02:05:20.png

概要

gRPCとは

https://grpc.io/
Protocol BuffersやHTTP2などを利用した環境に依存せず実行できる高パフォーマンスのRPCフレームワーク。

Protocol Buffersとは

https://developers.google.com/protocol-buffers
言語やプラットフォームに依存しない構造データを定義できる。
コンパイルして指定の言語のコードを生成できる。

proto

test.proto

service TestService {
  rpc Login(User) returns (User) {}
}

message User {
  string name = 1;
  string token = 2;
}

Go

protoファイルからコンパイルしてGoのコードを生成。
test.pb.go

func (t *testServiceClient) Login(ctx context.Context, in *User, opts ...grpc.CallOption) (*User, error) {
    out := new(User)
    err := t.cc.Invoke(ctx, "/test.TestService/Login", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

type User struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Name  string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
    Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"`
}

HTTP2とは

HTTP1からの大まかな変更

  • テキストからバイナリに。プログラムで解釈しやすくなった。
  • ステートレスからステートフル。ヘッダーなどを毎回送らなくても良くなった。
  • 1つのTCPコネクションの中で複数のHTTP Requestと複数のHTTP Response。一気に画像を取得できたり。

gRPCのメリット

JSONではなくProtocol Buffersを利用することによってコンピュータ間でやりとりするデータを厳密に表現できる。
HTTP2によって高速。

gRPC-Webとは

https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md
https://grpc.io/docs/platforms/web/basics/
一般的にgRPCはマイクロサービス間で通信を行う際に使われ、クライアントをブラウザにしたい場合はgRPC-Webを利用する。
ブラウザの制限によりネイティブのgRPCとは違う実装。

envoyとは

https://www.envoyproxy.io/docs/envoy/latest/
gRCPとgRCP-Webを接続するためには特別なプロキシが必要でデフォルトがenvoy。

コードの説明

protoファイル定義

ひとまず、APIを仕様を定義します。

syntax = "proto3";
package chat;

option go_package = "server/proto";

// よくあるデータ型は定義してあるので読み込む
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";

// やりとりを定義
service ChatService {
  rpc Login(User) returns (User) {}
  rpc Logout(User) returns (google.protobuf.Empty) {}
  rpc SendMessage(Message) returns (Message) {}
  // 複数のレスポンスの場合、stream使う。
  // Userを渡して複数のMessageを受け取る。
  rpc GetMessage(User) returns (stream Message) {}
}

// やりとりするデータを定義
message Message {
  // 番号はただの順番
  string content = 1;
  // 自分で定義した型を使える。
  User user = 2;
  google.protobuf.Timestamp created_at = 3;
}

message User {
  string name = 1;
  string token = 2;
}

上記以外にもいろんな書式があって表現力高い。

コンパイルしてコード生成

https://github.com/protocolbuffers/protobuf
からコンパイラをダウンロード。
パッケージマネージャーからインストールもできる。
Arch Linuxなら
$ sudo paman -S protobuf
Goのコードを生成するときは
$ go get -u github.com/golang/protobuf/protoc-gen-go
gRPC-Webのコードを生成するときは
$ npm install -g protoc-gen-grpc-web
言語、出力先をを指定してコンパイル

$ protoc chat.proto \
  --go_out=plugins="grpc:." \
  --js_out=import_style=commonjs:client/src/proto \
  --grpc-web_out=import_style=commonjs,mode=grpcwebtext:client/src/proto

生成したコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/proto/chat.pb.go
https://github.com/atsuya0/grpc-web-simple-chat/tree/master/client/src/proto

Docker

簡単に試せるようにDockerで環境を構築しています。

Go

FROM golang:latest

WORKDIR /server
COPY . .
RUN go mod download
RUN go build -o app
CMD ./app

JavaScript

FROM  node:lts-slim

WORKDIR /client

COPY . .
RUN npm install

docker-compose.yml

GoのサーバーとJSのサーバーとプロキシサーバーの3つを定義してます。

version: '3'
services:
  envoy:
    image: envoyproxy/envoy:v1.14.1
    command: /usr/local/bin/envoy -c /etc/envoy/envoy.yaml -l debug
    volumes:
      - ./envoy:/etc/envoy
    ports:
      - '10000:10000'
    links:
      - 'server'
    container_name: 'envoy'

  server:
    build:
      context: ./server
      dockerfile: Dockerfile
    command: /server/app
    ports:
      - '50051:50051'
    volumes:
      - ./server:/go/src/server
    container_name: 'server'

  client:
    build:
      context: ./client
      dockerfile: Dockerfile
    command: npm run serve
    ports:
      - '8080:8080'
    volumes:
      - ./client:/client
    links:
      - 'envoy'
    container_name: 'client'

Envoy

動かすにはenvoy.yamlが必要です。
$ docker run --rm -it envoyproxy/envoy:v1.14.1 bash
で/etc/envoy/envoy.yamlをコピーして来てポートなどを書き換えて利用します。
.ymlにするとエラーになり時間が消えてなくなります。

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: 10000 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  cluster: chat_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.grpc_web
          - name: envoy.cors
          - name: envoy.router
  clusters:
  - name: chat_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    # win/mac hosts: Use address: host.docker.internal instead of address: localhost in the line below
    hosts: [{ socket_address: { address: server, port_value: 50051 }}]

Go

Goの実際のコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/main.go
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/server.go
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/signal.go

生成されたインターフェイス

type ChatServiceServer interface {
    Login(context.Context, *User) (*User, error)
    Logout(context.Context, *User) (*empty.Empty, error)
    SendMessage(context.Context, *Message) (*Message, error)
  // 複数のレスポンスの場合、戻り値がない。引数にレスポンスのためのコネクション。
    GetMessage(*User, ChatService_GetMessageServer) error
}

コンパイルして生成されたGoのインターフェイスに合わせてメソッドを定義していく。

1つのリクエストで1つのレスポンス

protoファイルで定義したmessageのカラムにはゲッターとセッターが定義されています。
以下のGetName()、GetToken()のような。

func (s *server) Login(ctx context.Context, user *pb.User) (*pb.User, error) {
    log.Println("Try to logged in.")

    clientExists := false
    s.clients.Range(func(_, client interface{}) bool {
        if value, ok := client.(string); ok && value == user.GetName() {
            clientExists = true
            return false
        }
        return true
    })
    if clientExists {
        return &pb.User{}, fmt.Errorf("\"%s\" is already in use.", user.GetName())
    }

    user.Token = genToken()
    s.clients.Store(user.GetToken(), user.GetName())

    log.Printf("%s logged in.\n", user.GetName())
    return user, nil
}

1つのリクエストで複数のレスポンス

func (s *server) GetMessage(user *pb.User, stream pb.ChatService_GetMessageServer) error {
    s.wg.Add(1)
    defer s.wg.Done()
    streamCh := s.createStreamCh(user.GetToken())
    defer s.deleteStreamCh(user.GetToken())

    for {
        select {
        case msg, ok := <-streamCh:
            if !ok {
                return nil
            }
            // ここでレスポンスしてる。メソッドは終了しない。
            if err := stream.Send(msg); err != nil {
                log.Println("Sending error.")
                return err
            }
        case <-s.exitCh:
            log.Printf("%s exit.\n", user.GetName())
            return nil
        }
    }
}

JavaScript

JSの実際のコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/client/src/api/client.js
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/client/src/components/Chat.vue

コンパイルして生成したクライアント


import { ChatServiceClient } from '../proto/chat_grpc_web_pb'
export default new ChatServiceClient('http://localhost:10000', null, null)

Vueのscript

// クライアント読み込む
import client from '../api/client.js'
// コンパイルして生成した型を読み込む
import { Message, User } from '../proto/chat_pb'
// googleが定義してる型を読み込む
// import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';

export default {
  name: "Chat",
  data: () => ({
    userName: "",
    userToken: "",
    message: "",
    messages: [],
    stream: null,
  }),
  filters: {
    toLocaleString: (value) => {
      return (new Date(value.getSeconds() * 1000)).toLocaleString()
    }
  },
  methods: {
    login: async function(e) {
      e.preventDefault();
      if (!this.userName) {
        return;
      }
      await client
        .login(this.getUser(), {}, (err, user) => {
          if (err != null) {
            console.log(err);
          } else {
            this.userToken = user.getToken();
            this.stream = this.fetchMessageStream()
          }
        })
    },
    sendMessage: async function(e) {
      e.preventDefault();
      if (!this.message) {
        return;
      }
      // 生成した型に入れてく。
      // セッターが生えてるので利用する。
      const message = new Message();
      message.setContent(this.message);
      message.setUser(this.getUser());
      const timestamp = new Timestamp();
      // ここはどこにも書いてなくて、開発者コンソールで中身を全部読んだ。
      timestamp.fromDate(new Date());
      message.setCreatedAt(timestamp);

      await client
        .sendMessage(message, {}, (err, res) => {
          if (err != null) {
            console.log(err);
          }
          this.message = '';
        })
    },
    fetchMessageStream: function() {
      const stream = client.getMessage(this.getUser());
      // メッセージが来たら発火するイベント
      stream.on('data', message => {
        console.log(message);
        this.messages = [...this.messages, message];
      });
      return stream;
    },
    getUser: function() {
      const user = new User();
      user.setName(this.userName);
      user.setToken(this.userToken);
      return user;
    }
  }
};

参考

GoでgRPC使う際のクイックスタート
https://grpc.io/docs/languages/go/quickstart/
protocol buffersが生成するGoのコードの説明
https://developers.google.com/protocol-buffers/docs/reference/go-generated
gRCPのGo実装
https://github.com/grpc/grpc-go
ブラウザためのgRCPのJavaScript実装
https://github.com/grpc/grpc-web
Goのライブラリのドキュメント
https://godoc.org/google.golang.org/grpc
GCPのドキュメントにある構成例
https://cloud.google.com/endpoints/docs/grpc/grpc-service-config?hl=ja

試す

$ git clone https://github.com/atsuya0/grpc-web-simple-chat.git
$ cd grpc-web-simple-chat
$ docker-compose up -d --build
ブラウザでhttp://localhost:8080

サーバーだけ試す

curlは使えないのでgrpc-cli

パッケージマネージャーからインストール
$ sudo paman -S grpc-cli

$ grpc_cli ls localhost:50051 chat.ChatService -l
$ grpc_cli call localhost:50051 ChatService.Login 'name: "John"'
$ grpc_cli call localhost:50051 ChatService.SendMessage 'content: "Hey"'

最後に

Protocol Buffersで送信・受信するデータをテキストファイルで定義できるのは仕様が分かりやすくてとても良いですね。1つのTCPコネクションの中で複数のHTTP通信ができるのも幅が広がって期待に胸いっぱいです。

19
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?