20201218のGoに関する記事は8件です。

Goの準々標準ライブラリの探索

フューチャーアドベントカレンダー21日目のエントリーです。全25話中、神回が25回あったウルトラマンZが終わってしまいましたね。全日本人のうちの1億2000万人ほどがウルトラマンZロスでうちひしがれているころかと思います。

Goでは、Goをインストールしたときに一緒にインストールされるライブラリを「標準ライブラリ」と呼びます。また、golang.org/xなパッケージ群は準標準ライブラリと呼ばれます。準標準ライブラリには多彩なライブラリが含まれます。今ではもう本家に入ってしまって、後方互換性のためだけに残っているパッケージも一部ありますが・・・

これ以外にも https://github.com/golang/ にはたくさんパッケージがあります。一部はgolang/xのものも入っているのですが、それ以外のものをここでは仮に準々標準ライブラリと呼ぶことにします。

golang.org/xのものは、だいたいリポジトリの説明に[mirror]と入っているので、それを見れば識別しやすいかと思います。

スクリーンショット 2020-12-18 22.42.20.png

本エントリーではおすすめの準々標準ライブラリを紹介するとともにそのコードを読んでみます。ちなみに、go.chromium.orgも面白そうなライブラリがいっぱいあって、これのerrorsとかloggingとかモック可能なmath/randとか、標準ライブラリをとことん強くしてみた感が興味深いのですが、キリがないのでやめておきます。

あとは、pkg.go.devのコードのリポジトリにも、TeeProxyとか面白げなものがいろいろあったんですが、internalなのでそのままimportできずにコピーせざるを得ないという。やはりinternalは悪い文明!

github.com/golang/groupcache/consistenthash

github.com/golang/groupcacheはmemcachedのリプレースを狙った、分散キャッシュシステムとのこと。単独のアプリケーションのサーバーと、クライアントライブラリの組み合わせです。で、これは、分散キャッシュでよく利用されるアルゴリズムのコンシステントハッシュです。ワクワクしますね。

引数のkeysは一部peersの方が分かりやすいのではないか、というか、入力が全部keyなのは何かの間違い感があります。これを利用しているコードをみる方が分かりやすいですね。

package main

import (
    "fmt"
    "github.com/golang/groupcache/consistenthash"
)

func main() {
    h := consistenthash.New(3, nil)
    h.Add("host1", "host2", "host3", "host4", "host5")
    self := "host1" // 自分
    peer := h.Get("content's key")
    if self == peer {
        fmt.Println("このファイルはこのノードにあります。")
    } else {
        fmt.Printf("このファイルは%sにあります\n", peer)
    }
}

https://play.golang.org/p/GzEtCydb0Ij

これを利用している親パッケージのコードをみるだけだと、ノードが落ちた時の復旧とかどうするのか、そのあたりはよくわからなかったです。1つでも落ちたらイミュータブルにクラスタ丸ごと再起動とかやっているんですかね? デフォルトのレプリカ数50というのが強い。斜め読みしかしてないですが、これを紹介するスライドがありました。

github.com/golang/groupcache/lru

上記のキャッシュサーバー内部で使われているLRUキャッシュです。hashicorpの方が強そうですが、まあシンプルで良いですね。わかりやすいです。コードリーディングにもおすすめ。

package main

import (
    "fmt"
    "github.com/golang/groupcache/lru"
)

func main() {
    c := lru.New(10) // 0だと無限
    // 計算結果をキャッシュ
    c.Add(2, 2 * 2)
    c.Add(3, 3 * 3)
    c.Add(4, 4 * 4)
    result, found := c.Get(4)
    fmt.Println(result, found)
}

https://play.golang.org/p/QonPQi3UwzJ

github.com/golang/gddo/httputil

Webサービスであるgodoc.orgのリポジトリの中のパッケージです。リアルワールドなアプリケーション向けに作られた生々しいライブラリです。これが紹介したくてこのエントリーを書いたようなものです。サーバーとクライアントの間のコンテンツネゴシエーションを行う関数とか、あるいはバッファリングを行うhttp.ResponseWriterなどがあります。

コンテンツネゴシエーションは、ブラウザが欲しい(理解可能)なフォーマットと、サーバーが提供可能なフォーマットの仲介をする仕組みです。もちろん、みなさん、Real World HTTPですでに学習済みですよね。

きちんとq値を見ての選択もしてくれます。最近は画像フォーマットがいろいろ増えてきていています。お互いにより効率の良い形式で返したい、みたいなことが簡単に実現できます。標準ライブラリのhttputilに入れて欲しい。他にもNegotiateContentEncodingもあります。

package main

import (
    "fmt"
    "net/http/httptest"

    "github.com/golang/gddo/httputil"
)

func main() {
    req := httptest.NewRequest("GET", "http://localhost:8080/item", nil)
    req.Header.Set("Accept", "image/avif, image/webp;q=0.9, */*;q=0.8")

    t := httputil.NegotiateContentType(req, []string{"image/avif", "mage/heif", "image/heic", "image/webp", "image/png"}, "image/png")
    fmt.Printf("acceptable content-type: %s\n", t)
}

https://play.golang.org/p/g08pPFJUNM5

StaticServerは静的ファイルを返すサーバーです。標準ライブラリにもServeFileあるじゃんと思われた方もいると思いますが、こちらはより積極的なキャッシュ戦略が選択できます。

余談ですが、標準ライブラリのhttp.ServeFileには脆弱性があるんですね。知りませんでした。ServeFileのドキュメントにも、将来的には..はリジェクトされるよ、と書いてありますね。

こちらのStaticServerはディレクトリごと公開もできますし、特定のファイル(単数or複数)のみの公開もできるAPIも別に用意されています。ホワイトリストでできるのは安心ですね。

ResponseBufferは、http.ResponseWriterを満たす構造体です。ミドルウェアでwをこいつに差し替えて、レスポンスの内容をログに出力する、みたいなことが簡単にできますね。

CacheBustersはその名の通り、コンテンツが変わった時にキャッシュを使わせないようにクエリーパラメータを付与するライブラリです。ETagとかLast-Modifiedヘッダーを元に生成します。

