golangci-lint に独自の Linter を統合する

お久しぶりです

ちゃんと元気に生きています。 1ヶ月ほど禁酒していて体の調子がすごく良いです。その代わりアイスの消費量が増えました。毎日ハーゲンダッツとかガリガリ君とか食べてます。

独自Linterをgolangci-lintと統合する手順

まずこの記事でカバーする範囲について補足します。

概要

プロジェクトで Linter として golangci-lint を使用している場合、他の Linter と合わせてプロジェクト独自の Linter を追加し適用させたい、という機会があるかもしれない。 ここでは golangci-lint 用の独自Linter を書いたあと、それを golangci-lint 上で使用できるようにするための手順をまとめた。

環境

本記事は以下の環境で動作確認した。なお golangci-lint のソースインストールが必要なため Windows 環境では動作しない可能性がある(手元に環境がないため検証できなくてすみません)。

ハマりどころ

  • Linter プラグインが依存するパッケージのバージョンをすべて golangci-lint と同一にしなければならない。
    • golangci-lint 本体のバージョンが変わると、プラグイン側の依存パッケージのバージョンも要変更になる可能性がある

大まかな説明の流れ

  • golangci-lint インストール
  • 独自 Linter パッケージを作成
  • プラグインファイルを生成
  • Linter を適用するプロジェクトの作成
  • Linter を適用

ディレクトリ構成

ホームディレクトリ以下に work という作業用ディレクトリを作成し、それ以下で作業を進めた。本記事の手順を試すにはまず任意の場所に空の work ディレクトリ(名前は何でも良い)を作成してから始めれば良い。最終的には以下のようなディレクトリ構成になる。

  • example-plugin-linter: プラグインのコード
  • hello: Linter の適用対象となるサンプルプロジェクト
  • plugins: golangci-lint に組み込み可能なプラグインを配置する場所
work
├── example-plugin-linter
│   ├── README.md
│   ├── example.go
│   ├── go.mod
│   ├── go.sum
│   ├── lint_test.go
│   ├── plugin
│   │   └── example.go
│   ├── testdata
│   │   └── src
│   │       └── testlintdata
│   │           └── todo
│   │               └── todo.go
│   └── tools.go
├── hello
│   ├── .golangci.yml
│   ├── go.mod
│   └── main.go
└── plugins
    └── example.so

準備

PATH$GOPATH/bin を追加しておく。

$ export PATH=$PATH:$GOPATH/bin

golangci-lint インストール

今回は v1.42.0 を使用する。 カスタム Linter プラグインgolangci-lint に組み込むには、本体をソースからインストールする必要がある(といっても go install を使ってインストールするだけ)。バイナリインストールでは Plugin の組み込みができないようだ。 インストール方法の具体的な解説は Install | golangci-lint に掲載されているが、以下の通りに実行すれば良い。 なお、gcc を必要に応じてインストールする。

$ go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42.0

$ golangci-lint --version
golangci-lint has version v1.42.0 built from (unknown, mod sum: "h1:hqf1zo6zY3GKGjjBk3ttdH22tGwF6ZRpk6j6xyJmE8I=") on (unknown)

Linter プラグインの作成

今回は golangci/example-plugin-linter: example linter that can be used as a plugin for github.com/golangci/golangci-lint のサンプル Linter を使用する。

ちなみにこの LInter は、author の指定がない TODO: コメントを検知するためのもの。以下のような挙動をする。

// TODO: author がないので NG
// TODO(): author がないので NG
// TODO(dareka): これは author を指定しているからOK
main.go:5:1: todo: TODO comment has no author (example)
// TODO: author がないので NG
^
main.go:6:1: todo: TODO comment has no author (example)
// TODO(): author がないので NG
^

というわけで、Linter の構築を進める。

$ git clone https://github.com/golangci/example-plugin-linter.git
$ cd example-plugin-linter

このサンプルを元に、プラグインファイル(*.so)を生成する。

生成するコマンド自体は簡単。

