yikegaya’s blog

仕事関連(Webエンジニア)と資産運用について書いてます

GoとgRPCでサーバサイドのCRUD実装してみた

gRPCを試してみたく、個人開発でGoとgRPCを使って簡単なメモサービスのCRUDを実装したので振り返ってみます。

作ったもの

sqliteでid、title、descriptionレコードを持つmemosテーブルを作成してgRPCのリクエストからメモの一覧取得、詳細取得、作成、更新、削除ができるだけの簡単なサービスです。

フロントエンドもなしでCLIでのみ使えます。

この後別テーブル作ってもう少し作り込むかもしれませんが現時点ではこれのみ。

プロジェクトのレポジトリ

github.com

gRPCの教材

以下の記事がまとまっていていい教材になりました。 2023年現在だとgRPCを深く扱った書籍はなさそうです(オライリーから英語で出版されてるっぽいけど未翻訳)

正直Restでの開発経験があるエンジニアであれば技術書に頼らなくてもネット上の情報でキャッチアップできるんじゃないかな。と思ってます。

zenn.dev

gRPCでの開発フロー

  1. protoファイルを作成する
  2. protoファイルを元に必要なコードを自動生成する
  3. 自動生成されたコードを使ってサーバでリクエストを受けた後の処理を実装していく

protoファイルの作成と自動コード生成

任意のフォルダ(今回の場合はprotoフォルダ)に以下ようなprotofileを作成します。今回はmemoservice.protoという命名のファイルとして保存しています。

これはGoに限らずその言語でもgRPCプロジェクトであれば共通の形式。大体みたまんまでCRUDのリクエスト、レスポンスのインターフェイスを定義してます。

syntax = "proto3";

package service;

option go_package = "pkg/grpc";

service MemoAPI {
    rpc GetMemo(GetMemoRequest) returns (GetMemoResponse) {}
    rpc ListMemos(ListMemosRequest) returns (ListMemosResponse) {}
    rpc CreateMemo(CreateMemoRequest) returns (CreateMemoResponse) {}
    rpc UpdateMemo(UpdateMemoRequest) returns (UpdateMemoResponse) {}
    rpc DeleteMemo(DeleteMemoRequest) returns (DeleteMemoResponse) {}
}

message Memo {
    string id = 1;
    string title = 2;
    string description = 3;
}

message GetMemoRequest {
    string id = 1;
}

message GetMemoResponse {
    Memo memo = 1;
}

message ListMemosRequest {}

message ListMemosResponse {
    repeated Memo memos = 1;
}


message CreateMemoRequest {
    Memo memo = 1;
}

message CreateMemoResponse {
    bool success = 1;
    string id = 2;
}

message UpdateMemoRequest {
    Memo memo = 1;
}

message UpdateMemoResponse {
    bool success = 1;
    string id = 2;
}

message DeleteMemoRequest {
    string id = 1;
}

message DeleteMemoResponse {
    string id = 1;
}

自動コード生成するためにprotobufコマンドを取得します。macであればhomebrewで取得できます。

brew install protobuf

その後protoファイルが保存されているパスに移動して次のようなコマンドを実行すると自動でコードが生成されます。

cd proto

protoc --go_out=../pkg/grpc --go_opt=paths=source_relative memoservice.proto --go-grpc_out=../pkg/grpc --go-grpc_opt=paths=source_relative memoservice.proto

自動生成されたコードを使ったサーバの実装

サーバでリクエストを受けてsqliteと接続する実装です。

とりあえずgRPCでのリクエスト受信~DB接続を試したかっただけなのでフォルダ分割せずにmain.goファイルに全部実装してます。 エラーハンドリングも適当。

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"

    _ "github.com/mattn/go-sqlite3"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"

    memopb "grpc-memoapp/pkg/grpc"
)

type memoServer struct {
    memopb.UnimplementedMemoAPIServer
}

func NewMemoServer() *memoServer {
    return &memoServer{}
}

