文化を回す順番が回ってきた。あるいは、ソフトウェアエンジニアとして受け取ってきたものの記録

去年から仕事で新卒の子を二人見てきた。二人とも優秀だなぁと思うのと同時に、自分が見られる側ではなく見ている側になっていた事実から私にも順番が回ってきたなと明確に意識をし始めた。ここでいう順番とはソフトウェアエンジニアの文化を次世代へ伝える順番である。

退屈だと思われることを承知で自分語りをさせてほしい。

Linuxとプログラミングに没頭し文化を受け取った学生時代

私は学生時代にLinuxを触り、UNIX的な考え方やソフトウェアの思想に触れた。そこでは教員からUNIXという考え方、ソフトウェアとは何かというものを教わった。 当初Windowsしか知らなかった私はLinuxの思想にハマって先輩や教員に質問に質問を重ねていた。教員や先輩もめんどそうな顔をしながらも可愛がってもらったことを覚えている。

特別扱いしてもらったのか、春休みの間に学内に立てていたOpenPNEの更新を手伝ったことはもう15年も前だけど少し誇らしかった。

年上の教員からハッカーとは素晴らしいものだと力説されたが、この時点ではピンときていなかった。ここで文化を受け取ったなぁと振り返ると思う。

ハッカー文化の門を潜った瞬間

学生時代からLinuxを触って他の新卒より多少プログラミングができることに親近感を覚えたのか、職場のボスからあるDVDを借りた。 もうタイトルも思い出せず内容も霞がかっているが、Mozilla、旧Netscapeの人たちがOSSというものを生み出した。そいつらはOSSを使って世の中をハックしている。俺たちもOSSを盛り上げて世の中をハックしていこうぜという内容だったと思う。

そこに感銘を受けた私はOSSのコードを読み漁るようになった。当時触っていたPHPフレームワークEthnaSymfony辺りだったと記憶している。 職場のボスは正直私のことはどうでも良く共通の話題が欲しかっただけだったんじゃないかと推測をしている。一方でここで文化を広げる火を受け取ったと思っている。

ここで受け取った火のお陰で同世代より多少は良い報酬を貰えて、飯を食えているので感謝をしている。

上の世代の口癖の正体

そこからブラック企業に入り、心身を壊した後に拾ってもらった会社では上の世代の人から色んな支援を受けた。 ほとんどの人は見て見ぬふりをしていた。それは当然のことだろう。そんな中でも見ていられなかったのか・業務上の判断としてか、一部の人は支援をしてくれた。

それは食事を奢ってもらうという金銭的なこと、ミスした仕事を裏で助けてくれたこと、心身の不調をサポートしてくれたことなど本当に色んな支援を受けた。

ある程度復活して返そうとすると総じて皆こう言っていた。

次の若者に回してくれ

当時は「いい人だな」「ありがたいな」「こういう人がいるんだな」ぐらいに思っていたが、これは一方向の善意ではなく、受け取った者が次に渡すことを前提にした文化だったんだなと確信を抱いている。

この言葉はただ優しくしろとか面倒を見ろではない。若手が潰れるとチームは弱くなる、組織文化は途切れる、次が育たない、最終的に自分たちも孤立するという打算もあるはず。 それでも仕事への向き合い方や思想・判断基準を超えた何かを途切れさせるなって意味なんだろうなと考えている。

おそらく上の世代も私と同じように、何かを受け取ってきたのだろう。そして自分が受け取ったものをここで断ち切らせないというほとんど信仰に近い倫理を引き受けていたのだと思う。

それはソフトウェアエンジニアの文化に限らず、もっと大きな、人から人へと受け渡されていく継承の文化の火を灯し続けたい。という思いや願いだったのではないか?と今は振り返っている。

文化を回す順番が回ってきた

そして今、私は仕事で若者を見ている。そこで思ったことは自分が主役として与えられる側だった順番が終わり、上の世代がやっていた文化を次世代へ回す順番が回ってきた。あの時の上の世代の人たちと同じ立場に立っている。

私のハッカー思想を誰かに押し付けたいわけではない。ただ、文化の支援を受けてきた一人として、受け取った火をここで止めたくはないと思っている。 例えば、ミスを公開処刑にさせないこと、困っていそうなときに一言だけ声をかけること、背中で守ること。そうした小さな判断の積み重ねが文化なんだと思う。

これはそういう立場になった、というだけの話。そしてそれがうまくできているかどうかは、きっと次の世代が決めることなのだと思う。

Realize から Air に移行し、 Go(echo) + Air + docker-compose でホットリロードを利用して開発を行う。

この記事は Go 2 Advent Calendar 2020 の4日目の記事です。

tl;dr

  • Goでホットリロード(a.k.a Live reload)を利用する際には今までは Realize というライブラリを使っている人が多いが、開発が止まっている。
  • 代わりに Air というライブラリを利用してホットリロードを行う。

Go + docker-composeを利用してホットリロードを利用した開発を行いたいと検索をして一番多かったのがrealizeというライブラリだった。

github.com

が、開発が停滞しているようで、issueも溜まっており、go modの対応もされておらず、2020/10/25 現在の環境で新規に作ろうとすると、いくつもハマる点があった。

他にライブラリは無いかといくつか調べたところ、Airというライブラリが開発も頻繁にされており、star数も多く筋が良さそうだったので、こちらを利用することにした。

github.com

利用方法

github.com

Usageにもあるが、

  1. .air.toml ファイルを作成する
  2. repositoryの.air_example.toml をコピーペーストし、自分の環境に合わせる
  3. コマンドを実行する air

.air.tomlの設定について

repositoryにexampleがあるので、それを参考にする。

air/air_example.toml at 71d8bf298b426e631c7f51947e8d706246b4ff16 · cosmtrek/air · GitHub

