爆速でGo!

GoGo!

GoでDDD設計する際のrepositoryをどう定義するか

GoDDDでrepositoryを設計する際に色々考えたのでメモ

アーキテクチャ

この記事では、レイヤードアーキテクチャを使用します。
しかし正式なレイヤードでなく、以下のようにinfra層がdomain層に依存する形で設計します。

f:id:NakaWatch:20180210164321p:plain

ディレクトリ構成

.
├── app
├── domain
│   └── user
│       ├── user.go
│       └── user_repository.go
├── infra
│   └── mongo
│       └── user_repository.go
└── ui

repository定義

今回は以下のUserエンティティを永続化することを想定して勧めていきます。

package user

type User struct {
    ID   uint
    Name string
}

repositoryはinfra層とdomain層の2層に定義します。

domain層

domainディレクトリ配下に、
以下のようなRepositoryをインターフェースとして定義しておき、このRepositoryを実装すればUserエンティティが返ってくることを保証しておきます。

domain/user/user_repository.go

type UserRepository interface {
    Find(id int64) (User, error)
    FindAll(limit int) ([]User, error)
}

infra層

infraディレクトリ配下に、
domain層のRepositoryインターフェースを実装したRepositoryを定義します。

infra/mongo/user_repository.go

package mongo

type UserRepository struct {
    Context context.Context
}

func (r *UserRepository) Find(id int64) (user.User, error) {
    return r.find(r.Context, id)
}
func (r *UserRepository) FindAll(limit int) ([]user.User, error){
    return r.findAll(r.Context, limit)
}

repositoryの使用

package main

import (
    "fmt"

    "../domain/user"
    "../infra/mongo"
)

func main() {
        // Userエンティティが取得できることを保証
    var repo user.UserRepository
    repo = getUserRepoFromInfra()
    user, _ := repo.Find(1)
    fmt.Printf("type is %T", user) // => type is user.User
}

func getUserRepoFromInfra() user.UserRepository {
    return &mongo.UserRepository{}
}

窓口の抽象化

以上のように、domain層とinfra層の窓口を抽象化しておくことでインフラの差し替えを容易にする事が出来ます。

2018年のテーマは「持ちつ持たれつ」

2017年は4皮くらい剥けた年でした。 まずはそんな2017年のテーマを振り返ります。

2017年は決める年

nakawatch.hatenablog.com

去年正月に書いたとおり、2017年は決める年でした。

人生哲学を決める

決まりました。
どんどん見える世界が変わっているので確定ではありませんが、毛穴から手が出そうなくらいなりたい姿があります。
29歳までにカリスマのソフトウェアエンジニアになる
まつもとゆきひろさん、宮川達彦さん、中島聡さん、小飼弾さん、伊藤直也さん、笹田耕一さん、ひげぽんさん、、、、
29までに彼らのようなカリスマになって各分野のカリスマ達と対等に情報交換をし、今見えない世界を見たい。
それから30代の生き方を決めたい。
大きなOSSにコミットして、各地で講演会して、技術書書いて、WEB+DBに連載載せる29歳になりたい。
中学の時は、グラブ職人に憧れていました。知る人ぞ知る存在になりたい気持ちが昔から強かったのです。
この夢に全身が納得していますが、最初は上手くいきませんでした。

f:id:NakaWatch:20180102205204p:plain

このように、人生ピラミッドを作りまくってた時もありました。
外に説明するには非常に筋の通った綺麗なものにはなりましたが、自分は納得していませんでした。
なんでも筋を通せばいいってもんじゃないですね。

決めるスピードと精度を高める

「意思決定」が苦手だったので、この一年はそこを意識しました。
インターン先の上司との面談を繰り返して、自分は意思決定が苦手なのではなく、捨てることが苦手だということが分かりました。
何を捨てるか、どんな情報が集まれば決定出来るかを意識した結果後悔ない意思決定を素早く出来るようになりました。

逐一何時までに何をするかを決める

何時までに何をするか、小さな意思決定を毎日した一年でした。
12月にはサイバーエージェントにて、約2週間でマッチングアプリのサーバーサイド開発を完了させました。
時間密度を濃くすることを意識した成果がはっきりと見えました。

2018年は持ちつ持たれつ

