blog.syfm

徒然なるままに考えていることなどを書いていくブログ

gRPC における IDL と codec

codec

gRPC は非常に柔軟に設計されていて、例えば RPC でメッセージをやりとりする際に、どういうシリアライザでエンコードするかを自由に選ぶことができます。gRPC の公式ドキュメントではシリアライザとして Protocol Buffers のみが利用されていますし、デフォルトのシリアライザとなっているのであまりここを意識することはないかもしれません。

このシリアライザ機構は、codec として抽象化されています。Go の場合、google.golang.org/grpc/encoding 以下に Codec というインターフェースが定義されています。

type Codec interface {
    // Marshal returns the wire format of v.
    Marshal(v interface{}) ([]byte, error)
    // Unmarshal parses the wire format into v.
    Unmarshal(data []byte, v interface{}) error
    // Name returns the name of the Codec implementation. The returned string
    // will be used as part of content type in transmission.  The result must be
    // static; the result cannot change between calls.
    Name() string
}

Codec の実装は、encoding パッケージの RegisterCodec を利用して codec を登録します。*1
より正確で詳細なドキュメントは grpc-go/Documentation/encoding.md に置かれています。

IDL

gRPC では IDL を利用してインターフェース定義・コード生成を行います。IDL は Interface Definition Language (インターフェース定義言語) の略称で、こちらもほとんどの場合 Protocol Buffers が使われます。*2
例えば、以下のようにどういうサービスがあり、どのような RPC が属しているか、そのリクエストとレスポンスの型は何か、といった情報を独自の記法で記述します。 *3

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

普段 gRPC を触っている方であれば見慣れているかと思います。
この定義を元に protoc-gen-* を使い、クライアントとサーバのインターフェース部分のコードを自動生成することになります。

IDL と codec

普段は Protocol Buffers を使用しているため、IDL と codec という概念を意識することはないかもしれません。しかし、上に示したように、これらは明確に分離されていてそれぞれ別の実装を使用することが可能です。 例えば、IDL として Protocol Buffers を使い、codec として JSON を使うといったことができます。実際の例は以下の記事が非常に参考になります。

qiita.com

また、IDL を FlatBuffers 等に変更することもできますが、Protocol Buffers より操作が複雑であるため、codec に FlatBuffers を使いたい場合以外であまり使う理由はないでしょう。
当然 IDL を用意せずにパワーでクライアント・サーバを実装することもできますが、自動生成を使わないということは gRPC のメリットをすべて捨てていることと等価なのでやめたほうが良いでしょう。

*1:Protocol Buffers についてはデフォルトで使用されることになっているため、特別な設定は必要ありません。

*2:IDL は gRPC 固有の用語ではありません。

*3:このコードは grpc.io より引用しています。

追い出し LT で「つよくてニューゲーム」というタイトルで発表しました

卒業式の前日に会津大学の Zli というサークルの追い出し会として LT 会が開催され、そこで「つよくてニューゲーム」*1というタイトルで発表してきました。

普段は基本的に技術寄りの話ばかりしていますが、学生生活最後の発表だったので珍しく自分語りをしました。自分とコンピュータの馴れ初め、高校時代の挫折、大学での再スタートなど、今までのことをまとめています。 詳細は実際のスライドをアップロードしているのでそちらからどうぞ。

speakerdeck.com

このスライドをつくるにあたって、自分の大学生活を振り返ることができたので良かったです。

はたして自分が実践してきたことは正しかったのかは誰にもわかりませんが、高校時代に比べて想像以上の知識・経験を得ることができたため、何も後悔はありません。これで良かったのだと思っています。

4 月からは晴れて社会人となり、サーバサイドエンジニアとして働き始めることになります。これからも今まで考え続けてきたことを忘れずに心機一転、頑張っていきたいと思います。

*1:元ネタはクロノ・トリガーです

esa、Google Cloud Functions、Hugo、Netlify で簡易 CMS をつくる

Hugo は、Markdown ファイルを元にブログポストの HTML を静的に生成することのできるツールです。Hugo で生成した HTML を Netlify や GitHub Pages にデプロイすることでブログをホストするためのサーバを用意せずにブログを公開することができます。

