20200317のGoに関する記事は12件です。

エンジニア向けオープンプロジェクトトレース

https://www.reiwarss.com/OpenProject

Top tags
python
swift
javascript
go
C
C++
C#
Ruby
TypeScript
PHP

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

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系
  • 医療系
  • ママ/子育て系
  • 金融系
  • 音楽系

粒度がバラバラじゃん!って話なのですが、個人的にサンプルが思いつきそうなメディアがざっくりこのへんだったのでこのままいきます(笑)

カテゴリごとの記事を集めよう

Artboard.png

カテゴリごとに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.txt
Wano 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.0177098

ITというカテゴリワードがドンピシャだったせいか、__label__it (ITカテゴリ)のスコアが一番高く反映されました。いいかんじですね。

感想/課題つらつら

もちろん判定がうまくいかないサイトもあって、やはりスクレイプとカテゴリ分けの健全度が全て...と言う感想でした。
合宿の深夜でどんなソースをいれても金融メディア判定になることがあって、スクレイプを見直す羽目に。
ただ、付け焼き刃のアプローチでもこのようになかなかおもしろい分類器ができあがったので、もうすこし深めてみたいと考えています。

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

[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.go
package 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.go
import "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されないようにしました。

もっといい方法があればぜひ教えてください!!

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

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/go

Dockerの設定

Docker全体用の設定

今回はコンテナは1つしか作りませんが、
今後の拡張を考えたつくりにはしておきます。
常にクセづけておくことで、迷うことがなくなります。

.env
COMPOSE_PROJECT_NAME=go-docker-starter

goビルド用コンテナ

docker-compose.yml
version: '3'
services:

  go:
    build: ./go
    container_name: '${COMPOSE_PROJECT_NAME}-go'
    tty: true
    volumes:
      - "../src/go:/opt/src"
Dockerfile
FROM golang:1.14.0

Dockerコンテナ作成

cd ./docker
docker-compose build
docker-compose up -d

これで Dockerコンテナが起動します。
docker-compose build せずとも、 up -d だけでもビルド一緒にしてくれているのですが、
それは気を利かせてくれているだけなので、動きを理解するためにも build してみましょう。

go の初期ソースコード作成

./src/sample.go
package 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

https://github.com/dd0125/go-docker-starter

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

【Go】ポインタと変数宣言(new, make)

Goには複数の変数宣言(メモリ割り当てと値の初期化方法)がある

&T{...}、 &someLocalVar、 new、 make など

ポインタ型

基本こちらで理解

ポインタ メモリアドレスの秘密

徹底図解!C言語ポインタを初心者向けに分かりやすく解説

Go言語のメモリ管理

自分なりの整理

・ポインタ型とはメモリアドレスを格納することができる変数

→ ポインタ型(=大元アドレス保持型)
→ 指定された変数のアドレスを保持、その大元アドレスを参照し新たにデータ領域を作成する

【ポインタ型の宣言】 「new」 または 「&変数 によるアドレス指定」

基本こちらで理解

Why would I make() or 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」による変数宣言

基本こちらで理解

Golangのnew()とmake()の違い

Go: Make slices, maps and channels

Which is the nicer way to initialize a map in Golang?

Tutor of Go Channels

自分なりの整理

・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)

参照リンク

Goのnew()とmake()の違い

new() vs make()

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

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#L651

NON_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なので、ロールバックはされないですよ!
  • ロールバックして欲しいならば、自分でトランザクションをはりましょうね!

参考

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

【Go】変数宣言の違い( := 、 var )

変数宣言の違い( := 、 var )

基本的にはこちらで理解

他言語プログラマがgolangの基本を押さえる為のまとめ

自分なりの整理

:=

・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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Trello風Webアプリケーションを作成してみた

作ったもの

最近、プログラミングから少し離れていたので、思い出すこともかねてTrello風のWebアプリケーションを作ってみた。

vue-trello - Google Chrome 2020_03_17 3_05_51.png