冒頭で申したとおり、2017年は4皮くらい剥けた年でした。
それも、多くの人の助けがあったおかげです。
しかし、自分はヒトに頼るのが苦手な一匹狼体質です。(誕生時に姓名判断したところ、唯一心配だったのがこれだったらしい)
困っても一人で解決しようとする。ヒトにもあまり干渉しない。それが自分のアイデンティティであるとも思っていました。
しかしやっぱり一人の力には限界がある。そして寂しい
2017年の終盤は、1人では乗り越えられない壁がたくさん現れました。そこではちきれそうになった自分を救ってくれたのは、いつもヒトでした。
助けてもらうと、助け返したくなります。
助け合うと、二人の間に暖かい信頼関係が生まれることに気づきました。
この「暖かさ」を感じてる時が一番幸せです。
この形容し難い「暖かさ」を大切にしたい。それは必然的にヒトを大切にするということです。
今年は色々大きな勝負があると思います。ヒトを頼ってヒトに頼ってもらう一年にします。

サブテーマは「自信」

自信がある分野に関わってると楽しいですね。
自信がある時は、余裕があるので楽しいです。
逆に自分が生きづらいと感じる時は大体自信がない時です。
この生きづらさを潰すために、「自信」にフォーカスしていきたいと思います。
結果を出し、評価されないと自信を持てないタイプなので、勝負の結果にこだわっていきます。
勝負が苦手だった自分が去年一年で相当勝負強くなったので、引き続き1位にこだわっていきたいと思います。

持ちつ持たれつ壁を超えていって、ヒトとして自信をつける一年にします。

GAE/Goで位置情報マッチングAPIの設計を考える