設定ファイルもそんなに難しくなく、ある程度はわかると思う。特に重要な点は

  1. cmd
  2. bin

cmd は自分のGo Appをbuildするコマンドを記述する。 bincmd でbuildしたbinaryのpathを記述する

実際に利用するシーンを見たほうが早いので、GitHubにpushしたコードを参考に構築する。

github.com

ディレクトリ構成は下記

$ tree -I frontend
.
├── LICENSE
├── README.md
├── backend
│   ├── Dockerfile
│   ├── cmd
│   │   └── app
│   │       └── main.go
│   ├── go.mod
│   ├── go.sum
│   ├── handlers
│   │   ├── handler.go
│   │   ├── root.go
│   │   └── routes.go
│   ├── models
│   └── repositories
└── docker-compose.yml

6 directories, 10 files

実際にホットリロードを行う。今回の場合の.air.tomlの構成は下記

go-next.js-playground/.air.toml at 0aaad99873a2b42821912c9d2c96cfa1260d8eaf · teitei-tk/go-next.js-playground · GitHub

$ cd backend/
$ go get -u github.com/cosmtrek/air
$ air -c .air.toml

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ v1.12.1 // live reload for Go apps, with Go1.14.0

watching .
watching cmd
watching cmd/app
watching handlers
watching models
watching repositories
!exclude tmp
building...
running...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.1.16
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:5000

$ curl localhost:5000
{"result":true}

handlers/root.go に手を入れてみる。

$ air -c .air.toml

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ v1.12.1 // live reload for Go apps, with Go1.14.0

watching .
watching cmd
watching cmd/app
watching handlers
watching models
watching repositories
!exclude tmp
building...
running...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.1.16
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:5000
handlers/root.go has changed
building...
running...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.1.16
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:5000
$ $ curl localhost:5000
{"result":false}

JSON Responseが変わっていることがわかる。

docker-compose

ここまではローカル環境で行ってきたが、目的としてContainer上で行いたいのでdocker-composeで行ってみる。

Dockerfile

docker-compose.yml

$ docker-compose build api
Building api
Step 1/7 : FROM golang:1.15-alpine as base
 ---> b3bc898ad092
Step 2/7 : WORKDIR /app/go/base
 ---> Using cache
 ---> 99b11653d662
Step 3/7 : COPY go.mod .
 ---> 8d6ca0f99b03
Step 4/7 : COPY go.sum .
 ---> 72e5d889c991
Step 5/7 : RUN go mod download
 ---> Running in 5b61f9b803ee
Removing intermediate container 5b61f9b803ee
 ---> 05068d1b4923
Step 6/7 : RUN go get -u github.com/cosmtrek/air
 ---> Running in dbefc40ddd77
go: downloading github.com/cosmtrek/air v1.21.2
go: github.com/cosmtrek/air upgrade => v1.21.2
go: downloading github.com/fsnotify/fsnotify v1.4.9
go: downloading github.com/creack/pty v1.1.9
go: downloading github.com/imdario/mergo v0.3.8
go: downloading github.com/pelletier/go-toml v1.6.0
go: downloading github.com/fatih/color v1.7.0
go: github.com/pelletier/go-toml upgrade => v1.8.1
go: github.com/mattn/go-colorable upgrade => v0.1.8
go: github.com/mattn/go-isatty upgrade => v0.0.12
go: github.com/imdario/mergo upgrade => v0.3.11
go: github.com/fatih/color upgrade => v1.9.0
go: github.com/creack/pty upgrade => v1.1.11
go: golang.org/x/sys upgrade => v0.0.0-20201022201747-fb209a7c41cd
go: downloading github.com/fatih/color v1.9.0
go: downloading github.com/imdario/mergo v0.3.11
go: downloading github.com/creack/pty v1.1.11
go: downloading golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd
go: downloading github.com/pelletier/go-toml v1.8.1
go: downloading github.com/mattn/go-colorable v0.1.8
Removing intermediate container dbefc40ddd77
 ---> 7a9749558df6
Step 7/7 : COPY . .
 ---> 37c82f03a414

Successfully built 37c82f03a414
Successfully tagged go-nextjs-playground_api:latest

$ docker-compose up api
Recreating go-nextjs-playground_api_1 ... done
Attaching to go-nextjs-playground_api_1
api_1    |
api_1    |   __    _   ___
api_1    |  / /\  | | | |_)
api_1    | /_/--\ |_| |_| \_ v1.12.1 // live reload for Go apps, with Go1.14.0
api_1    |
api_1    | mkdir /app/go/base/tmp
api_1    | watching .
api_1    | watching cmd
api_1    | watching cmd/app
api_1    | watching handlers
api_1    | watching models
api_1    | watching repositories
api_1    | !exclude tmp
api_1    | building...
api_1    | running...
api_1    |
api_1    |    ____    __
api_1    |   / __/___/ /  ___
api_1    |  / _// __/ _ \/ _ \
api_1    | /___/\__/_//_/\___/ v4.1.16
api_1    | High performance, minimalist Go web framework
api_1    | https://echo.labstack.com
api_1    | ____________________________________O/_______
api_1    |                                     O\
api_1    | ⇨ http server started on [::]:5000
$ curl localhost:5000
{"result":true}

また handler/root.go のコードを変える。今回はJSON Responseの値をtrueからfalseに変更する。

api_1    | handlers/root.go has changed
api_1    | building...
api_1    | running...
api_1    |
api_1    |    ____    __
api_1    |   / __/___/ /  ___
api_1    |  / _// __/ _ \/ _ \
api_1    | /___/\__/_//_/\___/ v4.1.16
api_1    | High performance, minimalist Go web framework
api_1    | https://echo.labstack.com
api_1    | ____________________________________O/_______
api_1    |                                     O\
api_1    | ⇨ http server started on [::]:5000
$ curl localhost:5000
{"result":false}