package main

import (
    "fmt"
    "net/http"

    "github.com/golang/gddo/httputil"
)

func main() {
    cbs := &httputil.CacheBusters{Handler: http.FileServer(http.Dir("."))}
    path := cbs.AppendQueryParam("main.go", "cache")
    fmt.Printf("path: %s\n", path)
    // path: main.go?cache=Fri-18Dec202022-45-40GMT
}

github.com/golang/gddo/log

ロガーをcontext.Contextに差し込むミドルウェアです。contextの使用例として無駄がない。

その他の公式サービスが使っている気になったサードパーティライブラリ/ツール

公式が使っているのだからきっと質が高いものも多かろう、ということで。自分が知らなかったものをピックアップ。

github.com/client9/misspell/cmd/misspell

github.com/golang/blogやgithub.com/golang/pkgsiteで使われていたものです。名前からしてスペルチェックですよね。

sourcegraph.com/sourcegraph/go-template-lint

github.com/golang/blogやgithub.com/golang/pkgsiteで使われていたものです。テンプレートのLinterのようです。上のツールと同様、にGo製ウェブサイト用の汎用タスクのスクリプトで利用されていて、こちらも興味深いですね。

github.com/inconshreveable/log15

github.com/golang/gddo/logで使われていたライブラリです。シンプルな構造化ログとのこと。logfmt形式でデフォルトで出力されるが、JSONでも出力できるとのこと。

なお、github.com/golang/gddo/logはcontext.Contextにロガーを格納していました。context.Contextにはロガーなどは入れないみたいな説明が日本では見かけたことがありますが、公式がやっているんで、入れちゃってもいいってことですね。

github.com/yuin/goldmark

これも同じく、github.com/golang/websiteで使われていたMarkdownパーサーですね。

github.com/magiconair/properties

Javaのpropertiesファイルを読み込んで構造体にマッピングするライブラリ。エンタープライズな香りがする!

まとめ

最近、github.com/golangを覗いてみたらいろいろ見つけたのでその紹介でした。brotliをサポートしていたら、内部で持っているbrotli形式のデータをダイレクトに返すアセットバンドラー&http.FileServerもどき(インタフェースは満たしてないので別物)を実装したときに、httputil.NegotiateContentEncoding相当のものを雑ですが自前実装していたので、先に知っておけば!!!!!みたいに思った悔しさをエントリーにしました。

それ以外にも、有名どころだと github.com/golang/snappy とか、 github.com/golang/freetype とかありますが、そのあたりは今更説明するまでもないでしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

競技プログラミング用 CLI & テストライブラリ contest.go の紹介

はじめに

この記事は次のアドベントカレンダーの 18 日目の記事です。

当初 Go 3 カレンダーに投稿するつもりだった記事ですが、ふと競技プログラミングのカレンダーを見たらちょうど同じ 18 日目が空いておりましたので、両カレンダーにダブル投稿させていただくことにしました。 ダブル投稿できると思っていたら、実はできませんでした。すみません。うーむかっこ悪い…。

contest.go

contest.go は、 Go 言語で競技プログラミングコンテストに出場することを支援する目的で作った CLI とテストライブラリです。次のような特徴があります。

  • 解答プログラムが標準入出力を使用するコンテストに対応 (AtCoder, Codeforces, Google Code Jam など)
  • go test で自分の解答のテストができる (制限あり)
  • AtCoder 対応
    • 過去のコンテスト、開催中のコンテストの問題のテストケースを自動ダウンロードする機能
    • 解答を提出する機能はまだない

実際に contest.go を使ってみたいという方のために、チュートリアルの形で使用法を説明していきたいと思います。

チュートリアル

インストール

contest.go を使うには、まず Go の開発環境が必要になります。 Go で競技プログラミングコンテストに出場するのですから、当然用意していますよね?

ということで、最初に次のコマンドで CLI コマンドである contest-cli をインストールしてください。

$ go get github.com/yaegashi/contest.go/cmd/contest-cli

解答テンプレートとテストケースの生成

次に空の Git リポジトリないしフォルダを作り、その中で適当な名前の Go モジュールを作ってください。

$ git init solutions
Initialized empty Git repository in /home/yaegashi/solutions/.git/
$ cd solutions/
$ go mod init solutions
go: creating new go.mod: module solutions

conetst-cli new により、一般的な解答フォルダを作成できます。 contest.go ではこのように 1 プログラムごとに 1 フォルダを使用します。

$ contest-cli new foo
2020/12/18 20:54:12 I: Created foo/main.go
2020/12/18 20:54:12 I: Created foo/main_test.go
2020/12/18 20:54:12 I: Created foo/sample1.in.txt
2020/12/18 20:54:12 I: Created foo/sample1.out.txt

フォルダ内には contest-cli 内蔵のテンプレートから簡単な解答プログラムとテストプログラム、正解になるテストケースが生成されます。 go test を実行して確認してみましょう。

$ go test -v ./foo
go: finding module for package github.com/yaegashi/contest.go/tester
go: found github.com/yaegashi/contest.go/tester in github.com/yaegashi/contest.go v0.0.4
=== RUN   TestContest
=== RUN   TestContest/0:sample1.in.txt
--- PASS: TestContest (0.00s)
    --- PASS: TestContest/0:sample1.in.txt (0.00s)
PASS
ok      solutions/foo   0.291s

このように正解の解答プログラムではテストが成功します。

AtCoder テストケースのダウンロード

次に AtCoder の過去のコンテスト問題のテストケースを contest-cli atcoder new で取り寄せてみましょう。