先日、サイバーエージェントさんの学生版ガレスタというインターンに参加しました。
6週間で企画からサービス完成までを4人(ビジネス1人, デザイナー1人, エンジニア2人)で行うというものでした。
今記事ではこのインターンで得た技術的なことを、つらつら書いていきたいと思います(´・ω・`)

要件

位置情報を用いたマッチングモバイルアプリのAPI

技術選定

言語

Go

  • チャット実装時のマルチスレッド処理が簡潔
  • 薄くAPI作れるから短期間開発○
  • 丁度いい型安全
  • 開発効率を上げるエコシステムが整ってる
  • モバイルAPIの実例多い
  • インターン後仲間集める時に、若く優秀なエンジニアを集めやすい

http通信

net/http

  • 薄い
  • 慣れてる
  • 標準なのでバージョン管理安心

ORマッパー

gorm

  • Githubスター数一番多い
  • RORのActive Recordライクに書ける
  • 情報多い

RDB

MySQL

  • 慣れてる

KVS

Redis

  • 3.2から追加された"Geo"系コマンドを使って位置情報管理したい

インフラ

Google App Engine

  • サーバーサイドは一人なのでとにかく開発に集中したい
  • PaaSの中で最も妥当な柔軟性、料金と感じた

アプリケーション設計

$GOPATH
├── app
│   ├── app.yaml
│   ├── config.go
│   ├── handler.go # Application層
│   └── main.go # UI層
└── src
    ├── domain # Domain層
    │   ├── config.go
    │   ├── detail
    │   ├── feed
    │   ├── location
    │   └── registration
    ├── infra # Infrastructure層
    │   ├── cache
    │   ├── config
    │   ├── objstorage
    │   └── orm
    └── middleware

レイヤードアーキテクチャを用いた軽量DDDを採用しました。

DDDを完璧に実現しようとすると、分析、設計、実装というフローを踏まないといけないのですが、今回は短時間且つ仕様も変わっていくため、分析はせず軽量なDDDを実現しました。

DDDについてはまとまった知識がなかったので、今回エリック・エヴァンスのDDD本を初めて読みました。

www.shoeisha.co.jp

GAEは、app.yamlが置いてあるディレクトリ以下を全てビルドしてしまうので、Domain層以下は別packageとして管理しています。
集約毎にディレクトリを分け、その中でentity、value object、repositoryを定義しています。
infra配下ではDBに依存するコードをまとめ、出入りするデータをrepositoryにinterfaceとして定義しておきます。
このようにすることで、ミドルウェアの差し替えを容易にしています。

GAE/GoのDDD設計はまだまだデファクトスタンダードが定まっていないようなので、これからも熟考していこうと思います。

インフラ設計

全体アーキテクチャ

f:id:NakaWatch:20171226160338p:plain

ほとんどのデータはCloudSQLにMySQLを乗せて管理しました。
CloudSQLはフェイルオーバー時に数分のダウンタイムが発生してしまうというデメリットがありますが、短期開発ということで立ち上げ時の設定が楽であるという最大のメリットを汲み採用しました。
CloudStorageでは画像中心に、サイズの大きいオブジェクトを管理しています。
特筆すべきはredisです。

redisによる位置情報管理

位置情報機能の要件は"近い順にアクティブユーザーを返す"でした。
そこで最初に考えたアーキテクチャはこんなものでした

f:id:NakaWatch:20171226162856p:plain

列志向のBigtableに位置情報を溜めていき、アクティブユーザーを返す度にBigQueryを回すというものです。
ありえませんね。
ユーザー毎にクエリを回すというこのモデルではパフォーマンスにもお財布にも優しくないですね。
サイバーエージェントの社員さんに聞いたところ、redis3.2から位置情報を扱うコマンドが追加されたらしく、使ってみることにしました。
>>Aさんから半径100km以内のユーザーを近い順に頂戴!<<
わがままですね。
でもこんなわがままを、ジェントルマンなredisは1コマンドで叶えてくれます。
GEORADIUSBYMEMBERという夢のコマンドをredisにささやきます。

// GEOADDで追加
> GEOADD location 13.583333 37.316667 "Aさん" 13.361389 38.115556 "Bさん"
(integer) 2

// GEORADIUSBYMEMBERで取得
> GEORADIUSBYMEMBER location "Aさん" 100 km
1) "Aさん"
2) "Bさん"

GEOADDでsorted set型の集合にぶっこみ、ソートします。
GEORADIUSBYMEMBERを使用すればO(N+log(M))で検索することが可能です。
*N=指定範囲内の要素数, M=インデックス内の項目数

これで当初よりシンプル&ハイパフォーマンス&低料金で要件を実現することが出来ました。
社員さんありがとうございます〜〜

テーブル設計

ゼロイチでDB設計したことなかったので、しっかり以下3つ読みました。

www.oreilly.co.jp

www.shoeisha.co.jp

www.shoeisha.co.jp

プライマリーキーをとりあえず"id"というサロゲートキーにしてしまうアンチパターン「idリクアイアド」を避け、
なるべく自然キーを用いた設計を心がけました。

API設計

ゼロイチでAPI設計したことなかったので、しっかりこれを読みました。

www.oreilly.co.jp

リソースのページネーションを実現する際、最初は相対位置を利用していましたが、絶対位置を利用することでパフォーマンスと整合性の改善をしました。

まとめ

何が言いたいのかまとまっていませんが、とりあえず多くの技術的学びがあったということです。
痛感したのは、どんなに短期開発でも時間かけるべき部分には大いに時間をかけるべきだということです。
どこを丁寧に行うか適切に判断することが出来れば、スピードと品質は必ずしもトレードオフにならないと感じました。
このアプリは近いうちにリリースするので、その時にまた報告致します。

Goで軽量なスクレイピングライブラリを作ってみた

リポジトリ

github.com

はじめに

GoでWebクローラーを開発する際、皆さんどうされてますか??
フルスクラッチで作るのは少し面倒だし、asciimoo/collyのようなフルスタックなのはいらない、、という時に丁度いいライブラリがなかったので作りました!
本当に最低限の機能のみ搭載しました。

機能

主な3つの機能を紹介します。

オーガニック検索

キーワードを入れて自然検索結果画面に表示されるページURLを返します。

import "github.com/ryonakao/netsurfer"
urls, err := netsurfer.OrganicSearch("キーワード", 1)

順位調査

指定キーワードで検索した時、指定ページがオーガニックで何位に掲載されるかを返します。

import (
    "net/url"
    "github.com/ryonakao/netsurfer"
)
u, _ := url.Parse("https://qiita.com/ryonakao")
rank, _ := netsurfer.GetRank(u, "ryonakao", 2)

HTML取得

指定ページの静的ページを返します。

import "github.com/ryonakao/netsurfer"
html, err := netsurfer.GetHTML("https://qiita.com/ryonakao")

実装一部紹介

SERPsのurlスライスを返す以下の関数を御覧ください。

gist.github.com

ユーザーにdepthを指定してもらうことで、一回のスクレイピングで全SERPsを取得しています。

ryonakao/netsurderPuerkitoBio/goqueryに依存しています。

まとめ

ライブラリ開発めちゃんこ楽しい♪
どんどん作っていこうと思います!!

GoのcliツールUIライブラリを試す ~ 今日のGithub Trending ~

今日のGithub Trending

カリスマエンジニアになりたい!!
そう願う大学二年生の私は新たな企画を始めます\(^o^)/
題して、 >>今日のGithub Trending<<
Github Trendingを眺めて、面白そうなリポジトリがあったらガンガン試していきます。
Github Trendingとは、盛り上がってるリポジトリを探すことが出来るというGithubが提供するサービスです。
第一回のリポジトリは、今日だけで200スター以上ついているmarcusolsson/tui-goを試していこうと思います!!

f:id:NakaWatch:20171104103359p:plain

チャットクライアント風なcliツールを作ってみる

完成図

f:id:NakaWatch:20171104103118p:plain

実装

基本的に以下の2つの構造体を使って作っていきます。
box.go

ボックスはウィジェットを水平または垂直に配置するためのレイアウトです。 もし 水平にすると、すべてのウィジェットは同じ高さになります。 縦にすると、すべて同じ幅になります。

// Box is a layout for placing widgets either horizontally or vertically. If
// horizontally, all widgets will have the same height. If vertically, they
// will all have the same width.
type Box struct {
    WidgetBase

    children []Widget

    border bool
    title  string

    alignment Alignment
}

entry.go

エントリは、1行のテキストエディタです。 これによってユーザはアプリケーションにテキストを供給することができます。例えば、ユーザおよびパスワード情報を入力することができます。

// Entry is a one-line text editor. It lets the user supply the application
// with text, e.g., to input user and password information.
type Entry struct {
    WidgetBase

    text string

    onTextChange func(*Entry)
    onSubmit     func(*Entry)
}

サイドバー作成

sidebar := tui.NewVBox(
    tui.NewLabel(" <CHANNELS>"),
    tui.NewLabel(" general"),
    tui.NewLabel(" random"),
    tui.NewLabel(""),
    tui.NewLabel(" <DIRECT MESSAGES>"),
    tui.NewLabel(" maria"),
    tui.NewLabel(" john"),
    tui.NewSpacer(),
)
sidebar.SetBorder(true)
  1. NewVBox関数で、縦方向のBoxを作成できます。
  2. 任意数のWidgetを与えることが出来ます。
  3. SetBorderメソッドで枠を表示するかを決めます。

送信済みメッセージを表示

type post struct {
    username string
    message  string
    time     string
}

// 送信済みPostのスライス
var posts = []post{
    {username: "ryo", message: "Hello!", time: "14:41"},
}

history := tui.NewVBox()
history.SetBorder(true)
history.Append(tui.NewSpacer())

for _, m := range posts {
    history.Append(tui.NewHBox(
        tui.NewLabel(m.time),
        tui.NewPadder(1, 0, tui.NewLabel(fmt.Sprintf("<%s>", m.username))),
        tui.NewLabel(m.message),
        tui.NewSpacer(),
    ))
}

NewVBox関数で縦方向のBoxを作成し、Appendメソッドでメッセージを追加していきます。
Appendメソッドの実装を覗いてみましょう。
box.go

// Append adds the given widget at the end of the Box.
func (b *Box) Append(w Widget) {
    b.children = append(b.children, w)
}

Appendメソッドは、Widgetインターフェイスの値をBoxのchildrenというスライスに追加していきます。

入力ボックス作成

input := tui.NewEntry()
input.SetFocused(true)
input.SetSizePolicy(tui.Expanding, tui.Maximum)

inputBox := tui.NewHBox(input)
inputBox.SetBorder(true)
inputBox.SetSizePolicy(tui.Expanding, tui.Maximum)

Entry構造体を作ってNewHBoxの引数に渡すことで、Entry構造体を子要素として持ったBoxを作ることが出来ます。
NewHBoxメソッドの実装を覗いてみましょう。
box.go

// NewHBox returns a new horizontally aligned Box.
func NewHBox(c ...Widget) *Box {
    return &Box{
        children:  c,
        alignment: Horizontal,
    }
}

NewHBox関数はWidgetインターフェイスを子要素に含めることが出来ます。
そのためBox構造体だけではなく、Widgetインターフェイスが実装されているEntry構造体も渡すことができます。

投稿する

import "flag"
var name = flag.String("u", "kazuki", "Set your name")
flag.Parse()

input.OnSubmit(func(e *tui.Entry) {
    history.Append(tui.NewHBox(
        tui.NewLabel(time.Now().Format("15:04")),
        tui.NewPadder(1, 0, tui.NewLabel(fmt.Sprintf("<%s>", *name))),
        tui.NewLabel(e.Text()),
        tui.NewSpacer(),
    ))
    input.SetText("")
})

Entry構造体のOnSubmitメソッドで、Enterを押した時の挙動を設定出来ます。
historyにメッセージを追加します。
flagパッケージを使用して、実行時にユーザー名を指定出来るようにします。

UI作成

chat := tui.NewVBox(history, inputBox)
chat.SetSizePolicy(tui.Expanding, tui.Expanding)

root := tui.NewHBox(sidebar, chat)

ui := tui.New(root)
ui.SetKeybinding("Esc", func() { ui.Quit() })
  1. Boxの、Widgetインターフェイスを子要素に出来る機能を利用して、今まで作成したBoxをrootにまとめていきます。
  2. tcellUI構造体を作成し、Esc押下時にuiを終了するよう、キーバインディングを設定します。

実行

if err := ui.Run(); err != nil {
    panic(err)
}

Runメソッドを覗いてみましょう。
ui_tcell.go

func (ui *tcellUI) Run() error {
    if err := ui.screen.Init(); err != nil {
        return err
    }

    if w := ui.kbFocus.chain.FocusDefault(); w != nil {
        w.SetFocused(true)
        ui.kbFocus.focusedWidget = w
    }

    ui.screen.SetStyle(tcell.StyleDefault)
    ui.screen.EnableMouse()
    ui.screen.Clear()

    go func() {
        for {
            switch ev := ui.screen.PollEvent().(type) {
            case *tcell.EventKey:
                ui.handleKeyEvent(ev)
            case *tcell.EventMouse:
                ui.handleMouseEvent(ev)
            case *tcell.EventResize:
                ui.handleResizeEvent(ev)
            }
        }
    }()

    for {
        select {
        case <-ui.quit:
            return nil
        case ev := <-ui.eventQueue:
            ui.handleEvent(ev)
        }
    }
}
  1. 別ゴルーチンを立ててPollEventメソッドを呼ぶことで、イベント発生を監視します。
  2. イベントを検知したら、eventQueueというチャネルにenqueueしていきます。
  3. eventQueueの状態をメインゴルーチンで監視し、受信を確認したら実行します。

*PollEventはgdamore/tcellという別パッケージのメソッドです。

コード全貌

長くなるのでGIstに記載しました。

https://gist.github.com/ryonakao/6247637a39f32ba0543f5b0421297357

まとめ

評価されてるライブラリを試すことで以下のメリットがあることに気づきました。

  1. コードを多読することになるため、コーディングスキルがUP(今回は、marcusolssonさんのinterface設計がとても勉強になりました。)
  2. OSS界隈での需要が分かる
  3. ワクワクする

また時間があったらやってみたいと思います!!

オレ流学習フレームワーク

はじめに

今記事では、大学生の私が独学でコンピュータサイエンスを学習する際に使用している独自の学習フレームワークを紹介していきます。
あくまで持論なので、すべての人に最適であるとは限りません。
これから紹介するのは、いかに楽して知識を得られるかを念頭に置いたフレームワークです。
ここで言う"楽"というのは、無駄な体力を使わないという意味です。
他にこんな学習の仕方してるよ〜というのがあれば、コメントして頂けると幸いです!

主な学習フレームワーク

発信ドリブン型学習

ブログ等で発信したり、人に教えることを前提としたインプットを行います。
インプットしながらどうアウトプットするかを同時に考えることで、高速な理解が可能になります。
なぜ高速な理解が可能になるのでしょうか?
理解とは、脳の棚に整理された形で分けられている状態だと私は考えています。
人に伝えるためには、内容を整理しないといけません。
インプット中に棚に振り分けていくことで、インプット終了後には既に"理解した"という状態になっているという考え方です。
これを習得すると、書籍を読んでいる時に文章が構造的に見えてくるようになります。

遅延評価型学習

これは有名な遅延評価勉強法のことで、その知識が必要になった時に初めて勉強する方法です。
遅延評価とは、元々はプログラミング手法の一つで、必要になるまで値の評価をしないという意味の言葉です。
"必要は発明の母"という言葉がある通り必要性を感じた時の吸収力はかなり高いため、必要度に比例して吸収力も上昇します。
「直近で必要ではないけど、今学習しておきたいことだってあるぞ!」
そう思う方も少なくないと思います。
実際私も、ネットワークの仕組みやミドルウェアの特性等、学生生活で必要になることがほとんどない学習をしています。
別に必要になるのを待つ必要は無いのです。
必要を作り出せばいいのです。
先日私は、ネットワークの仕組みやミドルウェアの特性を理解しないと勝てない大会に出場することを決め、必要を作り出しました。
その時のレポート↓
CyberAgentのアドテクチャレンジで正確なDMPを作る - 爆速でGo!

このように、遅延評価型学習では必要をコントロールしていくことがキーとなってくると考えています。

フレームワークをどう組み合わせるか

f:id:NakaWatch:20171028152630p:plain

爆発的な吸収力を生む遅延評価型でインプットするところから始めます。
しかし遅延評価型でバラバラに集めた情報はTipsにはなるのですが、知識にはならないと思います。
知識にするためには、ある程度網羅的なインプットが必要です。
そこで発信ドリブン型を使います。
網羅的なインプットというと、分厚い書籍を1ページ目から最後まで順に読むという印象がありますが、これは中々体力を使いますね💦
これでは冒頭で述べた"楽して"という言葉に反します。
ここで私は、独自の読書フレームワークであるgoto文型リーディングを使います。

goto文型リーディング

goto文とはプログラミングの制御構造の一つで、指定された場所に処理をジャンプさせるための文です。
このフレームワークは、各章が独立しておらず順に読まないと意味が分からないタイプの書籍に有効です。
手順は以下の通りです。

  • 目次を見て、この書籍ではどんな内容に触れているかにしっかり目を通す。これは毎回読む前に行う

  • 一番興味ある部分から読む

  • 興味が無くなるまで以下を繰り返す

    • 意味が分からないワードや表現が出てくる

    • 目次が頭に入っていれば、その書籍のどこかで説明されていることが分かる

    • 該当箇所にジャンプする

書籍によりますが、これで大半は読むことが出来ます。
大半を読めばその書籍の内容に関してはかなり理解度があるため、残りの部分は小見出しに魅力がなくても興味が湧くことが多いです。
それでも興味がない部分は無理に読む必要ないと思います。
そっと本棚にしまいましょう。

モチベーション・マネジメント

上記を読んで正直面倒くさいと思った方もいると思います。
私も同感で、上記を完遂するためには一定のモチベーションが必要です。
上記を行うためにはモチベーション・マネジメントを常に行う必要があります。
私は、学習をしていく上でこれが一番重要だと思います。
夢や目標が遠すぎると挫折しがちですよね、、
私は、ちょっと頑張れば届く中期目標向かって進んでいる時が一番モチベーションを高く保つことが出来ます。
この中期目標の距離がどのくらい近いと良いのかは人によって違うと思います。
中期目標を最適な場所に置き続けることが大事だと思います。
これはスキルだと思っています。
環境が変化し続ける中で、常に適切な場所に中期目標を置けるスキルがある人の成長は止まらないと思います。

まとめ

最後まで読んで頂きありがとうございます。
冒頭でも申し上げましたが、あくまで自分の中でしっくりきているフレームワークなので、理解し難い部分があると思います。
学習の質をもっとあげていきたいと思っているので、他に実践されているものがあれば、教えて頂けますと幸いです!

CyberAgentのアドテクチャレンジで正確なDMPを作る

先日株式会社サイバーエージェントの2dayインターン、アドテクチャレンジに参加させて頂きました。
そしてなんと優勝させて頂きました!
私はインフラ設計の経験がなく、常に手探り状態でした💦
今記事では、優勝に至るまでの技術的苦悩をまとめていきます。

developers.cyberagent.co.jp

問題

概要

開発時間終了後、位置情報やIDFAなどのデータが3時間程度大量に送られてくる(2000QPS)。
1リクエストにつき100ms以内に返さないと無効になる。
送られてきたデータを元に問題を解答し、その精度を元に得点がつけられる。
*ちなみにGoogle Cloud Platform使い放題\(^o^)/

詳細

以下の2つの形式でそれぞれ何問か答える

【バッチ問題】

データ送信が完了した後に問題に答える
ex)○時から○時の間に〇〇から半径10km以内にいた人数は?

【リアルタイム問題】

データ送信中に、特定IDFA(端末ごとのID的なやつ)と時間がjsonで送られてくる。
そのユーザーがその時間にどこにいたか等を答える。
1000ms以内に返さないと無効。

アーキテクチャ

最初の案

f:id:NakaWatch:20171024183052p:plain

リアルタイム問題はレスポンスタイム制限があるため、直近で使うデータだけredisに置いておかないと💦
と思っていたのですが、1000msは余裕すぎました。
しかも直近で使うデータなんか分からないから却下。

二番目の案

f:id:NakaWatch:20171024184503p:plain

1000msは余裕と分かり、今回ネックなのは書き込みであることに気付きました。
正直2000QPSが想像つかなかったのですが、直接書き込むのではなくジョブキューを噛ませるのが安全だと考えました。
GCPのCloud Launcherなら、redisがプリインストールされているインスタンスを素早く立ち上げられるし、
クラスタリングも比較的簡単に出来そうだったので決まりか!?と一瞬笑顔になりました。
しかしredisはインメモリDBなので、ログを1つでも取りこぼしてはいけない今回は不適切でした。

三番目の案

f:id:NakaWatch:20171024185718p:plain

様々なミドルウェアを検討しましたが、フルマネージドサービスで統一した方ががつなぎ込みも楽だと考えたので、Cloud Pub/Subを採用しました。
データストアにはBigtableを採用しました。GCPサービスの中では最も時系列データを扱うのに向いていたからです。(後ほど説明します)
さらにバッチ問題用にBigQueryを採用しました。集計が速く、且つ全員が慣れていて学習コストが低いSQLで操作できるためです。
データをBigtableからBigQueryにエクスポートしようと思っていたのですが、そのためのBigtableのリージョンがus-centralとeurope-westしかありませんでした。。。
そこでログをリアルタイムでエクスポートしてくれるStackdriverLogging採用。(後ほど審査時に突っ込まれます)
完成!・・・
しかし全員が口をそろえて言いました。
時間足りなくね(-.-;)

採用案

f:id:NakaWatch:20171024190746p:plain

とりあえずQPS2000でリクエストを流してみたところ、ジョブキュー噛ませなくても余裕で耐えられることが判明しました。
ということで、APIから直接Bigtableに書き込むアーキテクチャで決定しました。

言語

Go

理由

2日間という短い期間で高トラフィックを捌くには最適

  • 実行速度○
  • ビルド速度○
  • go fmtのお陰で規約がいらない
  • GCPサービスとの相性良さそう
  • みんなのGoモチベが高い (超大事)

Bigtableスキーマ設計

問題は時間指定が多かったため、時間順にソートしておきたいと考えていました。
リファレンスを読むとこんなことが書いてありました。

Cloud Bigtable ではデータが非構造化列として行に保存され、各行には行キーがあり、行キーは辞書順で並べ替えられています。

そこで行キーを’timestamp#IDFA'という形式にすることで、探索アルゴリズムの高速化を図りました。
ex)'2017-07-03 10:57:23 +0000 UTC#xxxxxxxxxxxxxx'

StackdriverLoggingとAPIのつなぎ込み

以下の数行コードで通常のstring型ログの書き込みが可能です。
Payloadというinterface{}型フィールドに文字列を渡します。

import "cloud.google.com/go/logging"
var logClient *logging.Client

logger := logClient.Logger("テストログ")
logger.Log(logging.Entry{Payload: "テストテキスト"})

StackdriverLoggingはBigQueryで爆速集計するために採用したため、構造化したログを渡すことが必須でした。
BigQueryにはJsonPayloadという型があるため、Payloadフィールドにjsonで渡すのが最適解なのでは?という仮説のもとリファレンスを漁りましたが、のってませんでした(;´Д`)
GCPソースコードを読んでみました

