LoginSignup
41
22

More than 5 years have passed since last update.

PHPエンジニアが Go をいきなり業務で使った感想

Last updated at Posted at 2018-12-17

エンジニア(業務委託) の @junkitamura です。サーバーサイドエンジニアをしています。

この記事は Photocreate Advent Calendar 2018 の 18日目 の記事です。

はじめに

 簡単な自己紹介は Photocreate Advent Calendar 20188日目の記事を参照してください。
 フォトクリエイトさんでは、いわゆるクラウド化に向けて各種プロジェクトが進行中で、その中に AWS Batch を使う案件があります。このサービスでは Docker イメージでジョブ定義を作成し、ジョブキューを送信する事で、指定のコンピューティング環境上でバッチ処理を実行させる事ができます。
 この Docker イメージ内で実行されるプログラム(アプリケーション)の言語としてGo言語が選ばれました。ぱちぱちぃ:clap:

 とあるプロジェクトを終えて、「さて次は何を…Laravel かな…?」というタイミングでクラウド化のプロジェクトにアサインされました。
 PHP エンジニアとしてフォトクリエイトさんで初めて Symfony(2.8) に触れてから約1年、ようやくフレームワークに慣れてきたところで、いきなりGo言語へ。

 AWS も触ったことがなく色々と初めてづくしでしたがGo言語を使ってみて戸惑った事感心した事といった感想をまとめます。

Go言語の感想

 元々 CJava 等も(必要なシーンで)扱っておりましたので、「PHPエンジニアが…」という感想では無いですがご了承ください。

オブジェクト指向から離れましょう

 type .. struct ? レシーバー…関数名……あぁ classね!(:no_good:)
 PHP や Java でオブジェクト指向プログラミングをしてきた人が陥りやすい罠です。特に斜め読みしていると落ちます。
 構造体ってプロパティの集まりでしょ?メソッド(レシーバーと関数)、インターフェース ときたら「クラスじゃん?」となって、さらに埋め込み(embedding)を継承誤解しちゃうと抜けるのに苦労することになります。
 構造体(struct)は名前(name)と型(type)を持つフィールド(field)というものの集まりと説明されています。Struct types
 struct 構造(体)は typeによって識別子(型名)にとして結びつけられます。
 さらに混乱の元となるのが new 関数です。これは クラスをインスタンス化するものではありません。 あくまで各フィールドをゼロ化した構造体のメモリ領域を確保するだけです。初期値が与えられたり、コンストラクタが呼びだされたり、そういう事はいっさい発生しません。
 オブジェクト指向ちっくな書き方 (play.golang.org)。見た目にだまされないで!:imp:

オブジェクト指向ちっくな書き方
オブジェクト指向ちっく
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に触れたときは衝撃的:flushed:でした。可変変数に至ってはなんじゃこりゃ...:scream_cat:と驚愕したものです。

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のように文字列数値を演算子を使って結合することはできません。数値を文字列に変換してから結合となります。

PHP
$a = 1;
$b = 'abc';
$c = $b . $a; // abc1
Go
a := 1
b := "abc"
c := fmt.Sprintf("%s%d", b, a) // abc1
もしくは
c := b + strconv.Itoa(a) // abc1
もしくは...()

配列は固定長

 PHPでは配列の長さなんてものを気にしたことがない!という方が多いのではないかと思います。変数名の後ろに[]を付けて代入するだけで配列が延びるのですから便利この上ないです。

PHPの配列
$arr = [1, 2, 3];
echo count($arr); // 3

$arr[] = 4;
$arr[] = 5;
(..snip..)
$arr[] = 10;
echo count($arr); // 10

 Go言語では配列は固定長のみです。配列の要素を箱:package:と例えるのであれば、最初から箱の数が解っていないと使えません。例えば月(month)を格納するには1月から12月まで12個の箱を用意すれば良い事になります。
 一方で日(day)は 28の時もあれば30、もしくは31の時もあります。最大で31なので31個用意しておいて使わない箱には何も入れない、という使い方が良さそうです。
 友達参加:two_women_holding_hands:も自由だよ〜:ok_woman:という忘年会:beers:では参加する人数などは事前にわかりません。こういう場合、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箱分のスペースを用意した状態になります。スライスを代入している変数はポインタなので、先頭の箱のアドレスを示します。メモリが新たに別の場所に確保されたため、先頭の箱の場所も変わっており、つまりアドレスも変わっています。
 気をつけなくてはならないのは、新たにメモリ空間を確保するという処理が意外と重い:tired_face:ということ。PHP や Java で 大量の new はパフォーマンスを悪くするといわれているのはインスタンス化によるメモリ確保(と初期化処理)のコストが高いからです。
 メモリ確保を繰り返す処理は避けた方が良いのは Go言語 でも同じです。
 「ならばキャパシティを最初から1万とか大きくしておけば良いじゃん。」と思うかもしれませんが、限られたメモリ空間を使いもしないのに大きく陣取るのは..NGです:no_good:
 バランスを考えてスライスの長さ(length)キャパシティ(capacity)を設定しなくてはなりません。PHPでは考えなくても良い話でしたので、混乱するかもしれません。(ほんとはPHPでもメモリを考えた方が良いんだけどね:laughing:)
 array と slice (play.golang.org)

