gRPCによるKotlin & Ruby & Reactという構成でのWeb運用 <後編>

こんにちは、成田です。
本記事は先日投稿した以下の記事の後編です。

blog.engineer.adways.net

前編ではWeb構成に関する概要とgRPCのプロトコル定義を実装しました。
今回はSpring側とRails側で相互にgRPC通信を行う処理の実装、そしてRails側とフロントエンド間による通信処理を説明します。
完成後のソースは私のgithub上にリポジトリを公開しています。

github.com

以下の順序で実装していきます。

  1. Spring bootでgRPCサーバの実装
  2. Ruby on RailsでgRPCによるリクエスト処理の実装
  3. ReactでHTTPリクエスト処理の実装

今回のWeb構成を簡易的に表したものが下図です。
公開しているデモアプリケーションも下図の構成で実装しています。

f:id:AdwaysEngineerBlog:20180413052329p:plain

前編で実装したプロトコル定義は各言語間で参照しながら実装するので再掲しておきます。
(前編の実装内容に少し変更を加えています)

syntax = "proto3";

package taskstore;

option java_multiple_files = true;
option java_outer_classname = "TaskStore";
option java_package = "com.example.demo.taskstore";

service Taskstore {
  rpc GetTasks(GetTasksRequest) returns (GetTasksResponse) {}
  rpc AddTask(AddTaskRequest) returns (AddTaskResponse) {}
  rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse) {}
  rpc DeleteTask(DeleteTaskRequest) returns (DeleteTaskResponse) {}
}

message GetTasksRequest {
}
message GetTasksResponse {
  repeated Task task = 1;
}

message AddTaskRequest {
  string content = 1;
}
message AddTaskResponse {
  Task task = 1;
}

message UpdateTaskRequest {
  int64 id = 1;
  bool done = 2;
}
message UpdateTaskResponse {
  Task task = 1;
}

message DeleteTaskRequest {
  int64 id = 1;
}
message DeleteTaskResponse {
  bool success = 1;
}

message Task {
  int64 id = 1;
  string content = 2;
  bool done = 3;
}

1. Spring bootでgRPCサーバの実装

SpringのgRPCサーバの実装箇所のみに焦点を当てて解説するので、プロジェクト全体を俯瞰したい場合はリポジトリを参照してください。

まずgRPCのAPI実装を貼っておきます。

package com.example.demo.grpc

import com.example.demo.TaskData
import com.example.demo.ExposedTaskRepository
import com.example.demo.TaskRepository
import com.example.demo.taskstore.*
import org.lognet.springboot.grpc.GRpcService
import org.springframework.beans.factory.annotation.Autowired;
import io.grpc.stub.StreamObserver

@GRpcService
class TaskGrpcServer(@Autowired repository: TaskRepository) : TaskstoreGrpc.TaskstoreImplBase() {
  val repository = repository

  override
  fun getTasks(req: GetTasksRequest, res: StreamObserver<GetTasksResponse>) {
    val tasksBuilder = GetTasksResponse.newBuilder()
    val tasks = repository.findAll()

    tasks.forEach { task ->
      val taskBuilder = Task.newBuilder()
      taskBuilder.setId(task.id)
      taskBuilder.setContent(task.content)
      taskBuilder.setDone(task.done)
      tasksBuilder.addTask(taskBuilder)
    }
    res.onNext(tasksBuilder.build())
    res.onCompleted()
  }

  override
  fun addTask(req: AddTaskRequest, res: StreamObserver<AddTaskResponse>) {
    val task = repository.create(req.content)

    val addTaskResponseBuilder = AddTaskResponse.newBuilder()
    val taskBuilder = Task.newBuilder()
    taskBuilder.setId(task.id)
    taskBuilder.setContent(task.content)
    taskBuilder.setDone(task.done)
    addTaskResponseBuilder.setTask(taskBuilder.build())

    res.onNext(addTaskResponseBuilder.build())
    res.onCompleted()
  }