$ go build -buildmode=plugin -o (出力先) (AnalyzerPlugin が定義された .go ファイル)

ただハマりどころなのは、このプラグインが依存するパッケージのバージョンを、golangci-lint 本体のそれと完全に合わせておく必要があるというだ。少しでもバージョンが異なると Linter を適用する段階でエラーとなる。

例えば、Linter のプラグインを書く時に不可欠な go/analysisgolang.org/x/tools パッケージに含まれるが、golangci-lint が依存している golang.org/x/tools のバージョンは、本体のバージョンによって変わる。

(golangci-lint v.1.41.1 の場合)

$ go version -m /home/egawata/go/bin/golangci-lint | grep golang.org/x/tools
    dep golang.org/x/tools  v0.1.3    h1:L69ShwSZEyCsLKoAxDKeMvLDZkumEe8gXUZAjab0tX8=

(golangcl-lint v1.42.0 の場合)

$ go version -m /home/egawata/go/bin/golangci-lint | grep golang.org/x/tools
    dep golang.org/x/tools  v0.1.5    h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=

そして、プラグイン側も golang.org/x/tools のバージョンが上記のものに合うよう、go.mod を調整しておく必要がある。

とはいえ、 go version -m $GOPATH/bin/golangci-lint を実行してすべてのパッケージのバージョンを確認し、go.mod 側を1つずつ書き換えていくのはあまり現実的ではない。そこで以下の方法で、確実にバージョンを合わせていく方法を取る。

(なお、この手順の意味については 参考1 が分かりすい)

golangci-lint パッケージをブランクインポート

tools.go というファイルをプラグインプロジェクトのルートに作成し、以下の内容を記述する。これにより、このプラグインプロジェクトが golangci-lint に依存することを宣言できる。もちろん実際に参照している箇所はないのでブランクインポートで良い。 (tools.go というファイル名や tools というパッケージ名は何でも良いのだが、慣習的に tools を使用するらしい)

// +build tools

package tools

import (
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
)

golangci-lint の依存パッケージのバージョンを go.mod に反映させる

いったん go.mod を捨てて作りなおす。 4行目の golangci-lint のバージョンには、インストールしたバージョンと同一のものを指定するところがポイント。 また go mod tidy と順番を逆にすると正しいバージョンのパッケージが入らないので注意。

$ rm go.*
$ go mod init example
$ go clean -modcache
$ go get -d github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42.0
$ go mod tidy

プラグインファイルの出力

$ mkdir ../plugins     # なければ作る
$ go build -buildmode=plugin -o ../plugins/example.so plugins/example.go

-o に出力先を指定する。このパスはあとで設定に使用する。

Linter 適用対象のプロジェクトを作る

ここではサンプルプロジェクトとして hello というものを新規に作成する。もし既存のプロジェクトがあるならそれを利用してみても良い。

$ cd ~/work/
$ mkdir hello
$ cd hello
$ go mod init hello

この下に、チェック対象のソースコード(main.go)を以下のように作成する。 example Linter の検出対象となるよう、main() の前に問題のあるコメントを入れておく。

package main

import "fmt"

// TODO: implement here
func main() {
    fmt.Println("Hello")
}

Linter が example プラグインを使用するよう、 .golangci.yml に設定を追加する(なければ新規作成)

linters-settings:
  custom:
    example:
      path: ../plugins/example.so
      description: Check TODO without author
      original-url: github.com/golangci/example-linter

linters:
  enable:
    - example

linters-settings.custom 以下に Plugin 情報を設定する。pathプラグインの位置(絶対パス、もしくはhello プロジェクトからの相対パス)を設定する。 また linters.enable に、使用するプラグイン名を追加する。

Linter を適用

hello プロジェクトのルートディレクトリで golangci-lint 実行。正しく適用されていることが分かる。

$ golangci-lint run ./...
main.go:5:1: todo: TODO comment has no author (example)
// TODO: implement here
^

補足

ここでは、作成した独自 Linter を golangci-lintプラグインとして統合する方法を説明した。しかし今回の方法は、やや手順が煩雑だということのほかに懸念点が一つある。