$ contest-cli atcoder new abc069
2020/12/18 20:56:55 I: Fetching AtCoder contest abc069
2020/12/18 20:56:55 I: Created abc069/a/main.go
2020/12/18 20:56:55 I: Created abc069/a/main_test.go
2020/12/18 20:56:55 I: Created abc069/a/sample1.in.txt
2020/12/18 20:56:55 I: Created abc069/a/sample1.out.txt
2020/12/18 20:56:55 I: Created abc069/a/sample2.in.txt
2020/12/18 20:56:55 I: Created abc069/a/sample2.out.txt
2020/12/18 20:56:55 I: Created abc069/b/main.go
2020/12/18 20:56:55 I: Created abc069/b/main_test.go
2020/12/18 20:56:55 I: Created abc069/b/sample1.in.txt
2020/12/18 20:56:55 I: Created abc069/b/sample1.out.txt
2020/12/18 20:56:55 I: Created abc069/b/sample2.in.txt
2020/12/18 20:56:55 I: Created abc069/b/sample2.out.txt
2020/12/18 20:56:55 I: Created abc069/b/sample3.in.txt
2020/12/18 20:56:55 I: Created abc069/b/sample3.out.txt
2020/12/18 20:56:55 I: Created abc069/c/main.go
2020/12/18 20:56:55 I: Created abc069/c/main_test.go
2020/12/18 20:56:55 I: Created abc069/c/sample1.in.txt
2020/12/18 20:56:55 I: Created abc069/c/sample1.out.txt
2020/12/18 20:56:55 I: Created abc069/c/sample2.in.txt
2020/12/18 20:56:55 I: Created abc069/c/sample2.out.txt
2020/12/18 20:56:55 I: Created abc069/c/sample3.in.txt
2020/12/18 20:56:55 I: Created abc069/c/sample3.out.txt
2020/12/18 20:56:55 I: Created abc069/c/sample4.in.txt
2020/12/18 20:56:55 I: Created abc069/c/sample4.out.txt
2020/12/18 20:56:55 I: Created abc069/c/sample5.in.txt
2020/12/18 20:56:55 I: Created abc069/c/sample5.out.txt
2020/12/18 20:56:55 I: Created abc069/d/main.go
2020/12/18 20:56:55 I: Created abc069/d/main_test.go
2020/12/18 20:56:55 I: Created abc069/d/sample1.in.txt
2020/12/18 20:56:55 I: Created abc069/d/sample1.out.txt
2020/12/18 20:56:55 I: Created abc069/d/sample2.in.txt
2020/12/18 20:56:55 I: Created abc069/d/sample2.out.txt
2020/12/18 20:56:55 I: Created abc069/d/sample3.in.txt
2020/12/18 20:56:55 I: Created abc069/d/sample3.out.txt

上記の例では AtCoder Beginner Contest 069 に含まれる 4 つの問題の解答フォルダを作成しています。それぞれのフォルダに、解答のテンプレートとテスト実行のコード、そして AtCoder サイトからダウンロードされたテストケースが格納されています。

今回は解答テンプレートコードのままでは当然ながら不正解になるはずです。問題 A でテストしてみましょう。

$ go test -v ./abc069/a
=== RUN   TestContest
=== RUN   TestContest/0:sample1.in.txt
    tester.go:107: Wrong answer:
        --- want
        +++ got
        @@ -1,1 +0,0 @@
        -6
        @@ -0,0 +1,1 @@
        +hello, 3
=== RUN   TestContest/1:sample2.in.txt
    tester.go:107: Wrong answer:
        --- want
        +++ got
        @@ -1,1 +0,0 @@
        -1
        @@ -0,0 +1,1 @@
        +hello, 2
--- FAIL: TestContest (0.00s)
    --- FAIL: TestContest/0:sample1.in.txt (0.00s)
    --- FAIL: TestContest/1:sample2.in.txt (0.00s)
FAIL
FAIL    solutions/abc069/a      0.277s
FAIL

このように、不正解の場合はテストが失敗します。また正答とのプログラムの出力の差分を表示してくれます。差分は、ただのテキストだとよくわかりませんが、 実際の端末ではカラフルに表示されます。

image.png

解答プログラムの作成

ABC069 の A 問題の解答プログラムを作成してみましょう。エディタでテンプレート生成された abc069/a/main.go を開きます。解答を記述する場所は、ファイルの最後にある contest 型の main() メソッドです。

func (con *contest) main() error {
    var s string
    con.Scan(&s)
    con.Println("hello,", s)
    return nil
}

go test を使用する都合上、解答プログラムの入出力は fmt.Scan()fmt.Println() といった関数の代わりに con.Scan()con.Println() を使います。基本的には fmtcon に置き換えて書けばよいです。

AtCoder のようなコンテストでは解答プログラムは 1 ファイルで提出する必要があるため、利用可能なメソッドは main.go の中にすべて定義してあります。

さて、この問題の解答としては次のようなプログラムを書けばよいでしょう。

func (con *contest) main() error {
    var n, m int
    con.Scan(&n, &m)
    con.Println((n - 1) * (m - 1))
    return nil
}

正しいプログラムが書けたら、再度 go test を実行してみましょう。

image.png

このように go test が成功したらテストケースでの確認はできたことになりますが、それが正答とは限らないので、提出する前によく見直しましょう。

また contest.go が対応できないタイプの問題もあります。詳しくは後述する制限事項も参照してください。

AtCoder への提出

main.go ファイル全体が解答プログラムとなりますので、これをそのまま AtCoder に提出します。

ただし contest-cli にはまだ解答プログラムを提出する機能がないので、お手数ですが Web ブラウザを使って提出してください。

コンテストの解答プログラムは大した行数にはならないはずなので、エディタで main.go を全選択してからブラウザのフォームへのコピー・ペーストで実用上は問題ないと思います。

AtCoder ログイン

このチュートリアルでは AtCoder の過去コンテンストのテストケースをダウンロードしましたが、開催中のコンテストのテストケースをダウンロードすることもできます。そのためには contest-cli atcoder login でログインしておきます。

$ contest-cli atcoder login
AtCoder Login: yaegashi
Password: 

このようにログイン名とパスワードを入力してなにも表示されなければログインは成功しています。ログインのクッキーは ~/.contest-cli/atcoder.json に保存されています。

制限事項

現在の contest.go バージョン v0.0.4 には、次のような制約があります。

  • テストの判定では、テストケース出力に対する単純な行単位の文字列マッチングしか行わないため、次のような問題では正しく判定ができません。
    • 正答が 1 通りだけではない問題 (例: ABC069 問題 D)
    • 浮動小数点の数を出力する問題
    • インタラクティブ問題
  • 生成する解答テンプレートのカスタマイズはできません。コンテストに出場するなら Go の開発環境はあると思いますので、 template.go の文字列定数を編集して自分でビルドしてください。