  override
  fun updateTask(req: UpdateTaskRequest, res: StreamObserver<UpdateTaskResponse>) {
    val task: TaskData? = repository.update(req.id.toInt(), !req.done)

    val updateTaskResponseBuilder = UpdateTaskResponse.newBuilder()
    val taskBuilder = Task.newBuilder()
    if (task != null) {
      taskBuilder.setId(task.id)
                 .setContent(task.content)
                 .setDone(task.done)
      updateTaskResponseBuilder.setTask(taskBuilder.build())
      res.onNext(updateTaskResponseBuilder.build())
    } else {
      res.onError(null)
    }
    res.onCompleted()
  }

  override
  fun deleteTask(req: DeleteTaskRequest, res: StreamObserver<DeleteTaskResponse>) {
    val isSuccess = repository.delete(req.id.toInt())

    val deleteTaskResponseBuilder = DeleteTaskResponse.newBuilder()
    deleteTaskResponseBuilder.setSuccess(isSuccess)

    res.onNext(deleteTaskResponseBuilder.build())
    res.onCompleted()
  }
}

このコードを動作させるには、まずgradleのgenerateProtoタスクによりプロトコル定義ファイルからプロトコルバッファを生成しなければいけません。

$ ./gradlew clean generateProto

generateProtoタスク実行後は、生成されたプロトコルバッファ内に存在するクラスを継承しgRPCメソッドをオーバーライドする必要があります。
詳細はgRPCのドキュメントを参照ください。

grpc.io

定義ファイルから生成されるプロトコルバッファは各言語で命名規則があるようなので使用する言語でどのように実装するのかはドキュメントを参照しなければいけません。
JVM言語の場合はrpcメソッドがキャメルケースになったものがプロトコルバッファとして吐かれます。
上記の実装内にはプロトコル定義ファイルに定義されたrpcメソッドが頭文字が小文字化したキャメルケースのメソッドが存在します。
各メソッド内ではプロトコル定義の内容通りのレスポンスを返却するように実装します。
レスポンスmessage定義に気を配り実装することになります。

gRPCサーバのデバッグを行いたい場合、いくつかデバッグツールは存在すると思います。
個人のブログ記事でevansというgRPCクライアントを使用してデバッグした際のメモ書きがあるので、是非参照してください。
narinymous.hatenablog.com

2. Ruby on RailsでgRPCによるリクエスト処理の実装

こちらはgRPCサーバへ要求する側の処理ですのでプロトコル定義のリクエストmessageに注意しながら実装します。
今回検討したWeb構成ではRailsは単にViewをレンダリングし、gRPCサーバとフロントエンド間を中継する役割しか持たないのでControllerはかなりシンプルです。
今回のデモアプリケーションのルーティングは以下のようになっています。

Rails.application.routes.draw do
  get    "/"           => "home#index"
  get    "/tasks"      => "task#index"
  post   "/tasks"      => "task#create"
  patch  "/tasks/:id"  => "task#update"
  delete "/tasks/:id"  => "task#delete"
end

フロントエンドでReactからHTTP要求が送られてきた際に、以下のindex, create, update, deleteの4つのメソッドが発火します。
各メソッド内でSpringのgRPCサーバへ要求する処理が書かれています。

require 'task_services_pb.rb'
require 'task_pb.rb'

class TaskController < ApplicationController
  before_action :stub

  def index
    req = Taskstore::GetTasksRequest.new
    res = @stub.get_tasks(req)
    render json: res.task
  end

  def create
    req = Taskstore::AddTaskRequest.new(content: params["content"])
    res = @stub.add_task(req)
    render json: res.task.to_h
  end

  def update
    req = Taskstore::UpdateTaskRequest.new(id: params["id"].to_i, done: params["done"])
    res = @stub.update_task(req)
    render json: res.task.to_h
  end

  def delete
    req = Taskstore::DeleteTaskRequest.new(id: params["id"].to_i)
    res = @stub.delete_task(req)
    render json: res.success
  end

  private
  def stub
    @stub = Taskstore::Taskstore::Stub.new('localhost:6565', :this_channel_is_insecure)
  end
