こんにちは、@binarytaです。
本記事は先日投稿した以下の記事の後編です。
前編ではWeb構成に関する概要とgRPCのプロトコル定義を実装しました。
今回はSpring側とRails側で相互にgRPC通信を行う処理の実装、そしてRails側とフロントエンド間による通信処理を説明します。
完成後のソースは私のgithub上にリポジトリを公開しています。
以下の順序で実装していきます。
- Spring bootでgRPCサーバの実装
- Ruby on RailsでgRPCによるリクエスト処理の実装
- ReactでHTTPリクエスト処理の実装
今回のWeb構成を簡易的に表したものが下図です。
公開しているデモアプリケーションも下図の構成で実装しています。
前編で実装したプロトコル定義は各言語間で参照しながら実装するので再掲しておきます。
(前編の実装内容に少し変更を加えています)
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のドキュメントを参照ください。
定義ファイルから生成されるプロトコルバッファは各言語で命名規則があるようなので使用する言語でどのように実装するのかはドキュメントを参照しなければいけません。
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ドキュメント等を見ながらの開発になると思います。
以上で今回は終わりにします。
最後まで閲読頂きありがとうございました。