- 投稿日:2020-03-17T22:40:24+09:00
エンジニア向けオープンプロジェクトトレース
https://www.reiwarss.com/OpenProject
Top tags
python
swift
javascript
go
C
C++
C#
Ruby
TypeScript
PHP
- 投稿日:2020-03-17T20:09:30+09:00
fasttextでwebサイト分類したかった
Wano株式会社のfushimiです。
先日、弊社の2020年の開発合宿(一泊二日)でやったネタを投稿します。合宿日記も兼ねてるので完全にとりとめのない時系列順の出来事の羅列になりますが、ご容赦ください。
未知のwebサイトの自動カテゴリ分けをしよう
自然言語処理や機械学習は全然わからん勢なのですが、せっかくの合宿なのでwebサイトのコンテンツ解析をして遊んでみました。
作るものとしては表題通り、webサイトのカテゴリ分類器 です。
あらかじめ決めたカテゴリ分類に応じて、入力された未知のWebサイトが適切にカテゴライズされることを目的とします。
アプローチ
学習
- カテゴリごとのメディア記事をクロールして大量に集める
- 記事をきれいにする
- 記事を形態素解析する
- fasttextで学習済みモデルを作る
テスト
- 入力されたwebサイトのコンテンツをいくつか集める
- コンテンツをきれいにする
- コンテンツを形態素解析する
- 学習済みモデルに食わせて思った通りにカテゴリ分けできたら成功
fasttext
AWSのML系サービスで遊んでみることも考えましたが、今回はfasttextによる単語ベクトルの算出というアプローチをとってみました。
fasttextは、facebook製の自然言語処理ライブラリです。word2vecと同じく単語ベクトルを算出するライブラリと理解しました。
word2vec(Skip-Gram Model)の仕組みを恐らく日本一簡潔にまとめてみたつもり環境構築
以下のようなDockerfileを構築しました。
今回は慣れてるgoとdockerで作業を始めてしまったので、その影響でいろいろ入っています。FROM ubuntu:18.04 ENV HOME /root WORKDIR $HOME ENV DEBIAN_FRONTEND noninteractive SHELL ["/bin/bash", "-c" ] RUN apt-get update && \ apt-get -y install wget python3 python3-pip curl groff gcc make cmake g++ openssl git tree ca-certificates --no-install-recommends unzip #build-essential RUN echo "stty rows 50 cols 200" >> ~/.bashrc RUN wget https://github.com/facebookresearch/fastText/archive/v0.9.1.zip RUN unzip v0.9.1.zip RUN cd fastText-0.9.1 && make && cp ./fasttext $HOME/ RUN cp $HOME/fasttext /usr/bin/ && which fasttext WORKDIR $HOME # mecab RUN apt-get install -y mecab libmecab-dev mecab-ipadic mecab-ipadic-utf8 # 辞書 RUN apt-get install -y xz-utils patch file sudo RUN cd $HOME && git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git RUN cd mecab-ipadic-neologd && ls && ./bin/install-mecab-ipadic-neologd -n -y RUN echo `mecab-config --dicdir`"/mecab-ipadic-neologd" #### any env RUN git clone https://github.com/anyenv/anyenv $HOME/.anyenv && \ echo 'export PATH="$HOME/.anyenv/bin:$PATH"' >> $HOME/.bashrc && \ echo 'eval "$(anyenv init -)"' >> $HOME/.bashrc ENV ANYENV_HOME $HOME/.anyenv ENV ANYENV_ENV $ANYENV_HOME/envs ENV PATH $ANYENV_HOME/bin:$PATH ENV ANYENV_DEFINITION_ROOT $HOME/.config/anyenv/anyenv-install RUN mkdir -p $ANYENV_DEFINITION_ROOT && \ git clone https://github.com/anyenv/anyenv-install $ANYENV_DEFINITION_ROOT &&\ which anyenv && ls $ANYENV_DEFINITION_ROOT ### go ENV GO111MODULE on RUN anyenv install goenv ENV PATH $ANYENV_ENV/goenv/bin:$ANYENV_ENV/goenv/shims:$PATH ENV GOENV_ROOT $ANYENV_ENV/goenv ENV GOPATH /root/go RUN goenv install 1.14.0 && \ goenv global 1.14.0 && \ goenv rehash && \ echo 'eval "$(goenv init -)"' >> ~/.bashrc ENV PATH $PATH:/usr/local/go/bin:$GOPATH/bin ENV GOBIN $GOROOT/bin RUN echo 'export GOBIN=$GOROOT/bin' >> ~/.bashrc RUN echo 'export PATH=$PATH:/usr/local/go/bin:$GOPATH/bin' >> ~/.bashrc ##### aws-cli RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ unzip awscliv2.zip && \ ./aws/install #### 文字コードの設定 ENV LANG=C.UTF-8 ENV LANGUAGE=en_US: ##### after build RUN apt-get clean学習
学習元となるメディアカテゴリを決めよう
そもそもはfasttextを選んだのは、機械学習で大量のテキストをカテゴリ別に分類してみよう! を見ていて、「おもしろそう!」と思ったからです。なので先にカテゴリーを決めておくというアイディアもここから来ています。
まず、分類するメディアのカテゴリを以下のように決定します。
- IT系
- 医療系
- ママ/子育て系
- 金融系
- 音楽系
粒度がバラバラじゃん!って話なのですが、個人的にサンプルが思いつきそうなメディアがざっくりこのへんだったのでこのままいきます(笑)
カテゴリごとの記事を集めよう
カテゴリごとに7-10サイト、aタグで同ドメインのものを漁って各100-300ページくらい集めることにしました。
学習対象としてはドメインレベルで専門情報を扱ってるサイト(ex . ongaku.news.jp) のみにして、複数のカテゴリ記事を持っている統合情報メディアみたいのは今回は使いません。
aタグベースで関連記事を漁っていくのに当たり、いろんなカテゴリの記事があると数時間で学習元として使うには大変そうだったからですね。ちなみにGoで書いてます。goroutineはすごい。
集めた記事をきれいにしよう
合宿一日目の夕方くらいから方針決めてはじめてみたのはいいんですが、当たり前ですがそもそもこの作業自体が大変でした...
- 大変だったこと
- SJISのサイト -> utf8にする
- scriptだのstyleだのiframeだのいらないタグを排除する
- 排除するにしてもDOMのセレクタなんか忘れたわ...
- goroutineはすごい
このように、「きれいな学習用データを集める」という作業だけで一日目の深夜までいってしまいました。
このへんの質があとで響いてきます...。ちょっと記事に重み付け
本文のみでなく、いくつかのタグは「そのサイトにとって大事なもの」とみなして、抽出分をさらに記事にくっつけて強化しています。
どこぞのRTBのブログでこれをコンテンツ解析に使ってるっていってたので。
- title
- meta[name=description]
- meta[name=keywords]
形態素解析する
おなじみのmecabを使って形態素解析をしました。
辞書データとしてmecab-ipadic-neologdのお世話になりました。
そのままだと余計な品詞でゴミがのりそうだったので、試しに名詞形の影響力を強くしています。 (といっても複数回くっつけ直しただけ)goで作業してたのでmecabバインディングを使いましたが、やはりこの手のツールはpythonが一番豊富でしたね...
ここまでの作業で作ったファイルがこんな感じの1枚のテキストです。
(108MBほどになりました)
- IT系 => _labelit
- 医療系 => __label_medical
- ママ/子育て系 => __label_mama
- 金融系 => __label_money
- 音楽系 => __label_music
としてラベリングしています。
src.txt__label__music, All All Digital Digital Music Music と は 「 All All Digital Digital Music Music 」 は 、 世界 世界 最先端 最先端 の 音楽 音楽 ビジネス ビジネス と エンタテインメント・テクノロジー エンタテインメント・テクノロジー にで は 、 インディペンデント インディペンデント な アーティスト アーティスト や 作曲 家 、 プロデューサー プロデューサー 、 音楽 音楽 クリエイター クリエイター から 、 レコード レコード 会社 会社 を はじめ と する 音楽 音楽 業界 業界場 分析 、 音楽 音楽 ビジネス ビジネス に 影響 を 与える 戦略 戦略 や アイデア アイデア 、 未来 未来 に 向け て 考える べき ビジネス ビジネス モデル モデル や マーケティング マーケティング 、 テクノロジー テクノロジー 、 データ デー続 的 な .... __label__mama, 妊娠 が 成立 する と 体 に 様々 な 変化 が 現れ ます が 、 その 代表 的 な もの が 「 つわり つわり 」 です 。 吐き気 吐き気 や 眠気 眠気 、 頭痛 頭痛 など 様々 な 不快 症状 症状 が 現れる ので 、 妊娠 初期 初期 は妊婦 さん にとって は 本当に 辛い 時期 です ね 。 気持ち 気持ち 悪く て 何 も 食べ たく なくなる かも しれ ませ ん 。 しかし 、 つわり つわり の 症状 症状 を 軽減 し て くれる 効果 効果 が 期待 できる 栄養素 栄養素 も ある ので 、 妊娠 するfasttextで学習済みモデルを作る
以上で作ったsrc.txtからおもむろに学習済みモデルを作ります。
fasttext supervised -input src.txt -output output.model
output.model.bin
とoutput.model.vec
が生成されました。できあがったモデルで遊ぶ
取得してきた「未知の」webサイトに対して何ページかのスクレイプと「きれいにする」までの作業をほどこし、1枚のテキストにします。
ここでは弊社の開発者ブログを「未知の」Webサイトとし、wano.txtを生成しました。
wano.txtWano Wano Group Group Developers Developers Blog Blog Wano Wano グループ グループ エンジニア エンジニア による 開発 ブログ ブログ New New Posts Posts 1 … About About Wano Wano グループ グループ の エンジニア エンジニア や デザイナー デザイナー が 使っ て いる 技術 技術 、 興味 興味 の ある 技術 技術 、 ( 色んな 意味 で ) はまっ て いる 技術 技術 など を お伝え し て いき ます 。 KeywordsWano KeywordsWano Group Group Developers Developers Blog Blog | Wano Wano グループ グループ エンジニア エンジニア による 開発 ブログ ブログ Wano Wano Group Group Developers Developers Blog Blog | Wano Wano グループ グループ エンジニア ...学習済みモデルに喰わせます。
fasttext predict-prob output.model.bin /wano.txt 5__label__it, 0.761388 __label__music, 0.11441 __label__mama, 0.059265 __label__money, 0.0472763 __label__medical, 0.0177098ITというカテゴリワードがドンピシャだったせいか、
__label__it
(ITカテゴリ)のスコアが一番高く反映されました。いいかんじですね。感想/課題つらつら
もちろん判定がうまくいかないサイトもあって、やはりスクレイプとカテゴリ分けの健全度が全て...と言う感想でした。
合宿の深夜でどんなソースをいれても金融メディア判定になることがあって、スクレイプを見直す羽目に。
ただ、付け焼き刃のアプローチでもこのようになかなかおもしろい分類器ができあがったので、もうすこし深めてみたいと考えています。
- 投稿日:2020-03-17T18:17:39+09:00
[Firebase Authentication]IDトークンをRevokeさせないユーザー情報更新
はじめに
Firebase Authenticationはドキュメントはかなり充実しているが、
実務で足りない箇所があったため、備忘録的に残しておきます。実装はGoとAngularで書かれています。
IDトークンのRevokedをチェックする仕組み
フロント側でAuthorizationヘッダーにIDトークンを追加して、
APIのmiddlewareにてAuthoraizationヘッダーを検証します。auth.ts@Injectable({ providedIn: 'root' }) export class Auth { constructor(public afAuth: AngularFireAuth) { } // getIdToken IDトークン getIdToken(): Observable<string> { return this.afAuth.idToken; } }http.token.interceptor.ts@Injectable() export class HttpTokenInterceptor implements HttpInterceptor { constructor(private auth: Auth) {} intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return this.auth.getToken().pipe( switchMap((token: string) => { if (token) { const req = request.clone({ // tokenがあるならAuthorizationヘッダーに追加 setHeaders: { Authorization: `Bearer ${ token }` } }); return next.handle(req); } // tokenがなければAuthorizationヘッダーなし return next.handle(request); }) ).pipe(tap(() => {} , async (err: any) => { // エラーだったらログアウト }) ); } }middleware.gopackage middleware func checkUser(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { req := c.Request() // Authorizationヘッダーを取得 token := req.Header.Get("Authorization") if token == "" { return next(c) } // IDトークンを取得 bearerToken := strings.TrimPrefix(token, "Bearer ") ctx := context.Background() // VerifyIDTokenAndCheckRevokedにてRevokedされているかをチェック _, err := a.Authenticator.Client.VerifyIDTokenAndCheckRevoked(ctx, bearerToken) if err != nil { return err } // エラーがなければnext return next(c) } }Admin SDKを用いてパスワード、メールアドレスを変更した場合
ユーザーの情報はAdmin SDKの
UpdateUser
にて更新することができます。
ただし、これを行うとIDトークンがRevokedされるためVerifyIDTokenAndCheckRevoked
でエラーとなりますupdate.goimport "firebase.google.com/go/auth" // UpdateUser()を用いてユーザー情報を更新すると、IDトークンはRevokedされる _, err = *auth.Client.UpdateUser(ctx, uid, (&auth.UserToUpdate{}).Password(password)) if err != nil { return nil, err }reauthenticateWithCredentialを使う
クライアント側でユーザーにpasswordを入れてもらうことで、ユーザーの情報を更新できます。
セキュリティ上重要な操作を行う場合は、直前に認証を行っている必要があります。 → こちら
auth.ts@Injectable({ providedIn: 'root' }) export class Auth { constructor(public afAuth: AngularFireAuth) { } // updatePassword update current firebase user password updatePassword(password: string): Observable<any> { const user = this.afAuth.auth.currentUser; return from(user.updatePassword(password)); } }constructor(private auth: Auth) {} updateMethod() { const msg = '現在のパスワードを入力'; const password = prompt(msg); if (password === null) { return; } const user = this.auth.afAuth.auth.currentUser; const credential = firebase.auth.EmailAuthProvider .credential(user.email, password); from(user.reauthenticateWithCredential(credential)).pipe( mergeMap(() => { return this.auth.updatePassword(password); }) ).subscribe(res => { // 完了処理 }) }まとめ
Firebase Admin SDKを用いてIDトークンがRevokedされるのはわからなくはないが、
それでログアウトされるのもユーザー体験的に嫌なので、クライアント側からパスワードを変更して
IDトークンがRevokedされないようにしました。もっといい方法があればぜひ教えてください!!
- 投稿日:2020-03-17T15:40:47+09:00
Docker + Go の Hello Worldビルド
Dockerの中で go をビルドして、Hello World することがゴールです。
手順をシンプルに書いていきます。まず Docker と Docker-compose を入れておきます。これは省略します。
Dockerディレクトリを作成
以下のようにファイルを作ります。
./docker ./docker/.env ./docker/docker-compose.yml ./docker/go ./docker/go/Dockerfile ./src/ ./src/goDockerの設定
Docker全体用の設定
今回はコンテナは1つしか作りませんが、
今後の拡張を考えたつくりにはしておきます。
常にクセづけておくことで、迷うことがなくなります。.envCOMPOSE_PROJECT_NAME=go-docker-startergoビルド用コンテナ
docker-compose.ymlversion: '3' services: go: build: ./go container_name: '${COMPOSE_PROJECT_NAME}-go' tty: true volumes: - "../src/go:/opt/src"DockerfileFROM golang:1.14.0Dockerコンテナ作成
cd ./docker docker-compose build docker-compose up -dこれで Dockerコンテナが起動します。
docker-compose build せずとも、 up -d だけでもビルド一緒にしてくれているのですが、
それは気を利かせてくれているだけなので、動きを理解するためにも build してみましょう。go の初期ソースコード作成
./src/sample.gopackage main import "fmt" func main() { fmt.Printf("Hello World\n") }sample のビルド
デバッグ用ビルド & 実行
変更する度に行うビルドです。
docker exec go-docker-starter-go bash -c "cd /opt/src/ && GOOS=linux GOARCH=amd64 go run sample.go"ビルド
docker exec go-docker-starter-go bash -c "cd /opt/src/ && GOOS=linux GOARCH=amd64 go run sample.go"sample.go と同じディレクトリに出力されます。
-o
オプションで出力先を指定することもできます。github
- 投稿日:2020-03-17T12:39:25+09:00
【Go】ポインタと変数宣言(new, make)
Goには複数の変数宣言(メモリ割り当てと値の初期化方法)がある
&T{...}、 &someLocalVar、 new、 make など
ポインタ型
基本こちらで理解
自分なりの整理
・ポインタ型とはメモリアドレスを格納することができる変数
→ ポインタ型(=大元アドレス保持型)
→ 指定された変数のアドレスを保持、その大元アドレスを参照し新たにデータ領域を作成する【ポインタ型の宣言】 「new」 または 「&変数 によるアドレス指定」
基本こちらで理解
Is there a difference between new() and “regular” allocation?
自分なりの整理
・newのカッコに任意のタイプ型を指定する(または &変数名 でアドレスを指定する)
→ 大元アドレスが渡される
→ その大元アドレスを参照し新たにデータ領域が作成される
→ 受け取り側の変数はポインタ型(タイプ型名の前に*をつける)にする
→ 0で初期化される(不要な初期化時間や領域確保をなくす)【例:宣言の仕方】
func main() { var a *int = new(int) // OK b := new(int) // OK c := &int // NG (Not work this address set) // Works, but it is less convenient to write than new(int) var d int x := &d }【例:ストラクトとポインタ】
type House struct { Name string Room int } func main() { v := House{"YourHouse", 2} // Without pointer fmt.Println(v) a := new(House) // OK b := &House{} // OK c := &House{"MyHouse", 3} // Combines allocation and initialization fmt.Println(a) fmt.Println(b) fmt.Println(c) }結果
{YourHouse 2} &{ 0} &{ 0} &{MyHouse 3}→結果に表示される & は大元アドレス保持しているよという意味(=ポインタ型)
【例:関数とポインタ】
func three(x *int) { *x = 3 // アドレスの中身を変更 } func main() { var a int = 100 three(&a) // アドレスを渡す fmt.Println(a) // 値が変更されている }結果
3「make」による変数宣言
基本こちらで理解
Go: Make slices, maps and channels
Which is the nicer way to initialize a map in Golang?
自分なりの整理
・slice,map,channelのみで使用される
・メモリの領域を指定できる(予め領域確保することでリサイズなどが発生しにくいのかも)
・使う前にmakeで初期化した方が良いときがある(特にchannel?)
(mapは15個程データがあるならmakeにした方が良いとか)
・それぞれのタイプ型で初期化される(Structの中身を確認すると良い)// 固定長配列を定義 a := [4]int{1, 2, 3, 4} // サイズ等を持ったスライスを定義 b := make([]int, 4, 8) // バッファー無しのチャネル c := make(chan string) // バッファー有りのチャネル d := make(chan string, 10)参照リンク
- 投稿日:2020-03-17T11:24:02+09:00
GAE/Go 2nd generationでDatastoreのmutationを使う上でトランザクションに気をつけろ!
僕は普段GAE/Goで開発をしているのですが、自社でdatastoreの機能をまとめたライブラリを作ることになって、GoDocを読んでいるときに初めて知ったのですが、datastoreにMutationがあるのをしりました!
それでMutationの使い方や使い所の記事を探したのですが、SpannerのMutationの記事と公式ドキュメントしか出て来なかったので、自分で記事を書くことにしました!Mutationとは?
自分が探した限り、datastoreのmutationに関する説明は書いていなかったので、何とも言えないんですけど、
Insert, Update, Upsert, Deleteを一括で行うもので、副作用を起こすものですねで、何がよくなったかというと
- Insert, Update, Upsertが分離された!
- 副作用を起こす処理をまとめられるようになった!
この二点が大きいと感じています!
どこを気をつけるべきなのか?
まずはどうやって書くことができるのかをみていこうと思います
今までは更新とかは全てPutを使っていて、ある意味Upsertだけだったのですが以下のようにかけるようになりました!type Book struct { ID int64 Name string } func main() { ctx := context.Background() book := &Book{ ID: 1, Name: "hoge", } idKey := datastore.IDKey("Book", 1, nil) insert := datastore.NewInsert(idKey, book) book.Name = "hoge1" update := datastore.NewUpdate(idKey, book) book2 := book book2.ID = 2 upsert1 := datastore.NewUpsert(idKey, book2) book2.Name = "hoge2" upsert2 := datastore.NewUpsert(idKey, book2) delete := datastore.NewDelete(book) datastoreClient, err := datastore.NewClient(ctx, projectID) if err != nil { fmt.Errorf("%v", err) } if err := datastoreClient.Mutate(ctx, insert, update, upsert1, upsert2, delete); err != nil { fmt.Errorf("%v", err) } }go特有の、if err != nil {...}が一度だけMutationを呼ぶことでかなりスッキリ見えますよね。
さらに、ここからが問題で、そもそも、先ほども書きましたが、英語も日本語でも記事もブログも少ないのですが、RESTのドキュメントを覗くとMutationは以下のように書いてありますThe type of commit to perform. Defaults to TRANSACTIONAL.
commitを実行するタイプです。デフォルトではTRANSACTIONALとなってます。https://cloud.google.com/datastore/docs/reference/data/rest/v1/projects/commit?hl=ja
これを読んで僕は、
「Spanaerとかと同じで、途中でエラーになったらロールバックしてくれるのか!」
でも、これ実は罠なんですよ!これを検証して、途中でエラーになるようにしました
type Book struct { ID int64 Name string } func main() { ctx := context.Background() book := &Book{ ID: 1, Name: "hoge", } idKey := datastore.IDKey("Book", 1, nil) insert1 := datastore.NewInsert(idKey, book) book.Name = "hoge1" update := datastore.NewUpdate(idKey, book) insert2 := datastore.NewInsert(idKey, book) // 二回目にインサートするので、エラーになるはず delete := datastore.NewDelete(book) datastoreClient, err := datastore.NewClient(ctx, projectID) if err != nil { fmt.Errorf("%v", err) } if err := datastoreClient.Mutate(ctx, insert1, update, insert2, delete); err != nil { fmt.Errorf("%v", err) } }これを実行すると、途中でエラーになるので、ロールバックされて、Mutationは実行される前の状態になっていて欲しい!
ところが、このままだとそうはいきません、、、、、、
updateまで実行された状態になってしまいます!!!
ということで、なんでこうなるかというとMutationの中を見るとわかります!func (c *Client) Mutate(ctx context.Context, muts ...*Mutation) (ret []*Key, err error) { ctx = trace.StartSpan(ctx, "cloud.google.com/go/datastore.Mutate") defer func() { trace.EndSpan(ctx, err) }() pmuts, err := mutationProtos(muts) if err != nil { return nil, err } req := &pb.CommitRequest{ ProjectId: c.dataset, Mutations: pmuts, Mode: pb.CommitRequest_NON_TRANSACTIONAL, // <- ここです!!!! } resp, err := c.client.Commit(ctx, req) if err != nil { return nil, err } // Copy any newly minted keys into the returned keys. ret = make([]*Key, len(muts)) for i, mut := range muts { if mut.key.Incomplete() { // This key is in the mutation results. ret[i], err = protoToKey(resp.MutationResults[i].Key) if err != nil { return nil, errors.New("datastore: internal error: server returned an invalid key") } } else { ret[i] = mut.key } } return ret, nil } // https://github.com/googleapis/google-cloud-go/blob/master/datastore/datastore.go#L651NON_TRANSACTIONALなんですよ!!!
なので、このMutation関数を普通に使ってもロールバックはされません、、、
ではどうしたら良いのかというと、トランザクションをはって使う必要があります。
幸い、TransactionにもMutationがはえているので、それを使うようにするとロールバックされるようになります!type Book struct { ID int64 Name string } func main() { ctx := context.Background() book := &Book{ ID: 1, Name: "hoge", } idKey := datastore.IDKey("Book", 1, nil) insert1 := datastore.NewInsert(idKey, book) book.Name = "hoge1" update := datastore.NewUpdate(idKey, book) insert2 := datastore.NewInsert(idKey, book) // 二回目にインサートするので、エラーになるはず delete := datastore.NewDelete(book) datastoreClient, err := datastore.NewClient(ctx, projectID) if err != nil { fmt.Errorf("%v", err) } if _, err := datastoreClient.RunInTransaction(ctx, func(tx *datastore.Transaction) error { if _, err := tx.Mutate(insert1, update, insert2, delete); err != nil { return multiErrorToSingleError(err) } return nil }); err != nil { fmt.Errorf("%v", err) } }こうしておけば、トランザクションがはられて、ロールバックされるので、失敗する前の状態になることができます!!!
まとめ
- datastoreにもMutationあるよ!
- デフォルトではNON_TRANSACTIONALなので、ロールバックはされないですよ!
- ロールバックして欲しいならば、自分でトランザクションをはりましょうね!
参考
- https://pkg.go.dev/cloud.google.com/go/datastore?tab=doc
- https://github.com/googleapis/google-cloud-go/blob/datastore/v1.1.0/datastore/transaction.go
- https://github.com/googleapis/google-cloud-go/blob/master/datastore/datastore.go
- https://cloud.google.com/datastore/docs/reference/data/rest/v1/projects/commit
- 投稿日:2020-03-17T08:59:47+09:00
【Go】変数宣言の違い( := 、 var )
変数宣言の違い( := 、 var )
基本的にはこちらで理解
自分なりの整理
:=
・varとタイプ型を省略(自動判断してくれるのか、初期化の時間・容量に無駄がでるのか)
・関数内のみで宣言可能var
・指定したタイプ型によって初期化される(ポインタ型はnilで初期化)
・関数外でも宣言可能func main() { var a int var b string var c []int var d *int var e *string var f *[]int g := 1 fmt.Println(a) fmt.Println(b) fmt.Println(c) fmt.Println(d) fmt.Println(e) fmt.Println(f) fmt.Println(g) }結果
0 [] <nil> <nil> <nil> 1
- 投稿日:2020-03-17T08:09:49+09:00
Trello風Webアプリケーションを作成してみた
作ったもの
最近、プログラミングから少し離れていたので、思い出すこともかねてTrello風のWebアプリケーションを作ってみた。
▼デモ( https://x-color.github.io/vue-trello )
使用技術
フロントエンド
フロントエンドはSPAとなっており、Vue.jsで実装している。
- Vue.js: JavaScriptフレームワーク
- Vuex: Vue.js用状態管理ライブラリ
- Vue Router: SPA構築用のルーター
- Vuetify: Vue.jsのマテリアルデザインコンポーネントフレームワーク
- Vue.Draggable: ドラッグアンドドロップ処理用ライブラリ
バックエンド
バックエンドはAPIサーバーとなっており、Go言語で実装している。
実装内容(カードを動かす処理)
今回のアプリケーションで実装したカードを動かす処理の概要を以下で紹介していく。
基本的な実装
今回はカードを動かす処理に、Vue.Draggableを用いているため、
draggable
タグで動かしたいものを囲むだけで実装可能。
以下の例は、fruits
配列をドラッグアンドドロップで自由に並び替える処理。<template> <draggable v-model="fruits"> <!-- ここの要素がドラッグアンドドロップ可能になる --> <div v-for="(v, i) in fruits" :key="i">{{ v }}</div> </draggable> </template> <script> import draggable from 'vuedraggable' export default { components: { draggable, }, data() { return { fruits: [ "apple", "banana", "cherry" ], } } } </script>Vuexで管理しているデータを並び替える
今回の場合は、カードのデータや並び順をVuex内で管理しているため、Vuex内のデータを並び替える必要がある。
公式のREADMEに記載されている通り、computed
内からVuexのstate
を呼び出し、Setterを用いて更新することで対応可能。
シンプルな配列を並び替えたいときは、以下のようにするだけで並び変え可能。<template> <draggable v-model="list"> <div v-for="(v, i) in list" :key="i">{{ v }}</div> </draggable> </template> <script> import draggable from 'vuedraggable' export default { components: { draggable, }, computed: { list: { get() { return this.$store.state.list }, set(value) { this.$store.commit('updateList', value) }, }, }, } </script>今回作成したアプリでは、カードを並び替えた際に一部データの更新を行う必要があったので、以下のようにメソッドを呼び出し、データの更新処理を行ったあとにデータの移動を反映させる形にした。
computed: { lists: { get() { return this.getListsByBoardId(this.id); // list の配列を取得 }, set(value) { this.moveList(value); // list が移動した際に行う処理を実施 }, }, }実際には、移動したデータを追跡し、順番を保持している変数値の更新とAPIサーバーとの通信などを実施している。
動かせるものを指定する
<draggable>
で囲った要素は基本的にすべて、ドラッグ可能となってしまう。そのため、動かせないものを一緒にタグで囲わなければならない場合、動かしたいものを指定する必要がある。例えば今回の場合は、カードを追加するための「+」ボタンのカードを動かしたくなかった。以下は失敗例のサンプル。
<draggable>
の中に動かしたいカードと動かしたくない「+」ボタンが入ってしまっている。そのため、このままだとボタンがドラッグ可能となってしまう。<draggable v-model="lists"> <v-col v-for="(list, i) in lists" :key="i" cols="auto"> <card-list :id="list.id" /> </v-col> <!-- カード追加ボタン --> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>これを改善したのが以下のサンプル。Vue.Draggableでは、
draggable
属性を用いて、動かしたいものと動かしたくないものを対象のclass属性で判別することが可能。<!-- dragable属性(draggable=".item")を付与 --> <draggable v-model="lists" draggable=".item"> <!-- ドラッグ可能にするためにclass属性(class="item")を付与 --> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <card-list :id="list.id" /> </v-col> <!-- itemクラスが付与されていないためドラッグ不可 --> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>上記の例では、
draggable=".item"
を用いて、item
クラスを付与されているもののみ移動可能としている。
これにより、「+」ボタンカードを除いたカードのみ移動可能とすることができる。ドラッグ可能な箇所を指定する
先ほどのサンプルを再掲。
以下のコードだと、実はカード外部でドラッグ可能となってしまう。
ドラッグ対象が<v-col>
となっているので、実際ドラッグしたい<card-list>
外部でもドラッグが可能となってしまい、UI的にカードではない部分でドラッグできてしまう。<draggable v-model="lists" draggable=".item"> <!-- ドラッグ対象は以下の要素となってしまう --> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <!-- ドラッグしたいカード --> <card-list :id="list.id" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>改善した結果が以下のサンプルとなる。
handle
属性を用いて、ドラッグ判定を出す部分をclass属性で指定することが可能。<!-- handle属性(handle=".handle")を付与 --> <draggable v-model="lists" draggable=".item" handle=".handle"> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <!-- class属性(class="handle")を付与 --> <card-list :id="list.id" class="handle" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>上記の例では、
handle=".handle"
を用いて、handle
クラスを付与されているものがドラッグ可能と判定している。これにより、<card-list>
内(カード)をドラッグした場合のみドラッグ可能とすることができる。スムーズなドラッグアニメーションにする
デフォルトの移動時のアニメーションだと、動かしたというよりも瞬間移動した感じが出てしまう。そのため今回は、アニメーションを変更し、スムーズに動かした感じを出すこととした。
<!-- animation属性(:animation="300")を付与 --> <draggable v-model="lists" draggable=".item" handle=".handle" :animation="300"> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <card-list :id="list.id" class="handle" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>
animation
属性を用いてアニメーションの時間を変更している。今回は時間を多めにとることにより、動いている感じを出している。アニメーションも属性を一つ追加すればよいだけなのでとても簡単にできる。最後に
久々のプログラミングだったので、細かなところなどを結構忘れていて、実装に時間がかかってしまった。
また、初めてドラッグアンドドロップを実装したが、Vue.Draggableを用いることで簡単に実装することができた。ドラッグアンドドロップを実装したい場合はとてもおすすめ。
- 投稿日:2020-03-17T08:09:49+09:00
GoとVue.jsでTrello風Webアプリケーションを作成してみた
作ったもの
最近、プログラミングから少し離れていたので、思い出すこともかねてTrello風のWebアプリケーションを作ってみた。
▼デモ( https://x-color.github.io/vue-trello )
使用技術
フロントエンド
フロントエンドはSPAとなっており、Vue.jsで実装している。
- Vue.js: JavaScriptフレームワーク
- Vuex: Vue.js用状態管理ライブラリ
- Vue Router: SPA構築用のルーター
- Vuetify: Vue.jsのマテリアルデザインコンポーネントフレームワーク
- Vue.Draggable: ドラッグアンドドロップ処理用ライブラリ
バックエンド
バックエンドはAPIサーバーとなっており、Go言語で実装している。
実装内容(カードを動かす処理)
今回のアプリケーションで実装したカードを動かす処理の概要を以下で紹介していく。
基本的な実装
今回はカードを動かす処理に、Vue.Draggableを用いているため、
draggable
タグで動かしたいものを囲むだけで実装可能。
以下の例は、fruits
配列をドラッグアンドドロップで自由に並び替える処理。<template> <draggable v-model="fruits"> <!-- ここの要素がドラッグアンドドロップ可能になる --> <div v-for="(v, i) in fruits" :key="i">{{ v }}</div> </draggable> </template> <script> import draggable from 'vuedraggable' export default { components: { draggable, }, data() { return { fruits: [ "apple", "banana", "cherry" ], } } } </script>Vuexで管理しているデータを並び替える
今回の場合は、カードのデータや並び順をVuex内で管理しているため、Vuex内のデータを並び替える必要がある。
公式のREADMEに記載されている通り、computed
内からVuexのstate
を呼び出し、Setterを用いて更新することで対応可能。
シンプルな配列を並び替えたいときは、以下のようにするだけで並び変え可能。<template> <draggable v-model="list"> <div v-for="(v, i) in list" :key="i">{{ v }}</div> </draggable> </template> <script> import draggable from 'vuedraggable' export default { components: { draggable, }, computed: { list: { get() { return this.$store.state.list }, set(value) { this.$store.commit('updateList', value) }, }, }, } </script>今回作成したアプリでは、カードを並び替えた際に一部データの更新を行う必要があったので、以下のようにメソッドを呼び出し、データの更新処理を行ったあとにデータの移動を反映させる形にした。
computed: { lists: { get() { return this.getListsByBoardId(this.id); // list の配列を取得 }, set(value) { this.moveList(value); // list が移動した際に行う処理を実施 }, }, }実際には、移動したデータを追跡し、順番を保持している変数値の更新とAPIサーバーとの通信などを実施している。
動かせるものを指定する
<draggable>
で囲った要素は基本的にすべて、ドラッグ可能となってしまう。そのため、動かせないものを一緒にタグで囲わなければならない場合、動かしたいものを指定する必要がある。例えば今回の場合は、カードを追加するための「+」ボタンのカードを動かしたくなかった。以下は失敗例のサンプル。
<draggable>
の中に動かしたいカードと動かしたくない「+」ボタンが入ってしまっている。そのため、このままだとボタンがドラッグ可能となってしまう。<draggable v-model="lists"> <v-col v-for="(list, i) in lists" :key="i" cols="auto"> <card-list :id="list.id" /> </v-col> <!-- カード追加ボタン --> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>これを改善したのが以下のサンプル。Vue.Draggableでは、
draggable
属性を用いて、動かしたいものと動かしたくないものを対象のclass属性で判別することが可能。<!-- dragable属性(draggable=".item")を付与 --> <draggable v-model="lists" draggable=".item"> <!-- ドラッグ可能にするためにclass属性(class="item")を付与 --> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <card-list :id="list.id" /> </v-col> <!-- itemクラスが付与されていないためドラッグ不可 --> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>上記の例では、
draggable=".item"
を用いて、item
クラスを付与されているもののみ移動可能としている。
これにより、「+」ボタンカードを除いたカードのみ移動可能とすることができる。ドラッグ可能な箇所を指定する
先ほどのサンプルを再掲。
以下のコードだと、実はカード外部でドラッグ可能となってしまう。
ドラッグ対象が<v-col>
となっているので、実際ドラッグしたい<card-list>
外部でもドラッグが可能となってしまい、UI的にカードではない部分でドラッグできてしまう。<draggable v-model="lists" draggable=".item"> <!-- ドラッグ対象は以下の要素となってしまう --> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <!-- ドラッグしたいカード --> <card-list :id="list.id" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>改善した結果が以下のサンプルとなる。
handle
属性を用いて、ドラッグ判定を出す部分をclass属性で指定することが可能。<!-- handle属性(handle=".handle")を付与 --> <draggable v-model="lists" draggable=".item" handle=".handle"> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <!-- class属性(class="handle")を付与 --> <card-list :id="list.id" class="handle" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>上記の例では、
handle=".handle"
を用いて、handle
クラスを付与されているものがドラッグ可能と判定している。これにより、<card-list>
内(カード)をドラッグした場合のみドラッグ可能とすることができる。スムーズなドラッグアニメーションにする
デフォルトの移動時のアニメーションだと、動かしたというよりも瞬間移動した感じが出てしまう。そのため今回は、アニメーションを変更し、スムーズに動かした感じを出すこととした。
<!-- animation属性(:animation="300")を付与 --> <draggable v-model="lists" draggable=".item" handle=".handle" :animation="300"> <v-col v-for="(list, i) in lists" :key="i" cols="auto" class="item"> <card-list :id="list.id" class="handle" /> </v-col> <v-col cols="auto"> <v-btn> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> </draggable>
animation
属性を用いてアニメーションの時間を変更している。今回は時間を多めにとることにより、動いている感じを出している。アニメーションも属性を一つ追加すればよいだけなのでとても簡単にできる。最後に
久々のプログラミングだったので、細かなところなどを結構忘れていて、実装に時間がかかってしまった。
また、初めてドラッグアンドドロップを実装したが、Vue.Draggableを用いることで簡単に実装することができた。ドラッグアンドドロップを実装したい場合はとてもおすすめ。
- 投稿日:2020-03-17T05:55:28+09:00
エンジニアの貴方必見
https://www.reiwarss.com/OpenProject
Top tags
python
swift
javascript
go
C
C++
C#
Ruby
TypeScript
PHP
- 投稿日:2020-03-17T02:25:32+09:00
Go で GitHub App (Bot) を作る
シンプルな GitHub App (Bot) を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は「まとめ」に記載されているものをご参照ください。作るもの
下図のように Issue を作成すると
hello, ${username}
と反応してくれる Bot を作ります。準備
GitHub に動作確認用のリポジトリを作っておきしょう。
また、GitHub から Webhook イベントを受け取る必要があるので、パブリックアクセス可能な URL が必要です。
今回は開発しながら適宜動作確認が行えるように ngrok というプロキシサービスを利用してローカル開発マシンのポートをインターネットに公開することにします。こちら から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。セットアップが完了すると次のようなコマンドで開発マシンのポートを外部公開することができます。
$ nrgok http 8080
便利なサービスですがうっかり外部公開できないものを公開してしまわないように注意が必要です。
Step1: GitHub Apps 認証で API を叩く
まずはシンプルに GitHub API を叩く部分だけを作ってみます。
実行すると Issue にhello
とコメントするプログラムを作成します。GitHub App を登録する
アカウントの
Settings
からDeveloper settings
を開くと GitHub Apps の画面になるのでNew GitHub App
から登録を行います。登録フォームに入力する際は以下を参考にしてください。
Github App name
- ユニークな名前をつけてあげる必要があります
Homepage URL
- 必須項目なので動作確認用のリポジトリの URL でも入力しておきます
Identifying and authorizing users
- App がインストールされた後、アプリ側がインストールしたユーザを識別したり承認したりする必要がある場合に設定します
- 今回は不要なので空欄にしておきます
Post installation
- App がインストールされた後、ユーザに追加の設定を行わせたい場合に設定します
- 今回は不要なので空欄にしておきます
Webhook
- GitHub から Event を受け取る際に必要ですが、
Active
にチェックを入れてしまうとWebhook URL
の入力を求められてしまうので一旦チェックを外しておきます
Repository permissions
- Issue に読み書きしたいので
Issues
のAccess
をRead & write
に設定します- この際、自動的に
Metadata
のAccess
がRead-only
に設定されますOrganization permissions
,User permissions
- 組織やユーザに対する権限設定も行えますが今回は不要なので何も設定しません
Subscribe to events
- GitHub から受け取る Event を設定できますが、いずれかにチェックを入れると
Webhook URL
の入力を求められてしまうので一旦何もチェックしないでおきます
Where can this GitHub App be installed?
Only on this account
を選択して自分しかインストールできないようにしておきます設定が完了したら
Create GitHub App
ボタンを押して登録します。
登録後、次のように App の詳細画面に遷移するのでApp ID
を控えておきましょう。秘密鍵を発行する
作成後の画面を下にスクロールしていくと
Generate a private key
ボタンがあるのでクリックして秘密鍵を発行しましょう。
pem
ファイルがローカルにダウンロードされます。リポジトリにインストールする
左側のメニューから
Install App
を選択し、自分のアカウントにインストールします。インストール先は動作確認用に作ったリポジトリのみにしておきます。
インストール後の画面の URL 末尾の ID を控えておきましょう。認証時に
Installation ID
として使用します。実装
ようやく Go での実装を行っていきます。
GitHub API を叩くためのクライアントライブラリとして google/go-github というパッケージを使用します。
ただし、このパッケージは README の Authentication にも記載されているように認証処理は受け持ってくれないので自分で何とかする必要があります。ここで、GitHub Apps の認証方法を確認しておきましょう。
Authenticating as a GitHub App
に記載されているように、発行した秘密鍵を使用してRS256
アルゴリズムで署名した JWT をAuthorization
ヘッダに Bearer トークンとして入れることで GitHub App として認証されます。ただしこの JWT で可能なのは一時トークンの発行などに限定されており、インストール先のリポジトリを直接操作するような API を叩くことはできません。実際に API を叩くためにはインストールされた組織・ユーザ単位で一時トークンを発行し、そちらの方を使う必要があります。GitHub Apps は開発者以外のユーザや組織にインストールされて使われることが想定されているためこのような仕組みになっているのですが、この部分の実装を自分でやるのはちょっと面倒です。
そこで、認証部分に関しては bradleyfalzon/ghinstallation パッケージを使用することにします。上記の認証処理を自動で行ってくれるhttp.Transport
を提供してくれるパッケージで、google/go-github で生成される GitHub API Client に埋め込んで使用することができます。google/go-github の README でも GitHub Apps の認証用のパッケージとして紹介されているので安心して使うことができます。前置きが長くなりましたがこれらのパッケージを使って実装すると次のようなコードになります。
main.gopackage main import ( "context" "log" "net/http" "os" "strconv" "time" "github.com/bradleyfalzon/ghinstallation" "github.com/google/go-github/v29/github" ) const InstallationID = <your-installation-id> const RepoOwner = "<your-repo-owner>" const Repo = "<your-repo>" const IssueNumber = 1 func main() { appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) if err != nil { log.Fatal(err) } tr := http.DefaultTransport itr, err := ghinstallation.NewKeyFromFile(tr, appID, InstallationID, "private-key.pem") if err != nil { log.Fatal(err) } client := github.NewClient(&http.Client{ Transport: itr, Timeout: 5 * time.Second, }) ctx := context.Background() body := "hello" comment := &github.IssueComment{ Body: &body, } if _, _, err := client.Issues.CreateComment(ctx, RepoOwner, Repo, IssueNumber, comment); err != nil { log.Fatal(err) } }控えておいた
App ID
は環境変数から取得するようにしています。Installation ID
は本来 Webhook 経由で受け取るのが正しいので一旦ハードコードしています。
コメントを投稿する Issue のリポジトリ情報や対象の Issue 番号も Webhook イベントから取得したいのでここでは一旦ハードコードしておきます(動作確認用に適当な Issue を作成しておきましょう)。また、秘密鍵は同じディレクトリの
private-key.pem
というファイルに保存されていることを想定しています。実行してみましょう。
ダウンロードしたpem
ファイルをprivate-key.pem
にリネームしてmain.go
と同じディレクトリに配置し、環境変数GITHUB_APP_ID
をexport
してから実行します。$ mv frozenbonito-test-bot.2020-03-14.private-key.pem private-key.pem $ export GITHUB_APP_ID=<your-app-id> $ go run main.go
hello
とコメントしてくれました。上記のコードでは
ghinstallation.NewKeyFromFile()
を使ってファイルから秘密鍵を読み込んでいますが、ghinstallation.New()
の方を使えば変数から参照することも可能です。
以下は環境変数から秘密鍵を参照する例です。コンテナ環境で動かしたい場合はこちらの方が扱いやすいかもしれません。main.gotr := http.DefaultTransport key := os.Getenv("GITHUB_APP_PRIVATE_KEY") itr, err := ghinstallation.New(tr, appID, InstallationID, []byte(key)) if err != nil { log.Fatal(err) }秘密鍵を環境変数にセットしてから実行します。
$ export GITHUB_APP_PRIVATE_KEY=$(cat private-key.pem) $ go run main.goAWS Lambda など、環境変数に改行を入れるのが難しい環境では base64 エンコードしたものを入れておきコード中でデコードするのが良さそうです。
Step2: GitHub から Event を受け取って処理する (Webhook)
API を叩けることが確認できたので次は GitHub の Webhook を利用して Issue 作成に反応するようにします。
実装
先ほどのコードを以下のように修正します。
main.gopackage main import ( "context" "log" "net/http" "os" "strconv" "time" "github.com/bradleyfalzon/ghinstallation" "github.com/google/go-github/v29/github" ) func main() { http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() payload, err := github.ValidatePayload(r, nil) if err != nil { log.Println(err) w.WriteHeader(http.StatusBadRequest) return } webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload) if err != nil { log.Println(err) w.WriteHeader(http.StatusBadRequest) return } switch event := webhookEvent.(type) { case *github.IssuesEvent: if err := processIssuesEvent(ctx, event); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } } }) log.Println("[INFO] Server listening") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error { if event.GetAction() != "opened" { return nil } installationID := event.GetInstallation().GetID() client, err := newGithubClient(installationID) if err != nil { return err } repoOwner := event.Repo.GetOwner().GetLogin() repo := event.Repo.GetName() issue := event.GetIssue() issueNumber := issue.GetNumber() user := issue.GetUser().GetLogin() body := "hello, @" + user comment := &github.IssueComment{ Body: &body, } if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil { return err } return nil } func newGithubClient(installationID int64) (*github.Client, error) { appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) if err != nil { return nil, err } tr := http.DefaultTransport itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem") if err != nil { return nil, err } return github.NewClient(&http.Client{ Transport: itr, Timeout: 5 * time.Second, }), nil }
コードの解説
func main() { http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) { // ... }) log.Println("[INFO] Server listening") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }Webhook イベントを受け取るため、
main()
はサーバを起動するためのコードに書き換えています。8080 ポートの/github/events
で Webhook イベントを待ち受けます。payload, err := github.ValidatePayload(r, nil)
github.ValidatePayload()
を使って Webhook イベントの payload をチェックしています。
第二引数に Secret を与えることで署名を検証することができますが一旦ここではnil
を与えて署名検証を skip しています。webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)payload を parse してイベントを取得しています。ここで戻り値として得られるのは
interface{}
ですが、Type switches によってどのイベントかを判定することが可能です。
ちなみに GitHub Webhook イベントのタイプはリクエストのX-GitHub-Event
ヘッダに詰められて送られてきます。第一引数に与えているgithub.WebHookType()
はこのX-GitHub-Event
ヘッダからイベントタイプを読み取る関数です。switch event := webhookEvent.(type) { case *github.IssuesEvent: if err := processIssuesEvent(ctx, event); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } }Type switches を利用してイベントタイプを判定し、処理を行っています。今回は Issue が作成された場合に処理を行うので
*github.IssuesEvent
ケース内に処理を書いています。func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error { if event.GetAction() != "opened" { return nil } installationID := event.GetInstallation().GetID() client, err := newGithubClient(installationID) if err != nil { return err } repoOwner := event.Repo.GetOwner().GetLogin() repo := event.Repo.GetName() issue := event.GetIssue() issueNumber := issue.GetNumber() user := issue.GetUser().GetLogin() body := "hello, @" + user comment := &github.IssueComment{ Body: &body, } if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil { return err } return nil }Issues イベントを処理する関数です。
Issues イベントは Issue に関する様々な操作が行われた際に発生しますが、1行目で Action がopened
であることを判定しているので Issue の作成時にのみ反応するようになっています。残りの処理はほぼ Step1 で
main()
に書いていたものと同等ですが、必要な値が Webhook イベントから取得されるようになっています。
リポジトリのオーナーや Issue の作成者を取得する際、github.User
から取得することになりますがGetID()
やGetName()
ではなくGetLogin()
を使う必要があることに注意が必要です。func newGithubClient(installationID int64) (*github.Client, error) { // ... }先ほどは
main()
に書いていた GitHub Client の生成処理を別関数にしました。処理はほぼ変わっていませんが、引数に Webhook イベントから取得したInstallation ID
が渡されることを想定しています。今回はシンプルな実装にするため関数が呼ばれるたびに新しい GitHub Client を生成してしまっていますが、実際には
Installation ID
と GitHub Client の対応を map などで持たせておき同一のInstallation ID
に対しては Client を使いまわすようにすると良さそうです。Issues イベントが App に配信されるように設定する
Step1 で Webhook に関する設定を後回しにしたのでここで行います。
Webhook 用の URL が必要なので作成したプログラムを実行し、
ngrok
でポートを公開しておきます。$ go run main.go 2020/03/15 22:42:48 [INFO] Server listening$ ngrok http 8080 Session Status online Account frozenbonito (Plan: Free) Version 2.3.35 Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding http://xxxxxxxx.ngrok.io -> http://localhost:8080 Forwarding https://xxxxxxxx.ngrok.io -> http://localhost:8080GitHub App の設定ページを開きます(閉じてしまった場合は
Settings
>Developer settings
>GitHub Apps
で作成した App をEdit
)。下の方にある Webhook の
Active
にチェックを入れ、Webhook URL にはngrok
で得られた https の URL に Webhook イベントを待ち受けているパスである/github/events
を加えたものを入力します。
Webhook secret
の設定は後で行うので空欄のままにしておきます。設定できたら
Save changes
で保存します。次に Webhook で配信するイベントの設定を行います。
左側のメニューの
Permissions & events
を開きます。下の方にある
Subscribe to events
のIssues
にチェックを入れます。
ここでChange privileges to able to select events
とエラーが出た場合はRepository permissions
のIssues
のAccess
をNo access
にした後Read & write
に戻すなどの操作を行ってみてください。不具合なのかもしれませんが、Permission
の方をいじらないとチェックができない仕様になっているようです。
Save changes
で保存したら設定は完了です。動作確認用のリポジトリで新しい Issue を作成してみましょう。作成した Issue に対して Bot がコメントで反応してくれました。
Step3: GitHub からのリクエストであることを検証する
GitHub App はリポジトリへの参照権限や書き込み権限などを持っているのでセキュリティには十分注意が必要です。
Step2 までで表面的な機能は実装が完了しましたが、安全のためには届いたリクエストが GitHub からのものであることを検証しなくてはいけません。最後にこのリクエスト検証処理を実装していきます。
Webhook の Secret を設定する
GitHub からのリクエストを検証するためにはまず Webhook の Secret を設定する必要があります。
再度 GitHub App の設定ページを開きます(閉じてしまった場合は
Settings
>Developer settings
>GitHub Apps
で作成した App をEdit
)。下の方にある Webhook 設定のうち、先ほどは入力しなかった
Webhook secret
に Secret 文字列を入力します。入力する文字列は暗号学的に安全な乱数から生成したランダム文字列が良いでしょう。公式ドキュメント では Ruby を使って
puts SecureRandom.hex(20)
で生成しています。安全なランダム文字列が得られれば方法は何でもよいのですが、あえて Go でやるなら以下のような感じでしょうか。secret/main.gopackage main import ( "crypto/rand" "fmt" "log" ) func main() { b := make([]byte, 20) if _, err := rand.Read(b); err != nil { log.Fatal(err) } fmt.Printf("%x\n", b) }crypto/rand を使う必要があることに注意です。
Webhook secret
を入力したらSave changes
をクリックして保存しておきます。実装
Webhook secret
が設定されていると、GitHub は Secret を使用して payload を署名したものをX-Hub-Signature
ヘッダで送信するようになります。
この検証はgithub.ValidateSignature()
またはgithub.ValidatePayload()
で行うことが可能です。先ほどのコードを以下のように修正します。
main.gopackage main import ( "context" "log" "net/http" "os" "strconv" "time" "github.com/bradleyfalzon/ghinstallation" "github.com/google/go-github/v29/github" ) func main() { http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 修正 secret := os.Getenv("GITHUB_APP_SECRET") payload, err := github.ValidatePayload(r, []byte(secret)) if err != nil { log.Println(err) w.WriteHeader(http.StatusBadRequest) return } // 省略 // ... }) // 省略 // ... } // 省略 // ...環境変数から Secret を取得するようにし、
github.ValidatePayload()
の第二引数に与えるだけです。github.ValidatePayload()
は第二引数に Secret を与えると内部でgithub.ValidateSignature()
を呼び出して署名の検証を行ってくれます。実行してみましょう。
先ほど設定した Secret を環境変数に入れてから実行します。$ export GITHUB_APP_SECRET=<your-webhook-secret> $ go run main.goGitHub で Issue を作成した場合は正常に動作するはずです。
実際に外部からのリクエストを弾けるかも試してみましょう。
と言ってもgithub.ValidatePayload()
は payload の形式自体もチェックしてしまうので、適当なリクエストを偽装するのも面倒です。
そこで手元の環境変数だけ変更した上で再度 GitHub からイベントを飛ばすことで「間違った Secret で署名された偽装リクエストが届いた」状態を疑似的に再現することにします。環境変数を一時的に変更して再度実行します。
$ GITHUB_APP_SECRET=1234567890abcdef go run main.goここでは Issue を新しく作るのではなく GitHub のイベント再送機能を使ってみましょう。
GitHub App の設定ページの左側のメニューからAdvanced
を開きます。今までに GitHub から送信されたイベントの記録が残っています。
最新の成功したものを選択します。
Redeliver
ボタンで全く同じヘッダーと payload でイベントを再送することができます。実行すると再送は 400 で失敗し、手元で実行している App は以下のようにログを吐くはずです。
$ GITHUB_APP_SECRET=123456789abcdef go run main.go 2020/03/17 01:41:56 [INFO] Server listening 2020/03/17 01:42:04 payload signature check failedSecret が間違っている偽装リクエストをちゃんと弾けることが確認できました。
まとめ
Go 言語による GitHub App の作り方について解説しました。
実は今回のように Webhook イベントをトリガーに処理を行うケースでは GitHub Actions を使った方がサーバー不要な分お手軽に実現できます。しかし GitHub 外部で発生するイベントをトリガーにいろいろと処理をしたい場合などには GitHub App が活躍してくれそうです。最後に完成したコードの全体を載せておきます。
main.gopackage main import ( "context" "log" "net/http" "os" "strconv" "time" "github.com/bradleyfalzon/ghinstallation" "github.com/google/go-github/v29/github" ) func main() { http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() secret := os.Getenv("GITHUB_APP_SECRET") payload, err := github.ValidatePayload(r, []byte(secret)) if err != nil { log.Println(err) w.WriteHeader(http.StatusBadRequest) return } webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload) if err != nil { log.Println(err) w.WriteHeader(http.StatusBadRequest) return } switch event := webhookEvent.(type) { case *github.IssuesEvent: if err := processIssuesEvent(ctx, event); err != nil { log.Println(err) w.WriteHeader(http.StatusInternalServerError) return } } }) log.Println("[INFO] Server listening") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error { if event.GetAction() != "opened" { return nil } installationID := event.GetInstallation().GetID() client, err := newGithubClient(installationID) if err != nil { return err } repoOwner := event.Repo.GetOwner().GetLogin() repo := event.Repo.GetName() issue := event.GetIssue() issueNumber := issue.GetNumber() user := issue.GetUser().GetLogin() body := "hello, @" + user comment := &github.IssueComment{ Body: &body, } if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil { return err } return nil } func newGithubClient(installationID int64) (*github.Client, error) { appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) if err != nil { return nil, err } tr := http.DefaultTransport itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem") if err != nil { return nil, err } return github.NewClient(&http.Client{ Transport: itr, Timeout: 5 * time.Second, }), nil }
- 投稿日:2020-03-17T01:40:00+09:00
触り始めたので個人的な書き方のメモ
編集中。
メモ
これはメモです。
ソース
まずはダーッとソースを貼る。公式の使い方を見つつ適当に切ったり貼ったり。
gitとかで差分管理しながらやればいいんだろうけど、そういうの苦手。メモを大枠でどかっと載せて、あとから分解していこう作戦。気が向いたら更新。package gcp import ( "context" "crypto/tls" "crypto/x509" "encoding/base64" "fmt" "net/http" "k8s.io/client-go/rest" "k8s.io/client-go/kubernetes" // istioはやりたいことに合わせてどれをimportするか変える // 一覧:https://godoc.org/istio.io/client-go/pkg/clientset // kubernatesと同じでclientsetから一通りのクライアント(とバージョンの組み合わせ)が取れる istio "istio.io/client-go/pkg/clientset/versioned" "golang.org/x/build/kubernetes" "google.golang.org/api/container/v1" ) // SetupRestConfig returns an kube rest config. func SetupRestConfig(cluster container.Cluster) (rest.Config, error) { // Decode certs // Base64で加工してあるからもとに戻す decode := func(which string, cert string) []byte { if err != nil { return nil } s, decErr := base64.StdEncoding.DecodeString(cert) if decErr != nil { err = fmt.Errorf("error decoding %s cert: %v", which, decErr) } return []byte(s) } clientCert := decode("client cert", cluster.MasterAuth.ClientCertificate) clientKey := decode("client key", cluster.MasterAuth.ClientKey) caCert := decode("cluster cert", cluster.MasterAuth.ClusterCaCertificate) if err != nil { return nil, err } return rest.Config { Host: cluster.Endpoint, TLSClientConfig { // 検証スキップフラグ // Insecure: true // サーバー名はよくわからないこんな感じでいけるだろうか ServerName: cluster.Endpoint, CertData: clientCert, KeyData: clientKey, CAData: caCert, } } } // GetCluster クラスター情報の取得 func GetCluster(ctx context.Context, name string, project string, zone string) (container.Cluster, error) { containerService, err := container.New() if err != nil { return nil, fmt.Errorf("could not create client for Google Container Engine: %v", err) } return containerService.Projects.Zones.Clusters.Get(project, zone, name).Context(ctx).Do() } // NewKubernatesClient kubernatesのclientsetを作って返す func NewKubernatesClientset(ctx context.Context, cluster string, project string , zone string) (*kubernates.Clientset, error) { cluster, err := GetCluster(ctx, cluster, project, zone) if err != nil { return nil, err } config, err := SetupRestConfig(cluster) if err != nil { return nil, err } return kubernetes.NewForConfig(config) } // NewIstioNetworkClient // 以下を見ながら適当に使いたいものを引っ張ってくる // https://godoc.org/istio.io/client-go func NewIstioClientset(ctx context.Context, cluster string, project string , zone string) (*istio.Clientset, error) { cluster, err := GetCluster(ctx, cluster, project, zone) if err != nil { return nil, err } config, err := SetupRestConfig(cluster) if err != nil { return nil, err } return istio.NewForConfig(config) }