今後の計画

  • AtCoder 以外のコンテストの対応: Codeforces などにも対応したいです。
  • AtCoder 解答提出対応: ログインしてテストケースをダウンロードする処理まで作ったのですから、あと一歩で実現できる機能だと思いますが、 web ブラウザにコピペ提出で十分早いのでまだ手をつけずにいます。
  • テスト判定の改善: 前述の制限事項を解消するため改善が必要だと思っています。カスタムジャッジが書けるとよいのですが、実際のコンテンストではそんなのを書いている余裕はないため、必要ない機能かもしれません。
  • テンプレートのカスタマイズ: ~/.contest-cli フォルダにテンプレートファイルを置くだけの簡単なもので十分かもしれません。

おわりに

きっと同じような目的でもっと便利なソフトがすでに存在すると思うのですが、よく調査しておりませんでした。申しわけありません。

自分がこれまでのコンテストで Go 言語で作った解答プログラムやライブラリは diligence.go というリポジトリに記録していますので、よろしければ参考にしてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LeetCodeに毎日挑戦してみた 118. Pascal's Triangle(Python、Go)

Leetcodeとは

leetcode.com
ソフトウェア開発職のコーディング面接の練習といえばこれらしいです。
合計1500問以上のコーデイング問題が投稿されていて、実際の面接でも同じ問題が出されることは多いらしいとのことです。

golang入門+アルゴリズム脳の強化のためにgoとPythonで解いていこうと思います。(Pythonは弱弱だが経験あり)

28問目(問題118)

118. Pascal's Triangle

問題内容

Given a non-negative integer numRows, generate the first numRows of Pascal's triangle.

(日本語訳)

負でない整数numRowsが与えられた場合、 パスカルの三角形の最初のnumRowsを生成します。

img
In Pascal's triangle, each number is the sum of the two numbers directly above it.

Example:

Input: 5
Output:
[
     [1],
    [1,1],
   [1,2,1],
  [1,3,3,1],
 [1,4,6,4,1]
]

考え方

  1. 空の配列を作成し、数字の数だけfor分を回します

  2. ループ内、appendでその数に応じた[1]の要素を追加します

  3. 左端、右端は1なのでそれを除いた範囲で上の階層を参考に値を代入していきます

  4. 最終的に作成したlistsを戻り値とします

解答コード

class Solution:
  def generate(self, numRows):
      lists = []
      for i in range(numRows):
          lists.append([1]*(i+1))
          if i>1 :
              for j in range(1,i):
                  lists[i][j]=lists[i-1][j-1]+lists[i-1][j]
      return lists
  • Goでも書いてみます!