Containerを起動したまま、レスポンスが変わっていること(ホットリロードされていること)がわかる。

まとめ

  • Realizeは開発が停滞しており、今後扱うと負債になる可能性が高い。
  • 代わりにAirというライブラリを利用することで、ホットリロード (a.k.a ライブロード) を利用することが出来るのでそちらを利用する。

ref

参考Project

github.com

各種PR

github.com

github.com

階層構造(a.k.a ツリー構造・ディレクトリ構造・フォルダ)をDBでどう設計すべきか

SQLアンチパターン第2章を自分なりに噛み砕く。

階層構造とは

いわゆるTree Strcture(木構造)。

例として上げるのであれば、

ここではフォルダを想定する。

隣接リスト(Adjacency List)

Tree-Structure/adjacency_list at main · teitei-tk/Tree-Structure · GitHub

単純に考えると、各フォルダに親のフォルダを参照させるやり方が想定される。 しかし、このままだとフォルダ構成が深い場合、単一のSQLで取得することが難しい。実際に見てみる。

フォルダ構成は下記

f:id:teitei_tk:20201126221016p:plain

table

CREATE TABLE IF NOT EXISTS `adjacency_list` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `parent_id` bigint(20),
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ノードデータ

INSERT INTO `adjacency_list`
  (
    parent_id, name
  )
VALUES
  (
    null, "Home"
  ),
  (
    1, "Downloads"
  ),
  (
    2, "20201126"
  ),
  (
    1, "Desktop"
  ),
  (
    1, "Documents"
  ),
  (
    5, "Adobe"
  ),
  (
    6, "PremierePro"
  ),
  (
    6, "PhotoShop"
  )

このデータを一括で取得する場合は下記のSQLになる。

SELECT
  *
FROM
  adjacency_list as al
LEFT OUTER JOIN
  adjacency_list as al2
ON
  al.parent_id = al2.id
LEFT OUTER JOIN
  adjacency_list as al3
ON
  al2.parent_id = al3.id
LEFT OUTER JOIN
  adjacency_list as al4
ON
  al3.parent_id = al4.id

結果は下記の通りになる。

id parent_id name id parent_id name id parent_id name id parent_id name
1 NULL Home NULL NULL NULL NULL NULL NULL NULL NULL NULL
2 1 Downloads 1 NULL Home NULL NULL NULL NULL NULL NULL
3 2 20201126 2 1 Downloads 1 NULL Home NULL NULL NULL
4 1 Desktop 1 NULL Home NULL NULL NULL NULL NULL NULL
5 1 Documents 1 NULL Home NULL NULL NULL NULL NULL NULL
6 5 Adobe 5 1 Documents 1 NULL Home NULL NULL NULL
7 6 PremierePro 6 5 Adobe 5 1 Documents 1 NULL Home
8 6 PhotoShop 6 5 Adobe 5 1 Documents 1 NULL Home

クエリを見ての通り階層分 n回 LEFT OUTER JOINで直近の子と親を取得しているが、 このクエリのままではすべてのフォルダを一括で取得することが出来ない。やるとしても大量かつ複雑なSQLが必要になる。

この隣接リストモデルの代替となるものには、下記のような設計がある。

  • 経路列挙モデル(Path Enumeration)
  • 入れ子集合モデル(Nested Set)
  • 閉包テーブルモデル(Closure Table)など

順に解説をする

経路列挙 (Path Enumeration)

Tree-Structure/path_enumeration at main · teitei-tk/Tree-Structure · GitHub

経路列挙モデル(Path Enumeration)は先祖の系譜を表す文字列をノードとして扱うことで解決している。 具体的にはUnixディレクトリ構造。eg. /bin/sh

テーブル定義

CREATE TABLE IF NOT EXISTS `path_enumeration` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `path` varchar(1000),
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ノードデータ

INSERT INTO `path_enumeration`
  (
      path, name
  )
VALUES
  (
      "1/", "Home"
  ),
  (
      "1/2/", "Downloads"
  ),
  (
      "1/2/3/", "20201126"
  ),
  (
      "1/4/", "Desktop"
  ),
  (
      "1/5/", "Documents"
  ),
  (
      "1/5/6/", "Adobe"
  ),
  (
      "1/5/6/7/", "PremierePro"
  ),
  (
      "1/5/6/8/", "PhotoShop"
  );
結果
id path name
1 1/ Home
2 1/2/ Downloads
3 1/2/3/ 20201126
4 1/4/ Desktop
5 1/5/ Documents
6 1/5/6/ Adobe
7 1/5/6/7/ PremierePro
8 1/5/6/8/ PhotoShop

例として、PhotoShopディレクトリのすべての先祖を取得するクエリは下記のようになる。

SELECT
    pe.*,
    CONCAT(pe.path, "%") as LikeQuery
FROM
    path_enumeration AS pe
WHERE
    "1/5/6/8" LIKE CONCAT(pe.path, "%");
結果
id path name LikeQuery
1 1/ Home 1/%
5 1/5/ Documents 1/5/%
6 1/5/6/ Adobe 1/5/6/%
8 1/5/6/8/ PhotoShop 1/5/6/8%

CONCAT(pe.path, "%")が味噌。クエリ結果にLikeQueryを表示しているのでわかると思うが先祖のパスがLIKEパターンマッチしている。具体的には下記のノードと一致する。

  • 1/%
  • 1/5/%
  • 1/5/6/%

子孫を取得する場合にはLIKE句の引数を逆にする。

SELECT
    pe.*,
    CONCAT("1/5/", "%") AS LikeQuery
FROM
    path_enumeration AS pe
WHERE
    pe.path LIKE CONCAT("1/5/", "%");
