- 投稿日:2020-10-15T22:05:40+09:00
Firestoreを介して画面操作毎の登録内容を別システムに時系列で連携
想定する読者
お題
何かしらの情報に関して登録・更新・参照する、ごくごく普通のWebアプリケーションがあったとして、画面で操作した内容を自システムのDBに保存すると同時に、
操作した順序を保って
別システムにも反映したいという要件があったとする。
別システムでは機能ごとにデータ反映用のAPIを提供しているので、それを叩けばよいのだけど、問題は1つ1つのAPIが重い(=要するにリクエスト受けてからレスポンスまでの時間が5秒以上かかる)ということ。
したがって、単純に考えて、画面で操作した流れでそのままAPIを同期的に叩くというわけにはいかない。
(そんなことをしたら、ユーザが何かの情報を登録・更新するたびに7〜8秒くらい待たされるシステムになってしまう。)というわけで、APIを叩く部分は非同期にする。
すると、とたんに「操作した順序を保って
」の部分が怪しくなってくる。
さあ、どうしよう。
とりあえず非同期にするので、反映したい内容をメッセージキューにでも積むか。
※ちなみに、システムはGoogle Cloudを使うことが前提になっていることとする。
いろいろ選択肢はあるけど、一応、マネージドサービスを使うことを重視するという前提だと仮定して、以下を考える。ただ、上記を使おうとすると以下の点で今回の要件と抵触する。
- 実行順序を保証しない。※
- 重複実行の可能性がある。
https://cloud.google.com/tasks/docs/common-pitfalls?hl=ja
※Cloud Pub/Subの方は、Beta版ではあるものの「メッセージの順序指定」という機能が加わった。
参考までに。
https://qiita.com/sky0621/items/3df3ae65b859b8196e39
ただし、これも、Subscriptionからメッセージが送出される時の順序指定である。
少なくともPushタイプのSubscriptionにしている場合はメッセージ1をPush(=要するに指定のエンドポイントにリクエストすること)したあと、そのレスポンスを待ってメッセージ2をPushということはしてくれない。
(問答無用に「メッセージ1をPush」→「メッセージ2をPush」→・・・)また、「重複実行の可能性」についても、ある種のデータの更新であれば2回、別システムに同じ更新をしても問題ないかもしれないが、新規データの作成の場合は、そうはいかない。
同じデータが2個作られてしまう。つまり、メッセージが重複しても、それを排除する仕組みを別途用意しないといけない。
たぶん一般的には、メッセージの生成元でユニークなIDを振って、それを「処理済みかどうか」の確認のためにMemorystore等に登録する。(既に登録済みだったら、このメッセージは処理済み(つまり重複配信された)とみなして削除する実装を入れる。)実行順序の保証が難しいは重複実行への対処もしないといけないはで、やりたいことに対して考慮、実装しないといけない要素が多すぎる。
ということで、もっとシンプルに「画面操作の履歴をデータベースに持っておいて、格納順に処理(別システムのAPIを叩く)すればいいだけでは?」と考える。
とはいえ、スケーラビリティも考慮したい。
ということで、Datastoreの採用を考えたのだけど、「Firestore は次世代の Datastore です。
」なんて書いてあるので、Firestoreを使ってみることにする。システムの全体像
- 「Webアプリケーション」(今回は実質WebAPIサーバ)は、Cloud Run
- 「RDB」(今回のソースでは実際の書き込みロジックは省略)は、Cloud SQL
- 「NoSQLストア」は、Firestore
- 「同期サービス」(今回はAPIを擬似的に叩いた体裁で数秒スリープさせるだけの実装)は、GKE
Webアプリケーション概要
いつもこの手のお題としていい塩梅のものが見つからない。
今回は、「学校」や「学年」、「クラス」、「先生」、「生徒」といった情報の登録ができるものとする。
(あくまで擬似的なものだけど。)
※ちなみに、私は学生ではありません。エンドポイント
/add-school
・・・「学校」の登録/add-grade
・・・「学年」の登録/add-class
・・・「クラス」の登録/add-teacher
・・・「先生」の登録/add-student
・・・「生徒」の登録前提
- ローカルにGoの開発環境構築済み。
- GCP契約済み。
- ローカルでCloud SDKのセットアップ済み。
- ローカルの環境変数
GOOGLE_APPLICATION_CREDENTIALS
に(必要な権限を全て有したサービスアカウントの)鍵JSONファイルパス設定済み。開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.5 LTS (Bionic Beaver)"# バックエンド
# 言語 - Golang
$ go version go version go1.15.2 linux/amd64IDE - Goland
GoLand 2020.2.3 Build #GO-202.7319.61, built on September 16, 2020今回の全ソース
Webアプリケーション
https://github.com/sky0621/go-publisher-fs/tree/v0.1.1
同期サービス
https://github.com/sky0621/go-subscriber-fs/tree/v0.1.1
ソース抜粋解説
Webアプリケーション
main.gopackage main import ( "fmt" "log" "net/http" "os" "time" "cloud.google.com/go/firestore" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) func main() { project := os.Getenv("PUB_PROJECT") e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.GET("/add-school", handler(project, "add-school")) e.GET("/add-grade", handler(project, "add-grade")) e.GET("/add-class", handler(project, "add-class")) e.GET("/add-teacher", handler(project, "add-teacher")) e.GET("/add-student", handler(project, "add-student")) e.Logger.Fatal(e.Start(":8080")) } func handler(project, path string) func(c echo.Context) error { return func(c echo.Context) error { ctx := c.Request().Context() client, err := firestore.NewClient(ctx, project) if err != nil { log.Fatal(err) } defer client.Close() order := fmt.Sprintf("%s:%s", path, createUUID()) _, err = client.Collection("operation").Doc(order). Set(ctx, map[string]interface{}{ "order": order, "sequence": time.Now().UnixNano(), }, firestore.MergeAll) if err != nil { log.Fatal(err) } return c.String(http.StatusOK, order) } } func createUUID() string { u, err := uuid.NewRandom() if err != nil { log.Fatal(err) } return u.String() }同期サービス
main.gopackage main import ( "context" "log" "os" "strings" "time" "cloud.google.com/go/firestore" ) func main() { ctx := context.Background() client, err := firestore.NewClient(ctx, os.Getenv("PUB_PROJECT")) if err != nil { log.Fatal(err) } defer client.Close() operationIter := client.Collection("operation"). Where("sequence", ">", 0).OrderBy("sequence", firestore.Asc).Snapshots(ctx) defer operationIter.Stop() for { operation, err := operationIter.Next() if err != nil { log.Fatalln(err) } for _, change := range operation.Changes { ope, err := change.Doc.Ref.Get(ctx) if err != nil { log.Fatalln(err) } d := ope.Data() order, ok := d["order"] if ok { ods := strings.Split(order.(string), ":") if len(ods) > 0 { od := ods[0] switch od { case "add-school": time.Sleep(5 * time.Second) case "add-grade": time.Sleep(4 * time.Second) case "add-class": time.Sleep(3 * time.Second) case "add-teacher": time.Sleep(2 * time.Second) case "add-student": time.Sleep(1 * time.Second) } } } log.Printf("[operation-Data] %#+v", d) } } }実践
Firestoreへの書き込み側
以下5つのエンドポイントを上から順に叩いてみる、そして、それを2回繰り返すと、
/add-school
・・・「学校」の登録/add-grade
・・・「学年」の登録/add-class
・・・「クラス」の登録/add-teacher
・・・「先生」の登録/add-student
・・・「生徒」の登録※FirestoreはデフォルトではドキュメントIDの昇順で並ぶようなので、必ずしもエンドポイントを叩いた順に並ぶわけではない。
Firestoreから情報を
sequence
の昇順で取得する側ちゃんと時系列に処理されているかどうか、コンテナログを見てみる。
以下の順番で2巡させたので想定通り。
/add-school
・・・「学校」の登録/add-grade
・・・「学年」の登録/add-class
・・・「クラス」の登録/add-teacher
・・・「先生」の登録/add-student
・・・「生徒」の登録同期サービス内では、
/add-school
をFirestoreから読み取った場合は5秒のスリープを入れているが、当然、/add-grade
の読み取りに追い越されるなんてことはない。まとめ
こんな感じに、操作をFirestoreに時系列(が維持できるよう、OrderByがかけられるNanoレベルのUnixTimestampをフィールドに持つのが必要なわけだけど)に投入していくと、順番を保ったまま別システムに連携するシステムが作れる。
とはいえ、もちろんこれだけではプロダクションに上げるレベルのものにはならない。
例えば、Firestore内にドキュメントが溜まっていく一方(1回、別システムに連携したら不要なドキュメントなのに)である点や、それゆえに、実は、同期サービスを再デプロイすると、また、(処理済みなのに)1からドキュメントを拾って処理し始めてしまうという課題がある。
これについては、
「処理が終わるたびにドキュメント消せばいいのでは?」というのが単純な解になるかと思うけど、そうすると、「ドキュメントの削除」というのがトリガーとなって、1つのドキュメントを処理し終わったのに再度、同じドキュメントを処理し始める(削除されているので途中でエラーになる、ないし、ロジックによってはそのまま2回目の処理が走る)ことが起きる。
というわけで、
まだまだいろいろ考えるべきポイントはあるのだけど、とりあえず、いったんこのへんで。
- 投稿日:2020-10-15T21:50:13+09:00
GolangでCloudFrontのキャッシュを削除する
S3の画像をCloudFrontで流しているのですが、画像更新時にはキャッシュ(エッジサーバー上のキャッシュ)をクリアしたいなと思い、やり方を調べました。
Goで実装する
Golangで実装するには、AWS SDK GoのCloudFront周りの機能を使います。
公式はこちら: cloudfront - Amazon Web Services - Go SDKこのSDKの
CreateInvalidation
というメソッドを使います。
実装としては以下の感じです。const ( // CloudFrontのID CloudFrontID = "HOGEHOGE" //cloudfrontでキャッシュ削除するパス ClearTargetPath := "/*" ) func ClearCache() error { svc := cloudfront.New(session.New()) jstNow := time.Now().UTC().In(time.FixedZone("Asia/Tokyo", 9*60*60)) callerReference := jstNow.Format("200601021504") _, err := svc.CreateInvalidation(createInvalidationInput(callerReference)) if err != nil { return err } return nil } func createInvalidationInput(callerReference string) *cloudfront.CreateInvalidationInput { pathItems := []*string{&ClearTargetPath} return &cloudfront.CreateInvalidationInput{ DistributionId: aws.String(CloudFrontID), InvalidationBatch: &cloudfront.InvalidationBatch{ CallerReference: &callerReference, Paths: &cloudfront.Paths{ Quantity: aws.Int64(1), Items: pathItems, }, }, } }
CallerReference
というのはユニークな値であればいいらしいので、タイムスタンプを入れています。
CreateInvalidationInput
で削除対象のDistributionIdやパスを指定します。Lambdaで実行する場合には、CloudFrontの権限付与をお忘れなく。
余談(AWS webコンソールからの削除方法)
CloudFrontのエッジサーバー上のキャッシュ自体はAWSのwebコンソールからでも消せるようです。
[AWS] Amazon CloudFrontのキャッシュ削除(Invalidation)にあるように、Invalidation
というのがキャッシュ削除にあたる用語だそうです。
- 投稿日:2020-10-15T09:15:38+09:00
ebitenを使ったシンプルな時計を描画するサンプル
ただシンプルな時計を描画するだけのプログラム
ebiten というゲームエンジンを使って、時計を描画するサンプル
時計はとにかくシンプルなもの。
ソースコード
main.gopackage main import ( . "clock" "github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten/inpututil" "log" "time" ) type Game struct { time time.Time stop bool } func (g *Game) Update(screen *ebiten.Image) error { if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { g.stop = !g.stop } if !g.stop { g.time = time.Now() } return nil } func (g *Game) Draw(screen *ebiten.Image) { m := ClockImage(g.time) em, _ := ebiten.NewImageFromImage(m, ebiten.FilterDefault) screen.DrawImage(em, &ebiten.DrawImageOptions{}) } func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { return 320, 320 } func main() { game := &Game{} ebiten.SetWindowSize(320, 320) ebiten.SetWindowTitle("Hello, World!") if err := ebiten.RunGame(game); err != nil { log.Fatal(err) } }サンプルという意味を込めて、エスケープキーを押すと時計を止めることができるようになっています。
使い道は考えていませんが。
ClockImageの実装
肝心の時計の画像を生成するプログラム
clock.gopackage clock import ( "image" "github.com/fogleman/gg" "github.com/golang/freetype/truetype" "golang.org/x/image/font/gofont/gomedium" "time" ) func ClockImage(t time.Time) image.Image { const ( R = 160 Long = 108 //Second Midium = 85 // Minute Short = 50 // Hour Width = R*2 + 1 Height = R*2 + 1 CenterX = Width / 2 CenterY = Height / 2 M = 15 //その他メモリの線分 M5 = 26 //5分メモリの線分 ) dc := gg.NewContext(Width, Height) dc.SetHexColor("#ffffff") dc.Clear() // メモリの描画 dc.Push() dc.SetHexColor("#000000") for i := 0; i < 60; i++ { dc.Push() var m float64 = M if i%5 == 0 { dc.SetLineWidth(2) m = M5 } dc.MoveTo(CenterX, CenterY-R+m) dc.LineTo(CenterX, 0) dc.Stroke() dc.Pop() dc.RotateAbout(gg.Radians(float64(6)), CenterX, CenterY) } dc.Pop() dc.SetHexColor("#000") // AM / PM の描画 var AMPM = "AM" if t.Hour() >= 12 { AMPM = "PM" } f, _ := truetype.Parse(gomedium.TTF) face := truetype.NewFace(f, &truetype.Options{Size: 34}) dc.SetFontFace(face) dc.DrawString(AMPM, CenterX+37, CenterY+7) // 0時の時の短針の角度=0度としたいため、あらかじめ90度反時計周りに回転 dc.RotateAbout(gg.Radians(-90), CenterX, CenterY) dc.DrawCircle(CenterX, CenterY, R) dc.Stroke() // 短針の描画 dc.Push() dc.SetLineWidth(8) dc.MoveTo(CenterX, CenterY) HD := t.Hour()%12*30 + int(float64(t.Minute())*0.5) //t.Hour() range [0,23] 360/12 == 30 if HD != 0 { dc.RotateAbout(gg.Radians(float64(HD)), CenterX, CenterY) } dc.LineTo(CenterX+Short, CenterY) dc.Stroke() dc.Pop() // 長針の描画 dc.Push() dc.SetLineWidth(4) dc.MoveTo(CenterX, CenterY) MD := t.Minute() * 6 // t.Minute() range [0,59] 360/60 == 6 if MD != 0 { dc.RotateAbout(gg.Radians(float64(MD)), CenterX, CenterY) } dc.LineTo(CenterX+Midium, CenterY) dc.Stroke() dc.Pop() // 秒針の描画 dc.Push() dc.SetLineWidth(2) dc.MoveTo(CenterX, CenterY) SD := t.Second() * 6 // t.Second() range [0,59] 360/60 == 6 if SD != 0 { dc.RotateAbout(gg.Radians(float64(SD)), CenterX, CenterY) } dc.LineTo(CenterX+Long, CenterY) dc.Stroke() dc.Pop() return dc.Image() }時間を渡してその時間を示す時計の画像を返す
おわりに
説明もほとんどないですが、もっとわかりやすい解説を思いつけば更新したいと思います。
個人的には、Goは書きやすい言語だとは思いますが、どうしても型変換が冗長になってしまいがちですね。
暗黙の型変換をなくしてバグが減るのか、コードを書く量が増えるか......
- 投稿日:2020-10-15T08:46:35+09:00
Goでベジェ曲線の描画
Goでベジェ曲線の描画
細かい解説もしたいですが、ひとまずソースコード
ソースコード
package bezier import ( "math" ) type Point struct{ X,Y float64 } // n! (nの階乗) func factorial(n int)(int){ if n == 0{ return 1 } return n * factorial(n-1) } func biCoe(n,i int)(float64){ return float64(factorial(n) / (factorial(n-i) * factorial(i))) } func bernstein(n,i int,t float64)(float64){ var N float64 = float64(n) var I float64 = float64(i) return biCoe(n,i) * math.Pow(t,I ) * math.Pow(1-t,N-I) } func BezierCurve(p []Point,t float64)(result Point){ for i,v := range p{ B := bernstein(len(p)-1,i,t) result.X += v.X*B result.Y += v.Y*B } return } // aは変化量 値を大きくすればカクカクした曲線になる func Curve(p []Point,a float64)(result []Point){ var t float64 for { result = append(result, BezierCurve(p,t) ) t += a if t >= 1{ break } } return }Curve([]Point,float64)は制御点とtの変化量を与え、P(t,0>=t<=1)のスライスを返す関数
利用者は通常、この関数を利用して描画を行っていく(ことを想定している)。
利用例
package bezier import ( "testing" "github.com/fogleman/gg" ) func TestBezierCurve(t *testing.T){ // 制御点 P := []Point{Point{10,10},Point{10,590},Point{590,590}} // 変化量 const A = 0.01 result := Curve(P,A) dc := gg.NewContext(600,600) dc.SetHexColor("#fff") dc.Clear() // 制御点の描画 dc.Push() dc.SetHexColor("#0000ff") for _,v :=range P{ dc.DrawCircle(v.X,v.Y,4) dc.Fill() } dc.Pop() // 曲線の描画 dc.Push() dc.SetHexColor("#000") // 始点に移動 dc.MoveTo(P[0].X,P[0].Y) for _,v :=range result{ dc.LineTo(v.X,v.Y) } dc.Stroke() dc.Pop() // P(t)の描画 dc.Push() dc.SetHexColor("#f01000") for _,v :=range result{ dc.DrawCircle(v.X,v.Y,3) } dc.Stroke() dc.Pop() dc.SavePNG("out.png") }テストの実行結果
青の点が制御点で赤がP(t)の座標を中心とした円。
おわりに
数学が得意でもない私ですが、数式とにらめっこして数時間(時間かかりすぎ)で実装が出起案した。とても達成感がありました。(小学生並みの感想)
自分の元気な時に、ぼちぼち更新したいと思います。
こんな記事でも、誰かの役に立てば幸いです。
謝辞
http://github.com/fogleman/gg には、いつもお世話になっております。
@Rahariku様の記事がとても参考になりました。ありがとうございます。
参考
- 投稿日:2020-10-15T02:42:13+09:00
Goでデータベースの内容を取得する関数を作る
データベースから目的の内容を全て取る関数について今から書きます。すでにデータベースには、なにか入っている状態でします
interface{} な変数を型が決まっている関数の引数にする
「Golang」 ×「Gorm」でシンプルに「Mysql」を操作する
【go + gin + gorm】webアプリにログイン機能を追加してみる
Goでの Mysql接続から構造体へのデータ割り当てまで
Gin と GORM で作るオレオレ構成 API
installimport ( _ "github.com/go-sql-driver/mysql"// これは、自分で追加が必要 "github.com/jinzhu/gorm" ) // ないとき //go get github.com/jinzhu/gorm //go get github.com/go-sql-driver/mysqlまず、データベースと接続をします
main.gofunc openGormDB() *gorm.DB { // localhost DBMS := "mysql" USER := "user"//mysqlのユーザー名 PASS := "password"//パスワード PROTOCOL := "tcp(localhost:3306)" DBNAME := "sample"//データベース名 CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME + "?charset=utf8&parseTime=True&loc=Local" db, err := gorm.Open(DBMS, CONNECT) if err != nil { panic(err) } return db }これは、データベースの内容
main.gotype ShiromiyaChannelInfos struct { //ID uint64 ChannelID string ChannelName string ViewCount uint `gorm:"type:int"` SubscriberCount uint `gorm:"type:int"` VideoCount uint `gorm:"type:int"` CreatedAt time.Time }データベースの内容を取る関数
テーブルごとに書かくデータベースの内容を取る関数// この関数みたいな関数を何度も書かないといけない func GetDBShiro() []ShiromiyaChannelInfos/*<=①*/ { db := openGormDB() var shiroInfo []ShiromiyaChannelInfos/*<=①*/ db.Find(&shiroInfo)/*<=①*/ db.Close() return shiroInfo/*<=①*/ func GetDBHashi() []HashibaChannelInfos/*<=①*/ { db := openGormDB() var hashiInfo []HashibaChannelInfos/*<=①*/ db.Find(&hashiInfo)/*<=①*/ defer db.Close() return hashiInfo/*<=①*/ }データベースに接続して目的のテーブルの内容を全て取得する関数
上の①と、したところだけしが違う
あとは、ほとんど同じ上の(データベースの内容を取る関数)ようなものを何度も書かないといけないのでまとめた
main.go// これは、完成したものです func AllGetDBChannelInfo(chInfo string) (interface{}, error) { db := openGormDB() defer db.Close() switch chInfo { case "ShiromiyaChannelInfos": var channelInfo []entity.ShiromiyaChannelInfos db.Find(&channelInfo) return channelInfo, nil case "HashibaChannelInfos": var channelInfo []entity.HashibaChannelInfos db.Find(&channelInfo) return channelInfo, nil case "ChannelInfos": var videoInfo []entity.VideoInfos db.Find(&videoInfo) return videoInfo, nil default: return nil, errors.New("そのdb_nameありません") } }問題点:
AllGetDBはあとで関数を追加するときAllGetDBを上書きしなければならないGetDBは重複したものが多くなる関数も多くなる。でも、関数を上書きしなくていい追加するだけ