func generate(numRows int) [][]int {
    answer := make([][]int, numRows)
    for i := 0; i < numRows; i += 1 {
        a := make([]int, i+1)
        // add ones
        a[0], a[i] = 1, 1

        if i > 1 {
            for j := 1; j <= i/2; j += 1 {
                a[j] = answer[i-1][j-1] + answer[i-1][j]
                a[len(a)-1-j] = a[j]
            }
        }
        answer[i] = a
    }
    return answer
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goのredigoを使って堅牢なRedisとPub/Sub通信のPublishを行ってみる

はじめに

この記事はand factory Advent Calendar 202018日目の記事です。
昨日は @ichi-nakashimaさんの【React Hooks】Component から stateを伴ったロジックを切り出す技術たちでした。

RedisにはPub/Sub機能がある

Redisと言えばKVS(Key-Value Store)として使われるのが一般的と思いますが、Pub/Sub機能も備わっているので、こいつを使ってGoとやり取りをしてみたいと思います。

RedisのPub/Sub機能とは

それぞれPublish(発行)Subscrib(購読)という意味で、RedisではあるChannelを作成して、
そのChannelに対してメッセージをPublish(発行)したりSubscrib(購読)したりする(=メッセージの発信・受信みたいな)ことができる機能を指します。

今回は堅牢なRedisを立ててそいつに対してGoからPublishを行ってみます。

Redisサーバーを立てる

一応自分で試した環境はVagrant+VM上のCentOS7にRedisをインストールしました。
以下はインストール時の一連(EPELのリポジトリ追加→Redisインストール)のログ

まずはEPELのリポジトリの追加
(※EPELについては CentOSなどで使う、EPELってなんだ? - Qiita などを参照してください。)

[vagrant@localhost ~]$ sudo yum install epel-release
Loaded plugins: fastestmirror
Loading mirror speeds from cached hostfile
 * base: ftp.tsukuba.wide.ad.jp
 * extras: ftp.tsukuba.wide.ad.jp
 * updates: download.nus.edu.sg
Resolving Dependencies
--> Running transaction check
---> Package epel-release.noarch 0:7-11 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

===================================================================================================================================================================
 Package                                     Arch                                  Version                             Repository                             Size
===================================================================================================================================================================
Installing:
 epel-release                                noarch                                7-11                                extras                                 15 k

Transaction Summary
===================================================================================================================================================================
Install  1 Package

Total download size: 15 k
Installed size: 24 k
Is this ok [y/d/N]: y
Downloading packages:
epel-release-7-11.noarch.rpm                                                                                                                |  15 kB  00:00:00
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : epel-release-7-11.noarch                                                                                                                        1/1
  Verifying  : epel-release-7-11.noarch                                                                                                                        1/1

Installed:
  epel-release.noarch 0:7-11

Complete!

次にRedisのインストール

[vagrant@localhost ~]$ sudo yum install redis -y
Loaded plugins: fastestmirror
Loading mirror speeds from cached hostfile
epel/x86_64/metalink                                                                                                                        | 6.4 kB  00:00:00
 * base: ftp.tsukuba.wide.ad.jp
 * epel: download.nus.edu.sg
 * extras: ftp.tsukuba.wide.ad.jp
 * updates: download.nus.edu.sg
epel                                                                                                                                        | 4.7 kB  00:00:00
(1/3): epel/x86_64/group_gz                                                                                                                 |  95 kB  00:00:01
(2/3): epel/x86_64/updateinfo                                                                                                               | 1.0 MB  00:00:02
(3/3): epel/x86_64/primary_db                                                                                                               | 6.8 MB  00:00:01
Resolving Dependencies
--> Running transaction check
---> Package redis.x86_64 0:3.2.12-2.el7 will be installed
--> Processing Dependency: libjemalloc.so.1()(64bit) for package: redis-3.2.12-2.el7.x86_64
--> Running transaction check
---> Package jemalloc.x86_64 0:3.6.0-1.el7 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

===================================================================================================================================================================
 Package                                Arch                                 Version                                      Repository                          Size
===================================================================================================================================================================
Installing:
 redis                                  x86_64                               3.2.12-2.el7                                 epel                               544 k
Installing for dependencies:
 jemalloc                               x86_64                               3.6.0-1.el7                                  epel                               105 k

Transaction Summary
===================================================================================================================================================================
Install  1 Package (+1 Dependent package)

Total download size: 648 k
Installed size: 1.7 M
Downloading packages:
warning: /var/cache/yum/x86_64/7/epel/packages/jemalloc-3.6.0-1.el7.x86_64.rpm: Header V3 RSA/SHA256 Signature, key ID 352c64e5: NOKEY0 B/s |    0 B  --:--:-- ETA
Public key for jemalloc-3.6.0-1.el7.x86_64.rpm is not installed
(1/2): jemalloc-3.6.0-1.el7.x86_64.rpm                                                                                                      | 105 kB  00:00:02

(2/2): redis-3.2.12-2.el7.x86_64.rpm                                                                                                        | 544 kB  00:00:02
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
Total                                                                                                                              293 kB/s | 648 kB  00:00:02
Retrieving key from file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7
Importing GPG key 0x352C64E5:
 Userid     : "Fedora EPEL (7) <epel@fedoraproject.org>"
 Fingerprint: 91e9 7d7c 4a5e 96f1 7f3e 888f 6a2f aea2 352c 64e5
 Package    : epel-release-7-11.noarch (@extras)
 From       : /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : jemalloc-3.6.0-1.el7.x86_64                                                                                                                     1/2
  Installing : redis-3.2.12-2.el7.x86_64                                                                                                                       2/2
  Verifying  : redis-3.2.12-2.el7.x86_64                                                                                                                       1/2
  Verifying  : jemalloc-3.6.0-1.el7.x86_64                                                                                                                     2/2

Installed:
  redis.x86_64 0:3.2.12-2.el7

Dependency Installed:
  jemalloc.x86_64 0:3.6.0-1.el7

Complete!

Redisのインストールが完了したので立ち上げる

[vagrant@localhost ~]$ sudo systemctl start redis.service

Redisの自動起動を有効にする

[vagrant@localhost ~]$ sudo systemctl enable redis
Created symlink from /etc/systemd/system/multi-user.target.wants/redis.service to /usr/lib/systemd/system/redis.service.

最後にRedisの状態を確認する

[vagrant@localhost ~]$ sudo systemctl status redis.service
● redis.service - Redis persistent key-value database
   Loaded: loaded (/usr/lib/systemd/system/redis.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/redis.service.d
           └─limit.conf
   Active: active (running) since Mon 2020-07-13 09:43:21 UTC; 31s ago
 Main PID: 22002 (redis-server)
   CGroup: /system.slice/redis.service
           └─22002 /usr/bin/redis-server 127.0.0.1:6379

Jul 13 09:43:21 localhost.localdomain systemd[1]: Starting Redis persistent key-value database...
Jul 13 09:43:21 localhost.localdomain systemd[1]: Started Redis persistent key-value database.

念のためredis-cliコマンドでpingを行い、PONGと返ってくるか確認する。
※このpingコマンドは疎通確認によく使用されるコマンドです。

[vagrant@localhost ~]$ redis-cli ping
PONG

あとは/etc/redis.confを編集してIPアドレスなどを設定すればRedisのインストールは完了

[vagrant@localhost ~]$ sudo vi /etc/redis.conf

Redisを堅牢にする(パスワードの設定)

[vagrant@localhost ~]$ echo "secretkey" | sha256sum
92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a -
[vagrant@localhost ~]$ sudo vi /etc/redis.conf

SECURITYの項目があるので、その下にrequirepassを追加し、パスワードを記述する。

/etc/redis.conf
################################## SECURITY ###################################

# Require clients to issue AUTH <PASSWORD> before processing any other
# commands.  This might be useful in environments in which you do not trust
# others with access to the host running redis-server.
#
# This should stay commented out for backward compatibility and because most
# people do not need auth (e.g. they run their own servers).
#
# Warning: since Redis is pretty fast an outside user can try up to
# 150k passwords per second against a good box. This means that you should
# use a very strong password otherwise it will be very easy to break.
#
requirepass 92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a

(※ここではsha256sumを使って生成したハッシュ値をパスワードとして設定していますが、ある程度の長さがあればいいと思います。)

/etc/redis.conf編集後にRedisを再起動し、redis-cliから先ずはパスワードなしの状態でコマンドを実行する。

[vagrant@localhost ~]$ sudo systemctl restart redis.service
[vagrant@localhost ~]$ redis-cli
127.0.0.1:6379> set key 10
(error) NOAUTH Authentication required.

上記のように認証エラーがでることを確認。
次にauthコマンドを書き、先ほど設定したパスワードを入れて実行する。

127.0.0.1:6379> auth 92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a
OK

OKが出れば認証された状態なので、コマンドを実行すれば以下のようにふつうに動くようになる。

127.0.0.1:6379> set key 10
OK
127.0.0.1:6379> get key
"10"
127.0.0.1:6379> quit

Goのサンプルコード

サンプルコードはパスワードを設定したRedisにパブリッシュする処理を書いています。(※パスワード設定は必須ではないです。念のため)

package main

import (
    "fmt"
    "github.com/gomodule/redigo/redis"
)

// RedisPublish : コマンドをRedisへパブリッシュする
func RedisPublish(message string) (int, error) {
    conn, redisAuthPass, err := ConnectionRedis()
    if err != nil {
        fmt.Println(err)
        return 0, err
    }
    defer conn.Close()

    // パス認証
    if redisAuthPass != "" {
        _, err = conn.Do("AUTH", redisAuthPass)
        if err != nil {
            fmt.Println(err)
            return 0, err
        }
    }

    // パブリッシュ
    res, err := redis.Int(conn.Do("PUBLISH", "test_channel", message))
    if err != nil {
        fmt.Println(err)
        return 0, err
    }

    return res, err
}

// ConnectionRedis : Redisと接続し接続情報とAuthパスを返す
func ConnectionRedis() (redis.Conn, string, error) {
    // RedisのAUTHパス
    AuthPass := "92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a"            
    // RedisのIPアドレス
    RedisIPAddress := "192.168.33.100"
    // Redisのポート番号
    RedisPort := "6379"

    // 接続
    conn, err := redis.Dial("tcp", RedisIPAddress+":"+RedisPort)
    if err != nil {
        fmt.Println(err)
        return nil, "", err
    }

    return conn, AuthPass, err
}

実際にはAUTHパスとかは環境によって変わると思うので、ソースに書かずに.envとかに記述して読み込むのがいいと思います。
参考:【Go】.envファイルをGolangでも使用するためのライブラリ「godotenv」 - Qiita

Redisをさらに堅牢にする

個人的にはRedisに接続する際のAuthパスだけでもいいと思うんですが念には念を入れて強化する場合は以下のことが出来ます。

ポート番号の変更

Redisのデフォルトポート番号は6379番ですが、これを変更することで多少なりともセキュリティが向上します。

/etc/redis.conf
# Accept connections on the specified port, default is 6379 (IANA #815344).
# If port 0 is specified Redis will not listen on a TCP socket.
# port 6379
port 6597

変更後はRedisの再起動すること。

重要なコマンドの変更

例えばCONFIGSHUTDOWNFLUSHDBコマンドなど、実行されるとマズいコマンドをほかの文字列に置き換えることが出来ます。
代わりのコマンド名を以下のredis.confに追記して再起動をすることで適用されます。

/etc/redis.conf
# Command renaming.
#
# It is possible to change the name of dangerous commands in a shared
# environment. For instance the CONFIG command may be renamed into something
# hard to guess so that it will still be available for internal-use tools
# but not available for general clients.
#
# Example:
#
# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52
#
# It is also possible to completely kill a command by renaming it into
# an empty string:
#
# rename-command CONFIG ""
#
# Please note that changing the name of commands that are logged into the
# AOF file or transmitted to slaves may cause problems.
rename-command CONFIG configconfig
rename-command FLUSHDB SF_FLUSHDB
rename-command SHUTDOWN RENAME_SD

以下コマンドがかわっているか確認の様子

[vagrant@localhost ~]$ sudo systemctl restart redis.service
[vagrant@localhost ~]$ redis-cli
127.0.0.1:6379> auth 92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a // 上で設定したパス
OK
127.0.0.1:6379> config get requirepass
(error) ERR unknown command 'config' // configというコマンドは無い(上で変更したため)
127.0.0.1:6379> configconfig get requirepass
1) "requirepass"
2) "92d4e0a2ad853e81e6d4bf147618aae5a06f201e6e72a67794a542409c9d125a"
127.0.0.1:6379> exit