それは golangci-lint をソースインストールする必要があるという点で、この方法はいくつか問題を抱えているようだ。

Install | golangci-lint

Note: such go get installation aren't guaranteed to work. We recommend using binary installation.

公式ドキュメントではその理由をいくつか挙げた上で、ソースインストールではなくバイナリインストールを推奨しているようだ。公式が推奨しない方法で利用を続けていくというのはいささか不安を拭えない。

独自 Linter を利用する方法がこれしかない、ということであれば素直に従う必要があるのだろうが、go vet から起動する など他のお手軽な方法もある。今回取り上げた方法はあくまで選択肢の一つとしてとらえるのが丁度良いのかもしれない。

参考サイト

レビューについての雑感

  • レビューは人に対して行うものではありません。人の書いたコードに対して行うものです。
  • レビューは人を攻撃するために行うものではありません。その人の書いたコードをより良くするために行うものです。
  • レビューは自身の技術力を自慢するために行うものではありません。ただし、その知識を共有することは、行き過ぎさえなければ非常に良いことです。そして、それが必須でない限り、それを他人に強制すべきではありません。
  • コードの改変にともなうコストを意識すべきです。その機能の必要性と、それを実現するためのコストはバランスがとれているべきで、それを意識したレビューをすべきです。

かわいいスタックトレース in Perl

デバッグしているとき、スタックトレースはとても役に立つ。

でも...

f:id:taegawa:20180905234802p:plain

でも、読みたくない。

そこでちょっと見やすくしてみた。

github.com

f:id:taegawa:20180905235045p:plain

標準入力から読み込むだけのシンプルなもの。
もしそのファイルが存在すれば、中身を表示するようにした。

これで地味に作業がはかどるようになりそう。

zsh の配列の index は1から始まる

夏ですね

地下鉄の駅で電車待ってるとき、ついホームのところどころにあるエアコンの前に立っちゃいますよね!
あと、弱冷房車はあるのになんで強冷房車はないんだって時々考えてしまいますよね!

本題

というわけでまずは以下の実行結果を。

$ bash
$ ar=(Apple Banana Coconut Durian)
$ echo ${ar[1]}
Banana
$ zsh
$ ar=(Apple Banana Coconut Durian) 
$ echo ${ar[1]}
Apple