しかし、Hugo はデプロイのトリガーとして基本的に Git リポジトリへの push を必要とします。PC でブログを書いている場合はあまり気にならないかもしれませんが、スマホで雑にポストを書きたい時は Git 操作をするのは簡単ではありません。

そこで、esa でポストを管理しつつ、デプロイのトリガーまでできるしくみをシュッと作ってみました。

esa には Webhook が用意されており、ポストの変更に合わせて発火することができます。また、ポストの状態として WIP と (ほとんど) 完成が用意されているため、これを利用して下書き機能として利用できそうです。タグ機能もそのままポストに付けられたタグとして利用できるでしょう。

esa で設定した Webhook の発火により、Cloud Functions が起動し、変更された Markdown のコンテンツを Git リポジトリに適用し、push します。push により、Netlify が発火し、HTML を生成した後にサイトへデプロイします。 これらを図に表すと以下のような感じになります。

f:id:ktr_0731:20190304184259j:plain

esa の Webhook は以下のように設定します。ブログポストとして使うカテゴリ以下に対して Webhook を有効にします。(ここでは /blog 以下を対象にしています。) URL には Google Cloud Functions の関数への URL (後述) を指定します。

f:id:ktr_0731:20190303003311p:plain

Google Cloud Functions に登録する関数の例は以下のようになります。GitHub 上のブログコンテンツを管理するリポジトリをローカルへクローンし、Webhook から受け取ったコンテンツをコミットします。そしてそのコミットを GitHub へ push しています。

package p

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "strings"
    "time"

    "github.com/pkg/errors"

    "gopkg.in/src-d/go-billy.v4/memfs"
    git "gopkg.in/src-d/go-git.v4"
    "gopkg.in/src-d/go-git.v4/plumbing/object"
    githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
    "gopkg.in/src-d/go-git.v4/storage/memory"
)

const (
    kindCreate = "post_create"
    kindUpdate = "post_update"
    kindDelete = "post_delete"
)

var tmpl = `---
title: "%s"
date: %s
type: posts
draft: false
tags:
%s
---

%s
`

var (
    repoURL     string
    githubToken string
    githubName  string

    authorName  string
    authorEmail string
)

var auth *githttp.BasicAuth

type request struct {
    Kind string `json:"kind"`
    Post post   `json:"post"`
}

type post struct {
    Name         string `json:"name"`
    BodyMarkdown string `json:"body_md"`
    WIP          bool   `json:"wip"`
    URL          string `json:"url"`
}

func init() {
    if repoURL = os.Getenv("REPO_URL"); repoURL == "" {
        panic("$REPO_URL must be required")
    }
    if githubToken = os.Getenv("GITHUB_TOKEN"); githubToken == "" {
        fmt.Fprintln(os.Stderr, "$GITHUB_TOKEN is not found")
    }
    if githubName = os.Getenv("GITHUB_NAME"); githubName == "" {
        panic("$GITHUB_NAME must be required")
    }
    if authorName = os.Getenv("AUTHOR_NAME"); authorName == "" {
        fmt.Fprintln(os.Stderr, "$AUTHOR_NAME is not found")
    }
    if authorEmail = os.Getenv("AUTHOR_EMAIL"); authorEmail == "" {
        fmt.Fprintln(os.Stderr, "$AUTHOR_EMAIL is not found")
    }

    if githubToken != "" {
        auth = &githttp.BasicAuth{
            Username: githubName,
            Password: githubToken,
        }
    }

}

