Simple minds think alike

より多くの可能性を

Go Language Specification 輪読会の感想と学んだこと(型アサーション、スライス式)

先日、初めてGo Language Specification 輪読会 (#14) 2021/1/27開催に参加した感想と、型アサーションスライス式に関して学んだことを書いてみたいと思います。

Go Language Specification 輪読会に参加した感想

The Go Programming Language Specification を読んでいる時に、この英文どう解釈すれば良いんだろう。。と困るようなところで

  • Go言語強い方がいて、たぶんこういう意味だろうという推測が付いたり
  • 英語強い方がいて、表現に関して教えてもらえたり

1人で読んでいても分からない点がクリアになって凄くためになり、これが集まって輪読会をする利点なんだなぁと実感しました。

もちろん、輪読会の中で知った言語仕様に関しては勉強になりましたが、特に参加して良かったと感じたのは、Go Language Specificationに出てくる用語について、他の参加者の方が解説してくれていたことです。

具体的には

  • primary expression という表現が良くでてきますが、ここのことだよーと教えてくれた
  • addressable というのはこういうことだよー、とサンプルコードを書いてに関して教えてもらえた

という感じです。

そこで思ったのは「魚を与えるのでなく、釣り方を教えよ」という言葉がありますが、よく出てくる用語が分かり、自分で読むための力がつくのでこういうことを教えてもらえると凄く役に立つなと思いました。

あと、アットホームな感じでとても参加しやすく感じました。

学んだこと

言語仕様なのであまり実用性がない箇所もありますが、学んだ点に関しても4点共有してみたいと思います。

アサーション(Type Assertion)

①インタフェースで型アサーションするとき、動的型変数はインタフェースを実装していることを検証する

言語仕様の文章では

If T is an interface type, x.(T) asserts that the dynamic type of x implements the interface T.

という箇所で、具体的なコードを示すと

type Animal string
type Human string

type Thinkable interface {
    think()
}

func (Human) think() {}

func main() {
    // var thinkable interface{} = Human("安倍晋三") // こっちはOK
    var thinkable interface{} = Animal("My Cat") // こっちはNG

    var human = thinkable.(Thinkable)
    fmt.Println(human)
    // => panic: interface conversion:
    //    main.Animal is not main.Thinkable: missing method think
}

となって、型アサーションはインタフェースを実装しているかどうかもチェックしてくれる。へぇ。

簡易スライス式(Simple slice expressions)

nilスライスをスライスするとnilになる

言語仕様の文章では

If the sliced operand of a valid slice expression is a nil slice, the result is a nil slice.

という箇所。コードとしては

var slice []int
fmt.Println(slice[:] == nil)
=> true

という感じで確かに nil になる

ちなみに、要素がないスライスはどうなるんだろうと思って試してみたけど、この時は nil スライスではなく空のスライスなので nil にはならなかった。

slice := [5]int{1, 2, 3, 4, 5}
emptySlice := slice[0:0]
fmt.Println(emptySlice[:] == nil)
=> false

// 一応、容量が0になる場合も試してみたけど、関係なかった
emptySlice := slice[0:0:0]
fmt.Println(emptySlice[:] == nil)
=> false

③スライスされる配列はアドレス化可能(addressable) でなければならない

言語仕様の文章では

If the sliced operand is an array, it must be addressable.

という箇所。Addressableという用語は

The operand must be addressable, that is, either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array.

で、変数、ポインタ間接参照、スライスのインデックス操作、アドレス指定可能な構造体オペランドのフィールド・セレクタ、アドレス指定可能な配列の配列インデックス操作のいずれかということらしいので、試しに変数に入れずに配列をスライスしてみると

fmt.Println([3]string{"apple","banana","strawberry"}[:])
fmt.Println([]string{"apple","banana","strawberry"}[:]) 
=> ./prog.go:8:54: invalid operation [3]string literal[:]
   (slice of unaddressable value)

というように、たしかにビルド時のエラーになった。

完全スライス式(Full slice expressions)

④定数インデックスは負の数ではなく、int型の値で表現されなければならない

言語仕様の文章では

A constant index must be non-negative and representable by a value of type int;

という箇所。

例えばruneはint32のエイリアスでintタイプに該当するので、'🍣'みたいなものでもOK!

func main() {
    // intの要素の値が0で、長さ・容量が300,000のスライスを作る
    s3 := make([]int, 300_000)
    const c3 = '🍣'
    fmt.Println(c3)
    // => 127843

    // 長さが127,843のスライスができる    
    fmt.Println(s3[0:c3]) 
    fmt.Println(len(s3[0:c3])) 
    // => 127843
}

【Golang】ポインタの値で把握するスライスの挙動 ( スライス式 / copy / append ) について

前回のスライスと配列の特徴、違いについての記事に引き続き、今回はスライスの様々な操作の挙動に関して整理していきます。

simple-minds-think-alike.hatenablog.com

新しいメモリ領域の確保は比較的重い処理なので、できるだけ行われないように工夫してプログラムを実装したいです。

スライスのどの操作で新しいメモリ領域の確保されるかを把握することで、回数を減らす工夫に繋がり、効率的なプログラムを実装できるようになるかと思います。特にスライスはGo言語でも使用頻度の高いデータ構造なので効果性が高いです。

まとめ

先にまとめです。

  • 参照型の代入では、新しいメモリ領域を確保しない
  • 簡易スライス式・完全スライス式では、新しいメモリ領域を確保しない。
  • copy は、新しいメモリ領域を確保する。
  • append は、スライスの長さが容量を超えるまでは、新しいメモリ領域を確保しない。

1つづつ詳細を見ていきたいと思います。

スライスは参照型

まずはおさらいですが、スライスは参照型なので、以下のように他の変数に代入されても要素のそれぞれの値はコピーされず、変数peopleが持っている参照のみがコピーされます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 別のスライスに代入
people2 := people

// 2つのスライスが指す配列のポインタは同じ値になる。
fmt.Println(&people[0])
=> 0xc00006c150

fmt.Println(&people2[0])
=> 0xc00006c150

図の補足ですが、以下のコードから分かるようにスライスのDataのポインタと配列の1番目の要素のポインタは同じ値になるので、このような表現にしています。

people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// 配列の1番目の要素のポインタの値を表示
fmt.Println(&people[0])
=> 0xc000098150

// スライスのDataのポインタの値を表示 
// 10進数の824634343760は、16進数で0xC000098150になるので同じ値だと分かります
sh := (*reflect.SliceHeader)(unsafe.Pointer(&people))
fmt.Println(sh)
=> &{824634343760 3 3}

16進数の方がポインタの値だとイメージしやすいと思うので、コード上では配列の1番目の要素のポインタの値を確認していきます。

スライス式

簡易スライス式

簡易スライス式を使うと、配列やスライスの一部を抜き出して新しいスライスを作ることができます。

slice := []string{"apple", "banana", "peace"}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
partical := slice[1:3]
fmt.Println(partial)
=> [banana peace]

簡易スライス式でできたスライスは、新しいメモリ領域を確保せず、既存の配列の参照を持ちます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
people2 := slice[1:3]

// 元のスライスの2番目の要素のポインタと
// 新しくできたスライスの1番目の要素のポインタは同じ値になる。
fmt.Println(&people[1])
=> 0xc00006c160

fmt.Println(&people2[0])
=> 0xc00006c160

なので、新しくできたスライスの要素の値を変更すると元のスライスの値も変わります。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
people2 := slice[1:3]
// 新しいスライスの2番目の要素を変更する
people2[1] = "竈門 禰豆子(かまど ねずこ)"

// 元のスライスの3番目の要素を表示。
fmt.Println(people[2])
=> 竈門 禰豆子(かまど ねずこ)

完全スライス式

簡易スライス式では新しくできるスライスの容量は自動的に決まります(元の配列の参照していない範囲になる)が、完全スライス式を使うと容量を変えられます。 簡易スライスと同様に、新しいメモリ領域を確保せずに新しいスライスができます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字2-1(=1)までの要素で最後の要素2-1(1)のスライスを新しく作る。
// 容量は1になる。
people2 := slice[1:2:2]
// 新しいスライスの1番目の要素を変更する
people2[0] = "竈門 禰豆子(かまど ねずこ)"

// 元のスライスの2番目の要素を表示。
fmt.Println(people[1])
=> 竈門 禰豆子(かまど ねずこ)

// 新しいスライスの長さと容量を表示
fmt.Println(len(people2))
=> 1
fmt.Println(cap(people2)) // 簡易スライス式の場合は2
=> 1

簡易スライス式の場合でも、完全スライス式の場合でも、新しくメモリ領域を確保することはないことが分かります。

copy / append

スライスをコピーする

スライスを別の変数に代入したり、簡易スライス式で新しいスライスを作り、要素の値を変更すると元のスライスの値が変わるため、要素を新しいメモリ領域にコピーして使いたいことがあります。

そのような場合、copyやappendを使って、新しい領域を作って値を入れます。内容が同じでも、それぞれ異なる領域に要素の値を持っていることが分かります。

people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// スライスが指す配列の最初の要素のポインタを確認。
fmt.Println(&people[0])
=> 0xc00006c150

// makeで必要な分の領域を確保して、copyで値をコピーする
people2 := make([]string, len(people))
copy(people2, people)
fmt.Println(people2)
=> [竈門 炭治郎(かまど たんじろう) 我妻 善逸(あがつま ぜんいつ) 嘴平 伊之助(はしびら いのすけ)]

// 長さ、容量が3の新しいスライスができ、参照する配列は元のスライスとは別の領域。
fmt.Println(len(people2))
=> 3
fmt.Println(cap(people2))
=> 3
fmt.Println(&people2[0])
=> 0xc00006c180

// 空のスライスに要素をappendで入れる
people3 := append([]string(nil), people...)
fmt.Println(people3)
=> [竈門 炭治郎(かまど たんじろう) 我妻 善逸(あがつま ぜんいつ) 嘴平 伊之助(はしびら いのすけ)]

// 長さ、容量が3の新しいスライスができ、参照する配列は元のスライスとは別の領域。
fmt.Println(len(people3))
=> 3
fmt.Println(cap(people3))
=> 3
fmt.Println(&people3[0])
=> 0xc00006c1b0

既存のスライスよりも多くの要素を格納する場合、makeで十分な領域を確保して使うと、都度新しいメモリ領域の確保が発生せず、パフォーマンス的に良いです。

次にappendで新しい要素をスライスに追加した時にどのような場合に、新しい領域が確保されるか見ていきたいと思います。

appendの挙動

make でメモリ領域を確保して、append でスライスに要素を追加するコードをいくつか実行してみます。どのような場合に、新しいメモリ領域を確保し、どの程度の容量になるのかを把握しておくと良いです。

// 長さ3、容量4のスライスを作る
people := make([]string, 3, 4)
people[0] =  "竈門 炭治郎(かまど たんじろう)"
people[1] = "我妻 善逸(あがつま ぜんいつ)"
people[2] =  "竈門 炭治郎(かまど たんじろう)"

// この時点でのポインタの値を確認
fmt.Println(&people[0])
=> 0xc0000be040

// 要素を1つ追加
people = append(people, "竈門 禰豆子(かまど ねずこ)")

fmt.Println(people)
[竈門 炭治郎(かまど たんじろう) 
 我妻 善逸(あがつま ぜんいつ)
 竈門 炭治郎(かまど たんじろう)
 竈門 禰豆子(かまど ねずこ)]

// ポインタの値が変わっていないことを確認。
// 新しい領域は確保されず、既存の領域に値が入る。
fmt.Println(&people[0])
=> 0xc0000be040
// 長さ4、容量4になることを確認
fmt.Println(len(people))
=> 4
fmt.Println(cap(people))
=> 4

// 再度要素を1つ追加
people = append(people, "冨岡義勇(とみおかぎゆう)")
fmt.Println(people)
[竈門 炭治郎(かまど たんじろう)
 我妻 善逸(あがつま ぜんいつ)
 竈門 炭治郎(かまど たんじろう)
 竈門 禰豆子(かまど ねずこ)
 冨岡義勇(とみおかぎゆう)]

// 既存の2倍の容量で新しい領域が確保され
// そこに既存のデータが移されたうえで、要素が追加される
=> 0xc000108000
// 長さ5、容量8になる
fmt.Println(len(people))
=> 5
fmt.Println(cap(people))
=> 8

append の実装(2021/1/23時点)は下記の実装になっています。この処理の中で、新しい領域の確保が必要か決めていて、新しい領域を確保する必要があればgrowslice の実装の中で新しい領域が確保されます。

現在の実装では、容量が足りなくなると1 => 2 => 4 => 8 => 16 => …のように、倍々に新しい領域を確保していきます(容量が1,024までの場合)が、今後は挙動が変わるかもしれません。

スライス操作のテクニック

以下のSliceTricksという公式のwikiページに様々なスライス操作に関して記載されているので、一読しておくと、実業務で効率的なスライス操作を実装できるかと思います。

SliceTricks · golang/go Wiki · GitHub

参考資料

【Golang】スライスと配列の特徴、違いについて

スライスはGo言語の中でもっとも利用されるデータ構造だと思いますが、挙動が複雑で仕様を把握するのが難しく、利用頻度が高いがゆえによく分からず使っているとパフォーマンスの低下を招き易い機能と言えるかと思います。

スライス及び配列の特徴を把握することで、より実行効率の高いGoのコードを書けるようになるようになります。より詳細な情報を知りたい方は、以下の公式ブログのドキュメントを読まれることをお勧めします。

配列とスライスの特徴、違い

配列もスライスも、以下のような特定のデータ型(String等)の値を一定の長さの分だけ格納できるデータの入れ物のようなものができる点は同じです。

それぞれの特徴としては、主に

  • ①長さが固定か可変か
  • ②参照型かどうか

2つのが挙げられます。

①長さが固定か可変か

一番大きな違いとしては、最初に変数を定義した後、要素を増やしたりできるかどうかという点です。

  • 配列(固定長配列)
  • スライス(可変長配列)

以下で例を示しますが、配列・スライスのそれぞれの定義の仕方の相違点は

  • 配列: 長さの部分が固定または省略表記(etc. [3], [...])
  • スライス: 長さの部分が未指定、またはmakeで生成

という部分です。

【配列の場合】

//  長さの部分を3に固定して配列を定義
people := [3]string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 定義後長さを変えられず、要素を増やせない。appendできない。
// [エラー] => people = append(people, "竈門 禰豆子(かまど ねずこ)")

//  省略表記で配列を定義
people2 := [...]string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// peopleとpeople2は内容が同じ配列
reflect.DeepEqual(people, people2)
=> true

【スライスの場合】

//  長さの部分を未指定にしてスライスを定義
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 定義した後追加できる
people = append(people, "竈門 禰豆子(かまど ねずこ)")

//  makeを使って長さが4のスライスを定義
people2 := make([]string, 4)
people2[0] = "竈門 炭治郎(かまど たんじろう)"
people2[1] = "我妻 善逸(あがつま ぜんいつ)"
people2[2] = "嘴平 伊之助(はしびら いのすけ)"
people2[3] = "竈門 禰豆子(かまど ねずこ)"

// peopleとpeople2は内容が同じスライス
reflect.DeepEqual(people, people2)
=> true

②参照型かどうか

2つ目の違いは参照型かどうかです。配列とスライスは、定義の仕方がちょっと違うだけですが、関数に渡した時や変数に代入した時の挙動が異なります。

スライスは参照型であり、例えば関数の引数として指定した場合、参照が渡るため関数の中で値が変更されていれば、関数の外でも変更が反映されます。

配列の場合は、値渡しになるため関数内での変更が、関数の外に反映されません。(参照渡し・値渡しの違いに関しては別の記事で記載しています。)

【配列(値渡し)の場合】

func ChangeToNezuko(people [3]string) {
  people[0] = "竈門 禰豆子(かまど ねずこ)"
}

peopleArray := [3]string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// 関数の中で、配列の1つ目の要素"竈門 炭治郎(かまど たんじろう)"を
// "竈門 禰豆子(かまど ねずこ)"に変えようとしてみる
ChangeToNezuko(peopleArray)

//  関数の外には変更が反映されない
fmt.Println(peopleArray[0])
=>  "竈門 炭治郎(かまど たんじろう)"

【スライス(参照渡し)の場合】

func ChangeToNezuko(people []string) {
  people[0] = "竈門 禰豆子(かまど ねずこ)"
}

peopleSlice := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// 関数の中で、スライスの1つ目の要素"竈門 炭治郎(かまど たんじろう)"を
// "竈門 禰豆子(かまど ねずこ)"に変えてみる
ChangeToNezuko(peopleSlice)

//  関数の外でも変更が反映されている
fmt.Println(peopleSlice[0])
=>  "竈門 禰豆子(かまど ねずこ)"

なお、配列の場合でも、関数への渡し方を変えて、配列のポインタを関数の引数にすることで、スライス同様に関数内の変更を外に反映させることができます。

【配列(参照渡し)の場合】

func ChangeToNezuko(people *[3]string) {
  people[0] = "竈門 禰豆子(かまど ねずこ)"
}

peopleArray := [3]string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// 関数の中で、配列の1つ目の要素"竈門 炭治郎(かまど たんじろう)"を
// "竈門 禰豆子(かまど ねずこ)"に変えてみる
ChangeToNezuko(&peopleArray)

//  関数の外でも変更が反映されている
fmt.Println(peopleArray[0])
=>  "竈門 禰豆子(かまど ねずこ)"

スライスと配列の構造的な相違

スライスの構造は reflect.SliceHeader で確認することができるのですが、配列のラッパーになっていて以下の構造になっています。

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

それぞれ

  • Dataには、配列のポインタが格納されて
  • Lenには、長さ
  • Capには、容量

が保存されています。

図にすると以下のようになります。

コード上でLen(長さ)、Cap(容量)を確認する際は、 len()cap() の引数にスライスを指定します。

people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

len(people)
=> 3
cap(people)
=> 3

長さを未指定にすることで、長さと容量が同じスライスを生成することができますが、 make を使うことで長さと容量が異なるスライスを作ることができます。

メモリ領域の確保は比較的重い処理なので、定義の時点で必要な領域(容量)を事前に確保しておくことで、パフォーマンスの高いプログラムを実装できます。

make の1番目の引数でデータ型、2番目の引数でスライスの長さ、3番目の引数で容量を指定します。

people := make([]string, 3, 4)
people[0] = "竈門 炭治郎(かまど たんじろう)"
people[1] = "我妻 善逸(あがつま ぜんいつ)"
people[2] = "嘴平 伊之助(はしびら いのすけ)"

fmt.Println(people)
=> [竈門 炭治郎(かまど たんじろう) 
    我妻 善逸(あがつま ぜんいつ) 
    嘴平 伊之助(はしびら いのすけ)]

// スライス定義時と同じ長さ、容量
len(people)
=> 3
cap(people)
=> 4

// 長さ3なので、4番目の要素に値を入れようとするとエラーになる
// people[3] = "竈門 禰豆子(かまど ねずこ)"
// => panic: runtime error: index out of range [3] with length 3

// appendで要素を追加できる
people = append(people, "竈門 禰豆子(かまど ねずこ)")
[]string{
  "竈門 炭治郎(かまど たんじろう)",
  "我妻 善逸(あがつま ぜんいつ)",
  "嘴平 伊之助(はしびら いのすけ)",
  "竈門 禰豆子(かまど ねずこ)",
}

// 要素を追加したことで長さが4、容量が4になる
len(people)
=> 4
cap(people)
=> 4

追記

スライスの様々な操作 ( 簡易スライス式 / copy / append ) の挙動に関しては他の記事にまとめてみました。もし、よろしければこちらもご参照ください。 simple-minds-think-alike.hatenablog.com

参考資料

【Golang】正規表現チェックツールRegoについて

Go言語で正規表現のコードを書く際に、どういう表現ができるんだっけ?と思う時がありますよね。

Ruby(Ruby on Rails)の正規表現のコードを書く際はRubularというツールを使う方は多いと思いますが、Golangでも同じようなRegoという正規表現チェックツールがあって、よくそれを使っています。

https://regoio.herokuapp.com/regoio.herokuapp.com

使ってみる

分かりやすい対話的なインターフェースが便利です。

Regoを使うメリット

画面上のGo RegExp Quick Referenceに記載されている記法は Rubular とまったく同じなので一見Regoを使う必要はなさそうにみえます。

しかし、以下のRegoの実装のコードを見ると正規表現コンパイル・実行はGolangの実装で行っているので、Golangのプロジェクトの場合は事前にRegoで確認しておくと実際のコードの中で正規表現を使用した時に動かなくなる懸念が少なくて済みそうです。

https://github.com/stevedomin/rego/blob/master/rego.go#L38-L49

Regoで正規表現を決めた後は

正規表現を決めたら、コードの中で動かす前に、goreという対話シェル(REPL)を使って事前にRegexpの関数を動作確認しておくとスムーズです。(Regexpの様々な関数を使い慣れている方の場合は不要かと思います。)

というのもRegexpには挙動が分かりづらい関数も多く、例えば FindAllStringSubmatch を使って、以下の文字列からiMacという部分だけを抜き出すような場合、事前に関数の戻り値を把握しておきたいです。

アイマック(iMac)
r := regexp.MustCompile(`(\S+)\((\S+)\)`)
result := r.FindAllStringSubmatch("アイマック(iMac)", -1)
result[0][2] => "iMac"

ちなみにresultにはこんな値が入ります。

[][]string{
  []string{
    "アイマック(iMac)",
    "アイマック",
    "iMac",
  },
}

【Golang】golang-migrateでDBマイグレーションをする

Go言語で実装したWebアプリケーションサイトのDBマイグレーションツールとしてgolang-migrate/migrateを使っていて、そこそこ便利につかえているので紹介してみたいと思います。

migrateを使うメリット

migrateは、Go言語で作成されたDBマイグレーションツールの1つです。

migrateを使うことで得られるメリットは

の3つかと思います。

migrateを使うことにした背景

golang のORMフレームワークgorm を使っているのですが、CLIが提供されていないためDBマイグレーションのタイミングをコントロールしづらいので、 migrate でカバーすることにしました。

migrate を採用する際の注意点としては、gormは独自のDSLスキーマ定義を記載することでDBシステム間(MySQLPostgreSQL等)の差異を吸収してくれますが、 migrateの場合は定義ファイルに書かれたDDLを実行するだけなので、DBシステムの差異を意識して定義ファイルを記載する必要があります。

環境/前提

  • Ubuntu 18.04 LTS デスクトップ
  • PostgreSQLインストール済
  • PostgreSQLに"sample"という名前のデータベース作成済

インストール

インストール方法が、README.mdにもGETTING_STARTED.md にも書いていないので、分かりづらいですが以下に記載されています(2021/1/13時点)。今後READMEに移動するかもしれないので、都度最新の情報を確認してください。

https://github.com/golang-migrate/migrate/tree/master/cmd/migrate

ビルド済のバイナリをインストールする方法やaptでもインストールできますが、今回はgo getでインストールしました。

$ go get -tags 'postgres' -u github.com/golang-migrate/migrate/cmd/migrate

使ってみる

最初は以下のPostgreSQLチュートリアルをやってみるのがてっとり早いかと思います。 migrate/TUTORIAL.md at master · golang-migrate/migrate · GitHub

マイグレーションの実行に失敗したらどうなるか等把握するために色々試してみたいので、チュートリアルにの内容にちょっと手を加えて進めてみます。

マイグレーションの定義ファイルを作る

1つ目のマイグレーションとして、ユーザーテーブルを作って、インデックスを貼ってみたいと思います。

を指定して migrate createを実行すると、000001_create_users.(up/down).sql という2つのSQLファイルができます。

$ migrate create -ext sql -dir db/migrations -seq create_users
~/golang-migrate-sample/db/migrations/000001_create_users.up.sql
~/golang-migrate-sample/db/migrations/000001_create_users.down.sql

作成されたup/down.sqlファイルにそれぞれ以下のようにCREATE TABLEDROP TABLEDDLを書いてみます。PostgreSQLDDLでもトランザクションが使えるので使ってみます。

BEGIN;
CREATE TABLE IF NOT EXISTS users(
   id serial PRIMARY KEY,
   username VARCHAR (50) UNIQUE NOT NULL,
   age INT NOTNULL
);
CREATE INDEX on users (age);
COMMIT;
DROP TABLE IF EXISTS users;

同様に、コメントテーブルのマイグレーションも書いてみます。

$ migrate create -ext sql -dir db/migrations -seq create_comments
~/golang-migrate-sample/db/migrations/000002_create_comments.up.sql
~/golang-migrate-sample/db/migrations/000002_create_comments.down.sql
CREATE TABLE IF NOT EXISTS comments(
   id INT PRIMARY KEY,
   body VARCHAR (50) NOT NULL,
   user_id INT NOT NULL
);
DROP TABLE IF EXISTS comments;

ファイル/ディレクトリ構成確認

ファイル/ディレクトリ構成としては以下のようになっている状態かと思います。

└── db
    └── migrations
        ├── 000001_create_users.down.sql
        ├── 000001_create_users.up.sql
        ├── 000002_create_comments.down.sql
        └── 000002_create_comments.up.sql

マイグレーションの実行

現状は以下の図のように、1と2の定義ファイルがあるだけでDBにはテーブルが1つもない状態です。 マイグレーション実行前のDBの状態

さっそくマイグレーションを実行してみたいと思います。

migrate up 1を実行し、ユーザーテーブルをCREATEする

PostgreSQLの接続URLを環境変数に入れて、バージョンを1つ上げてみます。

$ export POSTGRESQL_URL='postgres://postgres:password@localhost:5432/sample?sslmode=disable'
$ migrate -path db/migrations -database ${POSTGRESQL_URL} up 1

以下の図のように、000001_create_users.up.sqlだけが実行され、schema_migrationsテーブル(version: 1のレコードが1件)とusersテーブル(内容は空)ができました。

migrate up 1を実行した時のDBの状態

マイグレーションを実行するとschema_migrationsテーブルに

  • 現在のバージョン(version)
  • エラーが発生するとdirty: true、しなければ dirty: false

が保存されます。

migrate down 1を実行し、ユーザーテーブルをDROPする

同様にバージョンを1つ下げてみます。

$ migrate -path db/migrations -database ${POSTGRESQL_URL} down 1

すると以下の図のようにusersテーブル、とschema_migrationsテーブルのレコードがなくなり、DROPできたことが確認できます。

migrate down 1を実行した時のDBの状態

わざと失敗してみる

先程の000001_create_users.up.sqlを以下のように修正し、CREATE TABLEはそのままでCREATE INDEXは失敗するようにしてみます。

BEGIN;
CREATE TABLE IF NOT EXISTS users(
   id serial PRIMARY KEY,
   username VARCHAR (50) UNIQUE NOT NULL,
   age INT NOTNULL
);
CREATE INDEX on users, (age);
COMMIT;
$ migrate -path db/migrations -database ${POSTGRESQL_URL} up 1
error: migration failed: ","またはその近辺で構文エラー (column 22) in line 7: BEGIN;
CREATE TABLE IF NOT EXISTS users(
   id INT PRIMARY KEY,
   username VARCHAR (50) UNIQUE NOT NULL,
   age INT NOT NULL
);
CREATE INDEX on users, (age);
COMMIT;
 (details: pq: ","またはその近辺で構文エラー)

すると、以下のようにトランザクションが効いてユーザーテーブルが作られずに、 schema_migrationsdirtytrue になることが確認できます。

わざと失敗した時のDBの状態

dirty: trueの状態を修正する方法をドキュメントから探していると forceを指定することで、dirtyfalse の状態(1つ前のバージョン)に強制的に変更できるという記載があったのでそれをやろうとしましたが、こちらのissueにバージョン1で失敗した場合はバージョン0にはできないと書いてあったので、仕方なく schema_migrationsテーブルは手動で削除しました。

バージョン2以降で失敗した場合には、以下のように force バージョンを指定することで、 schema_migrationsvarsion: 1, dirty: falseの状態にできます。

$ migrate -path db/migrations -database ${POSTGRESQL_URL} force 1

migrate upを実行し、ユーザーテーブルとコメントテーブルを作成

最後にmigrate up 実行することで、未実行のマイグレーションが全て実行されることを確認しました。000001_create_users.up.sqlの修正は元に戻して実行しています。

$ migrate -path db/migrations -database ${POSTGRESQL_URL} up
1/u create_users (25.104164ms)
2/u create_comments (53.025987ms)

migrate upを実行した時のDBの状態

使ってみた所感

ちょっとしたハマりどころもあるので、そんなによく作り込まれているという感じではなく万人受けはしなさそうだけど、インタフェースがシンプル(up、down、forceくらい)なので使い勝手がよく

  • 小規模のシステム
  • 個人開発

といった使用用途には良い気がしました。少なくとも個人開発で使っている分には困るケースには当たってないです。

サンプル

作ったサンプルのURLを載せておきますので、よかったら参考にしてみてください! https://github.com/moritamori/golang-migrate-sample

参考資料

Goのインタフェースを使って、共通のメソッド・属性を持つ構造体を作る

以前Goで開発をしている時に、インタフェースと構造体の理解が曖昧でハマることがあったので、理解を深めるためのサンプルコードを書いてみました。

要件

サンプルコードは、掃除機の共通のインタフェース(操作)

  • ①電源を入れる(TrunOn)
  • ②電源を切る(TrunOff)
  • ③掃除する(Clean)

を持つダイソン掃除機、マキタ掃除機の構造体を作り、構造体毎に異なるインタフェース(操作)の実装を持たせ、main関数から呼び出すというものです。

構造体のデータに関しては同じだと面白くないので、ダイソン掃除機だけワイヤレス掃除機という想定で、バッテリー残量という属性を持っているとします。

要件を簡単に以下の図にまとめてみました。

実装

以下のように実装してみました。

ポイントは最後のmain()関数の中でダイソン掃除機、マキタ掃除機の構造体ポインタのレシーバの型が共通のインタフェース(IVacuum)になっている点です。

package main

import "fmt"

// インタフェース
type IVacuum interface {
    TurnOn()
    TurnOff()
    Clean()
}

// ダイソン掃除機・マキタ掃除機共通の構造体
type Vacuum struct {
    Maker   string
    Model   string
    Serial  string
    Running bool
}

// ダイソン掃除機の構造体
type Dyson struct {
    Battery int
    Vacuum
}

// マキタ掃除機の構造体
type Makita struct {
    Vacuum
}

// ダイソン掃除機構造体のメソッド定義
func NewDyson(Maker string, Model string, Serial string) *Dyson {
    dyson := &Dyson{Battery: 100}
    dyson.Maker = Maker
    dyson.Model = Model
    dyson.Serial = Serial
    return dyson
}

func (d *Dyson) TurnOn() {
    d.Running = true
    d.Battery = 1
    fmt.Println("ダイソンの掃除機:電源ONになりました。")
}

func (d *Dyson) TurnOff() {
    d.Running = false
    d.Battery = 0
    fmt.Println("ダイソンの掃除機:電源OFFになりました。")
}

func (d *Dyson) Clean() {
    if d.Running && d.Battery > 0 {
        fmt.Println("ダイソンの掃除機で掃除する!")
        d.Battery -= 1
    } else if d.Battery <= 0 {
        fmt.Println("ダイソンの掃除機:バッテリーがありません充電してください。")
    } else {
        fmt.Println("ダイソンの掃除機:電源を入れてください。")
    }
}

// マキタ掃除機構造体のメソッド定義
func NewMakita(Maker string, Model string, Serial string) *Makita {
    makita := new(Makita)
    makita.Maker = Maker
    makita.Model = Model
    makita.Serial = Serial
    return makita
}

func (d *Makita) TurnOn() {
    d.Running = true
    fmt.Println("マキタの掃除機:電源ONになりました。")
}

func (m *Makita) TurnOff() {
    m.Running = false
    fmt.Println("マキタの掃除機:電源OFFになりました。")
}

func (m *Makita) Clean() {
    if m.Running {
        fmt.Println("マキタの掃除機で掃除する!")
    } else {
        fmt.Println("マキタの掃除機:電源を入れてください。")
    }
}

func main() {
    // ダイソン掃除機、マキタ掃除機を作り、同じ操作をする
    vacuums := []IVacuum{
        NewDyson("Dyshon", "V7 Fluffy Origin", "839YQBX"),
        NewMakita("Makita", "AC100V", "192IWID"),
    }
    for _, vacuum := range vacuums {
        vacuum.Clean()
        vacuum.TurnOn()
        vacuum.Clean()
        vacuum.Clean()
        vacuum.TurnOff()
        fmt.Println("")
    }
}

このコードを実行すると以下のように出力され、main関数から呼び出した際に同じ操作をしているにもかかわらず、異なる振る舞いをしていることがわかります。

$ go run main.go
ダイソンの掃除機:電源を入れてください。
ダイソンの掃除機:電源ONになりました。
ダイソンの掃除機で掃除する!
ダイソンの掃除機:バッテリーがありません充電してください。
ダイソンの掃除機:電源OFFになりました。

マキタの掃除機:電源を入れてください。
マキタの掃除機:電源ONになりました。
マキタの掃除機で掃除する!
マキタの掃除機で掃除する!
マキタの掃除機:電源OFFになりました。

値渡しと参照渡し

ハマっていたとき、値渡しと参照渡しを意識せずに以下のように書いていて、あれ、Runningがfalseになるのはなぜだ、、と思い、調べ始めたのでした。

type Dyson struct {
    Running bool
}

func NewDyson() Dyson {
    return Dyson{}
}

func (d Dyson) TurnOn() {
    d.Running = true
}

func (d Dyson) IsRugging() bool {
    return d.Running == true
}

func main() {
    vacuum := NewDyson()
    vacuum.TurnOn() // ここでRunningがtrueになる想定だった
    fmt.Println(vacuum.IsRugging()) // が、falseが返る
}

$ go run main.go
false

TurnOn, IsRunningメソッドのレシーバが値渡しになっているので、コンストラクで初期化したまま(Running: false)になっていて、メソッドが呼ばれた時にはコピーが渡されているので、変更が反映されないという現象が発生していました。

近年、RubyJavaのWeb開発案件が多かったせいか、値渡しなのか参照渡しなのかをあまり意識できなくなっているなと実感しました。。

これが何の役にたつのか?

最近goで作ったアプリで、とある商品のWebサイトをクロールして、実行結果を自分にメール通知するというアプリを作った際に似たような実装をしました。

クローラー自体は

  • インタフェース(操作)は同じ
  • 持つデータ構造もほぼ同じ
  • Webサイト毎にHTMLの構造が異なり、クロールの仕方が異なるのでクロールの仕方(Crawlメソッドの実装)は異なる

という特徴があり、ほぼサンプルと同じような実装にするとクローラを管理する機能をシンプルに実装できました。

PostgreSQL10 から PostgreSQL12 へのアップグレード手順(pg_upgrade)

古いPostgreSQLのバージョンがインストールされている開発マシンがあったので、pg_upgradeの公式ドキュメントを参考に、PostgreSQL10.15 から PostgreSQL12.5 に移行してみました。

ドキュメントに よると

pg_upgradeは8.4.X以降から現時点のPostgreSQLのメジャーリリース(スナップショット版やβリリースを含む)へのアップグレードをサポートします。

ということで、古めのバージョンであってもpg_upgradeで一気にバージョンを上げられます。

前提

  • 環境: Ubuntu 18.04 LTS デスクトップ
  • 移行前バージョン: PostgreSQL10.15
  • 移行後バージョン PostgreSQL12.5

バックアップを取得

念の為全DatabaseのDumpバックアップを取っておきます。

$ pg_dumpall > backup.sql

postgresql12をインストール

UbuntuへのPostgresqlのインストールは、公式の手順に従いました。

GPG keyを追加して、aptのsource.listにリポジトリを追加します。

$ wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
$ echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |sudo tee  /etc/apt/sources.list.d/pgdg.list

このリポジトリには

といった一般的なパッケージやサードパーティアドオンのような様々なパッケージが含まれているようです。

apt updateして、postgresql12をインストールします。

$ sudo apt update
$ sudo apt -y install postgresql-12 postgresql-client-12

インストールが終わったら、念の為がpostgresql12のクラスタサービスが起動しているか確認します。

$  systemctl status postgresql@12-main.service 
● postgresql@12-main.service - PostgreSQL Cluster 12-main
   Loaded: loaded (/lib/systemd/system/postgresql@.service; indirect; vendor pre
   Active: active (running) since Sat 2021-01-02 12:13:16 JST; 14min ago
  Process: 19223 ExecStop=/usr/bin/pg_ctlcluster --skip-systemctl-redirect -m fa
  Process: 22868 ExecStart=/usr/bin/pg_ctlcluster --skip-systemctl-redirect 12-m
 Main PID: 22877 (postgres)
    Tasks: 7 (limit: 4915)
   CGroup: /system.slice/system-postgresql.slice/postgresql@12-main.service
           ├─22877 /usr/lib/postgresql/12/bin/postgres -D /var/lib/postgresql/12
           ├─22882 postgres: 12/main: checkpointer   
           ├─22883 postgres: 12/main: background writer   
           ├─22884 postgres: 12/main: walwriter   
           ├─22885 postgres: 12/main: autovacuum launcher   
           ├─22886 postgres: 12/main: stats collector   
           └─22887 postgres: 12/main: logical replication launcher

バージョンを戻すことを考慮に入れて一応postgresqlサービスの状態を確認したうえで、停止しておきます。

$ systemctl is-enabled postgresql
enabled

$ sudo systemctl stop postgresql.service

アップグレード

postgresユーザでpg_upgradeを実行します。

$ sudo su postgres

 /usr/lib/postgresql/12/bin/pg_upgrade \
     --old-datadir=/var/lib/postgresql/10/main \
     --new-datadir=/var/lib/postgresql/12/main \
     --old-bindir=/usr/lib/postgresql/10/bin \
     --new-bindir=/usr/lib/postgresql/12/bin \
     --old-options '-c config_file=/etc/postgresql/10/main/postgresql.conf' \
     --new-options '-c config_file=/etc/postgresql/12/main/postgresql.conf'

整合性チェックを実行しています。
-----------------------------
Checking cluster versions                                   ok
Checking database user is the install user                  ok
Checking database connection settings                       ok
Checking for prepared transactions                          ok
Checking for reg* data types in user tables                 ok
Checking for contrib/isn with bigint-passing mismatch       ok
Checking for tables WITH OIDS                               ok
Checking for invalid "sql_identifier" user columns          ok
Creating dump of global objects                             ok
Creating dump of database schemas
                                                            ok
Checking for presence of required libraries                 ok
Checking database user is the install user                  ok
Checking for prepared transactions                          ok
Checking for new cluster tablespace directories             ok

この後pg_upgradeが失敗した場合は、続ける前に新しいクラスタを
initdbで再作成する必要があります。

アップグレードを実行しています。
------------------
Analyzing all rows in the new cluster                       ok
Freezing all rows in the new cluster                        ok
Deleting files from new pg_xact                             ok
Copying old pg_xact to new server                           ok
Setting next transaction ID and epoch for new cluster       ok
Deleting files from new pg_multixact/offsets                ok
Copying old pg_multixact/offsets to new server              ok
Deleting files from new pg_multixact/members                ok
Copying old pg_multixact/members to new server              ok
Setting next multixact ID and offset for new cluster        ok
Resetting WAL archives                                      ok
Setting frozenxid and minmxid counters in new cluster       ok
Restoring global objects in the new cluster                 ok
Restoring database schemas in the new cluster
                                                            ok
ユーザリレーションのファイルをコピーしています
                                                            ok
Setting next OID for new cluster                            ok
Sync data directory to disk                                 ok
Creating script to analyze new cluster                      ok
Creating script to delete old cluster                       ok

アップグレードが完了しました
----------------
オプティマイザーの統計は、pg_upgrade では転送されません。そのため
新サーバーを起動した後、./analyze_new_cluster.sh を動かすことを検討してください。


このスクリプトを実行すると、旧クラスタのデータファイル ./delete_old_cluster.shが削除されます:

アップグレードが終わったので、postgresユーザから一般ユーザに戻します。

  exit

新しくPostgreSQLバージョンのクラスタのポートがデフォルトポートではないため、postgresql.confのport番号を変更します。

$ sudo vim /etc/postgresql/12/main/postgresql.conf
port = 5433                             # (change requires restart)
=> port = 5432                             # (change requires restart)

同様にpostgresql10のポートも変更します。

$ sudo vim /etc/postgresql/10/main/postgresql.conf
port = 5432                             # (change requires restart)
=> port = 5433                             # (change requires restart)

もし、他にもpostgresql.confを変更している箇所があったり、rootユーザパスワードの認証なしでログインできる等の設定をpg_hba.confで行っている場合は、PostgreSQL12の方でも手動変更しておきます。

再度postgresサービスを起動し、接続を確認してみます。

sudo systemctl start postgresql.service

再度、postgresユーザに戻りpsqlコマンドがpostgresql12の方に接続しに行っているか確認します。

$ sudo su postgres
$  psql -c "SELECT version();"
PostgreSQL 12.5 (Ubuntu 12.5-1.pgdg18.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0, 64-bit

続いて、pg_upgrade実行時に生成されたanalyze_new_cluster.shを実行し、オプティマイザの統計情報を収集しておきます。

$ ./analyze_new_cluster.sh
vacuumdb: データベース"calendar_development"の処理中です: 最適化のための情報を最小限生成します(1対象)
vacuumdb: データベース"calendar_development"の処理中です: 最適化のための情報を最小限生成します(1対象)
〜〜〜
vacuumdb: データベース"world_myroom_development"の処理中です: 最適化のための情報をデフォルト数(全て)生成します

Done

postgresql10の削除

接続が確認できたらpostgresql10は不要なので削除します。

$ sudo apt-get remove postgresql-10 postgresql-server-dev-10
$ sudo rm -rf /etc/postgresql/10/

再度、postgresユーザに戻って、pg_upgrade時に生成されたdelete_old_cluster.shを実行しておきます。

$ sudo su postgres
./delete_old_cluster.sh

参考資料