- 投稿日:2019-12-23T23:52:46+09:00
10. 行数のカウント
10. 行数のカウント
行数をカウントせよ.確認にはwcコマンドを用いよ.
Go
package main import ( "bufio" "fmt" "os" ) func main() { // 読み込みファイルを指定 name := "../hightemp.txt" line := 0 // 読み込むファイルを開く f, err := os.Open(name) if err != nil { fmt.Printf("os.Open: %#v\n",err) return } defer f.Close() // 終了時にクリーズ // スキャナライブラリを作成 scanner := bufio.NewScanner(f) // データを1行読み込み for scanner.Scan() { line++; } // エラーが有ったかチェック if err = scanner.Err(); err != nil { fmt.Printf("scanner.Err: %#v\n",err) return } // 行数を表示 fmt.Println("Line",line) }python
import sys line = 0 # ファイルを開く with open("../hightemp.txt", "r") as f: # 一行ずつ読み込む for data in f: # 行数を加算 line += 1 # 行数を表示 print("Line",line)Javascript
// モジュールの読み込み var fs = require("fs"); var readline = require("readline"); var line = 0; // ストリームを作成 var stream = fs.createReadStream("../hightemp.txt", "utf8"); // readlineにStreamを渡す var reader = readline.createInterface({ input: stream }); // 行読み込みコールバック reader.on("line", (data) => { line = line + 1 }); // クローズコールバック reader.on("close", function () { console.log("Line",line); });まとめ
やっと 「第2章: UNIXコマンドの基礎」 へ突入!!。
2章に入ったとのことで、Pythonのバージョン設定をやっと 3.7 へ変更しました。
IDEの設定だけですけど・・・。設定がどこにあるか探すのが・・・。と言い訳。ファイルの読み込みをそれぞれの言語調べながら。
Go,Python はそれほど困らなかったが、
Javascirpt は。おぉおぉ。なんか面白い。非同期との事もあり考え方は注意が必要か。補足
Go 言語で変数名を fname としていたが、IDE(Golang) が typo? と言ってくる。
有り難いのかなぁ。とりあえず name へ変更し回避。
- 投稿日:2019-12-23T23:40:36+09:00
GoでAPI Clientのframeworkを考えてみた
今回Advent CalendarでいくつかAPI Clientを作って感じたことは、GoでAPI Clientの構成は細部は違くともほぼ同じ作りで大抵の箇所は前に作ったもののコピーでできてしまうということです。
しかしながら、外部パッケージではなくコピーしたところも動作保証しなければならないため、それらの箇所に関してもすべてテストする必要があります。
API ServerやApplicationを作るときにFrameworkを使うと最低限の箇所を作れば、いいのと同じように共通化できる箇所を1つのパッケージにまとめた
bone
を作りました。Bone
https://github.com/usk81/boneBoneは殆どのものにInterfaceを用意しています。
そのため、最小限の構成を考えた場合、以下のようになります。
クライアントの作成は、bone.NewClient
がほとんど対応してくれます。
足りない部分を記載してあげるだけで完了します。APIごとに1番異なるのが、レスポンスを受けたあとの処理とエラーレスポンスの処理方法です。
エラー対応はCheckResponse
をSetResponseChecker
で差し替えることができるので、独自の処理に置き換えて使ってください。
boneでstatus codeをチェックするだけの処理を用意しているので、それを使っても構いません。
ErrorResponse
はエラーレスポンスをエラーとして扱うためのものです。
こちらはinterfaceを用意していませんが、errorのダックタイピングになるので、Error()
メソッドが用意されていれば同様に扱えます。エラーレスポンスのボディの内容を格納するなどして使ってください。client.goconst ( defaultBaseURL = "https://httpbin.org/" userAgent = "example" tokenKey = "Authentication" ) func New(httpClient *http.Client, token string) (c *bone.DefaultClient, err error) { c = &bone.DefaultClient{ TokenKey: tokenKey, Token: token, } if err = bone.NewClient(c, httpClient); err != nil { return nil, err } c.SetBaseURL(defaultBaseURL) c.SetUserAgent(userAgent) c.SetResponseChecker(CheckResponse) es := &ExampleService{} es.SetClient(c) c.SetService("example", es) return } // An ErrorResponse reports the error caused by an API request type ErrorResponse struct { Response *http.Response } func (r *ErrorResponse) Error() string { // Error message } // CheckResponse checks the API response for errors, and returns them if present func CheckResponse(r *http.Response) error { if c := r.StatusCode; c >= 200 && c <= 299 { return nil } errorResponse := &ErrorResponse{Response: r} // set optional parameters return errorResponse }DefaultClientには、NewRequestが用意されているので、これを使えば、リクエストするAPIのURLやHeaderの設定、リクエストボディの設定をよしなにやってくれます。
DoでAPIリクエストを行って、
bone.JSONDecode
でレスポンスボディをJSONをDecodeしてresult
に格納しています。
bone.JSONDecode
はResponseDecode
という関数型なので、ResponseDecode
を満たすように作れば、XMLのレスポンスボディをDecodeすることも簡単にできます。さらにDoは
Client
で定義されているわけではなく、ResponseResolver
という別のinterfaceで定義されているので、リクエストを投げっぱなしにして結果を受け取りたくないのであれば、Do
をつくらずに独自処理で解決することもできます。example_service.gotype ProfileService struct { client bone.Client } func (s *ProfileService) SetClient(c bone.Client) { s.client = c } func (s *ProfileService) Get() (result ExampleResult, err error) { c, ok := s.client.(*bone.DefaultClient) if !ok { err = errors.New("client is invalid") return } req, err := c.NewRequest(http.MethodGet, "anything"), nil, nil) if err != nil { return } if _, err = c.Do(nil, req, bone.JSONDecode, &result); err != nil { return } return result, nil }Boneは1日ぐらいで作ったもので、試用はできますが、まだコンセプトレベルです。
(テストも書いてませんし)ですが、exampleを書いた感じだと開発工数は減らせそうな感触は得られたので、引き続き開発したいと思います。
- 投稿日:2019-12-23T22:16:08+09:00
[Go]termbox-goでpecoっぽい何かを作る
What's termbox-go
termbox-goはテキストベースのユーザインターフェースを作るためのツールです。pecoでもtermboxが使われています。
pecoっぽいとは
この記事での「pecoっぽい」とはインタラクティブに何かを選択できるものだと考えてください。今回は、引数でカレントディレクトリのファイルを選択できて、選択するとそのファイルの内容がプリントされるだけの物を作ります
できたもの
こんな感じの物を作りました。ソースコードは以下のようになっています
package main import ( "flag" "fmt" "io/ioutil" "log" "strconv" "github.com/mattn/go-runewidth" "github.com/nsf/termbox-go" log "github.com/sirupsen/logrus" ) const coldef = termbox.ColorDefault var list []string func drawBox(pos int) { termbox.Clear(coldef, coldef) for i := range list { if i == pos { drawText(0, i, coldef, termbox.ColorWhite, list[i]) continue } drawText(0, i, coldef, coldef, list[i]) } s := []rune(strconv.Itoa(pos)) termbox.SetCell(10, 10, s[0], coldef, coldef) termbox.Flush() } func main() { flag.Parse() list = flag.Args() pos := 0 if err := termbox.Init(); err != nil { log.Fatal(err) } drawBox(pos) var display bool MAINLOOP: for { switch ev := termbox.PollEvent(); ev.Type { case termbox.EventKey: switch ev.Key { case termbox.KeyEsc: break MAINLOOP case termbox.KeyArrowDown: if len(list)-1 > pos { pos++ } case termbox.KeyArrowUp: if pos > 0 { pos-- } case termbox.KeyEnter: display = true break MAINLOOP } drawBox(pos) default: drawBox(pos) } } termbox.Close() if display { file, _ := ioutil.ReadFile(list[pos]) fmt.Println(string(file)) } } func drawText(x, y int, fg, bg termbox.Attribute, text string) { for _, c := range text { termbox.SetCell(x, y, c, fg, bg) x += runewidth.RuneWidth(c) } }drawboxは実際にテキストを描写している部分です。
リストの中身を順番に全部描写していますが、選択されているテキスト(i == podの部分) だけ色を変えています。
termbox.ColorDefault
はターミナルのデフォルトの配色です。termbox.SetCell
でセルに描写したいものを配置し、termbox.Flush()
を呼ぶことで実際に描写が行われます。func drawBox(pos int) { termbox.Clear(coldef, coldef) for i := range list { if i == pos { drawText(0, i, coldef, termbox.ColorWhite, list[i]) continue } drawText(0, i, coldef, coldef, list[i]) } s := []rune(strconv.Itoa(pos)) termbox.SetCell(10, 10, s[0], coldef, coldef) termbox.Flush() }一番重要な重要なキーのイベントを受け取っている部分です。
MAINLOOP: for { switch ev := termbox.PollEvent(); ev.Type { case termbox.EventKey: switch ev.Key { case termbox.KeyEsc: break MAINLOOP case termbox.KeyArrowDown: if len(list)-1 > pos { pos++ } case termbox.KeyArrowUp: if pos > 0 { pos-- } case termbox.KeyEnter: display = true break MAINLOOP } drawBox(pos) default: drawBox(pos) } }
termbox.PollEvent()
でターミナルのイベントを無限ループで取得し、その中でキーが押されたイベントを示すtermbox.EventKey
の時に、押されたキーによって処理を変えています。ループを抜けるケース以外は、キーイベントを受け取るごとに再度描写しています。今回のプログラムでは
KeyEsc
(Escキー)の時に無限ループを抜け出し、矢印キーの上下(KeyArrowDown
,KeyArrowUp
)でカーソルの位置を示すpos変数を変化させています。そして、KeyEnter
(Enterキー)が押された時には、無限ループを抜け出す+選択されたファイルを読み込んで描写しています。終わり
簡単ですが、なんか便利なツールができそうな気がしてきましたね?
- 投稿日:2019-12-23T20:31:28+09:00
ファイルのコピー処理
man.gopackage main import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "regexp" ) const ( // 検索対象のパス。検索したいフォルダのパスを指定する Root = `xxx\xxx\xxx` // コピーしたフォルダを指定 DestPath = `xxx\xxx\` ) var ( // マッチしたファイルを追加するため変数 file string // 検索ワードを正規表現化して入れておく変数 re *regexp.Regexp ) // filepath.Walk に渡す関数 func visit(path string, info os.FileInfo, err error) error { if err != nil { return err } // ファイル検索 if !info.IsDir() && re.MatchString(info.Name()) { //同じファイルがあっても上書き file = path } return nil } func search(checkFile string) error { // 正規表現 var err error re, err = regexp.Compile(checkFile) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) } err = filepath.Walk(Root, visit) if err != nil { return err } // マッチしたファイルがなければ終了する if len(file) == 0 { return fmt.Errorf("file not matched") } // ファイルのコピー /* このやり方だと、コピーではなく転記になる w, err := os.Create(DestPath + checkFile) if err != nil { log.Fatal(err) } r, err := os.Open(file) if err != nil { log.Fatal(err) } _, err = io.Copy(w, r) if err != nil { log.Fatal(err) } */ cmd := exec.Command("cmd.exe", "/C", "copy", "/Y", file, DestPath) cmd.Start() return nil } func main() { fmt.Println("ファイル読み取り処理を開始します") // 検索対象のファイル名が記載されたファイルをOpenする f, err := os.Open("xxxx.txt") // 読み取り時の例外処理 if err != nil { fmt.Println("error") } // 関数が終了した際に確実に閉じるようにする defer f.Close() // 1行づつ読込む scanner := bufio.NewScanner(f) for scanner.Scan() { // 一行ずつ処理 err = search(scanner.Text()) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) } } }
- 投稿日:2019-12-23T19:54:22+09:00
サーバレス環境でbotを動かす(Go & Cloud Run)
こんにちは!私(@f_yamagami)です!
この記事は、All About Group(株式会社オールアバウト) Advent Calendar 2019 クリスマスイブの記事です。前談(The・前談)
クリスマスイブですね。
マジあれですね
みなさんはクリスマスプレゼントに何が欲しいですか?
ゲーム? お金? 休み?
やっぱりエンジニアならサーバですか?
あれ、そこのエンジニアさん
どうしてサーバと聞いて苦い顔をされていらっしゃるんですか?
え? サーバ障害の対応で、せっかくのクリスマスディナーが冷えてしまい、
恋人との関係も冷えてしまった過去が?ざまあみ(ry
※私はサーバにはなんの思い出もないです。ということで、今回は会社の開発合宿で先輩エンジニアに手取り足取りしていただきながら、サーバレスの環境でbatch処理を行った話を書いていきます。
[先輩のQiitaはこちら↓]
https://qiita.com/y_hideshi/items/085a92746578b5b7f01d今回作ったのは、先輩のQiitaにもある通り、
定期的にGCPの確約利用割引の期日の自動チェック & アラートあげるbotです。確約利用割引とは
確約利用割引はGCEの契約を1年間または3年間の支払いを確約する代わりに、特定の量の vCPU、メモリ、GPU、ローカル SSD を割引価格で購入できるものです。
マシンタイプやコア数、適用期間などを指定したコミットメントというものを購入すると、その分の利用分は確約利用割引が適用されるというものです。
雑な説明にはなりますが、年間パスポートのようなものです。
最初に一括で代金をを支払うわけではないので、通常の年間パスポートとは少し違いますが、下記の例を見るとだいたい年間パスポートみたいなものだという計算になると思います。例:8コア用のコミットメントを購入
- 4コアを使用した月 → 8コア用の確約された利用料金が請求される
- 8コアを使用した月 → 8コア用の確約された利用料金が請求される
- 12コアを使用した月 → 8コア用の確約された利用料金 + 4コア分は通常料金で請求される詳しくは公式のドキュメントでご確認ください。
https://cloud.google.com/compute/docs/instances/signing-up-committed-use-discounts?hl=ja前談2 (準備的な)
まず、定期的にGCPの確約利用割引の期日の自動チェック & アラートあげるbotを作るにあたって、実現したいことの認識合わせを先輩としました。
- 今ある課題
- 契約している確定利用割引の更新日が近づくと、メールでのアラートは来るけど見落としそう
- 見落として更新を忘れると通常価格での支払いとなり、損してないけど損した気分になるので嫌だ
- 実現したいこと
- 契約している確定利用割引の更新日が近づくと、Slackで通知させたい!
某総理大臣と某大統領のごとく、直接会談の結果、完全に認識が一致しました。
次に、実現方法について考えました。
確定利用割引のコミットメントはAPIで内容が確認できることがわかったので、
下記のように、私は考えました。1.下記のような簡単なアプリを作る
・コミットメントの終了日を取得するAPIを叩いて値を取得
・取得したコミットメントの終了日が1週間以内か確認
・1週間以内ならSlackのAPIを叩いて通知2.上記アプリをどっかのサーバに置いてcronで1日1回実行
私「先輩! こんな感じで考えてるんですけど、どこのサーバに置きますか?
てか、新しいサーバを立てますか?」1先輩「なるほど、君はどこのサーバにアプリを置くか迷っているようだけど、全てが違う。
サーバをどこに置くか以前に、サーバを立てる必要があるのかを考えたか?
貴様の悩むべきところは、サーバ代の金銭的負担だけでなく、
サーバ管理が必要となり、技術的負債まで残してしまうことを、1mmたりとも
考慮できていない己の無知に気が付いていないことではないのか?」あまりの衝撃的なお言葉に、なんとおっしゃっていたのかは正確には忘れてしまいましたが、
実際のところは「え?サーバは使わないでCloud Runでしてみない?」だった気もします。ということで、今回はサーバレス環境で実装することになりました。
また、私は
- コミットメントの終了日を取得するAPIを叩いて値を取得
- 取得したコミットメントの終了日が近いかの確認
の部分を実装したので、その辺り中心に書こうと思います。システム構成
(略)
※先輩のQiitaにて詳細に紹介済み
https://qiita.com/y_hideshi/items/085a92746578b5b7f01d#%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E6%A7%8B%E6%88%90実装
今回はgo言語で実装しました。
main.go/* * importは省略しています。 * 愚直に公式のExamplesに沿って認証を通していきます。 * 認証にApplication Default Credentialsを利用したサンプルです。 */ ctx := context.Background() c, err := google.DefaultClient(ctx, compute.CloudPlatformScope) if err != nil { log.Fatal(err) } computeService, err := compute.New(c) if err != nil { log.Fatal(err) } // プロジェクトとリージョンを指定 project := "my-project" region := "my-region" if err := req.Pages(ctx, func(page *compute.CommitmentList) error { // 今日の日付 today_str, _ := time.Parse("20060102", time.Now()) for _, commitment := range page.Items { // 文字列 → 日付型 endDate, _ := time.Parse("2006-01-02T15:04:05Z07:00", commitment.EndTimestamp) // 何日前か比較したいリスト dayArray := [3]int{7, 14, 30} for _, x := range dayArray { // 今日の日付からx日先の日付をcompareDateに設定する compareDate := today_str.AddDate(0, 0, x) // 日付型を整えた上で、アラートを出すべき日かを確認する。 if (endDate.Format("2006-01-02") == compareDate.Format("2006-01-02")){ message_str := "確約利用割引 `" + commitment.Name + "`の期限は `" + endDate.Format("2006-01-02") + "`までです:alert:" // pub/subにmessageを公開する PubsubFunc(message_str) } } } return nil }); err != nil { log.Fatal(err) }PubsubFunc関数.gofunc PubsubFunc(message_str string) { ctx := context.Background() client, err := pubsub.NewClient(ctx, "my-client") if err != nil { log.Fatal(err) } topicName := "slack-topics" topic = client.Topic(topicName) exists, err := topic.Exists(ctx) if err != nil { log.Fatal(err) } if !exists { log.Printf("Topic %v doesn't exist - creating it", topicName) } // slackに投稿するために必要な要素を設定する atr := map[string]string{ "Color":"warning", "Fallback":"メッセージ通知タイトル", "AuthorName":"Cloud Run", "AuthorSubname":"クラウドラン", "AuthorLink":"https://console.cloud.google.com/~~~", "AuthorIcon":"https://repository-images.githubusercontent.com/~~~", "Text":message_str, "Footer":"slackapi", "FooterIcon":"https://platform.slack-edge.com/img/~~~.png", "WebhookUrl":"https://hooks.slack.com/services/~~~", } topic.Publish(ctx, &pubsub.Message{Data: []byte("payload"), Attributes: atr}) }※特にmain.goの方で、ネストが深くなっていたり、無理やりな部分があり、見苦しく申し訳ないです。
最後に
今まではPHPでコードを書いて、すでに構築されているk8sの環境にリリースしたことぐらいしかなく、
go言語もサーバレス環境も全てが新鮮でとても勉強になりました。また、PubSubにメッセージを公開する部分は、ほとんど先輩に教えていただきました。
実装をGCRにあげるところや環境周りでも大変お世話になり、とても感謝しております。
ありがとうございました。
私の妄想上での発言です。 ↩
- 投稿日:2019-12-23T19:35:37+09:00
testingだけで完結するmockを作る #Golang
こんにちは。まいたけと申します。
こちらは、Go4 Advent Calendar 2019 の23日目の記事です。Goで実装して、テストも実装して、、とやっていると、
ある程度以上の大きさ(&複雑さ?)の実装となるとmockが必要になるケースが増えるかなと思います。GoMockを使って実装するやり方は結構情報あったのですが、
標準のtestingパッケージのみでmockを作る方法を詳しく書いている方があまり多くないようで(皆さんGoMockを利用されているということでしょうかね。。)
Goに入門して割りとすぐの頃にmockを作ろうとして少し苦労した記憶があるので、まとめてみたいと思います。テスト対象の実装
createTask
とupdateTask
のmockを作りたいとする。type TaskInterface interface { createTask(int,string) (string, error) updateTask(int,string) (string, error) } type TaskController struct { functions TaskInterface } func (c *TaskController) createTask(id int,title string) error { // 何か処理 } func (c *TaskController) updateTask(id int,title string) error { // 何か処理 }test側
Goのinterfaceはダックタイピングを採用しているため、
TaskInterface
のメソッドリストを実装してあげることで、mockを作成出来ます。type mockTaskController struct { TaskInterface mockCreateTask func(int,string) (string, error) mockUpdateTask func(int,string) (string, error) } // 内部処理はmockCreateTask,mockUpdateTaskに任せる func (m *mockTaskController) createTask(id int, title string) error { m.mockCreateTask(id, title) } func (m *mockTaskController) updateTask(id int, title string) error { m.mockUpdateTask(id, title) } ... //中略 func TestHoge(t *testing.T) { m = &mockTaskController{} // テスト内容に合わせてmockCreateTaskの戻り値を指定する m.mockCreateTask = func(int,string) (string, error) { return "bug fix", nil } ... }メソッドの内部処理は上記の例のように固定値を返すようにしても良いですが、
createTask
の戻り値によって動作が変わるメソッドのテストを書く場合もある(むしろこっちのほうが多い?)ので、テーブルドリブンテストのループ内でm.mockCreateTask
の宣言をする感じにも出来ます。test側 テーブルドリブンテストの場合
func TestHoge(t *testing.T) { m = &mockTaskController{} Cases := []struct { caseTitle string createTaskTitle string createTaskError } .... // 中略 } for _, c := range Cases { m.mockCreateTask = func(int,string) (string, error) { return c.createTaskTitle, c.createTaskError } ... //中略 } ... }まとめ
この方法だと既存のソース、パッケージ内のメソッドのmockも同様に作成可能です。便利。
mockを自動生成してくれるGoMockも魅力的ですが、今回ご紹介した方法だと柔軟にmockが作れるので良いなあと思います。
testingのみでサクッとmockを作りたい場合にはぜひ試してみて下さい。
- 投稿日:2019-12-23T19:14:16+09:00
[Go]マイグレーションツールsql-migrate使い方
公式リポジトリ
https://github.com/rubenv/sql-migrate
対応DB
MySQL / SQLite / PostgreSQL / MSSQL / Oracle Database
使い方
1. インストール
$ go get github.com/rubenv/sql-migrate/...2. 設定ファイルのdbconfig.ymlを作成する
development: dialect: mysql //使用するRDBMS datasource:[ユーザー名]:[パスワード]@/[データベース名]?parseTime=true dir: db/migrations //マイグレーションファイルを作成するディレクトリ table: migrations //マイグレーション履歴を保存するテーブル名マイグレーションファイルをどのディレクトに生成するか
dir:
で設定できます。
上記の場合、db/migrations
ディレクトリに生成されます。3. マイグレーションファイルの作成
sql-migrate new [テーブル名]
20191223120951-[テーブル名]
というファイルが作成されます。4. マイグレーションファイルに記述する
-- +migrate Up
以下に行いたいスキーマの変更のためのSQLを記述し、
-- +migrate Down
以下にそれを打ち消すSQLを記述します。20191223120951-users.sql-- +migrate Up CREATE TABLE IF NOT EXISTS `users` ( `user_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(255) NOT NULL, ); -- +migrate Down DROP TABLE IF EXISTS `users`;5. コマンド
マイグレーションファイルを実行する
$ sql-migrate up Applied 1 migrationsロールバックする
$ sql-migrate down Applied 1 migrationsマイグレーションの実行を確認する
$ sql-migrate status +--------------------------------------+-------------------------------+ | MIGRATION | APPLIED | +--------------------------------------+-------------------------------+ | 20191223120951-users.sql | 2019-12-23 08:00:00 +0000 UTC | +--------------------------------------+-------------------------------+直前の操作を再実行する
$ sql-migrate redoマイグレーションファイルを作成する
$ sql-migrate new [テーブル名]参考
https://github.com/rubenv/sql-migrate
https://medium.com/what-i-talk-about-when-i-talk-about-technology/a-db-migration-tool-for-go-rubenv-sql-migrate-718872ab9528
- 投稿日:2019-12-23T18:51:20+09:00
Goと勢いでDynamoDBのキャッシュを作ってみた
こんばんは、gureguです。
もうすぐクリスマスですね。日本のクリスマスはフライドチキンと恋愛のイメージがありますが、母国のアメリカでは実家に帰って家族と過ごす日本の正月みたいなもんですよ。もちろん、仕事は休みです。
ということでGo2 Advent Calendarの23日目の記事です。最近、私はDynamoDBおじさんになりつつあります。GoのDynamoDBライブラリを作っていますし、仕事でも個人開発でもなんとなくDynamoDBを使ってしまいます。安くて便利ですね。
皆さん「DAX」というサービスはご存知ですか?AWSが提供しているDynamoDBのキャッシュサービスです。DynamoDB専用のmemcachedみたいな感じですね。GoのDAXライブラリはaws-sdk-goのDynamoDBのAPIのインターフェスをそのまま実装しているので簡単に使えます。DAXは強いですが、お高いです。個人開発で作っているサービスはサーバ一台(DAU15人程度)だけなので、プラスDAX一台はちょっとキツイです。ある日シャワー浴びている時に突然インスピレーションが来た…!
「メモリー上にキャッシュすれば自作DAX作れるじゃね?」
と。
そしてそれを勢いで作って「本番」にデプロイしてみました。その話をしようと思います。まずラッパを作ってみよう
github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface.DynamoDBAPI
というインターフェスを実装すれば、guregu/dynamoでもどこでも本家のクライアントの代わりにカスタムなクライアントが使えます。まず本家のクライアントそのままembedして、一つだけのメッソドを「オーバーロード」してみましょう。
package localcache import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/client" "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface" "github.com/davecgh/go-spew/spew" ) func New(p client.ConfigProvider, cfgs ...*aws.Config) dynamodbiface.DynamoDBAPI { db := dynamodb.New(p, cfgs...) return NewWithDB(db) } func NewWithDB(client *dynamodb.DynamoDB) dynamodbiface.DynamoDBAPI { return &Cache{ DynamoDB: client, } } type Cache struct { // 本家のクライアントをembed *dynamodb.DynamoDB } func (c *Cache) GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error) { // とりあえずダンプしてみる spew.Dump(input) return c.DynamoDB.GetItemWithContext(ctx, input, opts...) }そして、個人のサービスで使ってみる
func newDynamo() *dynamo.DB { cache := localcache.New(session.New(), &aws.Config{ Region: aws.String("us-west-2"), }) return dynamo.NewFromIface(cache) }これで動きます!!が、ログを吐いているだけです。
キャッシュしてみよう
コンピュータサイエンスの名言があります。
「コンピュータサイエンスで難しい問題は二つだけだ。キャッシュの無効化と、名前をつけること。」
このプロジェクトはまさにその2つの問題への挑戦です。
名前をつけること
Goのキャッシュライブラリのほとんどはキーが文字列です。キャッシュする際に、DynamoDBの各APIの処理をどうにか文字列に化けないといけないです。つまり、名前をつけないと。
とりあえずやってみようと
※クソコード注意import "github.com/patrickmn/go-cache" type Cache struct { *dynamodb.DynamoDB getItem *cache.Cache } func (c *Cache) GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error) { key := *input.TableName + "$" + key2str(input.Key) if out, ok := c.getItem.Get(key); ok { log.Print("returning cached", key) return out.(*dynamodb.GetItemOutput), nil } out, err := c.DynamoDB.GetItemWithContext(ctx, input, opts...) if err != nil { return out, err } log.Print("caching", key) c.getItem.Set(key, out, cache.DefaultExpiration) return out, err } func key2str(key map[string]*dynamodb.AttributeValue) string { if len(key) == 1 { for k, v := range key { return k + ":" + av2str(v) } } var a, b string for k, v := range key { if a == "" { a = k + ":" + av2str(v) } b = k + ":" + av2str(v) } if a[0] < b[0] { return a + "/" + b } return b + "/" + a } func av2str(av *dynamodb.AttributeValue) string { switch { case av.S != nil: return *av.S case av.B != nil: return string(av.B) case av.N != nil: return *av.N } panic("invalid key av") }キーを「テーブル名$Key1名:価/Key2名:値」にしてみました。そして
*dynamodb.GetItemOutput
をそのままキャッシュして、次からはそれをキャッシュから取り出して返す。
そして、動く!!が… 実は様々な問題があります。キャッシュの無効化
この仕組みだと古いアイテムはexpireしないとずっと古い値が返されてしまいます。もっと賢くしたい…!PutItemをオーバーロードして、いい感じにキャッシュの無効化してみましょう。
しかし、PutItemのAPIだけだと、どの属性がHash Keyなのかは分からないのでテーブルのスキーマを取得してキャッシュキーを作ります。
func itemKey(table string, key map[string]*dynamodb.AttributeValue, schema []*dynamodb.KeySchemaElement) string { if len(schema) == 1 { return table + "$" + *schema[0].AttributeName + ":" + av2str(key[*schema[0].AttributeName]) } return table + "$" + *schema[0].AttributeName + ":" + av2str(key[*schema[0].AttributeName]) + "/" + *schema[1].AttributeName + ":" + av2str(key[*schema[1].AttributeName]) }前はキーがアルファベット順でしたが、これだとちゃんと「テーブル名$HashKey名:価/RangeKey名:値」になります。
func (c *Cache) PutItemWithContext(ctx aws.Context, input *dynamodb.PutItemInput, opts ...request.Option) (*dynamodb.PutItemOutput, error) { schema, err := c.schemaOf(*input.TableName) // 略 if err != nil { return nil, err } key := itemKey(*input.TableName, input.Item, schema) out, err := c.DynamoDB.PutItemWithContext(ctx, input, opts...) if err != nil { return out, err } c.setItem(key, input.Item) // ← キャッシュ更新! return out, err }よし、これでひとまずGetとPutは対応済みです。ついでに似た方法でBatchGetもBatchWriteを対応しました。UpdateとDeleteも。QueryもScanも(その話は別記事でしようと思います)。
本番デプロイしてみる
ローカルでは動いてるしとりあえず本番にデプロイしてみよう。いわゆる「Move fast and break things」つまり「本番でテスト」ですね。
そして、動いた!!
が、遅くなった!! ?キャッシュしているのに遅くなった?!ありえない!
色々試してみたら、使っていたライブラリのgithub.com/patrickmn/go-cache
はパーフォマンス性があまりよろしくない… とくに並行処理が多い場合に優れてないです。それで
github.com/karlseguin/ccache
に変えてみました。
速い!!!! ?ユーザーに感想を聞いてみると「なんとなく速くなった気がする」と言われてすごい達成感!!やった!
どれくらい速くなったかというと、複雑なページは倍くらい速くなりました。シンプルなページは10ms以下に。実は話がまだまだたくさんありますが、今日はここまでにしようと思います!
https://github.com/guregu/localcache でソースコードを公開しました。コミットを追えばなんとなくこの記事で話したことが見えるかと思います。
しかし勢いで1日で書いたしテストがないので使用はオススメしません!
本気出したらちゃんとしたライブラリにするかもしれません。※ この記事も勢いで書いたので変な日本語があったらスミマセンユルシテクダサイ
- 投稿日:2019-12-23T11:00:49+09:00
MultiPart のファイルアップロード実装のCLIクライアント・サーバーコードを理解する
Go の シンプルで動作の早いWebフレームワークであるginと、multipartのライブラリを使って、サーバーとクライアントの複数ファイルアップロードの仕組みを記述してみたいと思います。
サーバー側
ginはサンプルが充実しているので書くのがとても簡単でした。ルーターを作って、そこにルーティングを設定するだけです。
func main() { router := gin.Default() router.POST("/csv", ReceiveFiles) srv := &http.Server{ Addr: ":38081", Handler: router, } go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) } }() // send request err := SendFiles() log.Printf("error: %v\n", err) }POST の登録箇所の定義を見てみると、相対パスとハンドラがあります。
// POST is a shortcut for router.Handle("POST", path, handle). func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes { return group.handle("POST", relativePath, handlers) }ハンドラの条件はコンテキストを持った関数であればよさそうです。ここでは、ハンドラとして、ReceiveFiles という後で出てくるメソッドを渡しています。もちろん、他に,GET, PUT, DELETE 等の一通りのメソッドが定義されています。
ちなみに、go routine を使って server を起動しているのは、サーバーが上がった後の処理を書きたいからです。具体的には、このままでは、ctrl+c を押下しても停止しませんので、以前書いたブログのような作戦を使って、シグナルを受け取ったら終了するようにコードを書きます。// HandlerFunc defines the handler used by gin middleware as return value. type HandlerFunc func(*Context)さて、実際のファイルを受け取る部分も簡単です。
gin
が便利なユーティリティ関数を用意してくれていいます。func ReceiveFiles(c *gin.Context) { // Save file form, _ := c.MultipartForm() files := form.File["file"] configPath := filepath.Join(".", "volley", "csv") if _, err := os.Stat(configPath); os.IsNotExist(err) { err = os.MkdirAll(configPath, os.ModePerm) } for _, file := range files { log.Println(file.Filename) dist := filepath.Join(configPath, file.Filename) c.SaveUploadedFile(file, dist) } }マルチパートの受け取りはこの関数で
// MultipartForm is the parsed multipart form, including file uploads. func (c *Context) MultipartForm() (*multipart.Form, error) {このFormストラクトは下記の定義です。ValueとFileのmapを持っています。Fileからは、ファイルヘッダの構造体が返ります。
// Form is a parsed multipart form. // Its File parts are stored either in memory or on disk, // and are accessible via the *FileHeader's Open method. // Its Value parts are stored as strings. // Both are keyed by field name. type Form struct { Value map[string][]string File map[string][]*FileHeader }ファイルヘッダは、その中身やファイル名を持っていますので、
// A FileHeader describes a file part of a multipart request. type FileHeader struct { Filename string Header textproto.MIMEHeader Size int64 content []byte tmpfile string } : // Open opens and returns the FileHeader's associated File. func (fh *FileHeader) Open() (File, error) { if b := fh.content; b != nil { r := io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b))) return sectionReadCloser{r}, nil } return os.Open(fh.tmpfile) }SaveUploadedFile 関数を使えば、場所さえ指定したらファイルを書いてくれます。これは、multipart.FileHeader構造体に、Open()メソッドが生えているのですね。
// SaveUploadedFile uploads the form file to specific dst. func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error { src, err := file.Open() if err != nil { return err } defer src.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, src) return err }こんな感じでとても簡単にサーバー側のコードが書けました。
Multipartアップロードのクライアント側実装
ローカルのファイル探索
この例では、
csv
の拡張子を持つファイルを対象に、アップロードしています。アップロードするために、アップロード対象のファイルの検索を行うのは、filepath.Walk
メソッドが便利そうです。ルートディレクトリを指定すると、そのルートディレクトリにあるファイル、ディレクトリ、サブディレクトリを探索してくれます。ただし、あんまり深いディレクトリだと、効率が悪いそうです。(ソースコードのコメントより)WalkFunc
はファイルが見つかったときのコールバックで、ファイルのパスと、os.FileInfo
が返ります。最後のエラーは、探索の最中でエラーがあった場合の振る舞いを、WalkFunc側で決めるためのものです。func Walk(root string, walkFn WalkFunc) error { : type WalkFunc func(path string, info os.FileInfo, err error) error
os.FileInfo
はそのまま「ファイルの情報」ですね。// A FileInfo describes a file and is returned by Stat and Lstat. type FileInfo interface { Name() string // base name of the file Size() int64 // length in bytes for regular files; system-dependent for others Mode() FileMode // file mode bits ModTime() time.Time // modification time IsDir() bool // abbreviation for Mode().IsDir() Sys() interface{} // underlying data source (can return nil) }マルチパートの作成
Go言語のmime/multipartパッケージでファイルをアップロードしましょうというブログが素晴らしい内容でした。MultiPartは本来、生では次のようになります。具体的なデータは上記のブログから転載しています。
POST /upload HTTP/1.1 Host: localhost:3000 Accept-Encoding: gzip Content-Length: 254 Content-Type: multipart/form-data; boundary=c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c User-Agent: Go-http-client/1.1 --c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c Content-Disposition: form-data; name="file"; filename="hello.txt" Content-Type: application/octet-stream hello --c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c--マルチパートは、上記のように、
boundary
が設定されており、これを境界としていくつものファイルを一度に送ることができます。Content-Disposition
というヘッダの定義を見てみましょう。本文が multipart/form-data である場合、Content-Disposition ヘッダーは、マルチパートを構成する各サブパートに付与され、そのフィールドに関する情報を示します。サブパートはContent-Type ヘッダーで定義された boundary によって区切られます。マルチパートの本文体に付与した場合、Content-Disposition は何の意味も持ちません。上記の例ではフィールド名を
file
として、1つのファイル分を、boundary
の中で表現しています。複数のファイルをboundary
で区切って一回で送信することもできます。サーバーのところで出てきたfile
はこのフィールド名ですね。例
Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"コードの本文
multipart.NewWriter
さて、具体的にマルチパートのアップロードの部分を見てみると、
multipart.NewWriter
関数がまずあります。ここで、適当にバウンダリをランダムに作って保持しています。Writer を持っていますね。// NewWriter returns a new multipart Writer with a random boundary, // writing to w. func NewWriter(w io.Writer) *Writer { return &Writer{ w: w, boundary: randomBoundary(), } }multipart.CreateFromFile
こちらのメソッド見てみると、面倒なContent-Disposition やContent-Typeをファイルから作ってくれるようです。ただし、ヘッダのみなので、ご注意。
// CreateFormFile is a convenience wrapper around CreatePart. It creates // a new form-data header with the provided field name and file name. func (w *Writer) CreateFormFile(fieldname, filename string) (io.Writer, error) { h := make(textproto.MIMEHeader) h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(fieldname), escapeQuotes(filename))) h.Set("Content-Type", "application/octet-stream") return w.CreatePart(h) }そのあとで、
func Copy(dst Writer, src Reader) (written int64, err error)
によって、CreateFromFile
で返ってきたWriterにファイルの本体をコピーします。multipart.FromDataContentType
こちらで、ContentType を返します。このヘッダにboundaryが入っていますので、設定しています。
// FormDataContentType returns the Content-Type for an HTTP // multipart/form-data with this Writer's Boundary. func (w *Writer) FormDataContentType() string { b := w.boundary // We must quote the boundary if it contains any of the // tspecials characters defined by RFC 2045, or space. if strings.ContainsAny(b, `()<>@,;:\"/[]?= `) { b = `"` + b + `"` } return "multipart/form-data; boundary=" + b }func SendFiles() error { body := &bytes.Buffer{} mw := multipart.NewWriter(body) fieldName := "file" matchCsv := regexp.MustCompile(`.*\.csv`) err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error { if matchCsv.MatchString(path) { file, err := os.Open(path) if err != nil { return err } fw, err := mw.CreateFormFile(fieldName, path) if err != nil { return err } _, err = io.Copy(fw, file) if err != nil { return err } file.Close() } return nil }) if err != nil { return err } contentType := mw.FormDataContentType() err = mw.Close() if err != nil { return err } resp, err := http.Post("http://localhost:38081/csv", contentType, body) if err != nil { return err } defer resp.Body.Close() responseBody, err := ioutil.ReadAll(resp.Body) if err != nil { return err } fmt.Println(string(responseBody)) return nil }最後にPOSTリクエストを送信します。内部のマルチパートの組み立ては
multipart
パッケージの便利メソッドが程よくやってくれるので、楽ちんですね!resp, err := http.Post("http://localhost:38081/csv", contentType, body)実行結果
これら2つのファイルをアップロード
$ ls DocumentApi.csv DocumentApi2.csv go.mod go.sum main.goプログラムを動かすとサーバーが起動して、アップロードしてファイルを書いたら終了します。Windowsだと、ファイアーウォールのポート公開の警告がでますが、その頃には処理は終わっています。
$ go run main.go [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] POST /csv --> main.ReceiveFiles (3 handlers) 2019/12/23 08:48:58 DocumentApi.csv 2019/12/23 08:48:58 DocumentApi2.csv [GIN] 2019/12/23 - 08:48:58 | 200 | 16.1636ms | 127.0.0.1 | POST /csv 2019/12/23 08:48:58 error: <nil>ディレクトリを見ると、2つのファイルが作成されています。ちなみに、ファイルが存在してもエラーは出ず、上書きされるようですね。
$ cd volley/csv $ ls DocumentApi.csv DocumentApi2.csv挙動が理解できました。
- 投稿日:2019-12-23T03:01:04+09:00
【GKE】Nuxt.jsとGOでREST APIを構築した手順をまとめる
GKEでREST API構築のための前提知識
まずは、GKEでREST APIを構築するために、まずは以下の前提知識について補足していきます。
- Kubernetes
- REST API
※僕がインフラを構築するまでにインプットした知識を整理したいという思いもあって補足しております。
Kubernetes
Kubernetes (K8s)は、デプロイやスケーリングを自動化したり、コンテナ化されたアプリケーションを管理したりするための、オープンソースのシステムです。
引用:https://kubernetes.io/ja/Kubernetesの補足は引用ママですね。
また、どのGCPサービスを選ぶかみたいなツリーが、下のGoogle公式ドキュメントに書いていたりします。
Dockerを利用している時点で選択肢がだいぶ絞られていて、「本番環境でガチガチに運用していくんだったら恩恵が一番大きいGKEを利用した方がいいよね」みたいな意思決定となりました。
Kubernetesを利用せずに保守運用することについては、「時間とお金と労力を大量にかければ可能」かなとは思っていますが、コストに見合わなさすぎるので、大人しくコンテナ技術を扱うのに最適化されたツールを使うで良さそうです。
また、Kubernetesを利用するメリットをより理解したい場合は、以下の記事が参考になるかと思います。
参考:https://thinkit.co.jp/article/13289
※本記事の目的は、GKEでREST APIを構築する手順の共有なので、KubernetesにおけるPros Cons的なところには触れません。
REST API
以下がREST APIの概要となります。
RESTful API 【 REST API 】
RESTful APIとは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたもの。
引用:http://e-words.jp/w/RESTful_API.htmlまた、REST APIについてよりイメージしやすくする場合は、以下の引用が参考になると思ってます。
一般によく使われる(本来は狭義の)RESTは、パラメータを指定して特定のURLにHTTPでアクセスすると、XMLやJSONなどで記述されたメッセージが送られてくるようなシステム、および、そのような呼び出し規約(「RESTful API」と呼ばれる)のことを指す。
システムやセッションの状態に依存せず、同じURLやパラメータの組み合わせからは常に同じ結果が返されることが期待される。
引用:http://e-words.jp/w/REST.html※あくまで概要を共有したかっただけなので、REST APIで運用するメリットやデメリットみたいなところはこの記事には書きません。(もはや1本記事が書ける。)
GKEで構築したREST APIの全体像
今回、GKEで構築したREST APIの全体像は以下です。
矢印がトラフィックの流れとなります。
draw.ioでこのポンチ絵を作ったのですが結構便利ですね。
https://t.co/T0ZFqe3oPe超便利だな〜。
— アーサー@Flutter頑張る (@arthur_foreign) December 21, 2019地味にTwitterでボソっと呟いたら、ちょこちょこ反応が来たのでQiitaに埋め込むという…笑
また、DNS・ストレージ・DB・CDNのPros Consは別記事で書く想定です。(面倒臭くて関連記事を出すだけかも)
※マルチクラウドになっちゃってる理由は、上記のPros Consを出したことによる意思決定だからということになるわけですが、関連記事を乗っけた場合は、よりその理由について理解してもらえると思っています。
GKEでNuxt.jsとGOでREST APIを構築した手順の目次
これから、GKEでREST APIを構築した手順をまとめていきます。
- GKEでインフラ構築する場合の共通手順
- Nuxt.js(フロントエンド)側のデプロイ手順
- GO(バックエンド)側のデプロイ手順
まずは、浅い粒度だと上記のような感じです。
これから、それぞれの項目について詳細な手順の目次を書いていきます。
※DBサーバーの構築手順は別の記事に書きます。
GKEでインフラ構築する場合の共通手順の目次
まずは、共通の手順は以下です。
- GKEでプロジェクト作成
- クラスタ作成
- ターミナルでGCPのプロジェクトのパスを通す
- クラスタのクレデンシャルを取得
- フロントエンドとバックエンド用それぞれの静的IPを用意する
Nuxt.js(フロントエンド)側のデプロイ手順の目次
次に、Nuxt.js(フロントエンド)側の手順をまとめると以下です。
- Nuxt.jsについて補足
- Dockerイメージ作成
- イメージをGCRにPUSH
- アプリケーション(コンテナ)をデプロイ
- PodをNodePortで公開
- Ingressを作成
- 静的IPアドレスを予約する
- Ingressで静的外部IP使用の設定
- HTTP通信を無効化
- DNSでAレコードとCAAレコードを設定する
- SSL Policyの設定
- CDNの設定
GO(バックエンド)側のデプロイ手順の目次
最後に、GO(バックエンド)側の手順をまとめると以下です。(ほぼ一緒…)
1-10までNuxt.js(フロントエンド)側と同じ手順です。
12. Cloud Armorでアクセス出来るIPを制御※DBサーバーをSTGやPRD等で出し分ける場合は、ConfigMap等で環境変数を設定する方法をまとめているので、参考にしてもらえると幸いです。
参考:https://qiita.com/arthur_foreign/items/0710cb66996b727a34fd
GKEでインフラ構築する場合の共通手順
以下の手順でインフラ構築の下準備をやっていきます。(目次再掲)
- GKEでプロジェクト作成
- クラスタ作成
- 環境変数であるPROJECT_IDにGCPのプロジェクトIDを設定する
1-3までの手順は以下の公式ドキュメントを参考にしています。
参考:https://cloud.google.com/kubernetes-engine/docs/tutorials/hello-app?hl=ja
※コマンドラインツールはインストールしてる前提で話を進めています。
GKEでプロジェクト作成
まずは「新しいプロジェクト」をクリックしましょう。
次に、プロジェクト名を入力して「作成」をクリックしてください。
プロジェクト名は「test」とでもしておきましょう。
クラスタ作成
「クラスタを作成」をクリックしましょう。
クラスタの設定を色々調整して「作成」をクリックしてください。
クラスタ名は適当ですが「standart-cluster-1」にしておきます。
環境変数であるPROJECT_IDにGCPのプロジェクトIDを設定する
先ほど設定したプロジェクト名を環境変数である
PROJECT_ID
に設定しましょう。$ export PROJECT_ID=testGKEでNuxt.js(フロントエンド)のコンテナをデプロイする手順
以下の手順でGKEにNuxt.jsのアプリをデプロイしていきます。(目次再掲)
- Nuxt.jsについて補足
- Dockerイメージ作成
- イメージをGCRにPUSH
- アプリケーション(コンテナ)をデプロイ
- PodをNodePortで公開
- Ingressを作成
- 静的IPアドレスを予約する
- Ingressで静的外部IP使用の設定
- HTTP通信を無効化
- DNSでAレコードとCAAレコードを設定する
- SSL Policyの設定
- CDNの設定
Nuxt.jsについて補足
今回デプロイしたNuxt.jsのアプリケーションはSSRで開発しています。
※Nuxt.jsで開発するにあたって「SPA・SSR・静的」という選択肢があるのですが、以下のドキュメントが参考になりました。
参考1:https://ja.nuxtjs.org/guide/
参考2:https://qiita.com/nishinoshake/items/f42e2f03663b00b5886d
参考3:https://ssr.vuejs.org/ja/サービスを開発するにあたって以下を考慮しました。
- SEO対策
- 初回表示速度
- 情報の更新頻度
上記を考慮すると、最もバランスのいいSSRを
山本選手選びました。また、SSRで開発していくにあたって、以下の理由からGKEにデプロイすることとなった次第です。
SSR を使用する際に考慮すべきトレードオフも何点かあります:
静的ファイルサーバに展開できる完全静的 SPA とは異なり、サーバで描画されたアプリケーションでは Node.js サーバを実行できる環境が必要になります。
引用:https://ssr.vuejs.org/ja/Dockerイメージ作成
Dockerfileのソースコードはこの記事には書いていないため、以下の記事を参考にしてもらえると幸いです。
参考:https://qiita.com/arthur_foreign/items/fca369c1d9bde1701e38
Dockerイメージの作成方法は以下となります。
docker build -t gcr.io/${PROJECT_ID}/front-app:v1 .次に、GCR(Container Registry)で認証を行う設定をしましょう。(1回だけ必ず行う必要があるようです。)
gcloud auth configure-docker
イメージをGCRにPUSH
docker push gcr.io/${PROJECT_ID}/front-app:v1アプリケーション(コンテナ)をデプロイ
コマンドラインツールを利用してもいいのですが、今回の手順についてはコンソールからデプロイしていきます。
「Kubernetes Engine > ワークロード」とクリックして「デプロイ」をクリックしましょう。
「既存のコンテナイメージ」のチェックボックスを選択して「選択」をクリックしてください。
先ほど、GCR上にPUSHしたDockerイメージを選択して「続行」をクリックしましょう。
Dockerイメージを選択出来たら「続行」をクリックしてください。
また、コンテナのPortを開放したい場合は、YAMLを以下のように書き換えてあげましょう。
// 省略 spec: containers: - image: gcr.io/test/front-app:v1 imagePullPolicy: IfNotPresent name: front-app-sha256 ports: - containerPort: 3000 protocol: TCP // 省略また、
containerPort
の挙動については、以下の記事が参考になりました。参考:https://thinkit.co.jp/article/13610
アプリケーションのソースコードを書き換えて、差分を更新したい場合はローリングアップデートをしましょう。
やり方は以下にまとめているので、下の記事を参考にしていただけますと幸いです。
参考:https://qiita.com/arthur_foreign/items/9ea9b378419690c4eef2
PodをNodePortで公開
先ほど作成したPodをクリックしましょう。
するとPodの詳細を見ることが出来ます。
次に「操作」をクリックしましょう。
「公開」をクリックしてください。
「サービスのタイプ」をノードポートに設定しましょう。
containerPort
を設定した場合は、ターゲットポートに同じポート番号を入力してください。Ingressを作成
先ほど公開したNodePortのチェックボックスをクリックしてください。
次に「Ingressを作成」をクリックしましょう。
プロトコルは「HTTPS」を選択してください。
証明書を作成していない場合は「新しい証明書を作成」をクリックしましょう。
証明書の名前と名前解決する予定のURLを入力してください。
諸々の設定が完了したら「CREATE」をクリックしましょう。
これで、Ingressが作成出来ました。
また、Ingressの名前を設定する場合は「40文字以内」にしましょう。(不具合が起きるため)
Ingress の名前空間と名前の長さの合計は 40 文字以内にする必要があります。このガイドラインに従わないと、GKE Ingress コントローラが異常動作する恐れがあります。
引用:https://cloud.google.com/kubernetes-engine/docs/concepts/ingress?hl=ja問題については以下を参考にしましょう。
参考:https://github.com/kubernetes/ingress-gce/issues/537
それと、Ingressはヘルスチェックでこける場合に、Ingressの外部IPや名前解決したURLにアクセスすると502エラーを出したりします。
「ServiceとIngress」で作成したIngressの欄に「All backend services are in UNHEALTHY state」というエラーが出た場合は、以下に対策をまとめているので参考にしていただけますと幸いです。
参考:https://qiita.com/arthur_foreign/items/9e7a2cf4360ffcefcc9a
静的IPアドレスを予約する
Ingressに設定されている外部IPアドレスは変化する可能性があります。
デフォルトで、Ingress によって公開される HTTP アプリケーションに GKE が割り当てるのはエフェメラル外部 IP アドレスです。エフェメラル アドレスは変化する可能性があります。長期間使用する予定のウェブ アプリケーションでは、静的外部 IP アドレスを使用する必要があります。
引用:https://cloud.google.com/kubernetes-engine/docs/tutorials/http-balancer?hl=jaまた、IPアドレスが変更される条件は以下です。
エフェメラル外部 IP アドレスは、リソースを削除するとリソースから解放されます。VM インスタンスの場合、インスタンスを停止すると、エフェメラル外部 IP アドレスも解放されます。インスタンスを再起動すると、新しいエフェメラル外部 IP アドレスが割り当てられます。
引用:https://cloud.google.com/compute/docs/ip-addresses/?hl=ja#ephemeraladdressちなみに、IPアドレスが変化してもURLが変化しない場合は、SEO的な観点からすると特に問題はありません。
公式ドキュメント及びGoogle社員の回答から判断できます。
参考1:https://support.google.com/webmasters/answer/34437?topic=8524
参考2:https://www.suzukikenichi.com/blog/how-to-set-up-dns-when-moving-servers/ただ、DNSのフルリゾルバにキャッシュが残っている場合は、前のIPアドレスに対して名前解決をする場合があります。
そのため、TTLを短くして新しいIPアドレスを見にいくように名前解決する設定を迅速にやらなければなりません。
大量にトラフィックが飛ぶような人気サイトとかだと、フルリザルバに残ってる古いIPアドレスのキャッシュが残っていた場合、機会損失になりかねないので大人しく静的IPアドレスで運用した方が望ましいです。(SEO的には問題ないがシンプルにもったいない)
補足が長くなってしまいました…
手順に移ります。
「VPCネットワーク > 外部IPアドレス」に移動しましょう。
次に「静的アドレスを予約」をクリックしてください。
タイプは「グローバル」にして「予約」をクリックしましょう。
静的IPアドレスの名前は「test-static-ip」とでもしておきましょう。
これで静的IPアドレスを予約することが出来ました。
Ingressで静的外部IP使用の設定
IngressのYAMLを以下のように書き換えましょう。
apiVersion: extensions/v1beta1 kind: Ingress metadata: annotations: kubernetes.io/ingress.global-static-ip-name: test-static-ipこれで、Ingressの外部IPが先ほど予約した静的IPアドレスに変化しました。
HTTP通信を無効化
ついでにHTTP通信を無効化しましょう。
この対応はYAMLに以下を追記してください。
apiVersion: extensions/v1beta1 kind: Ingress metadata: annotations: kubernetes.io/ingress.allow-http: "false"DNSでAレコードとCAAレコードを設定する
こちらの対応については、以下の記事に全部まとめているので参考にしていただけますと幸いです。
ポンチ絵に書いてあるDNS通り、route53を利用した場合の対応となります。
参考:https://qiita.com/arthur_foreign/items/030caf092371175edfd6
SSL Policyの設定
「ネットワークセキュリティ > SSL ポリシー」と移動しましょう。
次に「ポリシーを作成」をクリックしてください。
「TLSの最小バージョン」と「プロフィール」と「ターゲット(Ingress)」を選択しましょう。
ちなみに、「プロフィール」を「互換」や「モダン」に設定すると、「QualysのSSL Server Test」で、URLを打ち込むと強度の弱い暗号方式が有効と表示されたりしました。
強度の弱い暗号方式については、以下の記事が参考になりました。
そのため、「制限付き」という設定にしております。
CDNの設定
「ネットワーク サービス > Cloud CDN」と移動して「送信元を追加」をクリックしましょう。
「ロードバランサ」の欄で作成したIngressを選んでください。
キャッシュ等の設定を変更したい場合は「設定」をクリックしましょう。
以下の項目について設定を変更することが出来ます。
設定が完了したら「追加」をクリックしましょう。
GKEでGO(バックエンド)のコンテナをデプロイする手順
最後に、以下の手順でGO(バックエンド)側のアプリをデプロイしていきましょう。(目次再掲)
1-10までNuxt.js(フロントエンド)側と同じ手順です。
12. Cloud Armorでアクセス出来るIPを制御1-10までのプロセスはNuxt.jsと同じような手順で実行しましょう。
Cloud Armorでアクセス出来るIPを制御
GO(バックエンド)側には、Nuxt.js(フロントエンド)側から、
axios
でリクエストを飛ばしてJSONを返すだけです。そのため、GO(バックエンド)側には、エンドユーザーからリクエストを送ることはありません。
したがって、Nuxt.js(フロントエンド)側からのIPアドレスのみを許可する設定をすれば、不要なトラフィックを抑えることが出来るはずです。
また、Cloud ArmorとCloud CDNは共存出来ません。
Cloud CDN では Google Cloud Armor を使用できません。Cloud CDN が有効であるときに Google Cloud Armor セキュリティ ポリシーをバックエンド サービスに関連付けようとすると、その構成は拒否されます。同様に、Google Cloud Armor セキュリティ ポリシーが関連付けられているバックエンド サービスに対して Cloud CDN を有効にしようとすると、構成プロセスは失敗します。
引用:https://cloud.google.com/cdn/docs/overview?hl=ja補足が長くなりましたが、手順に入っていこうと思います。
「ネットワークセキュリティ > Cloud Armor」に移動しましょう。
「ポリシーを作成」をクリックしてください。
「デフォルトのルールアクション」を「拒否」にしてしまえば、許可したIPアドレスからのアクセス以外に対して403エラーを返します。
次に、「ルールの追加」で許可する「IPアドレスの範囲」を入力しましょう。
入力するIPアドレスはIngressではなく、ノードの外部IPアドレスですね。
当然ながらGO(バックエンド側)にリクエストを送っているのはロードバランサではなく、デプロイしたアプリケーションなので当然っちゃ当然ですが…
ちなみに「ログビューア」でも、送信元であるIPアドレスを見ることが出来ます。
httpRequest: { remoteIp: "xx.xx.xxx.xx" // ノードの外部IP requestMethod: "GET" requestSize: "673" requestUrl: "https://test.com responseSize: "125" status: 403 userAgent: "xxxxxx" }
確認するためには「Compute Engine > VMインスタンス」に移動しましょう。
補足すると「ノード === VMインスタンス」です。
ノード
クラスタには、通常 1 つ以上のノードがあります。これは、コンテナ化されたアプリケーションや他のワークロードを実行するワーカーマシンです。個々のマシンは Compute Engine VM インスタンスであり、これらのインスタンスはクラスタ作成時に GKE によって自動的に作成されます。
引用:https://cloud.google.com/kubernetes-engine/docs/concepts/cluster-architecture?hl=ja補足を踏まえて「外部IP」という欄に書いてあるIPアドレスを、許可するような設定にしてください。
また、「アクション」を「許可」にすると、入力したIPアドレスからのトラフィックを許可することが出来ます。
Cloud Armorの設定を適用させたいIngress(バックエンド側)をターゲットにしてあげましょう。
これで、ポンチ絵通りの構成でREST APIを構築することが出来ました。
- 投稿日:2019-12-23T01:27:22+09:00
聖夜を彩るPOSTMANになろう
はじめに
皆さま、はじめましてDMMWEBCAMPでメンターをしています(元)軍人エンジニアの @HAGARIHAYATO と申します。
この記事はDMMWEBCAMPのアドベントカレンダー24日目の記事となっています。
Qiita初投稿なので暖かい目で見守ってくださると嬉しいです!概要
プログラミングを始めてアプリ開発ができるようになったらAPIの開発がしたくなってくるはずです!
ですがAPI開発はclient側とserver側が別動で行われることが多いと思います。その結果railsのようなフルスタックなフレームワークを使っていると確認ができるような、サーバーとのデータの送受信の際に困惑してしまうこともあるかもしれません。
そんなときに便利なツールがPOSTMANです!
リンクを踏んで頂くと公式ページに遷移するので、まずはダウンロードしてください。
ダウンロードができたらアプリケーションフォルダにも移しておくのも忘れずにやっておきましょう!
開くとこのような画面になっていると思います。画面中央左上のタブの+タブを押して新しいHTTPリクエストを作成しましょう。
今回はポケモンAPIを作っている方がいらっしゃるのでそのJSONをPOSTMANで叩いてみましょう。
github.comにあるJSONを使うので以下のURLを入力してSendボタンを押してください。https://raw.githubusercontent.com/kotofurumiya/pokemon_data/master/data/pokemon_data.json
すると上のようなデータが返ってくるので、これでgithub上のJSONデータを取ってくる事ができました!
HTTPとは
HTTPはテキストファイルのようなものを送信しています。
画面右側のCodeを押すと実際に今回のリクエストで送ったHTTPを見ることができるので確認してみましょう!1行目では HTTPメソッドとリクエスト先のURL、2行目ではURLのドメイン名を定義してあります。
それ以外の情報についてはオライリー出版のRealWorldHTTPを読むと理解できると思います!ちなみに、左上のHTTP▼をクリックしてjavascriptのajaxを選ぶとJQueryのコードに変換してくれます!すごい!
リクエストのためのサーバーを作成する
POSTやPUT等のメソッドを試すには実際のAPIサーバーがないといけません。
ですがPOSTやPUT等の作成や変更に関わるHTTPメソッドを受け入れてくれるAPIサーバーはなかなか有りません。
無いものは仕方がないので作ってしまいましょう!
ということで今回は
- 言語 ▶ Go
- フレームワーク ▶ Echo
を使って名前をPOSTするとべた褒めしてくれるAPIを作成します!メインはPOSTMANなのでコードはコピペで結構です!
Goのinstall
とはいえ、Goがinstallされていないと使うことができないのでまずは
こちらの方の記事がわかりやすいのでこの手順に沿ってインストールしてみてください。
インストールできましたら
御自身のホームディレクトリ/go/
に移動して
mkdir go-practice
cd go-practice
touch main.go
go get -u github.com/labstack/echo/...
以上のコマンドをしてもらいます。(最後のコマンドはフレームワークのパッケージのインストールです。)それができたら準備は万全なので、main.goを開いて実際にコードを書いていきましょう!
main.gopackage main import ( "math/rand" "net/http" "time" "github.com/labstack/echo" ) // 構造体を作ります!Messageの型を定義してあげています! type Message struct { Content string } // echoのサーバーの立ち上げとルーティングとハンドラーの呼び出しをしています。 func main() { e := echo.New() g := e.Group("/api/v1") // "localhost:1323/api/v1"に対してのハンドラの設定をしています。 g.GET("", getOverview) g.POST("", createMessage) e.Logger.Fatal(e.Start(":1323")) } // "api/v1"に表示されるJSONを返します。 func getOverview(c echo.Context) error { content := "ようこそ!!!名前をPOSTされると褒め言葉を返してくれます" // 構造体に文字列を入れています。 r := Message{ Content: content, } // http通信が正常に行われていることを示す200番台のコードとMessage構造体を返します! return c.JSON(http.StatusOK, r) } // POSTされたnameを使ってメッセージを生成して返します! func createMessage(c echo.Context) error { // formからnameの情報を取ってきます。 name := c.FormValue("name") content := name + createCompliment() // nameが未入力の場合のメッセージを入れます。 if name == "" { content = "名前を入力してください" } r := Message{ Content: content, } return c.JSON(http.StatusOK, r) } func createCompliment() string { // 現在時を使って乱数を作る記述です。 rand.Seed(time.Now().Unix()) // 褒め言葉のストックです。 compliments := []string{ "すごい", "かっこいい", "清潔感がある", "かわいい", "癒やし形", "愛されキャラ", "おしゃれ", "物知り", "優しい", } // 副詞のストックです。空の時もあります。 adverbs := []string{ "かなり", "めちゃくちゃ", "圧倒的に", "驚くほど", "限りなく", "なまら", "", } // i,j それぞれ引数分の個数の乱数を作っています! i := rand.Intn(9) j := rand.Intn(7) adverb := adverbs[j] compliment := "は" + adverb + compliments[i] // name以外の情報の入ったcomplimentを返します! return compliment }書き終わったら保存してください。
そのあと、main.goのあるフォルダ内で
go run main.go
と入力してサーバーを立ち上げてください。go/go-practice____ __ / __/___/ / ___ / _// __/ _ \/ _ \ /___/\__/_//_/\___/ v4.1.11 High performance, minimalist Go web framework https://echo.labstack.com ____________________________________O/_______ O\ ⇨ http server started on [::]:1323
上記のようにEchoサーバーが立ち上がればOKです!
POSTMANでPOSTしてみる
サーバーが立ち上がったら
http://localhost:1323/api/v1 にアクセスしてみましょう。
メッセージが表示されていると思います!
言われているようにnameをPOSTしてあげましょう。上の画像のように新しいリクエストを生成してURLに http://localhost:1323/api/v1 を入れてメソッドをPOSTにします。
その後Bodyをクリックしてform-dataにチェックを入れます。
後はKEYのところにnameとVALUEのところに好きな名前を入れてあげてSendでリクエストを送信します!どうでしょうか?べた褒めされたでしょうか?
べた褒めされたと思うことができたら完成です。まだ足りないなと思ったらcomplimentをあなた好みにカスタマイズしてみてください。Content-Typeについての注意
ついでにもう一度HTTPがどのように送られたかを確認するためにCodeを開いてみてください。
このようになっているはずです。
1行目にHTTPメソッドとリクエスト先のURL、そして2行目にドメイン名が表示されています。
少し違うのが8行目Content-Typeが multipart/form-data になっています。
これはリクエストしたデータの送り方のタイプです。
画像などのバイナリーファイル(機械語に翻訳された後のファイル)は記述が肥大化するので送りたいときには他の情報と区別がつきやすいようにboundaryという境界線を引いて送ってくれます。
ファイルをアップロードするときなどに使うのでこのContent-Typeは覚えておくと良いです。
今回はPOSTMANからform-dataで送ったためにmultipart/form-dataとなっています。
もし、JSONでリクエストしたいならapplication/jsonでないと正常にリクエストが処理されないので注意しましょう。最後に
駆け足に説明したのでPUT(更新)やDELETE(削除)については触れずに終りとなりますが、使い方は変わらないのでご自身で試してみてください。
この記事がAPI開発の参入障壁を少しでも低くすることができたなら幸いです。
メリークリスマスイブ!!
- 投稿日:2019-12-23T01:05:06+09:00
Bazelとgrpc-goを仲良くさせる
Bazelとは
前の記事 を参照してください。
Googleが中心となって開発している
- ビルドを独自のサンドボックス環境の中で行う
- ビルドの再現性が高い(サンドボックスの中で行われるので)
- 高速
- 複数の言語に対応できる
- 拡張性が高い
というような特徴を持ったビルドツールです。
Protocol Buffersをどう扱うか
以前書いた 方針から変わっていません。
protoファイルは生成されたソースコードもリポジトリに含めてしまいます。
生成されたソースコードをリポジトリに入れてしまうのは主にエディタのコード補完のためです。生成されたソースコードはサンドボックスの中に閉じ込められてしまうのでサンドボックスから救出してあげる必要があります。
Bazelとは別にprotoc
やprotoc-gen-go
を用意してそれでソースコードを生成することもできますが、複数の開発者がいる場合はバージョンの差異で余計なことに悩むことになるはずです。
なのでできるだけBazelが用意するProtocol Buffersのコンパイラを使うようにして全員の環境を揃えたいところです。方針
- Bazelが用意する
protoc
でコンパイルする- コンパイル結果をサンドボックスの中から救う
Bazelにコンパイルさせる
Bazelにコンパイルさせるとサンドボックスの中に閉じ込められてしまいますが、まずはなにはともあれコンパイルさせます。
コンパイルした結果をサンドボックスから救出するという方針でやります。
gazelle:proto disable_proto
を 指定している のでproto関連のルールは手動で各必要があります。load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") proto_library( name = "helloworld_proto", srcs = ["helloworld.proto"], visibility = ["//visibility:public"], ) go_proto_library( name = "helloworld_go_proto", compilers = ["@io_bazel_rules_go//proto:go_grpc"], importpath = "github.com/f110/bazel-example/tools/rpc/helloworld", proto = ":helloworld_proto", visibility = ["//visibility:private"], )ファイルが数個であれば手動で書けるレベルだとは思います。この手のルールを書くことに不慣れな人は gazelle でルールを生成するようにして1度だけ実行してみるといいと思います。
カスタムルールを作る
サンドボックスから生成されたファイルを救出するためのビルドルールを用意します。 (全体)
load("@bazel_skylib//lib:shell.bzl", "shell") def _proto_gen_impl(ctx): generated = ctx.attr.src[OutputGroupInfo].go_generated_srcs.to_list() substitutions = { "@@FROM@@": shell.quote(generated[0].path), "@@TO@@": shell.quote(ctx.attr.dir), } out = ctx.actions.declare_file(ctx.label.name + ".sh") ctx.actions.expand_template( template = ctx.file._template, output = out, substitutions = substitutions, is_executable = True, ) runfiles = ctx.runfiles(files = [generated[0]]) return [ DefaultInfo( runfiles = runfiles, executable = out, ), ] _proto_gen = rule( implementation = _proto_gen_impl, executable = True, attrs = { "dir": attr.string(), "src": attr.label(), "_template": attr.label( default = "//build/rules/go:move-into-workspace.bash", allow_single_file = True, ), }, ) def proto_gen(name, **kwargs): if not "dir" in kwargs: dir = native.package_name() kwargs["dir"] = dir _proto_gen(name = name, **kwargs)ファイルを救出するだけなのでやっていることは非常に単純でファイルをコピーするだけです。
ただし救出するファイル自体もサンドボックスに閉じ込める必要があります。
どういうことかと言うと、Bazelはターゲットごとに別のサンドボックスを用意します。
つまりファイルをコピーするターゲットに生成されたソースコードを含めなければいけません。
そのために少しビルド定義を書く必要があります。サンプルリポジトリ で上のルールを実行した時のサンドボックス内は以下のようになります
bazel-bin/tools/rpc/helloworld/gen.sh.runfiles/__main__ └── tools └── rpc └── helloworld ├── gen.sh └── linux_amd64_stripped └── helloworld_go_proto% └── github.com └── f110 └── bazel-example └── tools └── rpc └── helloworld └── helloworld.pb.go
helloworld.pb.go
のパスはctx.attr.src[OutputGroupInfo].go_generated_srcs.to_list()[0].path
に入っています。
コピー先はnative.package_name()
を取ることでWORKSPACEからターゲットまでのパスを手に入れることができます。
あとはこれらを組み合わせてファイルをコピーします。簡単ですね!load("//build/rules/go:proto.bzl", "proto_gen") proto_gen( name = "gen", src = ":helloworld_go_proto", visibility = ["//visibility:public"], )
src
にgo_proto_library
のターゲットを指定するだけです。
サンプルリポジトリであればbazel run //tools/rpc/helloworld:gen
でhelloworld.pb.go
がリポジトリ内にコピーされてきます。ワンライナーを用意して仕上げ
protoが複数のパッケージに分散している場合など、いちいち
bazel run
していくのは面倒なのでワンライナーを用意しておきましょう。$ bazel query 'attr(generator_function, proto_gen, //...)' | xargs -n1 bazel runこれでリポジトリ内の
proto_gen
を全て実行することができます。 このクエリ言語の強さもBazelの特徴です。全員が同じprotocを使える
最初に書いたようにここまで作ると
protoc
までBazelが用意します。
Goの場合はGoのランタイムもBazelが用意するので、なんとサンプルリポジトリのフルビルドに必要なのはBazelだけです。
BazelがGoのランタイムもprotocもそのプラグインも全て用意します。全てBazelが用意してくれるというのは非常に楽で、Bazelをインストールしリポジトリを持ってくればビルドできます。
この程度であれば環境構築に悩む必要がなく、その上全員の環境を統一できるメリットもあります。(MySQLなどを使うプロジェクトだとさらに工夫が必要になりそうですが)
リポジトリに長く関わっている人はソフトウェアの設計も把握しているので環境構築を一からやるのも難しくないでしょう。むしろ簡単というような感想になるはずです。
しかし初めてそれに触れる人は何も分からないので先人たちが書いた手順書に従って環境構築をするしかないのです。
Bazelはそういったコストも抽象化したプログラムに落とし込めるツールです。まとめ
Bazelでの扱いに少し苦労するProtocol Buffersの扱い方を紹介しました。
サンドボックス内でファイルを生成し、それを救出することでリポジトリへソースコードをコピーしています。これによりIDEで補完を行えるようになりますし、go test
でテストを実行することもできるようになります。
go test
でテストの実行ができるとGoLandから簡単にテストが実行できたりと開発効率の向上につながることでしょう。今回サンドボックス内で生成したファイルをサンドボックスから取り出すビルドルールを書いたので多少の修正でProtocol Buffers以外にも使えるかもしれません。
Goのコア側でこのようなサンドボックスに入ったソースコードの情報をexposeするツールが開発中のようです。
そちらが動作するようになりIDEがサポートしてくれればそちらへ移行する方がスマートではありますが現状はリポジトリへソースコードをコピーしてくる他ありません。(ビルドルールの詳細な書き方については解説しません。特にインターフェースについてはどんどん変わっていくためその時のバージョンに合わせてオフィシャルドキュメントを見る方がいいと思います。
Bazel独特のフェーズの動作についてはいつか解説します。)
- 投稿日:2019-12-23T00:14:18+09:00
Go を用いて猫 GIF に LGTM テキストをつけたかった
最初に
上記が
font.Drawer
を用いて GIF に対しLGTMを付与した例です![]()
まぁこれはこれでアリかなって思っちゃいますが、LGTM が躍動感出て不具合感が否めません。原因
https://golang.org/pkg/image/gif/#GIF
残像が残るのは Gif クラスの Disposal が原因と考えられる。
Go で設定出来る Disposal Method は3種類あり、DisposalNone = 0x01 DisposalBackground = 0x02 DisposalPrevious = 0x03取得元の GIF にはドキュメント通りデフォルトの
DisposalNone
が設定されていたので、
DisposalBackground
に変更すれば、残像が残らないのではと考えたが、
DisposalNone
からDisposalBackground
に Disposal を指定し直しても描画崩れは発生していた。※ Disposal の詳細は以下のページに詳しく解説されています。
http://www.snap-tck.com/room03/c02/cg/cg04_02.html残像が残ってしまうソースコード
func main() { f, err := os.Open("hoge.gif") if err != nil { panic(err) } defer f.Close() g, err := gif.DecodeAll(f) if err != nil { panic(err) } for _, img := range g.Image { if err = addText(img, "LGTM"); err != nil { panic(err) } } newFile, err := os.Create("edited_hoge.gif") if err != nil { return "", err } defer newFile.Close() if err := gif.EncodeAll(newFile, g); err != nil { return "", err } } // addText は img に対して text を中央下部に描画する func addText(img *image.Paletted, text string) error { tt, err := truetype.Parse(gobold.TTF) if err != nil { return err } d := &font.Drawer{ Dst: img, Src: image.NewUniform(color.White), Face: truetype.NewFace(tt, &truetype.Options{ Size:40.0, }), Dot: fixed.Point26_6{ fixed.Int26_6(((img.Rect.Dx()/2) - 60) * 64), fixed.Int26_6((img.Rect.Dy()-20) * 64), }, } d.DrawString(text) return nil }解決方法
font.Drawer
を用いるのではなく、
LGTM 文字列のみの画像を動的に作成し、GIF に合成 する方法で残像の問題は解決しました。残像がないソースコード
func main() { f, err := os.Open("hoge.gif") if err != nil { panic(err) } defer f.Close() g, err := gif.DecodeAll(f) if err != nil { panic(err) } lgtmImage, err := generateLGTMImage(g.Image[0]); if err != nil { panic(err) } var images []*image.Paletted var delays []int var disposals []byte for i, img := range g.Image { logoRectangle := image.Rectangle{image.Point{0, 0}, lgtmImage.Bounds().Size()} draw.Draw(img, logoRectangle, lgtmImage, image.Point{0, 0}, draw.Over) images = append(images, img) delays = append(delays, g.Delay[i]) disposals = append(disposals, gif.DisposalNone) } buf := new(bytes.Buffer) if err = gif.EncodeAll(buf, &gif.GIF{ Image: images, Delay: delays, Disposal: disposals, BackgroundIndex: g.BackgroundIndex, Config: g.Config, }); err != nil { panic(err) } return buf.Bytes(), nil } func generateLGTMImage(img *image.Paletted) (image.Image, error) { // gif のサイズに合わせて img を生成 newImg := image.NewRGBA(img.Rect) tt, err := truetype.Parse(gobold.TTF) if err != nil { return nil, err } d := &font.Drawer{ Dst: newImg, Src: image.NewUniform(color.White), Face: truetype.NewFace(tt, &truetype.Options{ Size:40, }, ), Dot: fixed.Point26_6{fixed.Int26_6(((newImg.Rect.Dx()/2) - 60) * 64), fixed.Int26_6((newImg.Rect.Dy()-20) * 64)}, } d.DrawString("LGTM") return newImg, nil }感想
この問題にハマった時に
font.Drawer
でどうにかならないか試行錯誤してましたが、
別の方法である「合成」で問題解消出来たのでよかったです。これで猫 gif で心置きなく LGTM 出来ます!
以下のサイトから使ってもいいですし、自分のオリジナルの LGTM gif でも活用してください。余談
LGTM Cat は Go + Firestore + Cloud Run で作成してます。
元々は Node.JS + GAE (F1 1台)で運用していたのですが、gif を並べて表示しているのでメモリが足りずによくサーバーが落ちてしまっていました。
その点 Cloud Run は無料枠が多い且つ、メモリも好きなだけ増やせれるので上記の問題を解決出来ました。