end

RubyでgRPCのプロトコルバッファを生成する場合はgrpc_tools_ruby_protocというgRPCのドキュメントで説明されているgemを使います。

$ bundle exec grpc_tools_ruby_protoc -I ../proto --ruby_out=lib --grpc_out=lib ../proto/task.proto

これでRailsプロジェクトのディレクトリ内のlib/配下にプロトコルバッファが生成されます。

上記コード内で~Requestクラスをnewする際の引数はプロトコル定義のrpcメソッドの引数に与えたリクエストのmessage定義通りにします。
動的言語であるRubyは、プロトコル定義通りの実装をしていない場合は実行時に例外が吐かれます。
一方Kotliinのような静的型付言語の場合はコンパイル段階で実装の不備に気づけます。

3. ReactでHTTPリクエスト処理の実装

最後はgRPCとはほぼ無関係ですが、Reactでフロントエンド部分の実装です。
今回検討したWeb構成でのReactの責務はViewの状態管理くらいです。

デモアプリケーションなので単純化のためコンポーネントやロジックのファイル分割はせず、すべての処理を1ファイルに実装しました。
以下がその実装内容です。

import React from 'react';
import axios from 'axios';
import 'babel-polyfill';

axios.defaults.headers['X-CSRF-TOKEN'] = document.getElementsByName("csrf-token")[0].content;

const Area = {
  Todo: 1,
  Done: 2
}

class TasksStore {
  async allTasks() {
    const res = await axios.get("/tasks");
    return res.data;
  }

  async createTask(text) {
    const res = await axios.post("/tasks", {content: text});
    return res.data;
  }

  async updateTask(task) {
    const res = await axios.patch(`/tasks/${task.id}`, {done: task.done});
    return res.data;
  }

  async deleteTask(taskId) {
    const res = await axios.delete(`/tasks/${taskId}`);
    return res.data;
  }
}

export class Tasks extends React.Component {
  constructor() {
    super();
    this.store = new TasksStore();
    this.dragStartArea = null;
    this.dragTarget = null;
    this.state = {
      tasks: []
    }
  }

  componentDidMount() {
    this.store.allTasks().then( (tasks) => {
      this.setState({
        tasks: tasks 
      });
    })
  }

  onInputTextEnter(e) {
    if (e.keyCode !== 13) return;
    this.store.createTask(this.state.text).then( (task) => {
      this.setState({
        tasks: this.state.tasks.concat(task)
      });
    });
  }

  onInputTextUpdate(e) {
    this.setState({
      text: e.target.value
    })
  }

  dragStart(e) {
    this.dragTarget = e.target;
    this.dragTarget.style.visibility = "hidden";

    if (this.isContainTodoArea(e)) {
      this.dragStartArea = Area.Todo;
    } else {
      this.dragStartArea = Area.Done;
    }
  }

  dragEnd(targetTask, e) {
    if (this.dragStartArea == Area.Todo && this.isContainDoneArea(e)) {
      this.updateTask(targetTask, true);
    } else if (this.dragStartArea == Area.Done && this.isContainTodoArea(e)) {
      this.updateTask(targetTask, false);
    }
    this.dragTarget.style.visibility = "visible";
  }

  updateTask(targetTask) {
    this.store.updateTask(targetTask).then(updatedTask => {
      const tasks = this.state.tasks.map( task => {
        if (task != targetTask) return task;
        return updatedTask;
      })
      this.setState({ tasks: tasks });
    });
  }

  onDeleteClick(targetTask, e) {
    this.store.deleteTask(targetTask.id).then( (success) => {
      if (!success) return;
      const tasks = this.state.tasks.filter(task => targetTask != task);
      this.setState({ tasks: tasks});
    });
  }