Redisデータディレクトリの権限変更

RedisユーザーとRedisグループのみがアクセスできるように770に変更する。

[vagrant@localhost ~]$ ls -l /var/lib | grep redis
drwxr-x---. 2 redis   redis     22 Jul 13 13:31 redis

[vagrant@localhost ~]$ sudo chmod 770 /var/lib/redis

[vagrant@localhost ~]$ ls -l /var/lib | grep redis
drwxrwx---. 2 redis   redis     22 Jul 13 13:31 redis

おわり

  • なんか走り書きしたら雑なまとめになってしまった感がある・・・
  • ちなみにSubscribは今度時間あれば書くかもしれません・・・(予定は未定です)
  • あとまだRedisは構成ファイルの権限変更したりなどで強固にできますがここでは割愛します(DigitalOceanさんの参考記事をどうぞ)

参考URL

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

レガシー環境用のTLS1.2対応リバースプロキシを golang で簡単に作る

はじめに

CentOS5 などのレガシーシステムでは TLS1.2 が利用できません。
OSバージョンアップや別サーバで Reverse Proxy を構築するのが一般的ですが結構な手間です。
そこで今回は、golang でちょちょいと TLS1.2 対応の Reverse Proxy を実装します。

やってみよう

必用なもの

  • golang v1.9以前(※ レガシーシステムではv1.9以前でコンパイルしないと動きません)

やりかた

golang ならわずか10行くらいのロジックでリバースプロキシができちゃいます。

main.go
package main

import (
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
)

func main() {
    // プロキシしたいレガシーサーバのURL
    target, _ := url.Parse("http://localhost:80")
    // シングルホストの場合はこれだけでOK
    proxy := httputil.NewSingleHostReverseProxy(target)
    // ハンドラ内でヘッダーを設定してプロキシする
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        r.Header.Set(`X-Forwarded-Host`, r.Header.Get(`Host`))
        r.Header.Set(`X-Forwarded-Proto`, r.URL.Scheme)
        proxy.ServeHTTP(w, r)
    })

    // TLS1.2でListen
    err = http.ListenAndServeTLS(":443", "path to ssl cert", "path to ssl key", nil)
    if err != nil {
        log.Fatal(err)
    }
}

また、http.HandlerFunc()内では、以下のように Request Header も設定できます。
レガシーサーバ側が X-Forwarded-Proto などに対応していない場合は、Request をゴニョゴニョやって対応しましょう!

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        r.Header.Set(`X-Forwarded-Host`, r.Header.Get(`Host`))
        r.Header.Set(`X-Forwarded-Proto`, r.URL.Scheme)
        r.URL.Host = target.Host
        r.URL.Scheme = target.Scheme
        proxy.ServeHTTP(w, r)
    })

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GORMがv2になっていたので、変更点を調べてみる