結果
id path name LikeQuery
5 1/5/ Documents 1/5/%
6 1/5/6/ Adobe 1/5/%
7 1/5/6/7/ PremierePro 1/5/%
8 1/5/6/8/ PhotoShop 1/5/%

1/5/%は下記のノードと一致する。

  • 1/5/
  • 1/5/6/
  • 1/5/6/7/
  • 1/5/6/8/

ノードの挿入

Home以下にMusicというノードを挿入する。

Query
INSERT INTO `path_enumeration`
    (
        name
    )
VALUES 
    (
        "Music"
    );

Update
    `path_enumeration`
SET
    path = CONCAT((
        SELECT
            x.path
        FROM (
            SELECT
                path
            FROM
                path_enumeration
            WHERE
                id = 1
        ) as x
    ), LAST_INSERT_ID())
WHERE
    id = LAST_INSERT_ID();

主キーがAutoIncremntの場合、挿入時点では自分のIDがわからないのでpathを構築出来ない。一旦pathを除いたノードを挿入し、Update文でpathを更新する。

デメリット

  • pathの管理はアプリケーションのコードに依存することになる。validationが複雑になりメンテンス性の心配が残る。
  • pathの型はvarchar型で、無限に保存出来るわけではない。

入れ子集合(Nested Set)

Tree-Structure/nested_set at main · teitei-tk/Tree-Structure · GitHub

入れ子集合(Nested Set)は、直近の親ではなく、子孫の集合に関する情報を各ノードに保存する。各ノードのnsleftおよびnsrightと呼ばれる数値で階層を表現する。

テーブル定義

CREATE TABLE IF NOT EXISTS `nested_set` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `nsleft` integer NOT NULL,
  `nsright` integer NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ノードデータ

INSERT INTO `nested_set` 
    (
        name, nsleft, nsright
    )
VALUES
    (
        "Home", 1, 15
    ),
    (
        "Downloads", 2, 5
    ),
    (
        "20201126", 3, 4
    ),
    (
        "Desktop", 6, 7
    ),
    (
        "Documents", 8, 14
    ),
    (
        "Adobe", 9, 12
    ),
    (
        "PremierePro", 10, 11
    ),
    (
        "PhotoShop", 12, 13
    )
id name nsleft nsright
1 Home 1 15
2 Downloads 2 5
3 20201126 3 4
4 Desktop 6 7
5 Documents 8 14
6 Adobe 9 12
7 PremierePro 10 11
8 PhotoShop 12 13

例として、Documents以下のノードを取得する場合は下記のクエリになる。

SELECT 
    s2.*
FROM
    nested_set as s1
INNER JOIN 
    nested_set as s2
ON
    s2.nsleft BETWEEN s1.nsleft AND s1.nsright
WHERE
    s1.id = 5

BEETWEENで範囲指定しているところが味噌。この例で言うと、id = 5であるDocumentsnsleft.8とnsright.14の間にある、nsleftの値を持っているすべての子孫ノードを取得することが出来る。

id name nsleft nsright
5 Documents 8 14
6 Adobe 9 12
7 PremierePro 10 11
8 PhotoShop 12 13

先祖を取得する場合は逆にする。

SELECT 
    s1.*, s2.*
FROM
    nested_set as s1
INNER JOIN 
    nested_set as s2
ON
    s1.nsleft BETWEEN s2.nsleft AND s2.nsright
WHERE
    s1.id = 5

id = 5であるDocumentsnsleft.8の値が nsleftnsrightの間にあるすべてのノードを取得出来る。この例でいうと、nsleftが1でnsrightが15であるHomeが該当する。

デメリット

  • ノードの挿入や移動などの際にツリー構造の操作が他のモデルに比べて複雑になる。そのためノードの挿入が頻繁に求められる場合は最適解にはならない。
    • ノードの挿入では新しいノードの左(nsleft)の値より大きいすべての左右のノードの値を計算し直す必要がある。

閉包テーブル(Closure Table)

Tree-Structure/closure_table at main · teitei-tk/Tree-Structure · GitHub

閉包テーブル(Closure Table)は今までもモデルとは違い直接の親子関係だけではなく、ツリー全体のパスをテーブルに格納する。

テーブル定義

今までのようなフォルダの定義に加えて、TreePathというテーブル定義する。