▼デモ( https://x-color.github.io/vue-trello
demo.gif

▼完成品のリポジトリ

使用技術

フロントエンド

フロントエンドはSPAとなっており、Vue.jsで実装している。

  • Vue.js: JavaScriptフレームワーク
  • Vuex: Vue.js用状態管理ライブラリ
  • Vue Router: SPA構築用のルーター
  • Vuetify: Vue.jsのマテリアルデザインコンポーネントフレームワーク
  • Vue.Draggable: ドラッグアンドドロップ処理用ライブラリ

バックエンド

バックエンドはAPIサーバーとなっており、Go言語で実装している。

  • Go言語
  • GORM: Go言語用ORMライブラリ
  • Echo: Go言語用Webフレームワーク
  • jwt-go: Go言語用JWTを扱うライブラリ

実装内容(カードを動かす処理)

今回のアプリケーションで実装したカードを動かす処理の概要を以下で紹介していく。

基本的な実装

今回はカードを動かす処理に、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を用いることで簡単に実装することができた。ドラッグアンドドロップを実装したい場合はとてもおすすめ。

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

GoとVue.jsでTrello風Webアプリケーションを作成してみた

作ったもの

最近、プログラミングから少し離れていたので、思い出すこともかねてTrello風のWebアプリケーションを作ってみた。

vue-trello - Google Chrome 2020_03_17 3_05_51.png

▼デモ( https://x-color.github.io/vue-trello
demo.gif

▼完成品のリポジトリ

使用技術

フロントエンド

フロントエンドはSPAとなっており、Vue.jsで実装している。

  • Vue.js: JavaScriptフレームワーク
  • Vuex: Vue.js用状態管理ライブラリ
  • Vue Router: SPA構築用のルーター
  • Vuetify: Vue.jsのマテリアルデザインコンポーネントフレームワーク
  • Vue.Draggable: ドラッグアンドドロップ処理用ライブラリ

バックエンド

バックエンドはAPIサーバーとなっており、Go言語で実装している。

  • Go言語
  • GORM: Go言語用ORMライブラリ
  • Echo: Go言語用Webフレームワーク
  • jwt-go: Go言語用JWTを扱うライブラリ

実装内容(カードを動かす処理)

今回のアプリケーションで実装したカードを動かす処理の概要を以下で紹介していく。

基本的な実装

今回はカードを動かす処理に、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を用いることで簡単に実装することができた。ドラッグアンドドロップを実装したい場合はとてもおすすめ。

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

エンジニアの貴方必見

https://www.reiwarss.com/OpenProject

Top tags
python
swift
javascript
go
C
C++
C#
Ruby
TypeScript
PHP

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

Go で GitHub App (Bot) を作る

シンプルな GitHub App (Bot) を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は「まとめ」に記載されているものをご参照ください。

作るもの

下図のように Issue を作成すると hello, ${username} と反応してくれる Bot を作ります。

githubapps1.png

準備

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 から登録を行います。

githubapps2.png

githubapps3.png

githubapps4.png

登録フォームに入力する際は以下を参考にしてください。

  • Github App name
    • ユニークな名前をつけてあげる必要があります
  • Homepage URL
    • 必須項目なので動作確認用のリポジトリの URL でも入力しておきます

githubapps5.png

  • Identifying and authorizing users
    • App がインストールされた後、アプリ側がインストールしたユーザを識別したり承認したりする必要がある場合に設定します
    • 今回は不要なので空欄にしておきます
  • Post installation
    • App がインストールされた後、ユーザに追加の設定を行わせたい場合に設定します
    • 今回は不要なので空欄にしておきます

githubapps6.png

  • Webhook
    • GitHub から Event を受け取る際に必要ですが、Active にチェックを入れてしまうと Webhook URL の入力を求められてしまうので一旦チェックを外しておきます

githubapps7.png

  • Repository permissions
    • Issue に読み書きしたいので IssuesAccessRead & write に設定します
    • この際、自動的に MetadataAccessRead-only に設定されます
  • Organization permissions, User permissions
    • 組織やユーザに対する権限設定も行えますが今回は不要なので何も設定しません

githubapps8.png

  • Subscribe to events
    • GitHub から受け取る Event を設定できますが、いずれかにチェックを入れると Webhook URL の入力を求められてしまうので一旦何もチェックしないでおきます

githubapps9.png

  • Where can this GitHub App be installed?
    • Only on this account を選択して自分しかインストールできないようにしておきます

githubapps10.png

設定が完了したら Create GitHub App ボタンを押して登録します。
登録後、次のように App の詳細画面に遷移するので App ID を控えておきましょう。

githubapps11.png

秘密鍵を発行する

作成後の画面を下にスクロールしていくと Generate a private key ボタンがあるのでクリックして秘密鍵を発行しましょう。

githubapps12.png

pem ファイルがローカルにダウンロードされます。

リポジトリにインストールする

左側のメニューから Install App を選択し、自分のアカウントにインストールします。

githubapps13.png

githubapps14.png

インストール先は動作確認用に作ったリポジトリのみにしておきます。

githubapps15.png

インストール後の画面の URL 末尾の ID を控えておきましょう。認証時に Installation ID として使用します。

githubapps16.png

実装

ようやく 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-githubREADME でも GitHub Apps の認証用のパッケージとして紹介されているので安心して使うことができます。

前置きが長くなりましたがこれらのパッケージを使って実装すると次のようなコードになります。

main.go
package 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_IDexport してから実行します。

$ 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 とコメントしてくれました。

githubapps17.png

上記のコードでは ghinstallation.NewKeyFromFile() を使ってファイルから秘密鍵を読み込んでいますが、 ghinstallation.New() の方を使えば変数から参照することも可能です。
以下は環境変数から秘密鍵を参照する例です。コンテナ環境で動かしたい場合はこちらの方が扱いやすいかもしれません。

main.go
    tr := 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.go

AWS Lambda など、環境変数に改行を入れるのが難しい環境では base64 エンコードしたものを入れておきコード中でデコードするのが良さそうです。

Step2: GitHub から Event を受け取って処理する (Webhook)

API を叩けることが確認できたので次は GitHub の Webhook を利用して Issue 作成に反応するようにします。

実装

先ほどのコードを以下のように修正します。

main.go
package 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:8080

GitHub App の設定ページを開きます(閉じてしまった場合は Settings > Developer settings > GitHub Apps で作成した App を Edit)。

下の方にある Webhook の Active にチェックを入れ、Webhook URL には ngrok で得られた https の URL に Webhook イベントを待ち受けているパスである /github/events を加えたものを入力します。
Webhook secret の設定は後で行うので空欄のままにしておきます。

githubapps18.png

設定できたら Save changes で保存します。

次に Webhook で配信するイベントの設定を行います。

左側のメニューの Permissions & events を開きます。

githubapps19.png

下の方にある Subscribe to eventsIssues にチェックを入れます。
ここで Change privileges to able to select events とエラーが出た場合は Repository permissionsIssuesAccessNo access にした後 Read & write に戻すなどの操作を行ってみてください。不具合なのかもしれませんが、Permission の方をいじらないとチェックができない仕様になっているようです。

githubapps20.png

Save changes で保存したら設定は完了です。動作確認用のリポジトリで新しい Issue を作成してみましょう。

githubapps1.png

作成した 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 文字列を入力します。

githubapps21.png

入力する文字列は暗号学的に安全な乱数から生成したランダム文字列が良いでしょう。公式ドキュメント では Ruby を使って puts SecureRandom.hex(20) で生成しています。安全なランダム文字列が得られれば方法は何でもよいのですが、あえて Go でやるなら以下のような感じでしょうか。

secret/main.go
package 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.go
package 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.go

GitHub で Issue を作成した場合は正常に動作するはずです。

実際に外部からのリクエストを弾けるかも試してみましょう。
と言っても github.ValidatePayload() は payload の形式自体もチェックしてしまうので、適当なリクエストを偽装するのも面倒です。
そこで手元の環境変数だけ変更した上で再度 GitHub からイベントを飛ばすことで「間違った Secret で署名された偽装リクエストが届いた」状態を疑似的に再現することにします。

環境変数を一時的に変更して再度実行します。

$ GITHUB_APP_SECRET=1234567890abcdef go run main.go

ここでは Issue を新しく作るのではなく GitHub のイベント再送機能を使ってみましょう。
GitHub App の設定ページの左側のメニューから Advanced を開きます。

githubapps22.png

今までに GitHub から送信されたイベントの記録が残っています。
最新の成功したものを選択します。

githubapps23.png

Redeliver ボタンで全く同じヘッダーと payload でイベントを再送することができます。

githubapps24.png

実行すると再送は 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 failed

Secret が間違っている偽装リクエストをちゃんと弾けることが確認できました。

まとめ

Go 言語による GitHub App の作り方について解説しました。
実は今回のように Webhook イベントをトリガーに処理を行うケースでは GitHub Actions を使った方がサーバー不要な分お手軽に実現できます。しかし GitHub 外部で発生するイベントをトリガーにいろいろと処理をしたい場合などには GitHub App が活躍してくれそうです。

最後に完成したコードの全体を載せておきます。

main.go
package 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
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

触り始めたので個人的な書き方のメモ

編集中。

メモ

これはメモです。

ソース

まずはダーッとソースを貼る。公式の使い方を見つつ適当に切ったり貼ったり。
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)
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む