はじめに

golangでプロトタイプを作成していて、いつも通り GORM を利用しようとした際、どうせなら最新版でと思ったところ、 大きく変わっていました。
詳細は、v2のリリースノートを見てもらうのが一番ですが、変更点も多いため、単純にバージョンアップする際にひっかかりそうなところを調べてみました。

インストール

この記事の段階での最新バージョンは、1.20.8 になりますが、
1.20系にバージョン番号が上がると、インストールする際のパスが変更されます。
いままでは

go get -u github.com/jinzhu/gorm

となっていましたが、 v2になって

 go get -u gorm.io/gorm

ドキュメントも https://v1.gorm.iohttps://gorm.io/ に分かれています。

Open

初期化する際の関数も引数が変更されています。

v1

func Open(dialect string, args ...interface{}) (db *DB, err error) 

第1引数が、"mysql""postgres"といった接続するRDBMSの種類を文字列で指定し、第2引数に接続文字列を渡します。
また、必要なDriverパッケージをブランク識別子を使用してインポートする必要があります。

import (
  "github.com/jinzhu/gorm"
  _ "github.com/go-sql-driver/mysql"
)

func main() {
  db, err := gorm.Open("mysql", "test:test@/testdb")
  ...
}

v2

func Open(dialector Dialector, config *Config) (db *DB, err error)

第1引数に gorm.Dialector という型で渡すように変更されています。
こちらは接続するRDBMSの種類に応じて、gorm用のDriverを初期化して渡す必要があります。
v2の場合は、ブランク識別子は不要になります。
また、第2引数のConfigでいろいろと細かい設定や機能が追加されていますが、今回は省略します。

import (
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

func main() {
  db, err := gorm.Open(mysql.Open("test:test@/testdb"), &gorm.Config{})
  ...
}

DeletedAt

論理削除をしたい場合、v1では DeletedAt フィールドを *time.Timeで用意していましたが、
v2では、フィールド名ではなく、gorm.DeletedAt という型で定義する必要があります。

v1
type Example struct {
...

  DeletedAt *time.Time
}
v2
type Example struct {
...

  Deleted gorm.DeletedAt
}

Hooks

実際にSQLが発行される前後のHook処理もインターフェースの条件が変更されています。

v1

DOMに下記の名前のメソッドを実装することで処理を呼ぶことができます。

BeforeSave
BeforeCreate
AfterCreate
AfterSave

v2

メソッド名に変更はありませんが、HooksThe type of hook methods should be func(*gorm.DB) error 記述にあるように
それぞれのメソッドの引数に *gorm.DB を渡す必要があります。
メソッドの型が異なると、実際の処理時には実行されないため注意が必要です。

RecordNotFound

Selectを発行して該当レコードがない場合に、db.RecordNotFound() メソッドで判定していましたが、
当該メソッドがなくなりました。errors.Is()を使用して、判定を行う必要があります。

v1
db = db.First(entity)
if db.RecordNotFound() {
  ...
}
v2
db = db.First(entity)
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
   ...
}

ログ出力

デバッグログの出力設定が変わりました。

v1

LogModeをtrueに設定するだけで、SQLがログ出力されます。

v1
db.LogMode(true)

v2

Logger を設定する必要があります。
新しくGORM用のLoggerが提供されており、出力レベルが変更できるようになりました。
レベルをInfo にすることで、SQLのログ出力が行われます。

logger/logger.go
const (
    Silent LogLevel = iota + 1
    Error
    Warn
    Info
)
v2
import (
  "gorm.io/gorm"
  "gorm.io/gorm/logger"
)

...
db.Logger = db.Logger.LogMode(logger.Info)
...

DB()

地味な変更ですが、v1の時には、 *gorm.DB の DB()メソッドを呼ぶことで、*sql.DB だけを取得することができましたが、
v2においては、errorも返すようになりました。

v1
db.DB().SetMaxIdleConns(10)
v2
if sqlDb, err := db.DB(); err != nil {
  panic(err)
} else {
  sqlDB.SetMaxIdleConns(10)
}

Close

v1においてはgorm.DBにCloseメソッドがありましたが、v2ではなくなりました。
v2では、
sql.DBを取得してCloseを実行する必要があります。

v1
 db.Close()
v2
if sqlDB, err := db.DB(); err != nil {
  ...
} else {
  if err := sqlDB.Close(); err != nil {
     ...
  }
}

まとめ

上にあげた点だけではなく、マイグレーションや、バッチ更新など、かなり機能が追加されています。
これから新しく作るプロダクトにおいては、最新版を使うことで、他のライブラリを探す手間がはぶけるというところでしょうか。
すでに動いているものを急いで更新するメリットはあまり感じません。
それでもバージョンアップを行う場合には、単にビルドが通ればいいというわけではなく、
ビルド時のエラーがでない変更も多いため、事前の動作確認を確実に行う必要があります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go1.16で追加されるembedとio/fsパッケージについてざっと調べた

はじめに

これは Kyash Advent Calendar 2020 の20日目の記事です。

Go1.16ではバイナリへのファイルやコンテンツを埋め込む //go:embed ディレクティブが有効になったり、ファイルシステムへの共通interfaceを提供する io/fs などが追加されてます。

それぞれ、configファイルをバイナリに埋め込むときにサードパーティライブラリを使わずにGo自体の仕組みで埋め込めるようになっていたり、ファイルツリーの形式を抽象化して統一したinterfaceで扱えるようにしてあったりします。

とても便利そうな機能なので、リリースされたら早速使ってみたいので下調べしてみました。

(余談ですが ちょうど記事を書いてる途中にGo1.16 Betaがリリースされた のでめちゃくちゃタイミングが良かったです)

embedについて

draftはこちら
ドキュメントはこちら

draftから気になってた機能その1です。

機能はシンプルで、 embed パッケージをimportしつつ目的の変数に //go:embed target/file/path というコメントを追加するとその内容がビルド時に変数に埋め込まれる!というすぐれものです。

