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

f:id:moritamorie:20210122234721p:plain

図の補足ですが、以下のコードから分かるようにスライスの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

f:id:moritamorie:20210123001243p:plain

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

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

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

f:id:moritamorie:20210123002106p:plain

完全スライス式

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

//  スライスを作る
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

f:id:moritamorie:20210123005041p:plain

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

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で十分な領域を確保して使うと、都度新しいメモリ領域の確保が発生せず、パフォーマンス的に良いです。

f:id:moritamorie:20210123021631p:plain

次に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

f:id:moritamorie:20210123194919p:plain

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

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

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

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

SliceTricks · golang/go Wiki · GitHub

参考資料

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

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

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

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

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

f:id:moritamorie:20210122110715p:plain

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

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

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には、容量

が保存されています。

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

f:id:moritamorie:20210122001720p:plain

コード上で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

f:id:moritamorie:20210122004224p:plain

追記

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

参考資料

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

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

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

regoio.herokuapp.com

使ってみる

f:id:moritamorie:20210119005710g:plain

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

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",
  },
}

Webサイトクローラー(スクレイピング)の目的・メリット、適切な設定とは

とあるWebサイトクローラーを開発していて、クローラーにとって適切な設定にしたいと考えることがありました。

クローラーの設定のように、選択肢が複数あり複雑なものは判断が難しくなります。そのようなものは、まず考えるための観点(例えば、セキュリティ面ではどう設定すると良いかなど。)から考えるとすんなり進みます。観点があると、一度に考えることが絞られるので考えやすくなります。

具体的にどのような観点かというと、クローラーの目的・得られる利益(メリット)に合った観点を洗い出すと良いです。

今回は一般的なWebサイトクローラーを元に観点を整理してみたので共有してみたいと思います。

f:id:moritamorie:20210118022541p:plain

(結論) Webサイトクローラー適切な設定とは

結論からですが、クローラーの目的から考えると、得られる情報により良い意思決定ができるような設定が適切という結論になり、一般的には以下のような観点で考えると良いと思います。

  • 情報が誤りがなく、正確であること
  • 情報に漏れやダブリがないこと
  • 情報が新しいこと
  • 情報が得られない状況を作らないこと
  • ※これらは一般的なケースであり、他の観点はケース次第。

この結論に行き着いた過程を以下に記載していきます。

クローラースクレイピング)の用途

まず、目的を明確にするには、一般的なクローラーの用途を考えると分かると思い、考えてみました。

用途は、数多くのWebサイトの中から目的の情報をもったWebサイトを巡回・解析し、自分達や世の中の人にとって意味のある情報を抽出・変換する、というものです。

"意味のある情報"という表現だと抽象的すぎるので、具体的な例を挙げると

  • あるモノの価格が上昇していることが分かれば、需要があがっているので、自社でもっと生産しよう・このモノを作る事業に参入しよう、と判断ができる。
  • 逆に、多くの競合他者が販売するあるモノの価格が下がっていることが分かれば、価格競争が始まって供給過多になっていそう。価格競争に巻き込まれると利益が出にくいので、他の事業に注力しよう・事業の撤退を検討し始めよう、と判断できる。

といった情報が、意味のある情報になるかと思います。

f:id:moritamorie:20210118023959p:plain

クローラースクレイピング)の目的

この具体例から、"意味のある情報" というのを別の言葉で言い換えると、"意思決定の判断材料になる情報"ということになるかと思います。

そうだとすると、意思決定の判断材料になる情報収集を機械的にすることがクローラーの目的だということが分かります。より良い意思決定に繋がる判断材料を得ることができれば、仕事などの物事を前に進めたり、より良い成果を得られるようになるかと思います。

より良い成果というと、「売上を上げる」や「早く仕事を終わらせられる」など仕事・ビジネス上での成果だけに聞こえますが、日常生活に関しても同じように言えます。

  • 安くモノ(洋服や家電など)が買える
  • 目的地に早く辿り着く方法を知れる
  • 自分好みのレストランを見つけられる

また成果に関しても

  • 量的(定量的)(安く買えるとか、早く着くとか)な観点
  • 質的(定性的)(美味しい食べ物、好みの洋服とか)な観点

のように観点を明確にすると考えやすくなるかと思います。

適切なクローラーの設定とは

このようにクローラーの目的まで考えると、適切なクローラーの設定とは、より良い成果を得るための意思決定ができるような情報を収集できる設定にするということが分かります。

意思決定にするための情報の精度を元にいくつか観点を洗い出してみました。

  • 情報が誤りがなく、正確であること
  • 情報が新しいこと
  • 情報に漏れやダブリがないこと
  • 情報が得られない状況を作らないこと

一般的にはこのような観点で考えると、適切なクローラーの設定ができそうです。

他の観点は、具体的になケースに依るかと思います。

  • セキュリティ面はどの程度か?
    • どの程度機密性が高い情報を扱うか等に依る
      • 例えば、公開されているサイトの情報を収集するのであれば情報が漏れることはリスクではないので、セキュリティ要件は低い。
  • パフォーマンス面はどの程度の速さを求めるのか?
    • どのくらいの量のデータを扱うのか等に依る
      • 例えば、収集する情報の量が少ないなら、さほど1件あたり早い時間で収集できなくて良い。
  • コストはどの程度までかけて良いか?
    • 情報を得ることでどの程度メリットがあるのか等に依る。
      • 例えば、直接売上には直結しない情報であれば、比較的コストが低いスペックのマシンで運用できる必要がある。

f:id:moritamorie:20210118125834p:plain

【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つもない状態です。 f:id:moritamorie:20210227151443p:plain

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

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テーブル(内容は空)ができました。

f:id:moritamorie:20210227151526p:plain

マイグレーションを実行すると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できたことが確認できます。

f:id:moritamorie:20210227151650p:plain

わざと失敗してみる

先程の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 になることが確認できます。

f:id:moritamorie:20210227151734p:plain

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)

f:id:moritamorie:20210227151757p:plain

使ってみた所感

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

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

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

サンプル

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

参考資料

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

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

要件

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

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

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

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

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

f:id:moritamorie:20210104205952p:plain

実装

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

ポイントは最後の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開発案件が多かったせいか、値渡しなのか参照渡しなのかをあまり意識できなくなっているなと実感しました。。

f:id:moritamorie:20210106133659p:plain

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

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

クローラー自体は

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

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

f:id:moritamorie:20210105010235p:plain