cloud.google.com/go/logging/logging.go 506~509行目

// Payload must be either a string or something that
// marshals via the encoding/json package to a JSON object
// (and not any other type of JSON value).
Payload interface{}

jsonオブジェクト渡せってことかな??
早速以下のように構造体をエンコードして[]byte型に変換!

import (
        "encoding/json"
        "cloud.google.com/go/logging"
)
type Info struct {
    Latitude  string `json:"latitude"`
    Longitude string `json:"longitude"`
    DeviceID  string `json:"device_id"`
    SysName   string `json:"sysname"`
    SysVer    string `json:"sysver"`
    Timestamp string `json:"timestamp"`
}
info := &Info{}
i, err := json.Marshal(info)
logger := logClient.Logger(logName)
logger.Log(logging.Entry{Payload: i})

エラー!!

深くまでソースを見てみると、
cloud.google.com/go/logging/logging.go 637行目

jb, err := json.Marshal(v)

Payloadフィールドの値を最終的にエンコードしてる!!
普通に構造体渡せば良かったのね!!!

import "cloud.google.com/go/logging"
type Info struct {
    Latitude  string `json:"latitude"`
    Longitude string `json:"longitude"`
    DeviceID  string `json:"device_id"`
    SysName   string `json:"sysname"`
    SysVer    string `json:"sysver"`
    Timestamp string `json:"timestamp"`
}
info := &Info{}
logger := logClient.Logger(logName)
logger.Log(logging.Entry{Payload: info})