配列 ar にいくつかの要素を格納して、その index = 1 の要素を取り出すと、bashzsh で取れる要素が異なるという(汗
zshのindexは1から始まるようだ。

ところでなんで他の多くのshellが配列の添字を0から始めているのにzshは1から始めているんだろうか。
いろいろ調べてみたところ、どうやら csh(同じく1から始まる) の影響なのではないか、と。

Re: zero- vs one- based array indexing?

> Can someone give me a quick history of why zsh defaults to one-based
> array indexing? Bash and ksh appear to use zero-based indexing, and
> probably most CS types prefer that too.

Basically from csh, I think. Arrays were a bit of an afterthought in
earlier Bourne-style shells, so zsh didn't take much notice.

1から始めるようにしたことについてそんなに深く考えたわけではなさそうだ、とのこと。
実際のところどうなんだろう。

ちなみに debian で採用されている dash では、高速軽量を目的としているからなのか、そもそも配列は使えない。

set -u : 未定義の変数を使用しようとしたらエラーにする

typoなんてのは普段よくあることで、そいつに無駄な時間をとられてしまうことが日常茶飯事だ。

#!/usr/bin/env bash

MY_NAME="Miku"
MY_BIRTHDAY="10/19"
MY_HOBBY="Dance"

echo $MY_NAME
echo $MY_BIRTDAY
echo $MY_HOBBY

上記の出力結果はどうなるだろうか。上から"Miku" "10/19" "Dance"と表示されそうなものだが、実際はこうなる。

Miku

Dance

MY_BIRTHDAYが出力されていないが、これは変数名を入力し間違えているから。
こういうtypoに気付きやすくするために、set -u を指定すると良い。

#!/usr/bin/env bash

set -u

MY_NAME="Miku"
MY_BIRTHDAY="10/19"
MY_HOBBY="Dance"

echo $MY_NAME
echo $MY_BIRTDAY
echo $MY_HOBBY

上記を実行すると、以下のようなエラーとなり、該当箇所で変数を入力し間違えていることに簡単に気づける。

Miku
miku.sh: line 10: MY_BIRTDAY: unbound variable

ただ、定義済みと思い込んでいた変数をうっかり使ってしまい上記のエラーに遭遇する場合も多々ある。例えば

#!/usr/bin/env bash

set -u

PERLLIB=$PERLLIB:$HOME/mylib

...

PERLLIB は Perl においてモジュールパスを指定する環境変数だが、常に定義済みとは限らず、Perl を普段から使っている人だと未定義の場合でもつい上記のように使ってしまいエラーに遭遇しやすいかもしれない(*1)。その場合は問題の箇所のみ set -u の宣言前に移動するとよい。

(*1) 昨日のオイラ

Many-to-many関係を使う

今回は GORM で Many-to-many 関係を扱う。

複数のユーザと複数の楽曲が用意されていて、各ユーザのお気に入りの楽曲を格納できるようにする。E-R図にするとこんな感じ。

f:id:taegawa:20170114140153p:plain

とりあえずコードはこんな感じ。

package main

import (
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

const (
	dsn = "root@/association?parseTime=true&loc=Asia%2fTokyo"
)

type User struct {
	gorm.Model
	Name      string
	FavMusics []Music `gorm:"many2many:user_fav_musics;"`
}

type Music struct {
	gorm.Model
	Title string
}

func main() {
	db := prepare()
	defer db.Close()

	for _, id := range []uint{1, 2, 3} {
		var user User
		db.First(&user, id).Related(&user.FavMusics, "FavMusics")
		fmt.Println("---------------------------")
		fmt.Printf("%s さんのお気に入り曲\n", user.Name)
		for _, music := range user.FavMusics {
			fmt.Printf("曲名: %s\n", music.Title)
		}
	}
	fmt.Println("---------------------------")
}

func prepare() *gorm.DB {
	db, err := gorm.Open("mysql", dsn)
	if err != nil {
		panic("Failed to connect database")
	}
	//db.LogMode(true)
	db.DropTableIfExists(&User{}, &Music{}, "user_fav_musics")

	db.AutoMigrate(&User{})
	db.AutoMigrate(&Music{})

	prepareUsers(db)
	prepareMusics(db)
	prepareFavMusics(db)

	return db
}

func prepareUsers(db *gorm.DB) {
	var usernames = []string{"Mario", "Luigi", "Peach"}

	for _, username := range usernames {
		user := User{
			Name: username,
		}
		db.Create(&user)
	}
}

func prepareMusics(db *gorm.DB) {
	var titles = []string{
		"放課後ロマンス",
		"私Loveなオトメ",
		"ニーハイエゴイスト",
		"禁断無敵のダーリン",
		"S・M・L",
	}

	for _, title := range titles {
		music := Music{
			Title: title,
		}
		db.Create(&music)
	}
}

func prepareFavMusics(db *gorm.DB) {
	//  お気に入り楽曲
	//  {user.ID, music.ID} の組
	var favorites = [][]uint{
		{1, 1}, {1, 2}, {1, 4},
		{2, 2}, {2, 3}, {2, 5},
		{3, 2}, {3, 5},
	}
	for _, favorite := range favorites {
		var user User
		user.ID = favorite[0]

		var music Music
		music.ID = favorite[1]

		db.Model(&user).Association("FavMusics").Append(&music)
	}
}

(実行結果)

---------------------------
Mario さんのお気に入り曲
曲名: 放課後ロマンス
曲名: 私Loveなオトメ
曲名: 禁断無敵のダーリン
---------------------------
Luigi さんのお気に入り曲
曲名: 私Loveなオトメ
曲名: ニーハイエゴイスト
曲名: S・M・L
---------------------------
Peach さんのお気に入り曲
曲名: 私Loveなオトメ
曲名: S・M・L
---------------------------

Model の定義

type User struct {
	gorm.Model
	Name      string
	FavMusics []Music `gorm:"many2many:user_fav_musics;"`
}

type Music struct {
	gorm.Model
	Title string
}

モデルはユーザ(User)と楽曲(Music)のみ定義する。User 側に FavMusics というフィールドを追加し、ここにお気に入りの楽曲が格納されるようにする。また FavMusics フィールドのタグで、many-to-many 関係を使用すること、および関係テーブルの名称を上記のように宣言する。AutoMigration 使用時は GORM が関係テーブル user_fav_musics を自動的に作成してくれる。

検索

		db.First(&user, id).Related(&user.FavMusics, "FavMusics")

特定ユーザに紐づくお気に入り楽曲を取得する場合は、Related()の第2引数でフィールド名を指定する。LogMode を true にすれば、上記を実行したとき以下のようなSQL文が発行されているのが分かる。

User 検索

SELECT * 
  FROM `users`  
 WHERE `users`.deleted_at IS NULL 
    AND ((`users`.`id` = '3')) 
 ORDER BY `users`.`id` ASC LIMIT 1

User のお気に入り楽曲を検索

SELECT `musics`.* 
  FROM `musics` 
    INNER JOIN `user_fav_musics` ON `user_fav_musics`.`music_id` = `musics`.`id` 
 WHERE `musics`.deleted_at IS NULL 
    AND ((`user_fav_musics`.`user_id` IN ('3'))) 
 ORDER BY `musics`.`id` ASC

関係の追加

		var user User
		user.ID = favorite[0]

		var music Music
		music.ID = favorite[1]

		db.Model(&user).Association("FavMusics").Append(&music)

多分 Association Mode を使う方法が一番素直なのではないかと。Association() の引数で関係を定義したフィールド名を指定し、Append()で追加する。ちなみに user, music は、両者の関連を特定するために必要なフィールド(ここではID)だけ定義されていれば十分。あらかじめすべてのフィールドをDBから問い合わせておく必要はない。

INSERT INTO `user_fav_musics` (`music_id`,`user_id`) 
SELECT '1','1' 
  FROM DUAL 
 WHERE NOT EXISTS 
    (SELECT * FROM `user_fav_musics` WHERE `music_id` = '1' AND `user_id` = '1')

発行されるSQL文は上記の通り。同じ組み合わせがすでに存在する場合、二重に登録されることはないようだ。

Association で任意の外部キー名を使う

初詣行ってきました

大吉引いて幸先良いです。
あと、巫女さんが可愛かったです。

本題

GORM の Association 関連で、任意の外部キー名を使用しようとしてハマったのでメモ。

標準の外部キー名を使う方法

その前に、まずは Convention に従った外部キー名を使う方法。
これはGORMのマニュアルにも書かれている通り。今回は Belongs-to の関連で試してみる。

f:id:taegawa:20170108063437p:plain

今回は上記の通り、ユーザ(users)と、それに紐づくアイコンイメージ(images)をデータとして保持できるようにする。アイコンイメージには任意のイメージ名と、そのURLを持っている。

※以下の例では association という database を使用する。もともと同名の database があって、かつ users, images というテーブルがあると一旦削除されてしまうので注意。

package main

import (
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

const (
	dsn = "root@/association?parseTime=true&loc=Asia%2fTokyo"
)

type User struct {
	gorm.Model
	Name    string
	Image   Image
	ImageID uint
}

type Image struct {
	gorm.Model
	Name string
	Url  string
}

func main() {
	db := prepare()
	defer db.Close()

	for _, id := range []uint{1, 2, 3} {
		var user User
		fmt.Println("-------------------------------")
		db.First(&user, id).Related(&user.Image)
		fmt.Printf("名前    : %s\n", user.Name)
		fmt.Printf("アイコン:\n")
		fmt.Printf("  名称: %s\n", user.Image.Name)
		fmt.Printf("  URL : %s\n", user.Image.Url)
	}
	fmt.Println("-------------------------------")
}

func prepare() *gorm.DB {
	db, err := gorm.Open("mysql", dsn)
	if err != nil {
		panic("Failed to connect database")
	}
	db.DropTableIfExists(&User{}, &Image{})

	db.AutoMigrate(&Image{})
	db.AutoMigrate(&User{})

	var members = []map[string]string{
		{"Name": "ミク", "ImageFile": "miku.jpg"},
		{"Name": "マホ", "ImageFile": "maho.jpg"},
		{"Name": "コヒメ", "ImageFile": "kohime.jpg"},
	}

	for _, member := range members {
		image := Image{
			Name: member["Name"] + "アイコン",
			Url:  "https://image.example.com/" + member["ImageFile"],
		}
		db.Create(&image)

		user := User{
			Name:    member["Name"],
			ImageID: image.ID,
		}
		db.Create(&user)
	}

	return db
}

実行結果

-------------------------------
名前    : ミク
アイコン:
  名称: ミクアイコン
  URL : https://image.example.com/miku.jpg
-------------------------------
名前    : マホ
アイコン:
  名称: マホアイコン
  URL : https://image.example.com/maho.jpg
-------------------------------
名前    : コヒメ
アイコン:
  名称: コヒメアイコン
  URL : https://image.example.com/kohime.jpg
-------------------------------

Userのモデルは以下のように定義されている。

type User struct {
	gorm.Model
	Name    string
	Image   Image
	ImageID uint
}

ここでは、フィールド Image が Image型であることが宣言されているが、その取得元レコードへの外部キーが ImageID であることは特に明示していない。また、問い合わせで

		db.First(&user, id).Related(&user.Image)

としているが、Related()で関連レコードを取得する際にも、外部キーが ImageID であることを明示していない。

それでも Related() が問題なく外部キーを特定し関連レコードを取得できるのは、特に指示のない限り、

  1. 関連モデルの名称 + 'Id'
  2. 自身のモデルの名称 + 'Id'

を外部キーとみなすようになっているから。
ここでは Image が Image型であるため、

  1. ImageID
  2. UserID

の順に外部キーを探していき、同名のキーが見つかった時点でそれを使用する。

任意の外部キーを使う方法

ところが、外部キーの名称を (モデル名) + 'Id' 「以外」にしたいケースというのも多々ある。例えば今回はアイコンイメージのIDを ImageID というキー名で参照しているが、これはあまり良い名称とは言えない。アイコンイメージであるなら分かりやすく IconImageID としたほうがいいし、また背景画像など、同じ Image モデルを参照する別のフィールドが追加されたときは、少なくともどちらかは ImageID 以外の外部キー名を使わなければならない。

そこで User モデルの ImageID を IconImageID に変更してみる。

type User struct {
	gorm.Model
	Name        string
	IconImage   Image
	IconImageID uint
}

名称変更自体はこれで良いのだが、IconImageID というキー名はこのままでは外部キーとして認識されない。これを解決する方法はいくつかあるが、GORM ドキュメントの Association の項で触れられている範囲内では2つのやり方がある。

Related() で明示的に外部キーを指定する方法

Related() の第2引数で外部キー名を明示的に指定することができる。

        db.First(&user, id).Related(&user.IconImage, "IconImageID")

お手軽。

Association Mode を使う

モデルを宣言する際、モデル間の関連についてタグで指定することができる。

type User struct {
	gorm.Model
	Name        string
	IconImage   Image `gorm:"ForeignKey:IconImageID"`
	IconImageID uint
}

上記のようにすると、「IconImage でレコードを取得する際は、外部キーとして IconImageID を使用する」と明示的に指示できる。
そして関連レコードを取得する際は、

        db.First(&user, id).Association("IconImage").Find(&user.IconImage)

のように、Association で取得対象のフィールド名を指定すれば、タグで指定したものが外部キーとして使用される。

※修正済みのソースはこちら