エンジニア(業務委託) の @junkitamura です。サーバーサイドエンジニアをしています。
この記事は Photocreate Advent Calendar 2018 の 18日目 の記事です。
はじめに
簡単な自己紹介は Photocreate Advent Calendar 2018 の 8日目の記事を参照してください。
フォトクリエイト
さんでは、いわゆるクラウド化
に向けて各種プロジェクトが進行中で、その中に AWS Batch を使う案件があります。このサービスでは Docker イメージ
でジョブ定義を作成し、ジョブキューを送信する事で、指定のコンピューティング環境上でバッチ処理を実行させる事ができます。
この Docker イメージ内で実行されるプログラム(アプリケーション)の言語としてGo言語
が選ばれました。ぱちぱちぃ
とあるプロジェクトを終えて、「さて次は何を…Laravel かな…?」というタイミングでクラウド化のプロジェクトにアサインされました。
PHP
エンジニアとしてフォトクリエイト
さんで初めて Symfony
(2.8) に触れてから約1年、ようやくフレームワークに慣れてきたところで、いきなりGo言語
へ。
AWS
も触ったことがなく色々と初めてづくしでしたがGo言語
を使ってみて戸惑った事
や感心した事
といった感想をまとめます。
Go言語の感想
元々 C
や Java
等も(必要なシーンで)扱っておりましたので、「PHPエンジニアが…」という感想では無いですがご了承ください。
オブジェクト指向から離れましょう
type .. struct ? レシーバー…関数名……あぁ class
ね!()
PHP や Java でオブジェクト指向
プログラミングをしてきた人が陥りやすい罠
です。特に斜め読みしていると落ちます。
構造体
ってプロパティの集まり
でしょ?メソッド
(レシーバーと関数)、インターフェース
ときたら「クラスじゃん?」となって、さらに埋め込み
(embedding
)を継承
と誤解しちゃうと抜けるのに苦労することになります。
構造体(struct)
は名前(name)と型(type)を持つフィールド(field)というものの集まりと説明されています。Struct types。
struct
構造(体)は type
によって識別子(型名)に型
として結びつけられます。
さらに混乱の元となるのが new 関数
です。これは クラスをインスタンス化するものではありません
。 あくまで各フィールドをゼロ化した構造体のメモリ領域を確保するだけです。初期値が与えられたり、コンストラクタが呼びだされたり、そういう事はいっさい発生しません。
オブジェクト指向ちっくな書き方 (play.golang.org)。見た目にだまされないで!
オブジェクト指向ちっくな書き方
package main
import "fmt"
// 鳴かせるインターフェース
type Animal interface {
MakeSound() string
}
// Pet 構造体
type Pet struct {
Color string
Name string
}
// Dog 構造体
type Dog struct {
Pet
}
// Dog の鳴き方
func (dog *Dog) Bow() string {
return "bow wow!"
}
// インターフェース実装
func (dog *Dog) MakeSound() string {
return fmt.Sprintf("'%s' barked the %s dog named %s.", dog.Bow(), dog.Color, dog.Name)
}
// Cat 構造体
type Cat struct {
Pet
}
// Cat の鳴き方
func (cat *Cat) Meow() string {
return "meow mew..."
}
// インターフェース実装
func (cat *Cat) MakeSound() string {
return fmt.Sprintf("'%s' purred the %s cat named %s.", cat.Meow(), cat.Color, cat.Name)
}
// Animal なら 鳴ける
func makeSound(pet Animal) {
fmt.Println(pet.MakeSound())
}
// 鳴かせてみる
func main() {
// 犬
dog := new(Dog)
dog.Color = "black"
dog.Name = "Jiro"
// 猫
cat := new(Cat)
cat.Color = "white"
cat.Name = "Tama"
// まずは犬
makeSound(dog)
// そして猫
makeSound(cat)
}
実行結果
'bow wow!' barked the black dog named Jiro.
'meow mew...' purred the white cat named Tama.
きっちり型にハメましょう
PHPの良さって何ですか?
と聞かれた時にメリットやデメリットがいろいろと思い浮かびますが、どちらなのか悩むのがゆるく扱われる型
です。さらには可変変数
という仕様。PHPでは動的型付け
が行なわれるため、整数として宣言した変数を文字列として扱う事ができます。例えば1
という数値を0x01
ではなく1
という文字として扱ってくれます。初めてPHPに触れたときは衝撃的でした。
可変変数
に至ってはなんじゃこりゃ...
と驚愕したものです。
PHP のゆるい型と可変変数
<?php
$strA = 'abc';
$intB = intval(1);
// 整数と文字列の結合
echo $strA . $intB;
// 結果: abc1
// abc1 という変数に文字列を代入
$abc1 = "var abc1";
// (strA という変数 と intB という変数の結合) という(名前の)変数を表示
echo ${($strA . $intB)};
// 結果: var abc1
// new にも使えます
$strC = 'xyz';
class xyz1abc {
public $foo = "C-B-A";
}
$className = $strC . $intB . $strA;
echo (new $className)->foo;
// 結果: C-B-A
class abcxyz1 {
public $foo = "A-C-B";
}
$className = $strA . $strC . $intB;
echo (new $className)->foo;
// 結果: A-C-B
型を明示的に宣言しなくても良い
というのはGo言語
では代入演算子(:=)
が用意されています。右辺がリテラルならばその型、関数ならば関数の戻り型となります。この時点で型が確定となるため、PHPのように文字列
と数値
を演算子を使って結合することはできません。数値を文字列に変換してから結合
となります。
$a = 1;
$b = 'abc';
$c = $b . $a; // abc1
a := 1
b := "abc"
c := fmt.Sprintf("%s%d", b, a) // abc1
もしくは
c := b + strconv.Itoa(a) // abc1
もしくは...(略)
配列は固定長
PHPでは配列の長さなんてものを気にしたことがない!
という方が多いのではないかと思います。変数名の後ろに[]
を付けて代入するだけで配列が延びる
のですから便利この上ないです。
$arr = [1, 2, 3];
echo count($arr); // 3
$arr[] = 4;
$arr[] = 5;
(..snip..)
$arr[] = 10;
echo count($arr); // 10
Go言語では配列は固定長のみです。配列の要素を箱と例えるのであれば、最初から箱の数が解っていないと使えません。例えば
月(month)
を格納するには1月から12月まで12個の箱を用意すれば良い事になります。
一方で日(day)
は 28の時もあれば30、もしくは31の時もあります。最大で31なので31個用意しておいて使わない箱には何も入れない、という使い方が良さそうです。
友達参加も自由だよ〜
という忘年会
では参加する人数などは事前にわかりません。こういう場合、Go言語では
スライス(slice)
を使います。最初に3人来る事は確定しており、その後は何人くらいくるか解りません。現時点では最大で5人くらいかなぁ…という時には、make([]string, 3, 5)
と宣言し、スライス型の変数を作成します。最初の3
は長さ(length)
を指定し次の5
はキャパシティ(capacity)
を指定します。
3人目まで(a[0]
, a[1]
, a[2]
まで)は箱が用意されているので、添え字付き変数にそのまま代入できます。
4人目以降は箱がありません(長さ3なので)。そこで参加者が増えるたびに箱を増やしていかなくてはなりません。PHPでは何も考えずに箱を増やせていたのですが(実はそういうわけでは無いけど)、Go言語ではメモリ確保(memory allocation)
という事を少しだけ考えなくてはなりません。今回はキャパシティ(capacity)
(意訳すると許容量
)を5
に指定しています。つまり、箱5つまでは(メモリを)確保済み
だよ、という事になります。append()
関数でスライスに要素を追加していくと、5つ目まではキャパシティの大きさは変わりません。6つ目を追加すると(今回の場合は)10
になります。この時、10箱分(のメモリ)が新たに確保されている
状態になります。言い換えると、メモリ空間の別の場所に10箱分のスペースを用意した状態になります。スライスを代入している変数はポインタなので、先頭の箱のアドレスを示します。メモリが新たに別の場所に確保されたため、先頭の箱の場所も変わっており、つまりアドレスも変わっています。
気をつけなくてはならないのは、新たにメモリ空間を確保する
という処理が意外と重い
ということ。PHP や Java で
大量の new はパフォーマンスを悪くする
といわれているのはインスタンス化によるメモリ確保
(と初期化処理)のコストが高いからです。
メモリ確保を繰り返す処理は避けた方が良い
のは Go言語 でも同じです。
「ならばキャパシティを最初から1万とか大きくしておけば良いじゃん。」と思うかもしれませんが、限られたメモリ空間を使いもしないのに大きく陣取るのは..NGです
バランスを考えてスライスの長さ(length)
とキャパシティ(capacity)
を設定しなくてはなりません。PHPでは考えなくても良い話でしたので、混乱するかもしれません。(ほんとはPHPでもメモリを考えた方が良いんだけどね)
array と slice (play.golang.org)
array と slice
package main
import (
"fmt"
)
func main() {
// array
a := [3]string{"aaa", "bbb", "ccc"}
fmt.Println(a) // [aaa bbb ccc]
// slice
s := make([]string, 3, 5)
fmt.Printf("sp:%p\n", s) // sp:0xc4200900f0
s[0] = "aaa"
s[1] = "bbb"
s[2] = "ccc"
// s[3] = "ddd" ← エラー[panic: runtime error: index out of range]
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:3 cap:5 sp:0xc4200900f0
s = append(s, "ddd") // s[3]
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:4 cap:5 sp:0xc4200900f0
s = append(s, "eee") // s[4]
// --- ここで capacity いっぱい.
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:5 cap:5 sp:0xc4200900f0
// --- さらに要素を追加する
s = append(s, "fff") // s[5]
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:6 cap:10 sp:0xc4200a0000
// --- capacity 延び、さらにポインタのアドレスも変わった.
s = append(s, "ggg") // s[6]
s = append(s, "hhh") // s[7]
s = append(s, "iii") // s[8]
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:9 cap:10 sp:0xc4200a0000
s = append(s, "jjj") // s[9]
// --- ここで延びた capacity もいっぱいに.
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:10 cap:10 sp:0xc4200a0000
// --- さらに要素を追加する
s = append(s, "kkk") // s[10]
fmt.Printf("len:%d cap:%d sp:%p\n", len(s), cap(s), s) // len:11 cap:20 sp:0xc420090000
// --- capacity がさらに延び、ポインタのアドレスも変わった.
fmt.Println(s) // [aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk]
}
制御系とか
特に困ることはありませんが、制御系に多少の違いがあります。
- do 〜 while が無い
- for 〜 each も無い(似た構造はある)
- switch に break が要らない
- goto がある
最後の goto
が驚きでした。N88−BASIC
の時代から構造化プログラミング
を叫ぶ方々には悪魔の子ダミアン
のように扱われていた
goto
がここにきて復活 (コメントいただき修正しました)、新たに設計された言語にもかかわらず残されているという…。当時はgoto 120
のように行番号
を指定していましたが Go言語ではラベルを指定します。
使いどころが解らないのですが、下記のように二重ループを抜ける時に使うのかなぁ…と。ちなみにgoto FinLoop
をbreak
にするとj ループは抜けるけど i ループは続きます
。_直近のループからのみ脱出(break)_します。((i,j) が (0,2) の次に (1,0) (1,1) (1,2) (2,0) ...と続き (9,2) で終わる)
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if j == 3 {
goto FinLoop
}
fmt.Printf("i:%d, j:%d\n", i, j)
}
}
FinLoop:
fmt.Println("exit loop")
実行結果
i:0, j:0
i:0, j:1
i:0, j:2
exit loop
意外と気づかずにやってしまうこと
その他の感想
§ 小文字で始まるものはすべからくprivate
になる
- 小文字で始まる
変数
はprivate
になる - 小文字で始まる
関数
もprivate
になる - 小文字で始まる
構造体のメンバ
もprivate
になる
ついつい、テスト用にパッケージ名をfoobar_test
とした時に、小文字で始まるメンバにアクセスできなかったりして悩んじゃったりする...人もいるかもしれない。てへぺろ
§ 変数名がパッケージ名と同じだった
GoLand
のような統合開発環境を使っていれば問題ないのでしょうが、変数でエラーになる時は変数名
がパッケージ名
と同じ名前にしてしまっていないか確認してみましょう。(import "github.com/aws/aws-sdk-go/aws/session"
としているのに session := "セッション"
と同名の変数を使うとエラーとなります)
§ リンカとかプリプロセッサは無いの?
C言語
などのコンパイラはプリプロセッサによるマクロ処理などがありますが Go言語にはありません。リンカはgo
コマンドに含まれています。
プリプロセッサが無いので#ifdef
等のように条件をつけてビルドができない、つまりwindows の時のみ
などの指定ができない、とお考えであれば心配ご無用です。
ビルドタグ
というものがありビルド時に条件を指定できます。
Build Constraints
§ バイナリが...ぽっちゃり系
go build
でビルドした後の実行ファイル(バイナリ)がデブぽっちゃり系です。コンパイルされたバイナリ
という言葉のイメージとのギャップに驚きます。
ldflags
オプションでリンカへオプションが渡せます。Linux系OSで使う場合は、多少のダイエットができます。他には
UPX
を使う方法などもあるようです。詳しくは The Go binary diet を参照してください。
$ go build -ldflags="-w"
-
-w
DWARF(Linux系のELF
のデバッグ情報)を生成しなくなります -
-s
デバッグ用のシンボルテーブルを生成しない(...らしいのですが、環境依存なのか、このオプションを指定してもバイナリファイルは小さくなりませんでした。指定の仕方が悪いのかも)
§ 並行処理を欲張らない
Go言語では比較的簡単に並行処理を書くことができます。goroutine
と呼ばれており、go func() {...}
と書くだけで関数を平行処理してくれます。
- 指定された AWS Bucket 内の全オブジェクトのサイズを取得する
- ローカルディレクトリ内のファイルを全て AWS S3 へアップロードする
- 自社サービスの API を同時にコールする
...など、並行処理を行なうと(全体的な)処理が早く終わるケースが多々あります。
あまり考えずにループを組んで平行処理してしまうと、
- 接続先のアクセス数制限にひっかかる
- 回線がパンクする
- ファイルを開きすぎて OS から怒られる(
too many open files
とか) - エラーが発生しているのに次から次へとループが進んでしまう(そして全てエラーになる)
...などなど弊害が発生します。
強力なモーターを積んで軽量化したミニ四駆がコーナーを勢いよく飛び出してしまった失敗経験が全く役にたっていません。
対策例
-
チャンネル(channel)
やセマフォ(semaphore)
で同時実行数を制限する -
エラーグループ(errgroup)
とコンテキスト(context)
でエラーが発生したら全部止まるようにしておく -
Mutex
で排他制御したりする- スライスに結果を溜め込みたいだけなら
チャンネル(channel)
にロック機能ついてるからmutex
などで無駄なロックをかけない
- スライスに結果を溜め込みたいだけなら
- (特にコネクト失敗などの)エラー時は
backoff
等で間隔をあけてリトライする
実行環境のスペックや制限(OSのファイルオープン可能数、使えるメモリ容量、ディスクスペース、利用可能な i-node 数、通信回線の太さ、接続先の接続可能数...その他いろいろ)などを考慮しつつリミッターを設定しバランスの良い平行数
で処理するようにしましょう。
まとめ
- Go言語の
特性を生かせるシーン
で使うべき - C言語で fork してました、とか Java でスレッドやってました、と並列もしくは平行処理に慣れていないと、
goroutine は最初はきつい
かも -
ライブラリが充実
していて楽チン
フォトクリエイトではGo言語バッチリです!
、
AWS 任せてください〜
...な方からの ご応募をお待ちしております。