無事StackdriverLoggigにjsonログが流れました。
BIgQueryには1カラムに複数のデータを入れることが出来るRECORD型というデータ型があります。
jsonログをStackdriverLoggingに流すことで自動的にRECORD型のカラムを生成してくれます。(設定は必要)

オペレーション

開発時間が終わり、いよいよデータ送信の時間になりました。
すごい速度でリクエストが流れ、興奮していたのも束の間。

http: Accept error: accept tcp [::]:8080: accept4: too many open files

開始30分後、テスト時には発生しなかったエラーが!!
TCPが開いたままになってるのかな??と思いプログラムコードを確認したところ、リクエストボディを閉じ忘れていました。

func collect(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
        // some codes...
}

すぐにデプロイ!
Goはシングルバイナリを上げればいい!素晴らしい!

http: Accept error: accept tcp [::]:8080: accept4: too many open files

なぜだ、、、
KeepAliveオフにしたら解決するかな、、
net/http/request.go 203~206行目

// For client requests, setting this field prevents re-use of
// TCP connections between requests to the same hosts, as if
// Transport.DisableKeepAlives were set.
Close bool

Closeという公開フィールドをfalseにすればDisableKeepAlivesにできるっぽい!
関数が用意されてない時点で非推奨だと思うけど、やるしかない!

func collect(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
        // some codes...
        r.Close = false
}
http: Accept error: accept tcp [::]:8080: accept4: too many open files

1分で死にました。
結局データ送信中は、人間が常にstatusを監視するという力技で乗り切りました笑
大会終了後に社員さんに聞いたところ、linuxのファイルディスクリプタ数(同時に開けるファイル数)の上限を上げれば解決したそうです。 何が起きていたかというと、

  • ファイルopenしてはcloseを繰り返す
  • リクエストが急増するとcloseがopenに追いつかず、too many open filesが発生

勉強になりました。
このような、知らないと解けない問題を解けるエンジニアになりたいです。

まとめ

とにかく死ぬほど楽しかったので、サーバーサイドに興味ある学生さんは絶対参加するべきだと思います!
技術的なことしか書きませんでしたが、チームビルディング等、多くのことを学びました。
また参加したいと思います!

*インフラ初体験の大学生2年生が書いてるので、間違いがあるかもしれません!
もし見つけたら指摘して頂けるとありがたいです。

優勝やったー!

f:id:NakaWatch:20171024221118j:plain

f:id:NakaWatch:20171024221317j:plain