使うのもカンタンだしバイナリへのファイル埋め込みは全部これにしたいですね。

例として適当な config.json があったと想定してembedをつかって埋め込んでみます。
Go1.16 Betaを使うと手元でも試せます。

config.json
{"key1":"val1","key2":"val2"}
example_embed_bytes.go
package main

import (
    _ "embed"
    "encoding/json"
    "fmt"
    "log"
)

//go:embed config.json
var configBytes []byte
type config struct {
    Key1 string `json:"key1"`
    Key2 string `json:"key2"`
}

func main() {
    var c config
    if err := json.Unmarshal(configBytes, &c); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%#v\n", c)
}

ビルドして実行した結果は以下。ちゃんとよみこめてる!

$ go build -o embedtest
$ ./embedtest
main.config{Key1:"val1", Key2:"val2"}

バイト列以外にもstringとしてもうけとれます。

examble_embed_string.go
package main

import (
    _ "embed"
    "fmt"
)

//go:embed config.json
var configString string

func main() {
    fmt.Printf("%#v\n", configString)
}
$ go build -o embedtest
$ ./embedtest 
"{\"key1\":\"val1\",\"key2\":\"val2\"}\n"

すごい便利だし楽!

埋め込みの際には、Go1.16から抽象化された io/fs.FS を実装してる embed.FS という型でも受け取れます。
後述しますが、この型は標準パッケージ間ですでに色々なところで使えるようになっていて非常に便利です。

いままでは、なにがしかのサードパーティツールを利用して go generate で埋め込み実行していたのが go build でできるようになるので、ファイルの埋め込みに関して開発者が気を使う必要がほぼなくなります!うれしい!

(なんかエラー出ると思ったら go generate 忘れててファイル埋め込めてないやん、みたいな経験をしたのは僕だけではないはず)

io/fsについて

draftはこちら
ドキュメントはこちら

draftから気になってた機能その2です。
こちらは1.16には入らないと思ってたのですが、beta版の内容を確認したら入ってたのでうれしいです。

これはどういうパッケージかというと、 io.Readerio.Writer の成功にならって、ファイルシステムツリーも抽象化しよう!というものです。

Interfaceは以下です

https://go.googlesource.com/go/+/refs/tags/go1.16beta1/src/io/fs/fs.go#20

FS.go
type FS interface {
    // Open opens the named file.
    //
    // When Open returns an error, it should be of type *PathError
    // with the Op field set to "open", the Path field set to name,
    // and the Err field describing the problem.
    //
    // Open should reject attempts to open names that do not satisfy
    // ValidPath(name), returning a *PathError with Err set to
    // ErrInvalid or ErrNotExist.
    Open(name string) (File, error)
}

https://go.googlesource.com/go/+/refs/tags/go1.16beta1/src/io/fs/fs.go#72

File.go
type File interface {
    Stat() (FileInfo, error)
    Read([]byte) (int, error)
    Close() error
}

要はいろいろなパッケージを使用し、いろいろな手段でファイル読み取りしていた部分を、読み取り専用の fs.File interfaceとそれを取得するための fs.FS interfaceを使うことで統一しよう!というわけですね。
(今までは text/template などにおける ParseFiles のようにパッケージごとに実装されてたりしていた)

なるほどそれは便利そう!
以下はembedで受け取ったFSを同じくGo1.16で新規に追加された http.FS に渡してつかってみる例です。

example_http_fs.go
package main

import (
    "embed"
    "log"
    "net/http"
)

//go:embed config.json
var configFS embed.FS

func main() {
    http.Handle("/", http.FileServer(http.FS(configFS)))
    log.Fatal(http.ListenAndServe(":8080", nil))
}
$ go build -o fstest
$ ./fstest

サーバーが立ち上がって fs.FS で渡したコンテンツが / 配下に配置されてることが確認できます。

$ curl localhost:8080/config.json
{"key1":"val1","key2":"val2"}

今回は試してませんが、 text/templatehtml/template パッケージも fs.FS を受け取れるようになってるようです。
いままでなら ParseFiles() で初期化していたものが、 ParseFS()
が使えるようになったそう。これも便利っぽい。

Go1.16では、このほかにもいくつかのパッケージが fs.FS に対応したようです!
一回取得した fs.FS を標準パッケージ間で統一したinterfaceの上で使い回せるのはすごくうれしいです!

さいごに

embed, io/fsともに必要最低限かつあると助かるものが追加されて個人的にはとても嬉しいです!
Kyashでもいくつか使いたい部分ががあるので今からリリースが楽しみです。

Go1.16はほかにもM1 Macのサポートがされたり、 go install の挙動が整理されたり、いろいろと使いやすそうになっているので早く使ってみたいですね!

Kyash Advent Calendar 2020 はまだまだつづきます、あしたの投稿もおたのしみに〜。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

はじめに

何だか良くわからないけどよく聞く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

https://http2.info/
HTTP1からの変更例

  • テキストからバイナリ
  • ステートレスからステートフル
  • 1つのTCPコネクションの中で複数のHTTP Requestと複数のHTTP Response

gRPC-Web

https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md
https://grpc.io/docs/platforms/web/basics/
ブラウザの制限によりネイティブのgRCPとは違う実装。

envoy

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

コード

https://github.com/tayusa/grpc-web-simple-chat

protoファイル定義

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使う。
  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/tayusa/grpc-web-simple-chat/blob/master/server/proto/chat.pb.go
https://github.com/tayusa/grpc-web-simple-chat/tree/master/client/src/proto

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

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

$ 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

https://github.com/tayusa/grpc-web-simple-chat/blob/master/server/main.go
https://github.com/tayusa/grpc-web-simple-chat/blob/master/server/server.go
https://github.com/tayusa/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
}

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

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

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

https://github.com/tayusa/grpc-web-simple-chat/blob/master/client/src/api/client.js
https://github.com/tayusa/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 bufferが生成する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/tayusa/grpc-web-simple-chat.git
$ cd grpc-web-simple-chat
$ docker-compose up -d --build
$ chromium 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"'

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む