- 投稿日:2020-08-20T22:58:31+09:00
ECS上のコンテナアプリをCircleCIでCI/CDする
この記事について
イケてる開発をするためにはCI/CDは欠かせません。
特にAWSを使って本番環境を作っている場合、デプロイ操作をいちいちWebコンソールでぽちぽちしながら行うのは結構面倒です。そのため今回は、ECSを使ったコンテナアプリを、CircleCIを使ってCI/CDパイプラインにのせるための手順、主にCircleCIの設定周りについて詳しく解説します。
使用する環境・バージョン
macOS Mojave 10.14.5
前提条件
GitHub, CircleCI, AWSのアカウントは取得済みの状態から始めます。
読者に要求する前提知識
- ECSへの手動デプロイが問題なくできること、およびECS用語(タスク・サービス等)の理解
- gitの基本的な操作(add, commit, push, リモートリポジトリとの連携)
- CircleCIでのプロジェクト作成(GitHubリポジトリとの連携)ができる
- IAMユーザーの作成ができる
構築するCI/CDパイプライン
今回構築するパイプライン全容は以上の図の通りです。言葉でも説明すると、
- 開発者がアプリのコードをGitHubにpushする
- CircleCIがGitHubにpushされたことを検知、以下3,4の手順でのデプロイを自動で行う
- GitHubにアップされたコードからコンテナイメージをビルド、それをECRの指定リポジトリにpush
- 3でpushされたコンテナイメージを使ったタスク・サービスに自動更新する
これが実現できるように、CircleCIの設定をすることが目標となります。
サンプルアプリの作成
最終的なファイル・ディレクトリ構造
ローカルでの最終的なファイル構造は以下のようになります。
. ├─.circleci │ └─config.yml ├─main.go ├─main_test.go └─Dockerfileアプリのコードを作成
今回は、Goを用いて基本的なウェブサーバーを作りました。
main.goに以下のように記述します。main.gofunc main() { port := "8080" fmt.Printf("Server Listening on port %s\n", port) http.HandleFunc("/", handler) err := http.ListenAndServe(":"+port, nil) if err != nil { log.Fatal("fatal err: ", err) } } func handler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, World!") }アプリのコンテナ化
コードを書いた後は、これをコンテナ化するために
Dockerfileを作成します。FROM golang:1.13 WORKDIR /go/src/app COPY . . CMD ["go", "run", "main.go"]
Dockerfile作成後は、docker buildコマンドを用いてビルドします。
ここではコンテナイメージに一回testserverという名前をつけました。$ docker build -t testserver .コンテナ化したアプリの動作を確認するためには、以下のコマンドを叩いて
http://localhost:8080にアクセスすればOKです。$ docker run -p 8080:8080 testserverECSへの初回デプロイをする
CircleCIでできるのは、「既にデプロイされているアプリを更新・アップデートすること」なので、初回デプロイは手動で済ませておく必要があります。
ECS手動デプロイ時の詳しい手順・設定については記事の本筋から外れてしまうため詳しくは説明せず、CircleCIの設定に絡む部分だけを記述します。
ECSの用語・概念・詳しいデプロイ手段について知りたい方は、別記事にまとめていますのでこちらをご覧ください(下記記事のvoting-appのデプロイがほぼそのままこれに適用できます)。
→Dockerコンテナで作ったアプリをECS+RDSでデプロイする
- ECRリポジトリの作成→コンテナイメージのpush
今回は「cicdtest」という名前のリポジトリを作成し、先ほど作ったサンプルアプリのコンテナイメージをpushしました。- タスク定義作成
先ほどpushしたコンテナイメージを使ってタスク定義を作成します。タスク名を「testserver」、コンテナ名を「testserverContainer」としました。- クラスター作成
今回は「circleciTest」という名前のクラスターにしました。- サービス作成
「CircleCItestService」という名前で、先ほどのタスクを起動するサービスを作りました。CircleCIの設定
初回デプロイが終わったら、これを継続的にアップデートできるようにCircleCIを用いてCI/CDパイプラインを構築していきます。
参考:CircleCI Orbsで ECR/ECS にデプロイ
参考:CircleCI + GitHub + Amazon Elastic Container Registry (Amazon ECR) + Amazon Elastic Container Service (Amazon ECS) (+ AWS Fargate) で継続的デリバリー環境を構成するIAMユーザー作成
CircleCIからAWSのリソースを扱うためには、CircleCI用のIAMユーザーを作成・権限付与をし、アクセスキーを払い出す必要があります。
ロールは以下の2つを付与します。
- AmazonEC2ContainerRegistryFullAccess
- AmazonEC2ContainerServiceFullAccess
作成したIAMユーザーのアクセスキーとシークレットアクセスキーは後ほど使うので、忘れないようにどこかに控えておきます。
configファイル作成
CircleCIの設定ファイルは、
.circleci/config.ymlとして用意します。
このファイルを作成して、以下のように記述します。config.yml# circleciのバージョン。後述するorbsを利用するためには2.1以上が必要 version: 2.1 orbs: # circleci/aws-ecr@6.1.0というorbをaws-ecrというaliasをつけて扱う aws-ecr: circleci/aws-ecr@6.1.0 aws-ecs: circleci/aws-ecs@0.0.8 workflows: # build_and_push_imageという名前のworkflowを定義 build_and_push_image: # workflowで実行するjobの順番を定義 jobs: - aws-ecr/build-and-push-image: region: AWS_REGION account-url: AWS_ECR_ACCOUNT_URL repo: 'cicdtest' tag: "${CIRCLE_SHA1}" - aws-ecs/deploy-service-update: requires: - aws-ecr/build-and-push-image family: 'testserver' cluster-name: 'circleciTest' service-name: 'CircleCItestService' container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'設定内容について詳しく説明します。
CircleCI Orbsとは
本来ならば、CircleCIでのCI/CDを実現するためには、Githubへのpush確認後にどんな操作をするべきかというのを「コマンドベース」で指定していく必要があります。
例えば、今回のような「コードを元にコンテナイメージをビルド→ECRにpush」だったら、
docker buildコマンドでコンテナイメージを作るdocker loginでECRにログインするdocker tagで1で作ったコンテナイメージに、AWS指定の名前・タグ付けを行う- 3で作ったイメージをECRに
docker pushするというように、いちいち何のコマンドを実行するのかをjobという形で定義しなくてはいけませんでした。
しかし、技術者・開発者がCircleCIでやりたい操作というのは皆似たり寄ったりです。そのため、みんながやりそうなjobについては「パッケージ」としてまとめて簡単に利用できるようにしよう、というのがOrbsです。
今回の場合は、「circleci/aws-ecr」と「circleci/aws-ecs」2つのOrbsを導入しています。
config.ymlorbs: aws-ecr: circleci/aws-ecr@6.1.0 aws-ecs: circleci/aws-ecs@0.0.8ただし、2つとも名前が長いので、それぞれ「aws-ecr」「aws-ecs」というエイリアスをつけて、以下のコードで簡単に名前を書けるようにしています。
参考:また車輪の再発明して消耗してるの?CircleCI Orbs使お?
workflowの作成
Orbsを用いて、CircleCIに自動でやって欲しい一連の処理(=workflow)について記述していきましょう。
今回は「build_and_push_image」という名前のworkflowを作成し、このworkflowでは2つのjobを順番に行ってもらうようにしました。job1: aws-ecr/build-and-push-image
先ほど導入したOrbsのうち、aws-ecrの方で提供されている「build-and-push-image」というjobをまず行います。
これはその名の通り、pushされたコードからコンテナイメージをビルド→ECRにpushまでの一連の操作を行うjobです。
どのAWSアカウントのどのECRリポジトリにpushするかの設定を記述します。config.yml- aws-ecr/build-and-push-image: region: AWS_REGION account-url: AWS_ECR_ACCOUNT_URL repo: 'cicdtest' tag: "${CIRCLE_SHA1}"
- region: AWSリージョンの指定。この後与える環境変数
AWS_REGIONで値を渡す。- account-url: 使いたいECRアカウントの指定。環境変数
AWS_ECR_ACCOUNT_URLで値を渡す。- repo: push先のECRリポジトリ名を指定。今回は先ほど作成したECRリポジトリ名cicdtestを指定。
- tag: CircleCIがビルドしたコンテナイメージにどんなタグをつけるかを指定。今回は、commitごとにつくSHA1ハッシュの値をそのままタグにすることで、タグ重複を防止する。
job2: aws-ecs/deploy-service-update
導入したOrbsのうち、aws-ecsの方で提供されている「deploy-service-update」というjobを利用します。これで「ECSのタスク・サービスの更新」を自動でやってくれます。
設定内容は以下の通りです。
config.yml- aws-ecs/deploy-service-update: requires: - aws-ecr/build-and-push-image family: 'testserver' cluster-name: 'circleciTest' service-name: 'CircleCItestService' container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'
- require: aws-ecr/build-and-push-imageのjobをPASSしないとこのjobを実行しないようにする
- family: 更新したいタスク名
- cluster-name: 更新したいサービスがあるクラスター名
- service-name: 更新したいサービス名
- container-image-name-updates: familyで指定したタスクの中で、どのコンテナを更新するかを指定。ここでは、「testserverContainer」という名前で、「${CIRCLE_SHA1}」のタグがついているものという指定。
GitHubにリモートリポジトリを作成→CircleCIと連携
GitHubにリモートリポジトリを作り(名前は任意)、そこにコードをpushします。
その後、そのリポジトリと連携させたCircleCI projectを作成します。CircleCIに環境変数を設定する
project作成後、CircleCIに以下の環境変数を設定します。
- AWS_ACCESS_KEY_ID : さっき作成したIAMユーザーのアクセスキー
- AWS_SECRET_ACCESS_KEY: さっき作成したIAMユーザーのシークレットアクセスキー
- AWS_ACCOUNT_ID: 自分のAWSアカウントID(12桁の数字)
- AWS_ECR_ACCOUNT_URL: [myaccountid].dkr.ecr.ap-northeast-1.amazonaws.com
- AWS_REGION: ap-northeast-1
この環境変数の設定後、もう一度pipelineを実行すると、正しくCI/CDが行われます。
テスト実行をパイプラインに組み込む
せっかくのCI/CDなので、GitHubにpushしたら「コードテストを実行→テストにPASSしたときだけデプロイに移行」というフローに改良してみたいと思います。
テストコードを作成
main_test.goというファイルを作成し、以下のように記述します。main_test.gofunc TestMyHandler(t *testing.T) { reqBody := bytes.NewBufferString("request body") req := httptest.NewRequest(http.MethodGet, "http://localhost:8080", reqBody) got := httptest.NewRecorder() handler(got, req) if got.Code != http.StatusOK { t.Errorf("want OK, but %d", got.Code) } wantBody := "Hello" if got := got.Body.String(); !strings.Contains(got, wantBody) { t.Errorf("get %s : response body does not contain %s", got, wantBody) } }これで、「アクセス時にhttpレスポンスコード200が返ってくるか」「レスポンスボディに"Hello"という文字が含まれているか」がテストできるようになりました。
ディレクトリ直下でgo testコマンドを叩くことでテスト実行ができます。configファイルの作成
テストを行うjobを新たに定義して、CircleCIのworkflowに組み込みます。
前述したconfig.ymlに追記してテストを組み込んだものがこちらです。config.ymlversion: 2.1 orbs: aws-ecr: circleci/aws-ecr@6.1.0 aws-ecs: circleci/aws-ecs@0.0.8 # 追加、testという名前のjobを定義 jobs: test: docker: - image: circleci/golang:1.13 working_directory: /go/src/github.com/myname/mydirectory steps: - checkout - run: go get -v -t -d ./... - run: go test -v ./... workflows: build_and_push_image: jobs: - test # 追加、デプロイ操作前にテストを実行 - aws-ecr/build-and-push-image: requires: - test # 追加、テストに成功したときだけデプロイ操作に移るようにする region: AWS_REGION account-url: AWS_ECR_ACCOUNT_URL repo: 'cicdtest' tag: "${CIRCLE_SHA1}" - aws-ecs/deploy-service-update: requires: - aws-ecr/build-and-push-image family: 'testserver' # タスク名 cluster-name: 'circleciTest' service-name: 'CircleCItestService' container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'参考:CI/CDパイプライン – GitHub, CircleCIの連携とGCP(GCR)へのプッシュ
masterブランチへのpushのときだけデプロイされるように制限する
このままだと、どのリモートブランチにpushされたとしてもECSへのデプロイが行われてしまいます。つまり、「まだリリースできる段階じゃないけど、とりあえずブランチを切ってリモートにもpushしておかないと」というときにもデプロイが動いてしまいます。
そのため、「テストはどのブランチへのpushでも行う」「デプロイ操作はmasterへのmerge/pushのときだけ行う」という風に設定しておくことができれば素敵なパイプラインになります。そのために、
config.ymlに以下のように追記します。config.yml(略) workflows: build_and_push_image: jobs: - test - aws-ecr/build-and-push-image: (略) filters: # ここを追加 branches: only: - master - aws-ecs/deploy-service-update: (略) filters: # ここを追加 branches: only: - masterこれで、「aws-ecr/build-and-push-image」「aws-ecs/deploy-service-update」のjobはmasterへのpushのときのみ動くようになりました。
- 投稿日:2020-08-20T13:42:50+09:00
【Go】ターミナルでホロライブの推しの配信予定を確認する
はじめに
Go言語でホロライブの推しの配信中の枠 or 配信予定の枠をターミナル上で確認できるツールを作りました.
こんな感じに名前とstatusを指定することで検索結果を表示します.
この記事では核となるYoubue DATA APIに関する説明をメインにします.
コードの全てを説明はしないので, 興味がある方はgithubのリポジトリをご覧になってください.Youtube DATA API
今回の核となるサービスです. Youtubeといろんな情報をやりとりするためのAPIで, ここにリファレンスやら使い方が載っています. さらにブラウザ上でAPIをお試しで使うこともできるので, 実際にプログラムから呼び出す前にどんなレスポンスが返って来るのか確かめることもできます.
今回は, その中のSearch APIを使っていきます. リクエストする際に色々なパラメータを指定できるのですが, 今回は以下の3つのパラメータだけ指定します.
- channelId: 検索したいチャンネルのID (上の例だと桐生ココさんのチャンネルIDを指定しています)
- maxResults: 返ってくる検索結果の最大数
- order: 検索結果の並び順 (日時が新しい順に結果が欲しいので, dateにしています)
今回はこのAPIをGo言語を使って呼び出すんですが, Go言語にはYoutube DATA APIにアクセスするためのパッケージが用意されています. (GoDoc)
GoでSearch APIをとりあえず呼び出してみようと思うと以下のように書けば結果が返ってきます. (簡単のためエラーを握り潰しています)import ( "net/http" "google.golang.org/api/youtube/v3" ) func main(){ sevice, _ := youtube.New(&http.NewClient{}) call := service.Search.List([]string{"snippet"}) resp, _ := call.Do() }どんなレスポンスが返ってきてるかというと, ちょっと長いですがこんな感じ.
{ "kind": "youtube#searchListResponse", "etag": "C-VRzIwOOxWVPQvWTxeLEYpPU_o", "nextPageToken": "CAEQAA", "regionCode": "JP", "pageInfo": { "totalResults": 254, "resultsPerPage": 1 }, "items": [ { "kind": "youtube#searchResult", "etag": "0-mINIWG26fwpoxKsChMO9DXFaM", "id": { "kind": "youtube#video", "videoId": "2rOsa2StCAg" }, "snippet": { "publishedAt": "2020-08-18T16:03:15Z", "channelId": "UCS9uQI-jC3DE0L4IpXyvr6w", "title": "【#桐生ココの怖い話】ASMR怪談話‥其の二‥【ヘッドホン推奨】", "description": "視聴者のみなさんからいただいた恐ろしい怪談話を語り部:いながわココじが、ASMRマイクでじっとりと語る配信第二弾! ※イヤホン・ヘッドホンでの視聴を推奨します ※本 ...", "thumbnails": { "default": { "url": "https://i.ytimg.com/vi/2rOsa2StCAg/default_live.jpg", "width": 120, "height": 90 }, "medium": { "url": "https://i.ytimg.com/vi/2rOsa2StCAg/mqdefault_live.jpg", "width": 320, "height": 180 }, "high": { "url": "https://i.ytimg.com/vi/2rOsa2StCAg/hqdefault_live.jpg", "width": 480, "height": 360 } }, "channelTitle": "Coco Ch. 桐生ココ", "liveBroadcastContent": "upcoming", "publishTime": "2020-08-18T16:03:15Z" } } ] }その中でも必要な情報はこれだけ.
"items": [ { "id": { "videoId": "2rOsa2StCAg" }, "snippet": { "title": "【#桐生ココの怖い話】ASMR怪談話‥其の二‥【ヘッドホン推奨】", "liveBroadcastContent": "upcoming" } } ]
- videoId: 枠のURLを表示するために必要 (https://youtube.com/watch?v=videoIdとなっている)
- title: 枠タイトル
- liveBroadcastContent: 枠のステータス
- upcoming: 配信予定
- live: 配信中
- none: 動画
本当はいつから配信開始なのかも表示したかったのですが取ってこれず...
Goではこの検索結果に対応するSearchResultという構造体が定義されていてJSONで見たまんまのアクセスができます.終わり
Youtube DATA APIには1日あたりのリクエスト上限が設けられていて無限に使える訳ではありません. ブラウザが使えるならホロジュールを使いましょう.
- 投稿日:2020-08-20T10:57:16+09:00
Go 1.15 testing パッケージの TestMain は os.Exit を呼ばなくてよくなった
TL;DR
- Go1.15 がリリースされました
- testing パッケージの実装の変更にて TestMain で os.Exit を呼ばなくてよくなりました
- testing.M の実装変更及び、 go test で生成される main 関数のテンプレート修正によって実現されている
Go 1.15
2020年8月11日、 The Go Blog より、 Go 1.15 のリリースが公表されました。
https://blog.golang.org/go1.15
Release Note を読むと、さまざまな機能が追加されています。
これを読んでいて、ふと Minor changes to the library から testing パッケージにいくつか変更があって、バージョンアップしたらちょっっっとテストの書き方が変わるなって思ったので深堀り確認してみました。
※ はてなブログで同じものを見たぞと思ったとしたら、それは同一の記事です。 https://khigashigashi.hatenablog.com/entry/2020/08/20/110803
ブログの書き先に迷子になった結果です。
testing の変更
testing パッケージには、いくつかマイナーな変更が加えられています。
https://golang.org/doc/go1.15#testing
- T.Deadline の追加
The testing.T type now has a Deadline method that reports the time at which the test binary will have exceeded its timeout.
- TestMain は os.Exit を呼ばなくて良くなった
A TestMain function is no longer required to call os.Exit. If a TestMain function returns, the test binary will call os.Exit with the value returned by m.Run.
- T.TempDir / B.TempDir の追加
The new methods T.TempDir and B.TempDir return temporary directories that are automatically cleaned up at the end of the test.
- go test -v の表示改善
go test -v now groups output by test name, rather than printing the test name on each line.
タイトルのとおりですが、この中の 2 つめに焦点を当ててみます。
TestMain は os.Exit を呼ばなくて良くなった
A TestMain function is no longer required to call os.Exit. If a TestMain function returns, the test binary will call os.Exit with the value returned by m.Run.
https://golang.org/doc/go1.15#testing
たとえば、TestMain を使うテストコードはGo 1.14 以前は次のようなコードだったと思います。
Go_1.14以前package hoge_test import ( "log" "testing" ) func TestMain(m *testing.M) { shutdown := setupDB() defer shutdown() os.Exit(m.Run()) } func setupDB() func() { return func() { // shutdown } }testing.m.Run() は プログラムの終了コード に該当する int 値を返してくるので、それを os.Exit() に明示的に渡すようにしていました。
https://golang.org/pkg/testing/#M.Run
Go 1.15 以降は、この
os.Exitが不要になりました。Go_1.15以降package hoge_test import ( "log" "testing" ) func TestMain(m *testing.M) { shutdown := setupDB() defer shutdown() m.Run() // os.Exit は不要 } func setupDB() func() { return func() { // shutdown } }使い手としてはこれだけです。マイナーな変更ですね。まぁそれだけでは、へぇそうなんだねって話なので、実際中ではどういう変更があったのってところを見たいなと思います。
Dive into testing.M
Issue
この変更は、次の issue で 2019年9月6日に提案されているものです。
https://github.com/golang/go/issues/34129
issue を読んでいると、よく Buggy なテストが生まれることが観測されていたと、Russ Cox氏が言及しています。
This mistake does happen a lot, and the testing package that called TestMain and prepared the m can certainly record the result of m.Run for use in its own outer os.Exit.
https://github.com/golang/go/issues/34129#issuecomment-531011027
実際にどういうミスかと言うと、こういうテストコードの場合です。
hoge_test.gopackage sample_test import ( "os" "testing" ) func TestMain(m *testing.M) { var exitCode int defer os.Exit(exitCode) setup() defer teardown() exitCode = m.Run() } func setup() { // } func teardown() { // } func TestHoge(t *testing.T) { t.Fail() }TestMain() では、deferでos.Exit をcallするようにしています。そして当該パッケージ内には失敗するテストコードがあるので、これは失敗するはずです。これを実行すると次のようになります。
$ go test -v hoge_test.go === RUN TestHoge --- FAIL: TestHoge (0.00s) FAIL ok command-line-arguments 0.369s
okとなってしまいました。終了コードがうまく反映されていません。このような失敗していてもテストコマンドが静かにそのバグを報告しなくてなってしまうことに対して、 Accept されたのは以下でした。
- m.Run records its own result in an unexported field of m, and then
- if TestMain(m) returns, the outer test func main harness calls os.Exit with that code.
1. m.Run records its own result in an unexported field of m
この課題に対して実際に実装された結果は 下記の commit から見て取れます。
https://go-review.googlesource.com/c/go/+/219639/10/src/testing/testing.go
L220 にてコメントは次のように変更されています。
src/testing/testing.go// and teardown is necessary around a call to m.Run. m.Run will return an exit // status that may be passed to os.Exit. If TestMain returns, the test wrapper // will pass the result of m.Run to os.Exit itself. When TestMain is called, // flag.Parse has not been run. If TestMain depends on command-line flags, // including those of the testing package, it should call flag.Parse explicitly.If TestMain returns, the test wrapper will pass the result of m.Run to os.Exit itself.
TestMain が return したら、 the test wrapperさんがそれをos.Exitに渡してくれるというものです。
そのためにここでは、 「1. testing.M の構造体に、終了コードが追加」し、「2. testing.M.Run は M.exitCode に終了コードを設定」しています。
- testing.M の構造体に、終了コードが追加された。
src/testing/testing.go// M is a type passed to a TestMain function to run the actual tests. type M struct { deps testDeps tests []InternalTest benchmarks []InternalBenchmark examples []InternalExample timer *time.Timer afterOnce sync.Once numRun int // value to pass to os.Exit, the outer test func main // harness calls os.Exit with this code. See #34129. exitCode int // <= これが今回増えました }
- testing.M.Run は M.exitCode に終了コードを設定する
return する際、これまで終了コードを返していただけでしたが、 M.exitCode に設定するようにしています。
src/testing/testing.goif *parallel < 1 { fmt.Fprintln(os.Stderr, "testing: -parallel can only be given a positive integer") flag.Usage() // return 2 これがもともと、普通に終了コードを返答していた m.exitCode = 2 // m.e return }※ 関数冒頭に defer func で m.exitCode を戻り値として返却するように記述しています。
src/testing/testing.gofunc (m *M) Run() (code int) { defer func() { code = m.exitCode }()2. if TestMain(m) returns, the outer test func main harness calls os.Exit with that code.
TestMain が終了後、外の main 関数が m.exitCode を見て、os.Exit をcallするようにしています。これは、以下のコミットにその反映を見ることが出来ます。
https://go-review.googlesource.com/c/go/+/219639/10/src/cmd/go/internal/load/test.go
この変更は、
go testによって生成される main 関数のテンプレートの修正です。TestMain の場合は、 reflect パッケージを追加し、 構造体 M の exitCode の field をとっています。
src/cmd/go/internal/load/test.goos.Exit(int(reflect.ValueOf(m).Elem().FieldByName("exitCode").Int()))この変更によって、testing.M の内部の unexported な exitCode の値を使って、 os.Exit をコールしています。
以上
Go 1.15 におけるテストの書き方の変更と、その背景・内部実装の深堀りでした。 Congrats Go 1.15 release!!!
(以上)
- 投稿日:2020-08-20T04:22:00+09:00
Golangで動的SQLを作成するライブラリ(sqlpatchwork)を作ってOSSに公開してみた
bubusuke/sqlpatchworkについてご紹介します!
- Golang勉強中です!
- 初めてのOSS。しかもAuthor。
- みんなに使ってもらいたいので説明頑張りました!長文です。ご容赦ください。
Update!!
SQLファイルなしで使えるようになりました!(2020.08.21)
2020.08.21.gofunc simpleSample() { qps := map[string]string{ "prefix": "INSERT INTO hoge_table (col1, col2) VALUES", "loopVal": "(:col1_@@,:col2_@@)", "loopDelim": ",", } spw := NewSimplePWSkipPrs("skipPrsTest", qps) // 後の使い方は使用方法デモの章と一緒 } func onOffSample() { qps := OnOffQPs( OnOffQP("SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s"), OnOffQP("INNER JOIN item_master i ON i.item_code = s.item_code", "itemTypeNotNil", "colorCodeNotNil"), OnOffQP("WHERE 1=1"), OnOffQP("AND i.item_type = :item_type", "itemTypeNotNil"), OnOffQP("AND i.color_code = :color_code", "colorCodeNotNil"), OnOffQP("GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date")) spw := NewOnOffPWSkipPrs("skipPrsTest", qps) // 後の使い方は使用方法デモの章と一緒 }使用方法デモ
本当は最初に書きたかったですが、めっちゃ長いので最後に記載します。
作成に至った経緯
JavaのMyBatisやUrobroSQLのようなものを探したのですが見つかりません。
具体的には、以下の3つの機能をもつものを探してました。
- SQLファイルを読み込む(goにSQLを埋め込みたくない・素のSQLを書きたい読みたい)
- パラメータに応じて動的にSQLを構築する(似たようなSQLを何度も書きたくない)
- BulkInsertやMultiRow-Insertを実行できる(一度に沢山データを処理したい)
あれ、。。。ない?
→ 仮に作るならどういうものが嬉しいだろう。
→ ちょっといい感じのコンセプト浮かぶ。
→ できそうな気がする。。。
→ よっしゃ!作ってみるか!!という流れ。
ライブラリ説明
このライブラリがやること
以下の3つをするだけです。
step 1. SQLファイルに記載されたSQLを複数のパーツ(query-piece)に分ける。
step 2. 利用するquery-piece を選ぶ。
step 3. 選んだquery-pieceをつなぎ合わせる。ピースをツギハギするという意味で、SQL Patchworkと名付けました。
ツギハギするためのstep. 2に当たる分岐ロジックは各自、Golangで実装して使うため、
SQLファイル上には、If/Choose/for などの制御構文がないことが特徴です。(2020.08.21追記ここから)
SQLファイルなしでも使えるようになりました。
SQLファイルなしの場合、以下のように使います。step 1. SQLを複数のパーツ(query-piece)に分けたものを引数で渡す。
step 2. 利用するquery-piece を選ぶ(SQLファイル利用と一緒)。
step 3. 選んだquery-pieceをつなぎ合わせる(SQLファイル利用と一緒)。
(2020.08.21追記ここまで)Point 1
@startキーワードでQuery-pieceブロックを開始します。@startキーワードに続く単語がQuery-pieceのIDとなります。
- 二単語目以降は無視されます。例
/*@start ThisIsID ThisIsIgnored*/- SimplePatchworkモード(後述)の場合、IDを一意にする必要があります。
- OnOffPatchworkモード(後述)の場合、複数のQuery-pieceに同じIDを付与できます。
- OnOffPatchworkモード(後述)の場合、一つのQuery-pieceに対して、
/区切りで複数のIDを付与できます。@endキーワードでQuery-Pieceブロックを終了します。- Query-Pieceブロックの入れ子構造には対応していません。
Point 2
- SQL中のCommentoutや、Commentblockは全て無視されます(最終アウトプットで出力されません)。
Point 3
- Query-Pieceの選択方法が異なる、SimplePatchworkモードとOnOffPatchworkモードという2つ機能を提供します。例に示す図の処理はどちらのモードでも実現できます(詳しくは後述)。
Point 4
- 本機能の最終アウトプットはSQLのQueryテキストです(string型)。DBアクセス等の機能はありません。
- 最終アウトプットは、改行なし、コメントなし、Trimなどの整形処理を施したQueryとなります。
- 使用したSQLファイル、選択したQueryPieceのIDをコメントとして記載したQueryをアウトプットすることも可能です。
コンセプト
低い学習コスト
例えば、MyBatis。便利なのですが、利用するにはいくつものMyBatis固有の文法を覚える必要があり、学習コストは決して低くはないです。誰でも利用できるよう、SQLに記載する固有の文法は限りなく減らし、学習コストを下げようと考えました(この方がParse処理でバグがないですしね)。
他のDBアクセスライブラリとの共存共生(当ライブラリをアドオン的に利用)
DBアクセス周りやORM周りでは既に素晴らしいライブラリがいくつも存在します。そのようなライブラリとの共存共生を目指しました。つまり、このライブラリはSQL Query Builderであり、DBアクセス・ORMに関する一切の機能は持ちません。
Easy to test
SQLファイル上に独自の制御構文があると、goの魅力的なテストツールの恩恵が受けられません。このため、ツギハギロジックはGolangで利用者に実装してもらうようにしました。このため、「独自制御構文での分岐ロジックを全てカバーできているか」という問題に悩まさせることはありません。
SimplePatchworkモードとOnOffPatchworkモード
Query-Pieceの選択方法が異なる、SimplePatchworkモードとOnOffPatchworkモードという2つ機能を提供します。
SimplePatchworkモード
QueryPieceを指示した順 につなぎ合わせるSimpleかつ強力なモードです。
Point 1
- Query-pieceのIDは一意に設定する必要があります。
Point 2, 3
- 同じQuery-pieceを複数回指定すること可能です。
@@キーワードは、そのQueryPieceを指定した数だけインクリメントした数値に置換されます(0からインクリメントしていきます)。バインド変数につけておくことで、Multirow-insertなどのクエリ構築時にバインド変数を各行で変更できます。この@@キーワード置換機能はSimplePatchworkモードのみ提供しており、OnOffPatchworkモードは提供していません。2. OnOffPatchworkモード
SimplePatchworkはシンプルに組み合わせる機能のため、汎用性が高い反面、選択ロジックが長くなったり、SQLファイルに本当に継ぎはぎのパーツを記載されて可読性が下がることが懸念されます。
この懸念事項の解決策として提供する機能がOnOffPatchworkモードです。
このモードでは、Query-pieceをSQLファイルで記載されている順 につなぎ合わせます。
そしてつなぎ合わせる際に、Query-pieceを指示したものだけをつなげます。
Point 1
- 必ず選択するQueryPieceは
@startと@endの指定を省略できます(省略した部分には、__defaultというIDが付与されます)。Point 2
- 複数のQuery-pieceに同じIDを付与できます
- 一つのQuery-pieceに対して、
/区切りで複数のIDを付与できます。Point 3
- 使用する(Onにする)IDを選択すれば該当するQuery-pieceをつなげます(SQLファイルに記載された順につなげます)。
SQLファイルに導入した独自文法
以下の三つのみです。
/*@start someID*/: someIDというIDをもつQuery-Pieceの開始を宣言します。/*@end*/: Query-Pieceの終了を宣言します。@@: 同じQuery-Pieceの利用回数の数値で置換されます。0,1とインクリメントされていきます。詳しくはSimplePatchworkのPoint2をご確認ください。(SimplePatchworkモードのみの機能です)。(2020.08.21追記ここから)
SQLファイルなしで本機能を使う場合は、@@キーワードのみを覚えればよいですね。
(2020.08.21追記ここまで)ライセンス
MITです。どうぞご利用ください!
想定問答
Q1. SimplePatchworkとOnOffPatchworkをどう使い分けたらよい?
A1. OnOffPatchworkの方が、Golangに実装するロジックも、SQLの構文もスッキリし、可読性が高くなると思います。可能な限りOnOffPatchworkの利用を推奨します。(当初、OnOffPatchworkのみ実装予定でしたが、これではMultirow-insertを実現できないため、SimplePatchworkを作りました。)Q2. IN句の動的に展開したい場合はSimplePatchworkを利用すべきでしょうか。
A2. 既存のDBアクセスライブラリによっては、IN句のSlice型バインド変数を展開する機能が存在します。それを利用すればOnOffPatchworkの利用でも動的に展開することは可能です。Q3. IN句の複数列指定の場合(
(col1, col2) IN ((?,?),...,(?,?))の場合)はどうでしょうか。
A3. おそらく既存のDBアクセスライブラリは対応できていないため、SimplePatchworkでツギハギしてください。(2020.08.21追記ここから)
Q4. バルクインサートでちょっと使いたいだけなのにSQLファイル必要なの?
A4. SQLファイルなしで利用できるようアップデートしました。
(2020.08.21追記ここまで)課題・TODO(もしよければご協力ください)
- 内部はかなり泥臭い処理になってます。特に[]byte型とstring型を行ったり来たりしてます。この変換ってパフォーマンス大丈夫かなと気になってますが力及ばず。
- 英語に自信なし!
- テストのカバレッジは90%。100%までちょくちょく足していきます。
最後に
やっぱり自分で手を動かしてつくるとわかんないところ多くて苦労しました。いい勉強になった!
Golang初学者が作成したため、上記課題に限らず改善箇所の宝庫のはずw
変なところあればコメントやPR頂けると嬉しいです。使用方法デモ
お待たせしました。こんな感じで使っていただければ。
SimplePatchwork
package main import ( "fmt" "log" "github.com/bubusuke/sqlpatchwork" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type hoge struct { Col1 int `db:"col1"` Col2 string `db:"col2"` } func main() { hoges := []hoge{ {Col1: 100, Col2: "foo"}, {Col1: 200, Col2: "bar"}, {Col1: 300, Col2: "hoge"}, } spw, err := sqlpatchwork.NewSimplePatchwork("./sqls/simplePatchwork.sql") if err != nil { fmt.Println(err) } bindMap := make(map[string]interface{}) if err := spw.AddQueryPiecesToBuild("prefix"); err != nil { fmt.Println(err) } for i, h := range hoges { if i != 0 { if err := spw.AddQueryPiecesToBuild("loopDelim"); err != nil { fmt.Println(err) } } if err := spw.AddQueryPiecesToBuild("loopVal"); err != nil { fmt.Println(err) } bindMap[sqlpatchwork.LoopNoAttach("col1_@@", i)] = h.Col1 bindMap[sqlpatchwork.LoopNoAttach("col2_@@", i)] = h.Col2 } fmt.Println("==========================") fmt.Println("TargetIDs are") fmt.Println(spw.TargetIDs()) fmt.Println("==========================") fmt.Println("BuildingQuery is") fmt.Println(spw.BuildQuery()) fmt.Println("==========================") fmt.Println("BuildingQueryWithTrace is") fmt.Println(spw.BuildQueryWithTraceDesc()) fmt.Println("==========================") fmt.Println("BindMap is") fmt.Println(bindMap) fmt.Println("==========================") insertDemoByUsingSqlx(spw.BuildQuery(), bindMap) } //insertDemoByUsingSqlx is using jomoiron/sqlx to execute SQL. func insertDemoByUsingSqlx(query string, bindMap map[string]interface{}) { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } db.MustExec("DROP TABLE IF EXISTS hoge_table") db.MustExec("CREATE TABLE hoge_table (col1 int, col2 varchar(25))") tx := db.MustBegin() _, err = tx.NamedExec(query, bindMap) if err != nil { log.Fatalln(err) } tx.Commit() hoges := []hoge{} err = db.Select(&hoges, "SELECT col1, col2 FROM hoge_table ORDER BY col1") if err != nil { log.Fatalln(err) } fmt.Println("==========================") fmt.Println("The result of query execution is") fmt.Println(hoges) fmt.Println("==========================") } // The file content of ./sqls/simplePatchwork.sql // -------------------------------------------- // /*@start prefix */ // INSERT INTO hoge_table (col1, col2) VALUES // /*@end*/ // /*@start loopVal */ // (:col1_@@,:col2_@@) // /*@end*/ // /*@start loopDelim */ // , // /*@end*/ // STDOUT // -------------------------------------------- // ========================== // TargetIDs are // [prefix loopVal loopDelim loopVal loopDelim loopVal] // ========================== // BuildingQuery is // INSERT INTO hoge_table (col1, col2) VALUES (:col1_0,:col2_0) , (:col1_1,:col2_1) , (:col1_2,:col2_2) // ========================== // BuildingQueryWithTrace is // INSERT /* ./sqls/simplePatchwork.sql [prefix loopVal loopDelim loopVal loopDelim loopVal] */ INTO hoge_table (col1, col2) VALUES (:col1_0,:col2_0) , (:col1_1,:col2_1) , (:col1_2,:col2_2) // ========================== // BindMap is // map[col1_0:100 col1_1:200 col1_2:300 col2_0:foo col2_1:bar col2_2:hoge] // ========================== // ========================== // The result of query execution is // [{100 foo} {200 bar} {300 hoge}] // ==========================OnOffPatchwork
package main import ( "fmt" "log" "github.com/bubusuke/sqlpatchwork" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type req struct { ItemType string `db:"item_type"` ColorCode string `db:"color_code"` } type Res struct { ItemCode int `db:"item_code"` SalesDate string `db:"sales_date"` Count int `db:"count"` } func buildQuery(r *req, isTraceDesc bool) string { spw, err := sqlpatchwork.NewOnOffPatchwork("./sqls/onoffPatchwork.sql") if err != nil { fmt.Println(err) } if r.ItemType != "" { spw.AddQueryPiecesToBuild("itemTypeNotNil") } if r.ColorCode != "" { spw.AddQueryPiecesToBuild("colorCodeNotNil") } fmt.Println("==========================") fmt.Println("request is") fmt.Println(r) fmt.Println("==========================") fmt.Println("TargetIDs are") fmt.Println(spw.TargetIDs()) fmt.Println("==========================") fmt.Println("BuildingQuery is") fmt.Println(spw.BuildQuery()) fmt.Println("==========================") fmt.Println("BuildingQueryWithTrace is") fmt.Println(spw.BuildQueryWithTraceDesc()) fmt.Println("==========================") if isTraceDesc { return spw.BuildQueryWithTraceDesc() } return spw.BuildQuery() } func main() { resetDB() req1 := &req{ItemType: "T-shirts"} query1 := buildQuery(req1, false) selectDemoByUsingSqlx(query1, req1) req2 := &req{ColorCode: "W"} query2 := buildQuery(req2, false) selectDemoByUsingSqlx(query2, req2) req3 := &req{} query3 := buildQuery(req3, false) selectDemoByUsingSqlx(query3, req3) } //resetDB reset DB Data. func resetDB() { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } db.MustExec("DROP TABLE IF EXISTS sales_tran") db.MustExec("DROP TABLE IF EXISTS item_master") db.MustExec("CREATE TABLE item_master (item_code varchar(4), item_type varchar(25), color_code varchar(2))") db.MustExec("INSERT INTO item_master VALUES " + "('1000', 'T-shirts', 'B')" + ",('2000', 'T-shirts', 'W')" + ",('3000', 'pants', 'W')") db.MustExec("CREATE TABLE sales_tran (item_code varchar(4), sales_date date)") db.MustExec("INSERT INTO sales_tran VALUES " + "('1000','2020-08-20')" + ",('1000','2020-08-20')" + ",('1000','2020-08-19')" + ",('2000','2020-08-20')" + ",('2000','2020-08-19')" + ",('2000','2020-08-19')" + ",('3000','2020-08-19')" + ",('3000','2020-08-18')" + ",('3000','2020-08-18')") } //selectDemoByUsingSqlx is using jomoiron/sqlx to execute SQL. func selectDemoByUsingSqlx(query string, rq *req) { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } rows, err := db.NamedQuery(query, rq) if err != nil { log.Fatalln(err) } fmt.Println("==========================") fmt.Println("The result of query execution is") res := Res{} for rows.Next() { err := rows.StructScan(&res) if err != nil { log.Fatalln(err) } fmt.Println(res) } fmt.Println("==========================") } // The file content of ./sqls/onoffPatchwork.sql // -------------------------------------------- // SELECT // s.item_code // , s.sales_date // , COUNT(*) AS count // FROM // sales_tran s // /*@start itemTypeNotNil/colorCodeNotNil */ // INNER JOIN // item_master i // ON // i.item_code = s.item_code // /*@end*/ // WHERE 1=1 // /*@start itemTypeNotNil*/ // AND i.item_type = :item_type // /*@end*/ // /*@start colorCodeNotNil*/ // AND i.color_code = :color_code // /*@end*/ // GROUP BY // s.item_code // , s.sales_date // ORDER BY // s.item_code // , s.sales_date // STDOUT // -------------------------------------------- // ========================== // request is // &{T-shirts } // ========================== // TargetIDs are // [__default itemTypeNotNil] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.item_type = :item_type GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default itemTypeNotNil] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.item_type = :item_type GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {1000 2020-08-19T00:00:00Z 1} // {1000 2020-08-20T00:00:00Z 2} // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // ========================== // ========================== // request is // &{ W} // ========================== // TargetIDs are // [__default colorCodeNotNil] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.color_code = :color_code GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default colorCodeNotNil] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.color_code = :color_code GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // {3000 2020-08-18T00:00:00Z 2} // {3000 2020-08-19T00:00:00Z 1} // ========================== // ========================== // request is // &{ } // ========================== // TargetIDs are // [__default] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s WHERE 1=1 GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s WHERE 1=1 GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {1000 2020-08-19T00:00:00Z 1} // {1000 2020-08-20T00:00:00Z 2} // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // {3000 2020-08-18T00:00:00Z 2} // {3000 2020-08-19T00:00:00Z 1} // ==========================
- 投稿日:2020-08-20T04:22:00+09:00
Golangで動的SQLを作成するライブラリ(sqlpatchwork)を作ってGitに公開してみた
bubusuke/sqlpatchworkについてご紹介します!
- Golang勉強中です!
- 初めてのOSS。しかもAuthor。
- みんなに使ってもらいたいので説明頑張りました!長文です。ご容赦ください。
できるようになること
- 動的SQLの仕組みを簡単に導入できます。
- 複数レコードを一括処理するバルククエリ(MultiRow-INSERTなどのMultiRowクエリ)のSQLを動的に簡単に作れます。
- MultiRowクエリの書き方や特徴について知りたい方は こちら も合わせてご覧ください。
Update!!
SQLファイルなしでも使えるようになりました!(2020.08.21)
2020.08.21.gopackage main import "github.com/bubusuke/sqlpatchwork" func simpleSample() { qps := map[string]string{ "prefix": "INSERT INTO hoge_table (col1, col2) VALUES", "loopVal": "(:col1_@@,:col2_@@)", "loopDelim": ",", } spw := sqlpatchwork.NewSimplePWSkipPrs("SkipFileParse", qps) // 後の使い方は使用方法デモの章と一緒 } func onOffSample() { qps := sqlpatchwork.OnOffQPs( sqlpatchwork.OnOffQP("SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s"), sqlpatchwork.OnOffQP("INNER JOIN item_master i ON i.item_code = s.item_code", "itemTypeNotNil", "colorCodeNotNil"), sqlpatchwork.OnOffQP("WHERE 1=1"), sqlpatchwork.OnOffQP("AND i.item_type = :item_type", "itemTypeNotNil"), sqlpatchwork.OnOffQP("AND i.color_code = :color_code", "colorCodeNotNil"), sqlpatchwork.OnOffQP("GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date")) spw := sqlpatchwork.NewOnOffPWSkipPrs("SkipFileParse", qps) // 後の使い方は使用方法デモの章と一緒 }使用方法デモ
本当は最初に書きたかったですが、めっちゃ長いので最後に記載します。
作成に至った経緯
JavaのMyBatisやUrobroSQLのようなものを探したのですが見つかりません。
具体的には、以下の3つの機能をもつものを探してました。
- SQLファイルを読み込む(goにSQLを埋め込みたくない・素のSQLを書きたい読みたい)
- パラメータに応じて動的にSQLを構築する(似たようなSQLを何度も書きたくない)
- BulkInsertやMultiRow-Insertを実行できる(一度に沢山データを処理したい)
あれ、。。。ない?
→ 仮に作るならどういうものが嬉しいだろう。
→ ちょっといい感じのコンセプト浮かぶ。
→ できそうな気がする。。。
→ よっしゃ!作ってみるか!!という流れ。
ライブラリ説明
このライブラリがやること
以下の3つをするだけです。
step 1. SQLファイルに記載されたSQLを複数のパーツ(query-piece)に分ける。
step 2. 利用するquery-piece を選ぶ。
step 3. 選んだquery-pieceをつなぎ合わせる。ピースをツギハギするという意味で、SQL Patchworkと名付けました。
ツギハギするためのstep. 2に当たる分岐ロジックは各自、Golangで実装して使うため、
SQLファイル上には、If/Choose/for などの制御構文がないことが特徴です。(2020.08.21追記ここから)
SQLファイルなしでも使えるようになりました。
SQLファイルなしの場合、以下のように使います。step 1. SQLを複数のパーツ(query-piece)に分けたものを引数で渡す。
step 2. 利用するquery-piece を選ぶ(SQLファイル利用と一緒)。
step 3. 選んだquery-pieceをつなぎ合わせる(SQLファイル利用と一緒)。
(2020.08.21追記ここまで)Point 1
@startキーワードでQuery-pieceブロックを開始します。@startキーワードに続く単語がQuery-pieceのIDとなります。
- 二単語目以降は無視されます。例
/*@start ThisIsID ThisIsIgnored*/- SimplePatchworkモード(後述)の場合、IDを一意にする必要があります。
- OnOffPatchworkモード(後述)の場合、複数のQuery-pieceに同じIDを付与できます。
- OnOffPatchworkモード(後述)の場合、一つのQuery-pieceに対して、
/区切りで複数のIDを付与できます。@endキーワードでQuery-Pieceブロックを終了します。- Query-Pieceブロックの入れ子構造には対応していません。
Point 2
- SQL中のCommentoutや、Commentblockは全て無視されます(最終アウトプットで出力されません)。
Point 3
- Query-Pieceの選択方法が異なる、SimplePatchworkモードとOnOffPatchworkモードという2つ機能を提供します。例に示す図の処理はどちらのモードでも実現できます(詳しくは後述)。
Point 4
- 本機能の最終アウトプットはSQLのQueryテキストです(string型)。DBアクセス等の機能はありません。
- 最終アウトプットは、改行なし、コメントなし、Trimなどの整形処理を施したQueryとなります。
- 使用したSQLファイル、選択したQueryPieceのIDをコメントとして記載したQueryをアウトプットすることも可能です。
コンセプト
低い学習コスト
例えば、MyBatis。便利なのですが、利用するにはいくつものMyBatis固有の文法を覚える必要があり、学習コストは決して低くはないです。誰でも利用できるよう、SQLに記載する固有の文法は限りなく減らし、学習コストを下げようと考えました(この方がParse処理でバグがないですしね)。
他のDBアクセスライブラリとの共存共生(当ライブラリをアドオン的に利用)
DBアクセス周りやORM周りでは既に素晴らしいライブラリがいくつも存在します。そのようなライブラリとの共存共生を目指しました。つまり、このライブラリはSQL Query Builderであり、DBアクセス・ORMに関する一切の機能は持ちません。
Easy to test
SQLファイル上に独自の制御構文があると、goの魅力的なテストツールの恩恵が受けられません。このため、ツギハギロジックはGolangで利用者に実装してもらうようにしました。このため、「独自制御構文での分岐ロジックを全てカバーできているか」という問題に悩まさせることはありません。
SimplePatchworkモードとOnOffPatchworkモード
Query-Pieceの選択方法が異なる、SimplePatchworkモードとOnOffPatchworkモードという2つ機能を提供します。
SimplePatchworkモード
QueryPieceを指示した順 につなぎ合わせるSimpleかつ強力なモードです。
Point 1
- Query-pieceのIDは一意に設定する必要があります。
Point 2, 3
- 同じQuery-pieceを複数回指定すること可能です。
@@キーワードは、そのQueryPieceを指定した数だけインクリメントした数値に置換されます(0からインクリメントしていきます)。バインド変数につけておくことで、Multirow-insertなどのクエリ構築時にバインド変数を各行で変更できます。この@@キーワード置換機能はSimplePatchworkモードのみ提供しており、OnOffPatchworkモードは提供していません。2. OnOffPatchworkモード
SimplePatchworkはシンプルに組み合わせる機能のため、汎用性が高い反面、選択ロジックが長くなったり、SQLファイルに本当に継ぎはぎのパーツを記載されて可読性が下がることが懸念されます。
この懸念事項の解決策として提供する機能がOnOffPatchworkモードです。
このモードでは、Query-pieceをSQLファイルで記載されている順 につなぎ合わせます。
そしてつなぎ合わせる際に、Query-pieceを指示したものだけをつなげます。
Point 1
- 必ず選択するQueryPieceは
@startと@endの指定を省略できます(省略した部分には、__defaultというIDが付与されます)。Point 2
- 複数のQuery-pieceに同じIDを付与できます
- 一つのQuery-pieceに対して、
/区切りで複数のIDを付与できます。Point 3
- 使用する(Onにする)IDを選択すれば該当するQuery-pieceをつなげます(SQLファイルに記載された順につなげます)。
SQLファイルに導入した独自文法
以下の三つのみです。
/*@start someID*/: someIDというIDをもつQuery-Pieceの開始を宣言します。/*@end*/: Query-Pieceの終了を宣言します。@@: 同じQuery-Pieceの利用回数の数値で置換されます。0,1とインクリメントされていきます。詳しくはSimplePatchworkのPoint2をご確認ください。(SimplePatchworkモードのみの機能です)。(2020.08.21追記ここから)
SQLファイルなしで本機能を使う場合は、@@キーワードのみを覚えればよいですね。
(2020.08.21追記ここまで)ライセンス
MITです。どうぞご利用ください!
想定問答
Q1. SimplePatchworkとOnOffPatchworkをどう使い分けたらよい?
A1. OnOffPatchworkの方が、Golangに実装するロジックも、SQLの構文もスッキリし、可読性が高くなると思います。可能な限りOnOffPatchworkの利用を推奨します。(当初、OnOffPatchworkのみ実装予定でしたが、これではMultirow-insertを実現できないため、SimplePatchworkを作りました。)Q2. IN句の動的に展開したい場合はSimplePatchworkを利用すべきでしょうか。
A2. 既存のDBアクセスライブラリによっては、IN句のSlice型バインド変数を展開する機能が存在します。それを利用すればOnOffPatchworkの利用でも動的に展開することは可能です。Q3. IN句の複数列指定の場合(
(col1, col2) IN ((?,?),...,(?,?))の場合)はどうでしょうか。
A3. おそらく既存のDBアクセスライブラリは対応できていないため、SimplePatchworkでツギハギしてください。(2020.08.21追記ここから)
Q4. バルクインサートでちょっと使いたいだけなのにSQLファイル必要なの?
A4. SQLファイルなしで利用できるようアップデートしました。
(2020.08.21追記ここまで)課題・TODO(もしよければご協力ください)
- 内部はかなり泥臭い処理になってます。特に[]byte型とstring型を行ったり来たりしてます。この変換ってパフォーマンス大丈夫かなと気になってますが力及ばず。
- 英語に自信なし!
- テストのカバレッジは90%。100%までちょくちょく足していきます。
最後に
やっぱり自分で手を動かしてつくるとわかんないところ多くて苦労しました。いい勉強になった!
Golang初学者が作成したため、上記課題に限らず改善箇所の宝庫のはずw
変なところあればコメントやPR頂けると嬉しいです。使用方法デモ
お待たせしました。こんな感じで使っていただければ。
SimplePatchwork
package main import ( "fmt" "log" "github.com/bubusuke/sqlpatchwork" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type hoge struct { Col1 int `db:"col1"` Col2 string `db:"col2"` } func main() { hoges := []hoge{ {Col1: 100, Col2: "foo"}, {Col1: 200, Col2: "bar"}, {Col1: 300, Col2: "hoge"}, } spw, err := sqlpatchwork.NewSimplePatchwork("./sqls/simplePatchwork.sql") if err != nil { fmt.Println(err) } bindMap := make(map[string]interface{}) if err := spw.AddQueryPiecesToBuild("prefix"); err != nil { fmt.Println(err) } for i, h := range hoges { if i != 0 { if err := spw.AddQueryPiecesToBuild("loopDelim"); err != nil { fmt.Println(err) } } if err := spw.AddQueryPiecesToBuild("loopVal"); err != nil { fmt.Println(err) } bindMap[sqlpatchwork.LoopNoAttach("col1_@@", i)] = h.Col1 bindMap[sqlpatchwork.LoopNoAttach("col2_@@", i)] = h.Col2 } fmt.Println("==========================") fmt.Println("TargetIDs are") fmt.Println(spw.TargetIDs()) fmt.Println("==========================") fmt.Println("BuildingQuery is") fmt.Println(spw.BuildQuery()) fmt.Println("==========================") fmt.Println("BuildingQueryWithTrace is") fmt.Println(spw.BuildQueryWithTraceDesc()) fmt.Println("==========================") fmt.Println("BindMap is") fmt.Println(bindMap) fmt.Println("==========================") insertDemoByUsingSqlx(spw.BuildQuery(), bindMap) } //insertDemoByUsingSqlx is using jomoiron/sqlx to execute SQL. func insertDemoByUsingSqlx(query string, bindMap map[string]interface{}) { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } db.MustExec("DROP TABLE IF EXISTS hoge_table") db.MustExec("CREATE TABLE hoge_table (col1 int, col2 varchar(25))") tx := db.MustBegin() _, err = tx.NamedExec(query, bindMap) if err != nil { log.Fatalln(err) } tx.Commit() hoges := []hoge{} err = db.Select(&hoges, "SELECT col1, col2 FROM hoge_table ORDER BY col1") if err != nil { log.Fatalln(err) } fmt.Println("==========================") fmt.Println("The result of query execution is") fmt.Println(hoges) fmt.Println("==========================") } // The file content of ./sqls/simplePatchwork.sql // -------------------------------------------- // /*@start prefix */ // INSERT INTO hoge_table (col1, col2) VALUES // /*@end*/ // /*@start loopVal */ // (:col1_@@,:col2_@@) // /*@end*/ // /*@start loopDelim */ // , // /*@end*/ // STDOUT // -------------------------------------------- // ========================== // TargetIDs are // [prefix loopVal loopDelim loopVal loopDelim loopVal] // ========================== // BuildingQuery is // INSERT INTO hoge_table (col1, col2) VALUES (:col1_0,:col2_0) , (:col1_1,:col2_1) , (:col1_2,:col2_2) // ========================== // BuildingQueryWithTrace is // INSERT /* ./sqls/simplePatchwork.sql [prefix loopVal loopDelim loopVal loopDelim loopVal] */ INTO hoge_table (col1, col2) VALUES (:col1_0,:col2_0) , (:col1_1,:col2_1) , (:col1_2,:col2_2) // ========================== // BindMap is // map[col1_0:100 col1_1:200 col1_2:300 col2_0:foo col2_1:bar col2_2:hoge] // ========================== // ========================== // The result of query execution is // [{100 foo} {200 bar} {300 hoge}] // ==========================OnOffPatchwork
package main import ( "fmt" "log" "github.com/bubusuke/sqlpatchwork" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type req struct { ItemType string `db:"item_type"` ColorCode string `db:"color_code"` } type Res struct { ItemCode int `db:"item_code"` SalesDate string `db:"sales_date"` Count int `db:"count"` } func buildQuery(r *req, isTraceDesc bool) string { spw, err := sqlpatchwork.NewOnOffPatchwork("./sqls/onoffPatchwork.sql") if err != nil { fmt.Println(err) } if r.ItemType != "" { spw.AddQueryPiecesToBuild("itemTypeNotNil") } if r.ColorCode != "" { spw.AddQueryPiecesToBuild("colorCodeNotNil") } fmt.Println("==========================") fmt.Println("request is") fmt.Println(r) fmt.Println("==========================") fmt.Println("TargetIDs are") fmt.Println(spw.TargetIDs()) fmt.Println("==========================") fmt.Println("BuildingQuery is") fmt.Println(spw.BuildQuery()) fmt.Println("==========================") fmt.Println("BuildingQueryWithTrace is") fmt.Println(spw.BuildQueryWithTraceDesc()) fmt.Println("==========================") if isTraceDesc { return spw.BuildQueryWithTraceDesc() } return spw.BuildQuery() } func main() { resetDB() req1 := &req{ItemType: "T-shirts"} query1 := buildQuery(req1, false) selectDemoByUsingSqlx(query1, req1) req2 := &req{ColorCode: "W"} query2 := buildQuery(req2, false) selectDemoByUsingSqlx(query2, req2) req3 := &req{} query3 := buildQuery(req3, false) selectDemoByUsingSqlx(query3, req3) } //resetDB reset DB Data. func resetDB() { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } db.MustExec("DROP TABLE IF EXISTS sales_tran") db.MustExec("DROP TABLE IF EXISTS item_master") db.MustExec("CREATE TABLE item_master (item_code varchar(4), item_type varchar(25), color_code varchar(2))") db.MustExec("INSERT INTO item_master VALUES " + "('1000', 'T-shirts', 'B')" + ",('2000', 'T-shirts', 'W')" + ",('3000', 'pants', 'W')") db.MustExec("CREATE TABLE sales_tran (item_code varchar(4), sales_date date)") db.MustExec("INSERT INTO sales_tran VALUES " + "('1000','2020-08-20')" + ",('1000','2020-08-20')" + ",('1000','2020-08-19')" + ",('2000','2020-08-20')" + ",('2000','2020-08-19')" + ",('2000','2020-08-19')" + ",('3000','2020-08-19')" + ",('3000','2020-08-18')" + ",('3000','2020-08-18')") } //selectDemoByUsingSqlx is using jomoiron/sqlx to execute SQL. func selectDemoByUsingSqlx(query string, rq *req) { db, err := sqlx.Connect("postgres", "user=postgres password=pass dbname=postgres sslmode=disable") if err != nil { log.Fatalln(err) } rows, err := db.NamedQuery(query, rq) if err != nil { log.Fatalln(err) } fmt.Println("==========================") fmt.Println("The result of query execution is") res := Res{} for rows.Next() { err := rows.StructScan(&res) if err != nil { log.Fatalln(err) } fmt.Println(res) } fmt.Println("==========================") } // The file content of ./sqls/onoffPatchwork.sql // -------------------------------------------- // SELECT // s.item_code // , s.sales_date // , COUNT(*) AS count // FROM // sales_tran s // /*@start itemTypeNotNil/colorCodeNotNil */ // INNER JOIN // item_master i // ON // i.item_code = s.item_code // /*@end*/ // WHERE 1=1 // /*@start itemTypeNotNil*/ // AND i.item_type = :item_type // /*@end*/ // /*@start colorCodeNotNil*/ // AND i.color_code = :color_code // /*@end*/ // GROUP BY // s.item_code // , s.sales_date // ORDER BY // s.item_code // , s.sales_date // STDOUT // -------------------------------------------- // ========================== // request is // &{T-shirts } // ========================== // TargetIDs are // [__default itemTypeNotNil] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.item_type = :item_type GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default itemTypeNotNil] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.item_type = :item_type GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {1000 2020-08-19T00:00:00Z 1} // {1000 2020-08-20T00:00:00Z 2} // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // ========================== // ========================== // request is // &{ W} // ========================== // TargetIDs are // [__default colorCodeNotNil] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.color_code = :color_code GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default colorCodeNotNil] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s INNER JOIN item_master i ON i.item_code = s.item_code WHERE 1=1 AND i.color_code = :color_code GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // {3000 2020-08-18T00:00:00Z 2} // {3000 2020-08-19T00:00:00Z 1} // ========================== // ========================== // request is // &{ } // ========================== // TargetIDs are // [__default] // ========================== // BuildingQuery is // SELECT s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s WHERE 1=1 GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // BuildingQueryWithTrace is // SELECT /* ./sqls/onoffPatchwork.sql [__default] */ s.item_code , s.sales_date , COUNT(*) AS count FROM sales_tran s WHERE 1=1 GROUP BY s.item_code , s.sales_date ORDER BY s.item_code , s.sales_date // ========================== // ========================== // The result of query execution is // {1000 2020-08-19T00:00:00Z 1} // {1000 2020-08-20T00:00:00Z 2} // {2000 2020-08-19T00:00:00Z 2} // {2000 2020-08-20T00:00:00Z 1} // {3000 2020-08-18T00:00:00Z 2} // {3000 2020-08-19T00:00:00Z 1} // ==========================