func (s *memoServer) GetMemo(ctx context.Context, req *memopb.GetMemoRequest) (*memopb.GetMemoResponse, error) {
    db, err := sql.Open("sqlite3", "./grpc_memoapp.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    id := req.Id

    row := db.QueryRow("SELECT title, description FROM memos WHERE id = ?", id)
    memo := &memopb.Memo{}

    err = row.Scan(&memo.Title, &memo.Description)
    if err != nil {
        if err == sql.ErrNoRows {
            log.Println("Memo not found")
            return nil, err
        }
        log.Println("Error fetching memo")
        return nil, err
    }

    response := &memopb.GetMemoResponse{
        Memo: memo,
    }

    return response, nil
}

func (s *memoServer) ListMemos(ctx context.Context, req *memopb.ListMemosRequest) (*memopb.ListMemosResponse, error) {
    db, err := sql.Open("sqlite3", "./grpc_memoapp.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    sqlStmt := `
  SELECT * FROM memos;
  `
    rows, err := db.Query(sqlStmt)
    if err != nil {
        log.Println(err)
    }
    defer rows.Close()

    var grpcMemos []*memopb.Memo
    for rows.Next() {
        var id int
        var title, description string
        err := rows.Scan(&id, &title, &description)
        if err != nil {
            log.Println(err)
            return nil, err
        }

        grpcMemo := &memopb.Memo{
            Title:       title,
            Description: description,
        }
        grpcMemos = append(grpcMemos, grpcMemo)
    }

    response := &memopb.ListMemosResponse{
        Memos: grpcMemos,
    }

    log.Println("success ListMemos")

    return response, nil
}

func (s *memoServer) CreateMemo(ctx context.Context, req *memopb.CreateMemoRequest) (*memopb.CreateMemoResponse, error) {
    db, err := sql.Open("sqlite3", "./grpc_memoapp.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    tx, err := db.Begin()
    if err != nil {
        log.Fatal(err)
    }

    insertSqlStmt := "INSERT INTO memos(title, description) VALUES(?, ?);"
    _, err = db.Exec(insertSqlStmt, req.Memo.Title, req.Memo.Description)

    if err != nil {
        log.Println(err)
    }

    err = tx.Commit()
    if err != nil {
        log.Println(err)
    }

    var lastInsertID string
    // SELECT last_insert_rowid()で最後のIDが取得できないのでこの実装。今回はgRPCの実験なので深追いしない
    err = db.QueryRow("SELECT id FROM memos ORDER BY id DESC LIMIT 1").Scan(&lastInsertID)
    if err != nil {
        log.Println(err)
    }

    response := &memopb.CreateMemoResponse{
        Success: true,
        Id:      lastInsertID,
    }

    log.Println("success CreateMemo")
    return response, nil
}

func (s *memoServer) UpdateMemo(ctx context.Context, req *memopb.UpdateMemoRequest) (*memopb.UpdateMemoResponse, error) {
    db, err := sql.Open("sqlite3", "./grpc_memoapp.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    tx, err := db.Begin()
    if err != nil {
        log.Fatal(err)
    }

    updateSqlStmt := "UPDATE memos SET title = ?, description = ? where id = ?;"
    _, err = db.Exec(updateSqlStmt, req.Memo.Title, req.Memo.Description, req.Memo.Id)

    if err != nil {
        log.Println(err)
    }

    err = tx.Commit()
    if err != nil {
        log.Println(err)
    }

    response := &memopb.UpdateMemoResponse{
        Success: true,
        Id:      req.Memo.Id,
    }

    log.Println("success UpdateMemo")
    return response, nil
}

func (s *memoServer) DeleteMemo(ctx context.Context, req *memopb.DeleteMemoRequest) (*memopb.DeleteMemoResponse, error) {
    db, err := sql.Open("sqlite3", "./grpc_memoapp.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    id := req.Id

    _, err = db.Exec("DELETE FROM memos WHERE id = ?", id)
    if err != nil {
        log.Fatal(err)
    }

    response := &memopb.DeleteMemoResponse{
        Id: id,
    }

    return response, nil
}

func main() {
    port := 8080
    listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
    if err != nil {
        panic(err)
    }

    s := grpc.NewServer()

    memopb.RegisterMemoAPIServer(s, NewMemoServer())

    reflection.Register(s)

    go func() {
        log.Printf("start gRPC server port: %v", port)
        s.Serve(listener)
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit
    log.Println("stopping gRPC server...")
    s.GracefulStop()
}

動作検証

protodファイルからgRPCでクライアントコードを実装するためのコードも生成されているのでそれを使ってクライアント側を実装して上記のコードにリクエストしてもいいんですがgrpcurlコマンドを使うとREST APIにおけるcurlと同じように動作検証できます。

brew install grpcurlなどでコマンドをinstallしたのちに以下のようにリクエストをシェルから送信できます。

grpcurl -plaintext -d '{"id": "1"}' localhost:3333 service.MemoAPI.GetMemo
grpcurl -plaintext localhost:3333 service.MemoAPI.ListMemos
grpcurl -plaintext -d '{
  "memo": {
    "title": "titleDemo",
    "description": "descriptionDemo"
  }
}' localhost:3333 service.MemoAPI.CreateMemo
grpcurl -plaintext -d '{
  "memo": {
    "id": "1",
    "title": "updateDemo",
    "description": "updateDemo"
  }
}' localhost:3333 service.MemoAPI.UpdateMemo
grpcurl -plaintext -d '{"id": "1"}' localhost:3333 service.MemoAPI.DeleteMemo

終わり

次はクライアント側のコードも書きたい。