CREATE TABLE IF NOT EXISTS `closure_table` (
  `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE IF NOT EXISTS `tree_paths` (
  `ancestor` BIGINT(20) UNSIGNED NOT NULL,
  `descendant` BIGINT(20) UNSIGNED NOT NULL,
  PRIMARY KEY (`ancestor`, `descendant`),
  FOREIGN KEY (`ancestor`) REFERENCES closure_table(`id`),
  FOREIGN KEY (`descendant`) REFERENCES closure_table(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

閉包テーブルでは今までモデルとは違い、ツリー構造のデータをTreePathsテーブルに保存する。このテーブルの各行には先祖(ancestor)/子孫(descendant)を共有するノードの組み合わせを保存する。これはツリー状の離れた位置にあるノードも含めたすべてのノードが対象になる。また、各行のノードには自分自身を参照するレコードも追加する。

ノードデータ

INSERT INTO `closure_table`
  (
    name
  )
VALUES
  (
    "Home"
  ),
  (
    "Downloads"
  ),
  (
    "20201126"
  ),
  (
    "Desktop"
  ),
  (
    "Documents"
  ),
  (
    "Adobe"
  ),
  (
    "PremierePro"
  ),
  (
    "PhotoShop"
  );

INSERT INTO `tree_paths` 
  (
    ancestor, descendant
  )
VALUES
  (
    1, 1
  ),
  (
    1, 2
  ),
  (
    1, 3
  ),
  (
    1, 4
  ),
  (
    1, 5
  ),
  (
    1, 6
  ),
  (
    1, 7
  ),
  (
    1, 8
  ),
  (
    2, 2
  ),
  (
    2, 3
  ),
  (
    3, 3
  ),
  (
    4, 4
  ),
  (
    5, 5
  ),
  (
    5, 6
  ),
  (
    5, 7
  ),
  (
    5, 8
  ),
  (
    6, 6
  ),
  (
    6, 7
  ),
  (
    6, 8
  ),
  (
    7, 7
  ),
  (
    8, 8
  );
closure_table
id name
1 Home
2 Downloads
3 20201126
4 Desktop
5 Documents
6 Adobe
7 PremierePro
8 PhotoShop
tree_paths
ancestor descendant
1 1
1 2
2 2
1 3
2 3
3 3
1 4
4 4
1 5
5 5
1 6
5 6
6 6
1 7
5 7
6 7
7 7
1 8
5 8
6 8
8 8

f:id:teitei_tk:20201128185710p:plain

ノードを取得するクエリは入れ子集合よりも更に簡単になる。ID=2Downloads以下を取得するにはtree_pathsをJoinして、anestor(先祖)が2の行を探すだけ。

SELECT
    c.* 
FROM
    closure_table as c
INNER JOIN
    tree_paths as t
ON 
    c.id = t.descendant
WHERE
    t.ancestor = 2
result
id name
2 Downloads
3 20201126

先祖の取得も簡単で、先程のクエリのancestordescendantを逆に書くだけ。ID=8の先祖を取得する。

SELECT
    c.*
FROM
    closure_table as c
INNER JOIN
    tree_paths as t
ON
    c.id = t.ancestor
WHERE
    t.descendant = 8
result
id name
1 Home
5 Documents
6 Adobe
8 PhotoShop

新たにノードを挿入する場合、例えばID=4Desktop以下にノードを挿入するには、まずclosure_tableにフォルダのデータを挿入する。

INSERT INTO `closure_table`
(
    name
)
VALUES
(
    "Macintosh HD"
)

次にID=4を子孫として参照する行の集合を取得し、子孫をLAST_INSERT_ID()で置き換えて新たに挿入する。

INSERT INTO `tree_paths` 
(
    ancestor, descendant
)
SELECT
    t.ancestor, LAST_INSERT_ID() as descendant
FROM
    tree_paths as t
WHERE
    t.descendant = 4
UNION ALL
    SELECT LAST_INSERT_ID(), LAST_INSERT_ID()

INSERT句で利用しているSELECT句の結果は下記になる。

ancestor descendant
1 9
4 9
9 9

となっていることがわかる。

ノードを削除する場合、例えば先程挿入した`ID=9'を削除する場合には、子孫として9を参照するすべての行を削除する。具体的に削除するレコードは上記の結果である。

DELETE FROM 
    `tree_paths`
WHERE
    descendant = LAST_INSERT_ID();

サブツリー全体、例えばID=6とその子孫をすべて削除するには、子孫としてID=6を参照する全ての行と、ID=6の子孫を子孫として参照するすべての行を削除する。

具体的には下記のSQLになる

DELETE FROM 
    `tree_paths`
WHERE
    descendant IN (
        SELECT
            x.id
        FROM
            (
                SELECT
                    descendant as id
                FROM
                    `tree_paths`
                WHERE
                    ancestor = 6
            ) as x
    )

DELETE文の条件に利用しているSELECT文の結果は下記

SELECT
    x.id
FROM
(
    SELECT
        descendant as id
    FROM
        `tree_paths`
    WHERE
        ancestor = 6
)
結果
id
6
7
8

IDが6, 7, 8と、ID=6以下のツリーが削除されることがわかる。

結局どの設計を使うべきなのか

SQLアンチパターンに記述があるのでそちらを引用します

設計 子へのクエリ実行 ツリーへのクエリ実行 挿入 削除 参照整合性維持
隣接リスト 簡単 難しい 難しい 簡単 可能
経路列挙モデル 簡単 簡単 簡単 簡単 不可
入れ子集合モデル 難しい 難しい 難しい 難しい 不可
閉包テーブルモデル 簡単 簡単 簡単 簡単 可能

個人的な意見

仕様にもよりますが、消去法で閉包テーブル設計かなとなります。

隣接リスト

単純に筋が悪い設計だと考えている。要件がかなり簡易な場合(一つの親しか持たない)にはありかもしれないが、そのような仕様は時間が経つに連れて変わっていくし、すぐに新しく親を設計したいと出てくる。それを考えると別の手法を設計すべきと考える。

経路列挙

筋は良いが、経路列挙のpathの管理が辛そうと考えている。アプリケーション側でpathのValidationなどが長大になりそうなのを考えるとメンテンス性に欠ける。メンテンス性に欠けるということはバグの温床になりやすい。そう考えると閉包テーブルモデルの方が良いと考える。

また、pathに複数の値(フォルダの経路)を格納する事はSQLアンチパターンのジェイウォークに当てはまる。

www.slideshare.net

入れ子集合モデル

ノードの挿入や移動などの際にツリー構造の操作が他のモデルに比べて複雑になるのが欠点。挿入や更新が多い要件には向いていない。レコード数が大量にあると更新に時間がかかる。個人的にはクエリが複雑になる事が一番の欠点。コードもクエリも設計もシンプルに収めたほうが良いと考える人間なので、筋は良いが、それなら閉包テーブルモデルを採用したほうが良いと判断。

閉包テーブル

ツリー構造を格納するテーブルのレコードが爆発的に増えるが、その分取得、挿入、更新、削除もシンプルに出来る。トレードオフだが、複雑性が増して不具合の温床になるよりはレコード数が増えるデメリットを受け入れたほうが長期的に見て良いと思っている。

Docker with Multi Stage Buildを利用したNext.jsのDockerイメージを作る

tl;dr

  • Next.jsのSSG(Static Site Generator)を利用したDocker Imageを作る際にMulti Stage BuildでImageを作ると、利用しない場合に比べて453MB変わる

Dockerfile

以前とは違い、Production環境でも利用出来るように構築をした。SSGを利用する前提で書いている。 ただ、実際にはSSGをProductionで利用する場合はNetlifyやVercel、S3など静的コンテンツを置くのに適したプラットフォームを利用すると思うので、あまり意味はないかもしれない。

gist.github.com

それぞれbase, builder, productionとステージを分けている。

base

依存関係にあるnpm packageをインストールする。開発環境ではbase stageを利用し、docker build時の引数でnext コマンドを指定するだけで良い。

$ docker build -t next-app:base --target base .
Sending build context to Docker daemon  177.8MB
Step 1/7 : FROM node:14-alpine as base
 ---> fa2fa5d4e6f4
Step 2/7 : WORKDIR /app/js/base
 ---> Using cache
 ---> 3bcd585979e7
Step 3/7 : COPY package.json .
 ---> Using cache
 ---> 73e62066ac85
Step 4/7 : COPY yarn.lock .
 ---> Using cache
 ---> d6ed6b01b3ed
Step 5/7 : COPY tsconfig.json .
 ---> Using cache
 ---> 7a8ae367da60
Step 6/7 : RUN yarn install
 ---> Using cache
 ---> dd874aced5fe
Step 7/7 : COPY . .
 ---> Using cache
 ---> 5ad130f7577a
Successfully built 5ad130f7577a
Successfully tagged next-app:base

$ docker run -it --rm -p 3000:3000 next-app:base yarn next
yarn run v1.22.5
$ /app/js/base/node_modules/.bin/next
ready - started server on http://localhost:3000
info  - ready on http://localhost:3000
event - build page: /
wait  - compiling...
A codemod is available to fix the most common cases: https://nextjs.link/codemod-ndc
info  - ready on http://localhost:3000

builder

アプリケーションをbuildする層。この層でアプリケーションbuildしproduction stageへは不要なもの(node_modules, ソースコード etc)を持ち込まないようにする。

production

Production環境に適応したイメージを作る層。ここで不要なファイルが作成されるとその分Imageサイズが大きくなるので不要なものは入れてはいけない。 このstageでbuildをしてしまうとソースコードなど不要なものが必要になるので、一つ前のbuilder stageから必要なものをコピーする。

最後のyarn install --productionでproductionに必要な依存関係のみをインストールする。

結果

Multi Stage Buildを利用した場合と利用しない場合で比較をしてみる。

Multi Stage Buildを利用する

$ docker build -t next-app:base . --target base
Sending build context to Docker daemon  194.2MB
Step 1/7 : FROM node:14-alpine as base
 ---> fa2fa5d4e6f4
Step 2/7 : WORKDIR /app/js/base
 ---> Using cache
 ---> 7a04d844b125
Step 3/7 : COPY package.json .
 ---> Using cache
 ---> ab5127932992
Step 4/7 : COPY yarn.lock .
 ---> Using cache
 ---> e8ba5b6cb1ab
Step 5/7 : COPY tsconfig.json .
 ---> Using cache
 ---> 10b359198a18
Step 6/7 : RUN yarn install
 ---> Using cache
 ---> affbace5d646
Step 7/7 : COPY . .
 ---> Using cache
 ---> ce9e93809dc2
Successfully built ce9e93809dc2
Successfully tagged next-app:base

builder stage

$ docker build -t next-app:builder . --target builder
Sending build context to Docker daemon  194.2MB
Step 1/12 : FROM node:14-alpine as base
 ---> fa2fa5d4e6f4
Step 2/12 : WORKDIR /app/js/base
 ---> Running in ad2141be97fb
Removing intermediate container ad2141be97fb
 ---> 6d182a28bf59
Step 3/12 : COPY package.json .
 ---> 4a44d0936af9
Step 4/12 : COPY yarn.lock .
 ---> 489f697cba57
Step 5/12 : COPY tsconfig.json .
 ---> b155046ac03c
Step 6/12 : RUN yarn install
 ---> Running in 937d5fb38b0e
yarn install v1.22.5
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.13: The platform "linux" is incompatible with this module.
info "fsevents@1.2.13" is an optional dependency and failed compatibility check. Excluding it from installation.
info fsevents@2.1.3: The platform "linux" is incompatible with this module.
info "fsevents@2.1.3" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
warning " > file-loader@6.0.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
warning " > url-loader@4.1.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
[4/4] Building fresh packages...
Done in 49.13s.
Removing intermediate container 937d5fb38b0e
 ---> 27a2d7a30d34
Step 7/12 : COPY . .
 ---> c19dc009a6a6
Step 8/12 : FROM node:14-alpine as builder
 ---> fa2fa5d4e6f4
Step 9/12 : ENV NODE_ENV=production
 ---> Running in 3bd3364115b5
Removing intermediate container 3bd3364115b5
 ---> 0cfcd40518b3
Step 10/12 : WORKDIR /app/js/builder
 ---> Running in 0e20dd40600f
Removing intermediate container 0e20dd40600f
 ---> 9dac22ae0e11
Step 11/12 : COPY --from=base /app/js/base /app/js/builder
 ---> 208200318cf4
Step 12/12 : RUN ["yarn", "build"]
 ---> Running in ef55ba375411
yarn run v1.22.5
$ next build && next export
Creating an optimized production build...

Compiled successfully.

Automatically optimizing pages...

Page                                                           Size     First Load JS
┌ ● /                                                          9.31 kB        90.9 kB
├   /_app                                                      1.9 kB           60 kB
├ ○ /404                                                       3.25 kB        63.2 kB
└ ● /articles/[id]                                             344 B          81.9 kB
    ├ /articles/1
    └ /articles/2
+ First Load JS shared by all                                  60 kB
  ├ static/pages/_app.js                                       1.9 kB
  ├ chunks/444ecc389d1b2833cd1c90b9f6d2e962a7bb15f6.c2e7b8.js  10.7 kB
  ├ chunks/62e1b0f2.e05b9c.js                                  64 B
  ├ chunks/framework.4dd100.js                                 40.3 kB
  ├ runtime/main.9f98e5.js                                     6.28 kB
  ├ runtime/webpack.c21266.js                                  746 B
  ├ css/7ed58071346528c353bf.css                               97.4 kB
  └ css/cfb837a2694c3ad390b0.css                               108 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)(Static)  automatically rendered as static HTML (uses no initial props)(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

> using build directory: /app/js/builder/.next
  copying "static build" directory
> No "exportPathMap" found in "next.config.js". Generating map from "./pages"
  launching 1 workers
Exporting (0/2)
  copying "public" directory
Exporting (1/2)
Exporting (2/2)

Export successful
Done in 42.64s.
Removing intermediate container ef55ba375411
 ---> ef487ea233e3
Successfully built ef487ea233e3
Successfully tagged next-app:builder

production stage

$ docker build -t next-app:production .
Sending build context to Docker daemon  194.2MB
Step 1/18 : FROM node:14-alpine as base
 ---> fa2fa5d4e6f4
Step 2/18 : WORKDIR /app/js/base
 ---> Running in 141707b10d1b
Removing intermediate container 141707b10d1b
 ---> 7a04d844b125
Step 3/18 : COPY package.json .
 ---> ab5127932992
Step 4/18 : COPY yarn.lock .
 ---> e8ba5b6cb1ab
Step 5/18 : COPY tsconfig.json .
 ---> 10b359198a18
Step 6/18 : RUN yarn install
 ---> Running in 25c5e101426f
yarn install v1.22.5
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.13: The platform "linux" is incompatible with this module.
info "fsevents@1.2.13" is an optional dependency and failed compatibility check. Excluding it from installation.
info fsevents@2.1.3: The platform "linux" is incompatible with this module.
info "fsevents@2.1.3" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
warning " > file-loader@6.0.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
warning " > url-loader@4.1.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
[4/4] Building fresh packages...
Done in 78.52s.
Removing intermediate container 25c5e101426f
 ---> affbace5d646
Step 7/18 : COPY . .
 ---> ce9e93809dc2
Step 8/18 : FROM node:14-alpine as builder
 ---> fa2fa5d4e6f4
Step 9/18 : ENV NODE_ENV=production
 ---> Running in 05b08f765682
Removing intermediate container 05b08f765682
 ---> 43348a423c2a
Step 10/18 : WORKDIR /app/js/builder
 ---> Running in e924b7124b7e
Removing intermediate container e924b7124b7e
 ---> d872eca49f96
Step 11/18 : COPY --from=base /app/js/base /app/js/builder
 ---> 5aa288be27aa
Step 12/18 : RUN ["yarn", "build"]
 ---> Running in c1762e58a430
yarn run v1.22.5
$ next build && next export
Creating an optimized production build...

Compiled successfully.

Automatically optimizing pages...

Page                                                           Size     First Load JS
┌ ● /                                                          9.31 kB        90.9 kB
├   /_app                                                      1.9 kB           60 kB
├ ○ /404                                                       3.25 kB        63.2 kB
└ ● /articles/[id]                                             344 B          81.9 kB
    ├ /articles/1
    └ /articles/2
+ First Load JS shared by all                                  60 kB
  ├ static/pages/_app.js                                       1.9 kB
  ├ chunks/62e1b0f2.e05b9c.js                                  64 B
  ├ chunks/9a9e7c821a17e6790c99026b07c1c46382c721c2.c2e7b8.js  10.7 kB
  ├ chunks/framework.4dd100.js                                 40.3 kB
  ├ runtime/main.9f98e5.js                                     6.28 kB
  ├ runtime/webpack.c21266.js                                  746 B
  ├ css/7ed58071346528c353bf.css                               97.4 kB
  └ css/cfb837a2694c3ad390b0.css                               108 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)(Static)  automatically rendered as static HTML (uses no initial props)(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

> using build directory: /app/js/builder/.next
  copying "static build" directory
> No "exportPathMap" found in "next.config.js". Generating map from "./pages"
  launching 1 workers
Exporting (0/2)
  copying "public" directory
Exporting (1/2)
Exporting (2/2)

Export successful
Done in 51.92s.
Removing intermediate container c1762e58a430
 ---> d264d497a0a5
Step 13/18 : FROM nginx:latest as production
 ---> f35646e83998
Step 14/18 : WORKDIR /app/js/src
 ---> Running in 89ce96f55a15
Removing intermediate container 89ce96f55a15
 ---> fd0fb921aa75
Step 15/18 : RUN rm -rf /etc/nginx/conf.d
 ---> Running in d73698e7006d
Removing intermediate container d73698e7006d
 ---> 85cc19c895e4
Step 16/18 : ADD conf/nginx/conf.d /etc/nginx/conf.d
 ---> 27ee1c3cfac8
Step 17/18 : COPY --from=builder /app/js/builder/out ./out
 ---> 5d4d1bd4881e
Step 18/18 : EXPOSE 80
 ---> Running in ef6862c2ccd5
Removing intermediate container ef6862c2ccd5
 ---> 46fc04190def
Successfully built 46fc04190def
Successfully tagged next-app:production

Multi Stage Buildを利用しない

$ cat Dockerfile_not_msb
FROM alpine:latest

RUN apk update
RUN apk add --update nodejs nodejs-npm yarn

WORKDIR /app/js

COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .

RUN yarn install
COPY . .

RUN ["yarn", "build"]

RUN apk add nginx openrc
RUN rm -rf /etc/nginx/conf.d
ADD conf/nginx/conf.d /etc/nginx/conf.d

EXPOSE 80

$ docker build -t next-app:not_msb -f Dockerfile_not_msb .
Sending build context to Docker daemon    224MB
Step 1/14 : FROM alpine:latest
 ---> a24bb4013296
Step 2/14 : RUN apk update
 ---> Using cache
 ---> 5a65f08f5db7
Step 3/14 : RUN apk add --update nodejs nodejs-npm yarn
 ---> Using cache
 ---> c97ad0a3e846
Step 4/14 : WORKDIR /app/js
 ---> Using cache
 ---> a37951715a8d
Step 5/14 : COPY package.json .
 ---> Using cache
 ---> 6faddfc45d20
Step 6/14 : COPY yarn.lock .
 ---> Using cache
 ---> bd166cc22fbc
Step 7/14 : COPY tsconfig.json .
 ---> Using cache
 ---> f827439198ba
Step 8/14 : RUN yarn install
 ---> Using cache
 ---> 46c450c588da
Step 9/14 : COPY . .
 ---> Using cache
 ---> 7b18d341a97a
Step 10/14 : RUN ["yarn", "build"]
 ---> Using cache
 ---> 578c525c3ce1
Step 11/14 : RUN apk add nginx openrc
 ---> Using cache
 ---> 94434ea28089
Step 12/14 : RUN rm -rf /etc/nginx/conf.d
 ---> Using cache
 ---> 571eedb7cbb5
Step 13/14 : ADD conf/nginx/conf.d /etc/nginx/conf.d
 ---> Using cache
 ---> 1a765adec9ca
Step 14/14 : EXPOSE 80
 ---> Using cache
 ---> 5df8f8cc2b74
Successfully built 5df8f8cc2b74
Successfully tagged next-app:not_msb

結果

$ docker images | grep next-app
next-app                           not_msb                 5df8f8cc2b74        7 minutes ago       614MB
next-app                           builder                 ef487ea233e3        About an hour ago   327MB
next-app                           production              46fc04190def        About an hour ago   161MB
next-app                           base                    ce9e93809dc2        About an hour ago   599MB

まとめ

  • Multi Stage Buildを利用することで一つのDockerfileで開発環境・Production環境をすぐに作ることが出来る。
  • Multi Stage Buildを利用した場合、利用しない場合に比べて453MB変わる。

GitLab CIでTrivyを利用しDocker Imageの脆弱性を検知し、Slackに投稿する

tl;dr

  • Pipeline Schedule を利用し一定周期にscanを実行することで開発を止めずに脆弱性を検知する。
  • Slackに投稿することで脆弱性の検知をメンバーの目に入れることができる。

Trivyについて

昔書いたのでそちらを参照してください。 teitei-tk.hatenablog.com

コードを見せろ

Sample Repository

gitlab.com

Merge Request

gitlab.com

工夫したところ

gitlab-ci.ymlは特殊な事は書いておらず、ほとんどGitHubと、GitLabのサンプルコードをコピーしただけです。

工夫した点としては、Trivyの検知した結果のレポートをJUnitのフォーマットにしています。 GitHubのサンプルコードではGitLabのContainer scanningフォーマットで書かれていますが、Container ScanningはGold/Ultimate Planでないと利用できないため、JUnitフォーマットを利用しています。

今回はあえて、サンプルのため脆弱性のあるRack Gemなど、古いgemを利用しています。

JUnitフォーマットを利用した場合、MergeRequestの画面ではこのように表示されます。 f:id:teitei_tk:20201003212704p:plain

CVE-2019-16782などが表示されていることが確認できると思います。 リンクをクリックすると、CVEの詳細が表示されます。

f:id:teitei_tk:20201003213625p:plain

Schedule Pipelineを利用して一定周期でImageのScanを行う

新しくSchedule Pipelineを設定します。特に難しい点は無いと思うのでので設定は省略。 gitlab-ci.ymlの内容は下記です。特に変な点は書いていませんが、only: schedulesでSchedule Pipelineでのみ実行されるようにしています。

image_scan:
  image: docker:stable
  services:
    - name: docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_DRIVER: overlay2
    # See https://github.com/docker-library/docker/pull/166
    DOCKER_TLS_CERTDIR: ""
    IMAGE: trivy-ci-test:$CI_COMMIT_SHA
  before_script:
    - export TRIVY_VERSION=$(wget -qO - "https://api.github.com/repos/aquasecurity/trivy/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
    - echo $TRIVY_VERSION
    - wget --no-verbose https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz -O - | tar -zxvf -
  script:
    # Build image
    - docker build -t $IMAGE .
    # Fail on high and critical vulnerabilities
    - ./trivy --exit-code 1 --cache-dir .trivycache/ --severity CRITICAL,HIGH,MEDIUM --no-progress $IMAGE
  cache:
    paths:
      - .trivycache/
  only:
    - schedules

設定すると、CI/CDページにてSchduleと表示されていることが確認できると思います。

f:id:teitei_tk:20201003213848p:plain

SlackへScan結果を通知する。

ここも特に難しいことはないです。SlackにてApps -> Incoming WebHooksを追加。WebHook URLをコピーし、GitLabRepositoryのSettings -> CI/CD -> Slack notificationに移動。

Pipelineにチェックを入れ、通知するSlack Channelを記入するだけ。

その後、Schedule Pipelineを実行すると、このようにSlackに通知されます。

f:id:teitei_tk:20201003214742p:plain