func UpdatePost(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    body, err := decodeRequestBody(r.Body)
    if err != nil {
        fmt.Fprintf(w, "failed to get the request body: %s", err)
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    if body.Post.WIP {
        w.WriteHeader(http.StatusNotModified)
        return
    }

    title, tags := decodeName(body.Post.Name)

    switch body.Kind {
    case kindCreate, kindUpdate:
        var tagStr string
        for _, tag := range tags {
            tagStr += "  - " + tag + "\n"
        }
        content := fmt.Sprintf(tmpl, title, time.Now().Format(time.RFC3339), tagStr, body.Post.BodyMarkdown)
        if err := flush(title, content); err != nil {
            panic(err)
        }
        fmt.Fprintln(w, "done")
    case kindDelete:
        if err := remove(title); err != nil {
            panic(err)
        }
        fmt.Fprintln(w, "done")
    }
}

func flush(title, body string) error {
    fs := memfs.New()
    repo, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{URL: repoURL, Auth: auth})
    if err != nil {
        return errors.Wrap(err, "failed to clone the blog repository")
    }
    fname := fmt.Sprintf("content/posts/%s.md", strings.Replace(title, " ", "-", -1))

    f, err := fs.Create(fname)
    if err != nil {
        return errors.Wrap(err, "failed to write content to the file")
    }
    defer f.Close()
    _, err = io.WriteString(f, body)
    if err != nil {
        return errors.Wrap(err, "failed to write content to the file")
    }

    w, err := repo.Worktree()
    if err != nil {
        return errors.Wrap(err, "failed to get the work tree")
    }
    _, err = w.Add(fname)
    if err != nil {
        return errors.Wrap(err, "failed to add the written file")
    }
    _, err = w.Commit(fmt.Sprintf("create or update %s", fname), &git.CommitOptions{
        Author: &object.Signature{
            Name:  authorName,
            Email: authorEmail,
            When:  time.Now(),
        },
    })
    if err != nil {
        return errors.Wrap(err, "failed to commit the changes")
    }
    if err := repo.Push(&git.PushOptions{Auth: auth}); err != nil {
        return errors.Wrap(err, "failed to push the committed changes")
    }
    return nil
}

func remove(title string) error {
    fs := memfs.New()
    repo, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{URL: repoURL, Auth: auth})
    if err != nil {
        return errors.Wrap(err, "failed to clone the blog repository")
    }
    fname := fmt.Sprintf("content/posts/%s.md", strings.Replace(title, " ", "-", -1))

    err = fs.Remove(fname)
    if err != nil {
        return errors.Wrap(err, "failed to remove the post")
    }

    w, err := repo.Worktree()
    if err != nil {
        return errors.Wrap(err, "failed to get the work tree")
    }
    _, err = w.Add(fname)
    if err != nil {
        return errors.Wrap(err, "failed to add the removed file")
    }
    _, err = w.Commit(fmt.Sprintf("delete %s", fname), &git.CommitOptions{
        Author: &object.Signature{
            Name:  authorName,
            Email: authorEmail,
            When:  time.Now(),
        },
    })
    if err != nil {
        return errors.Wrap(err, "failed to commit the changes")
    }
    if err := repo.Push(&git.PushOptions{Auth: auth}); err != nil {
        return errors.Wrap(err, "failed to push the committed changes")
    }
    return nil
}

func decodeRequestBody(b io.Reader) (*request, error) {
    var r request
    if err := json.NewDecoder(b).Decode(&r); err != nil {
        return nil, err
    }
    return &r, nil
}

func decodeName(n string) (string, []string) {
    n = filepath.Base(n)
    prev := strings.Index(n, "#")
    if prev == -1 {
        return n, nil
    }

    var tags []string
    var title string
    title = strings.TrimSpace(n[:prev])

    for {
        current := strings.Index(n[prev+1:], "#")
        if current == -1 {
            tags = append(tags, strings.TrimSpace(n[prev+1:]))
            return title, tags
        }
        tags = append(tags, strings.TrimSpace(n[prev+1:prev+current+1]))
        prev += current + 1
    }
    return title, tags
}

UpdatePost 関数を実行する関数に指定し、URL を先程の esa の Webhook に登録します。

Netlify ⇔ Hugo でやることは以下のページそのままなので割愛します。

gohugo.io

まとめ

esaCMS 的に使うことで Git 操作なしにブログの更新ができるようになりました。
今回紹介した、 Cloud Functions で使用しているソースは非常に単純なものなので、必要に応じて変更してください。