array と slice
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 がある:bangbang::eyes:

 最後の goto が驚きでした。N88−BASICの時代から構造化プログラミングを叫ぶ方々には悪魔の子ダミアン:smiling_imp:のように扱われていたgotoここにきて復活 (コメントいただき修正しました)、新たに設計された言語にもかかわらず残されているという…。当時はgoto 120のように行番号を指定していましたが Go言語ではラベルを指定します。
 使いどころが解らないのですが、下記のように二重ループを抜ける時に使うのかなぁ…と。ちなみにgoto FinLoopbreakにするとj ループは抜けるけど i ループは続きます直近のループからのみ脱出(break)します。((i,j) が (0,2) の次に (1,0) (1,1) (1,2) (2,0) ...と続き (9,2) で終わる)

goto,label
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

意外と気づかずにやってしまうこと:warning: その他の感想:thought_balloon:

§ 小文字で始まるものはすべからくprivateになる

  • 小文字で始まる変数privateになる
  • 小文字で始まる関数privateになる
  • 小文字で始まる構造体のメンバprivateになる

 ついつい、テスト用にパッケージ名をfoobar_testとした時に、小文字で始まるメンバにアクセスできなかったりして:disappointed:悩んじゃったりする...人もいるかもしれない。てへぺろ:yum:

§ 変数名がパッケージ名と同じだった

 GoLandのような統合開発環境を使っていれば問題ないのでしょうが、変数でエラーになる時は変数名パッケージ名と同じ名前にしてしまっていないか確認してみましょう。(import "github.com/aws/aws-sdk-go/aws/session" としているのに session := "セッション" と同名の変数を使うとエラーとなります)

§ リンカとかプリプロセッサは無いの?

 C言語などのコンパイラはプリプロセッサによるマクロ処理などがありますが Go言語にはありません。リンカはgoコマンドに含まれています。
 プリプロセッサが無いので#ifdef等のように条件をつけてビルドができない、つまりwindows の時のみなどの指定ができない、とお考えであれば心配ご無用です:ok_hand:ビルドタグというものがありビルド時に条件を指定できます。
 Build Constraints

§ バイナリが...ぽっちゃり系

 go build でビルドした後の実行ファイル(バイナリ)がデブぽっちゃり系です。コンパイルされたバイナリという言葉のイメージとのギャップに驚きます:fearful:
 ldflagsオプションでリンカへオプションが渡せます。Linux系OSで使う場合は、多少のダイエット:sweat_drops:ができます。他にはUPXを使う方法などもあるようです。詳しくは The Go binary diet を参照してください。

$ go build -ldflags="-w"
  • -w DWARF(Linux系のELFのデバッグ情報)を生成しなくなります
  • -s デバッグ用のシンボルテーブルを生成しない(...らしいのですが、環境依存なのか、このオプションを指定してもバイナリファイルは小さくなりませんでした。指定の仕方が悪いのかも)

§ 並行処理を欲張らない

 Go言語では比較的簡単に並行処理を書くことができます。goroutineと呼ばれており、go func() {...}と書くだけで関数を平行処理してくれます。

  • 指定された AWS Bucket 内の全オブジェクトのサイズを取得する
  • ローカルディレクトリ内のファイルを全て AWS S3 へアップロードする
  • 自社サービスの API を同時にコールする

 ...など、並行処理を行なうと(全体的な)処理が早く終わるケースが多々あります。
 あまり考えずにループ:arrows_clockwise:を組んで平行処理してしまうと、

  • 接続先のアクセス数制限にひっかかる
  • 回線がパンクする
  • ファイルを開きすぎて OS から怒られる(too many open filesとか)
  • エラーが発生しているのに次から次へとループが進んでしまう(そして全てエラーになる)

 ...などなど弊害が発生します。
 強力なモーターを積んで軽量化したミニ四駆がコーナーを勢いよく飛び出してしまった失敗経験:trollface:が全く役にたっていません。

 対策例

  • チャンネル(channel)セマフォ(semaphore)で同時実行数を制限する:construction:
  • エラーグループ(errgroup)コンテキスト(context) でエラーが発生したら全部止まるようにしておく:no_entry:
  • Mutexで排他制御したりする:lock:
    • スライスに結果を溜め込みたいだけならチャンネル(channel)にロック機能ついてるからmutexなどで無駄なロックをかけない:closed_lock_with_key:
  • (特にコネクト失敗などの)エラー時はbackoff等で間隔をあけてリトライ:repeat:する

 実行環境のスペックや制限(OSのファイルオープン可能数、使えるメモリ容量、ディスクスペース、利用可能な i-node 数、通信回線の太さ、接続先の接続可能数...その他いろいろ)などを考慮しつつリミッター:underage:を設定しバランスの良い平行数:100:で処理するようにしましょう。

まとめ

  • Go言語の特性を生かせるシーンで使うべき
  • C言語で fork してました、とか Java でスレッドやってました、と並列もしくは平行処理に慣れていないと、goroutine は最初はきついかも
  • ライブラリが充実していて楽チン

フォトクリエイトではGo言語バッチリです!:thumbsup:AWS 任せてください〜:laughing: ...な方からの ご応募をお待ちしております

41
22
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
22