  isContainTodoArea(e) {
    const todoArea = document.getElementsByClassName("todo-tasks")[0];
    const isContainYAxis = (e.pageY <= todoArea.getBoundingClientRect().bottom && e.pageY >= todoArea.getBoundingClientRect().top)
    const isContainXAxis = (e.pageX <= todoArea.getBoundingClientRect().right && e.pageX >= todoArea.getBoundingClientRect().left)
    console.log(e.pageY);
    return isContainYAxis && isContainXAxis;
  }

  isContainDoneArea(e) {
    const doneArea = document.getElementsByClassName("done-tasks")[0];
    const isContainYAxis = (e.pageY <= doneArea.getBoundingClientRect().bottom && e.pageY >= doneArea.getBoundingClientRect().top);
    const isContainXAxis = (e.pageX <= doneArea.getBoundingClientRect().right && e.pageX >= doneArea.getBoundingClientRect().left);
    return isContainYAxis && isContainXAxis;
        }

  render() {
    return (
      <div className="wrapper">
        <h1 className="title">Today's Todo</h1>
        <div className="new-task">
          <input
            placeholder="Enter after you write something to do ...."
            onKeyDown={this.onInputTextEnter.bind(this)}
            onChange={this.onInputTextUpdate.bind(this)}
          />
        </div>
        <section className="tasks todo-tasks">
          <h2 className="tasks-title">Todo</h2>
          {this.state.tasks.filter(task => !task.done).map(task => {
          return (
            <div
              className="task"
              draggable='true'
              onDragStart={this.dragStart.bind(this)}
              onDragEnd={this.dragEnd.bind(this, task)}
            >
              <span className="delete-icon" onClick={this.onDeleteClick.bind(this, task)}></span>
              <p className="task-content">{task.content}</p>
            </div>
          )
          })}
        </section>

        <section className="tasks done-tasks">
          <h2 className="tasks-title">Done</h2>
          {this.state.tasks.filter(task => task.done).map(task => {
          return (
            <div
              className="task"
              draggable='true'
              onDragStart={this.dragStart.bind(this)}
              onDragEnd={this.dragEnd.bind(this, task)}
            >
              <p className="task-content">{task.content}</p>
            </div>
          )
          })}
        </section>
      </div>
    )
  }
}

RailsサーバへHTTP要求をする箇所はTaskStoreというクラスに全てまとめています。
特に複雑な処理はしていませんので以下抜粋した箇所のみでリクエスト処理は完結しています。
ReactによるDOMの状態管理に関する処理は今回の記事の内容と関係ないので省きます。

class TasksStore {
  async allTasks() {
    const res = await axios.get("/tasks");
    return res.data;
  }

  async createTask(text) {
    const res = await axios.post("/tasks", {content: text});
    return res.data;
  }

  async updateTask(task) {
    const res = await axios.patch(`/tasks/${task.id}`, {done: task.done});
    return res.data;
  }

  async deleteTask(taskId) {
    const res = await axios.delete(`/tasks/${taskId}`);
    return res.data;
  }
}

まとめ

前編では複雑化したWeb構成をgRPCを使ってサーバを分断化するメリットとプロトコル定義の実装に関して紹介しました。
後編である本記事では主に各言語間でgRPC通信する処理の実装面の解説をしました。

改めて、gRPCを使ってみた感想を共有します。
gRPCを採用することで複雑なAPIリクエストのパラメータに予期せぬ型の値が送られてくることをある程度防げます。
また、 プロトコル定義がAPIドキュメントとしての役割になり得るので実装時にプロトコル定義ファイルを参照しながら開発できます。
プロトコルバッファは各言語のコードに変換されて生成されるので、コード補完などによりAPI実装効率が増します。
REST APIではリクエスト、レスポンスの内容をどのような構造で送るか、または返すのかを開発チーム内で決めたAPIドキュメント等を見ながらの開発になると思います。

以上で今回は終わりにします。
最後まで閲読頂きありがとうございました。