Works by

Ren's blog

@rennnosuke_rk 技術ブログです

【Golang】Goで形態素解析する - mecab-golang

MeCab とは

MeCab: Yet Another Part-of-Speech and Morphological Analyzer

自然言語処理界隈では ChaSen と並び有名な形態素解析エンジン。
形態素解析とは、テキストデータを文法や単語の品詞情報(辞書)を元に言語の最小単位(形態素)へと分割し、各形態素の品詞などを判別すること。

ex. 裾野は長し赤城山

裾野 名詞,一般,*,*,*,*,裾野,スソノ,スソノ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
長し 形容詞,自立,*,*,形容詞・アウオ段,文語基本形,長い,ナガシ,ナガシ
赤城山 名詞,固有名詞,地域,一般,*,*,赤城山,アカギヤマ,アカギヤマ

mecab-golang

Go から mecab を使用するライブラリ。mecab の go wrapper。 なので使用する場合は mecab-golang とは別に、MeCab や辞書をインストールする必要がある。

Install

Mecab のインストール

mecab をインストール。
mecab を扱うには、MeCab形態素解析に使用する辞書が別途必要になる。
mecab-ipadic はその MeCab 用の辞書の一つであり、日本語の単語情報(読み方や品詞など)を掲載している。1

$ brew install mecab
$ brew install mecab-ipadic

mecab-ipadic-NEologd のインストール

mecab-ipadic-NEologd

Web 上から逐一新語を登録してくれる辞書 mecab-ipanic では判断できない新語を(全てではないが)サポートする。 ↓ こんなかんじに、例えば名詞をより細かい品詞に分割して解釈してくれる。

default system dictionary      | mecab-ipadic-NEologd
ピット 星 人             | ピット 星人
この 世界 の 片隅 に          | この世界の片隅に
辛 坊                   | 辛坊
東海大 市原 望 洋          | 東海大市原望洋
方言 ラジオ 体操               | 方言 ラジオ体操
コイ キング              | コイキング
はだし の ゲン              | はだしのゲン
あさ パラ             | あさパラ
雪 組                   | 雪組

-n で最新 ver 辞書をインストール

$ git clone git@github.com:neologd/mecab-ipadic-neologd.git
$ cd mecab-ipadic-neologd
$ ./bin/install-mecab-ipadic-neologd -n

mecab-golang のインストール

$ export CGO_LDFLAGS="-L/{libフォルダへのパス}/lib -lmecab -lstdc++"
$ export CGO_CFLAGS="-I/{includeフォルダへのパス}/include"
$ go get github.com/bluele/mecab-golang

Usage

bluele/mecab-golang)サンプルコードを参考にした。

package main

import (
    "fmt"
    "strings"

    "github.com/bluele/mecab-golang"
)

const BOSEOS = "BOS/EOS"

func parseToNode(m *mecab.MeCab, text string) error {

    tg, err := m.NewTagger()
    if err != nil {
        return err
    }
    defer tg.Destroy()

    lt, err := m.NewLattice(text)
    if err != nil {
        return err
    }
    defer lt.Destroy()

    node := tg.ParseToNode(lt)
    for {
        features := strings.Split(node.Feature(), ",")
        if features[0] != BOSEOS {
            fmt.Printf("%s %s\n",node.Surface(), node.Feature())
        }
        if node.Next() != nil {
            break
        }
    }
    return nil
}

func main() {
    m, err := mecab.New("-Owakati")
    if err != nil {
        panic(err)
    }
    defer m.Destroy()

    err = parseToNode(m, "すもももももももものうち")
    if err != nil {
        panic(err)
    }
}

解説

func main()

mecab.New()MeCab モデルオブジェクトを生成する。
引数には元々の MeCab 実行バイナリの引数に指定できるものを渡すことができる。ここでは形態素解析の結果(後述の Tagger::Parse で取得)を分かち書きで出力させるオプション -Owakati を指定している。
他のオプションはここを参照。

parseToNode() は第 2 引数にとる文字列を形態素解析・その結果を標準出力へ書き込む。

func parseToNode()

引数に取る MeCab モデルオブジェクトと文字列から、形態素解析の結果を出力する。

以下で説明する関数やメソッドは MeCab c++ ライブラリ準拠のもののため、MeCab 本家の C/C++ライブラリを読めば問題なさそう。2

MeCab::NewTagger()

辞書オブジェクト Tagger を生成する。

MeCab::NewLattice()

解析に必要なローカル変数を含むオブジェクト Lattice を生成する。
生成時に解析対象となる文字列を渡す。

Tagger::ParseToNode()

Lattice を引数に取ることで、 Lattice が保持する文字列を解析し、木構造のオブジェクト Node として解析結果を返す。

ちなみに parseToNode() 内では BOS/EOS( BOS は beginning of sentence で文頭、EOS は end of sentence で文末) 品詞タグのついた形態素情報以外を出力するようにしている。

Node::Feature()

Node の解析結果を出力する。
例えば分かち書き指定の場合こんな感じ。

名詞,一般,*,*,*,*,すもも,スモモ,スモモ
Node::Surface()

単語の品詞を出力する。

実行結果

すもも 名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも 名詞,一般,*,*,*,*,もも,モモ,モモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも 名詞,一般,*,*,*,*,もも,モモ,モモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
うち 名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

独自フォーマットで形態素解析の結果を取得する

Tagger::ParseToNode で取得する Node からは特定のフォーマットで結果が出力できるが、 mecab.New() でフォーマットを指定することで、 Tagger::Parse の文字列結果を変更することができる。

分かち書き

m, err := mecab.New("-Owakati" )
すもも も もも も もも の うち

フォーマット

-F オプションを使用すると、下記の出力フォーマットを使用して自由に出力を切り替えられる。 出力フォーマット

ex.
m, err := mecab.New("-F%m(素性ID:%h)\\n")

3

すもも(素性ID:38)
も(素性ID:16)
もも(素性ID:38)
も(素性ID:16)
もも(素性ID:38)
の(素性ID:24)
うち(素性ID:66)
EOS

参考文献

MeCab: Yet Another Part-of-Speech and Morphological Analyzer

mecab-ipadic-neologd/README.ja.md at master · neologd/mecab-ipadic-neologd

MeCab: Yet Another Japanese Dependency Structure Analyzer

bluele/mecab-golang: A golang wrapper for mecab.

IPADIC(IPA 辞書)とはなにものか? - ぱらめでぃうす


  1. 元々は別の形態素解析エンジンである ChaSen に使用された辞書を、MeCab 用に更に変更を加えたもの。ipa とついているのは、その品詞フォーマットが情報処理振興事業協会(IPA)で設定された IPA 品詞体系(THiMCO97)に基づいているため。参考:http://parame.mwj.jp/blog/0209

  2. この C/C++ライブラリドキュメントでは Tagger によるシングルスレッド環境向けサンプルと Tagger/Model(NewMecab 返戻値型に対応)/Lattice によるマルチスレッド環境向けサンプルがある。mecab-golang では Go を使用する以上、goroutine によるマルチスレッド環境の可能性を前提にライブラリが組まれていると思われ、そのため Tagger::Parse/ParseToNode は Lattice を引数に取っている。

  3. 素性 ID - 品詞/活用/読みに割り振られた MeCab 内部 ID

【Golang】Golang:logパッケージを読む

log パッケージを読みました。

src/log/log.go - The Go Programming Language

log

Go の log はロギング処理に使用するパッケージ。
log パッケージはテストコードを除けば log.go のみで構成されており、log.go も 400 数行からなるシンプルな構成となっている。
sysloglog パッケージ配下に置かれているが、それとは分けて話す )

log パッケージ概要

// Package log implements a simple logging package. It defines a type, Logger,
// with methods for formatting output. It also has a predefined 'standard'
// Logger accessible through helper functions Print[f|ln], Fatal[f|ln], and
// Panic[f|ln], which are easier to use than creating a Logger manually.
// That logger writes to standard error and prints the date and time
// of each logged message.
// Every log message is output on a separate line: if the message being
// printed does not end in a newline, the logger will add one.
// The Fatal functions call os.Exit(1) after writing the log message.
// The Panic functions call panic after writing the log message.
package log
  • ロギングに使用する Logger 型と、それに紐づくメソッド(Formatter など)を定義している
  • Logger を明示的に生成してロギングするか、ヘルパ=関数経由で標準 Logger によるロギングができる
    • ヘルパー関数を使用すると、標準 Logger がログメッセージに日付・時刻を付加する
  • Logger 経由のロギングは以下の特徴を持つ
    • 1 ログにつき 1 行(カスタム Logger でなければ)
    • Fatal 系関数はロギング後 os.Exit(1)を呼ぶ
    • Panic 系関数はロギング後 panic を呼ぶ

Logger 構造体

// A Logger represents an active logging object that generates lines of
// output to an io.Writer. Each logging operation makes a single call to
// the Writer's Write method. A Logger can be used simultaneously from
// multiple goroutines; it guarantees to serialize access to the Writer.
type Logger struct {
    mu     sync.Mutex // ensures atomic writes; protects the following fields
    prefix string     // prefix on each line to identify the logger (but see Lmsgprefix)
    flag   int        // properties
    out    io.Writer  // destination for output
    buf    []byte     // for accumulating text to write
}

Logger 構造体は log パッケージが提供するロギング機能を実現するための構造体。

  • mu: sync.Mutex 値。goroutine による並行処理上でも排他制御しつつロギングするために使用する。
  • prefix : ログ行の先頭に付与する文字列。ただし、 Lmsgprefixflag に指定するとメッセージの先頭に付与される。
  • flag : ログの接頭辞を制御するフラグ。指定できるフラグは以下。ビットフラグになっており、 OR 演算で結合して複数指定できる。 ex. Ldate | Lmicroseconds
  • out: io.Writer 実装型の値。ログの出力先。
  • buf: ログ書き込み文字列のバッファ。
const (
    Ldate         = 1 << iota     // ローカルタイムゾーンの日時を次のフォーマットで表示: 2009/01/23
    Ltime                         // ローカルタイムゾーンの時刻を次のフォーマットで表示: 01:23:23
    Lmicroseconds                 // ローカルタイムゾーンの時刻を次のフォーマットで表示: 01:23:23.123123.  assumes Ltime.
    Llongfile                     // パス/ファイル名と行数を表示: /a/b/c/d.go:23
    Lshortfile                    // ファイル名と行数を表示、Llongfileを上書きする: d.go:23.
    LUTC                          // Ldate, Ltimeが設定されていた場合、タイムゾーンをUTCとする
    Lmsgprefix                    // prefixをログ先頭ではなくメッセージ先頭に移す
    LstdFlags     = Ldate | Ltime
)

Logger を明示的に作成して使用する

log パッケージには Logger 値参照を取得するヘルパー関数 New が提供されている。
引数の詳細は上記参照。

// New creates a new Logger. The out variable sets the
// destination to which log data will be written.
// The prefix appears at the beginning of each generated log line, or
// after the log header if the Lmsgprefix flag is provided.
// The flag argument defines the logging properties.
func New(out io.Writer, prefix string, flag int) *Logger {
    return &Logger{out: out, prefix: prefix, flag: flag}
}

Logger public メソッド

l := log.New(os.Stdout, "prefix: ", log.Ldate | log.Lmicroseconds | log.LUTC)
l.Println("Hello, Logger.")

Logger が使用できる出力系関数は以下。

func (l *Logger) Printf(format string, v ...interface{})
func (l *Logger) Print(v ...interface{})
func (l *Logger) Println(v ...interface{})
func (l *Logger) Fatal(v ...interface{})
func (l *Logger) Fatalf(format string, v ...interface{})
func (l *Logger) Fatalln(v ...interface{})
func (l *Logger) Panic(v ...interface{})
func (l *Logger) Panicf(format string, v ...interface{})
func (l *Logger) Panicln(v ...interface{})

これらの関数全ては、内部で Logger::Output 関数を呼び出している。

Logger::Output

Logger::Output は、各種出力系関数から呼ばれる汎用出力関数。
呼び出すごとに1行のログを出力する。

// Output writes the output for a logging event. The string s contains
// the text to print after the prefix specified by the flags of the
// Logger. A newline is appended if the last character of s is not
// already a newline. Calldepth is used to recover the PC and is
// provided for generality, although at the moment on all pre-defined
// paths it will be 2.
func (l *Logger) Output(calldepth int, s string) error {
// ...
}

Loggersync.Mutex を使用して排他制御をしているため、ロギングはスレッドセーフ(goroutine セーフ?)。

   l.mu.Lock()
    defer l.mu.Unlock()
    // ...
    _, err := l.out.Write(l.buf)
    return err

Logger.flagLshortfile Llongfile が設定されている場合、ファイル名・行数を取得するため、ロックを外して runtime.Caller を呼ぶ。

   if l.flag&(Lshortfile|Llongfile) != 0 {
        // Release lock while getting caller info - it's expensive.
        l.mu.Unlock()
        var ok bool
        _, file, line, ok = runtime.Caller(calldepth)
        if !ok {
            file = "???"
            line = 0
        }
        l.mu.Lock()
    }

Callerruntime パッケージの関数で、ゴルーチンスタック中の関数呼び出し情報を取得できる。この関数呼び出しの情報の中にロギング時のファイル・行数情報が含まれていて、 Output ではそれを使う。

package runtime

import "runtime/internal/sys"

// Caller reports file and line number information about function invocations on
// the calling goroutine's stack. The argument skip is the number of stack frames
// to ascend, with 0 identifying the caller of Caller.  (For historical reasons the
// meaning of skip differs between Caller and Callers.) The return values report the
// program counter, file name, and line number within the file of the corresponding
// call. The boolean ok is false if it was not possible to recover the information.
func Caller(skip int) (pc uintptr, file string, line int, ok bool) {
    rpc := make([]uintptr, 1)
    n := callers(skip+1, rpc[:])
    if n < 1 {
        return
    }
    frame, _ := CallersFrames(rpc).Next()
    return frame.PC, frame.File, frame.Line, frame.PC != 0
}

フラグ指定による接頭辞を付与するために、 formatHeader を呼ぶ。

   l.buf = l.buf[:0]
    l.formatHeader(&l.buf, now, file, line)

formatHeaderLogger.flags ビットフラグに従い接頭辞を指定する。
接頭辞は以下の優先順位で付与される。

  • Logger.prefix に指定した文字列
  • 日時・時刻
  • ファイル名・行数
  • LmsgprefixLogger.prefix に設定した場合 )Logger.prefix に指定した文字列
// formatHeader writes log header to buf in following order:
//   * l.prefix (if it's not blank and Lmsgprefix is unset),
//   * date and/or time (if corresponding flags are provided),
//   * file and line number (if corresponding flags are provided),
//   * l.prefix (if it's not blank and Lmsgprefix is set).
func (l *Logger) formatHeader(buf *[]byte, t time.Time, file string, line int) {
// ...
}

あとは Logger.out に文字列を書き込む。
文字列末尾に改行が含まれていなければ追加する。

   l.buf = append(l.buf, s...)
    if len(s) == 0 || s[len(s)-1] != '\n' {
        l.buf = append(l.buf, '\n')
    }
    _, err := l.out.Write(l.buf)
    return err

Logger をヘルパー関数経由で使用する

log パッケージクライアントは Logger 型の値を生成しなくても、下記関数でロギング機能を使用することはできる。

func Printf(format string, v ...interface{})
func Print(v ...interface{})
func Println(v ...interface{})
func Fatal(v ...interface{})
func Fatalf(format string, v ...interface{})
func Fatalln(v ...interface{})
func Panic(v ...interface{})
func Panicf(format string, v ...interface{})
func Panicln(v ...interface{})

これらの関数は共通のローカル Logger 値である std を使用する。
std の出力先は os.Stderr であり、接頭辞として日付・時刻がつく。

var std = New(os.Stderr, "", LstdFlags)

上記関数群は内部で std.Output を呼び、ロギング処理を実行する。
std 経由で Logger メソッドを呼び出さない理由は不明(ユースケース別にあえて分けた?)。

// Printf calls Output to print to the standard logger.
// Arguments are handled in the manner of fmt.Printf.
func Printf(format string, v ...interface{}) {
    std.Output(2, fmt.Sprintf(format, v...))
}

参考文献

src/log/log.go - The Go Programming Language

【AWS】DynamoDB local でローカル上にAWS DynamoDB 環境を作る

DynamoDB local

AWS は、ローカル環境上で DynamoDB を使用したアプリケーションを開発・テストするための DynamoDB local を提供している。

DynamoDB local は以下の手順でデプロイ可能。

  • DynamoDB local をローカルにインストールする
  • Apache Maven リポジトリを追加して DynamoDB をデプロイする
  • DynamoDB Docker イメージをインストールする

今回は Docker イメージをインストールして検証した。

Usage

Dynamo DB local

docker-compose でコンテナをデプロイする。

docker-compose.yml

version: '3.7'
services:
  dynamodb-local:
    image: amazon/dynamodb-local:latest
    container_name: dynamodb-local
    ports:
      - '8000:8000'
$ ls
docker-compose.yml
$ docker-compose up -d

これで Dynamo DB local コンテナデプロイは完了。

ホスト OS にポート 8000 を開けているので、 http://localhost:8000/shell をブラウザで開くと、ローカルの DynamoDB に対してコード経由で処理を実行できるエディタが開ける。

f:id:rennnosukesann:20210102173000p:plain

エディタは JS に対応している。

Code(JS)

AWS.config.endpoint = new AWS.Endpoint('http://localhost:8000');
let dynanodb = new AWS.DynamoDB();

let region = 'us-west-2';

AWS.config.update({
  region: region,
});

let tableName = 'Articles';

let log = (err, data) => {
  if (err) {
    console.log(err);
  } else {
    console.log(data);
  }
};

// create table
let createTable = () => {
  let tableParams = {
    TableName: tableName,
    KeySchema: [{ AttributeName: 'ID', KeyType: 'HASH' }],
    AttributeDefinitions: [{ AttributeName: 'ID', AttributeType: 'N' }],
    ProvisionedThroughput: {
      ReadCapacityUnits: 10,
      WriteCapacityUnits: 10,
    },
  };
  dynamodb.createTable(tableParams, log);
};

// check table existence
let checkTableParams = {
  TableName: tableName,
};
dynamodb.describeTable(checkTableParams, function (err, data) {
  if (err) {
    createTable();
  }
});

// PUT Item
let putParams = {
  TableName: tableName,
  Item: {
    ID: { N: '1' },
    Title: { S: 'title' },
    Content: { S: 'content' },
  },
};
dynamodb.putItem(putParams, log);

// GET Item
let getParams = {
  TableName: tableName,
  Key: {
    ID: { N: '1' },
  },
};
dynamodb.getItem(getParams, log);
=>
{}
{"Item":{"Title":{"S":"title"},"Content":{"S":"content"},"ID":{"N":"1"}}}

f:id:rennnosukesann:20210102173009p:plain

参考文献

DynamoDB ローカル (ダウンロード可能バージョン) のセットアップ

【Golang】GoのSQL driver mock `go-sqlmock` を試す

go-sqlmock とは

go-sqlmock は Go の database/sql/driver の実装で、DB ドライバの振る舞いをモック化できるライブラリ。 go-sqlmock を使用することで、DB ドライバを必要するロジックと、実際の DB ドライバ以降の処理を分離してテストできる。

go-sqlmock の特徴

DB ドライバに対するクエリ単位でモック設定ができる

sql.DB::Querysql.DB::Exec に指定する個々のクエリの内容ごとに返戻する行や結果を指定できる。 複数パターンのクエリに対して同一の結果を返したい場合も、正規表現で指定可能。

DB ドライバに対する関数呼び出しの順序検証ができる

ドライバのモック化だけでなく、ドライバモックに対して呼ばれたクエリやトランザクション処理の順序を検証する機能も持つ。

その他特徴

  • 安定版である
  • 並行性と同時接続サポート
  • Go1.8 以降の Context に対応:SQL パラメータの命名とモッキング
  • 仕様元ソースコードの変更必要なし
  • どの sql/driver メソッドの振る舞いもモック化できる
  • 厳密な期待順序マッチングを搭載
  • サードパーティライブラリへの依存なし

Usage

Install

$ go get github.com/DATA-DOG/go-sqlmock

検証のため、今回は MySQL ドライバを使用。

$ go get github.com/go-sql-driver/mysql

検証用コード

今回使用する検証用コードは以下に置いてあります。
rennnosuke/go-playground/go-sqlmock

取得クエリ(SELECT)の検証

テスト対象コード

DB ドライバを使用するコードを用意する。

import (
    "database/sql"
    "fmt"

    _ "github.com/go-sql-driver/mysql"
)

type Article struct {
    ID      int
    Title   string
    Content string
}

func GetByID(id int, db *sql.DB) (*Article, error) {
    row := db.QueryRow("SELECT * FROM ARTICLES WHERE ID = ? AND IS_DELETED = 0", id)

    e := Article{}
    if err := row.Scan(&e.ID, &e.Title, &e.Content); err != nil {
        return nil, fmt.Errorf("failed to scan row: %s", err)
    }

    return &Article{ID: e.ID, Title: e.Title, Content: e.Content}, nil
}

テストコード

ドライバを使用する実装に対する、go-sqlmock を使用したテストコードを用意。

import (
    "fmt"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
)

func TestSQLMock_Select(t *testing.T) {

    // モックDBの初期化
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("failed to init db mock")
    }
    defer db.Close()

    id := 1

    // dbドライバに対する操作のモック定義
    columns := []string{"id", "title", "content"}
    mock.ExpectQuery("SELECT (.+) FROM ARTICLES"). // expectedSQL: 想定される実行クエリをregexpで指定(指定文字列が含まれるかどうかを見る)
        WithArgs(id).                                          // 想定されるプリペアドステートメントへの引数
        WillReturnRows(sqlmock.NewRows(columns).AddRow(1, "test title", "test content")) // 返戻する行情報の指定

    // テスト対象関数call
    article, err := GetByID(id, db)
    if err != nil {
        t.Fatalf("failed to get article: %s", err)
    }
    fmt.Printf("%v", article)

    // mock定義の期待操作が順序道理に実行されたか検査
    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatalf("failed to ExpectationWerMet(): %s", err)
    }
}

モック DB の初期化

sqlmock.New() ファクトリ関数で sql.DB 値への参照と、モックを定義するための SqlMock 型の値を取得できる。 sql.DB は DB ドライバを使用するロジックに引き渡し、DB ドライバ以降の処理のモックとして使用される。 SqlMock は主にテスト関数内で、想定される DB ドライバに対する操作と、それらに対する結果を定義する。

// モックDBの初期化
db, mock, err := sqlmock.New()

ドライバへの想定操作と結果の定義

次に、DB ドライバに対してどのようなクエリが実行されるのかを SqlMock::ExpectQuery() 関数で定義する。 この定義は後述する SqlMock::ExpectationsWereMet() の検証で使用される。
指定するクエリ文字列は regexp パッケージ準拠の正規表現で記述できる。またデフォルト設定の場合、指定した文字列が実際に呼び出されたクエリに部分マッチすれば検証は Pass される。

クエリにプリペアドステートメントが指定されている場合、WithArts で実際に挿入する値を指定できる。

また、各々の操作に対してどのような結果を返すのかを SqlMock::WillReturnRows() で定義している。 返す行のカラムを NewRows() で、行データを *Rows.AddRow() で指定している。

// dbドライバに対する操作のモック定義
columns := []string{"id", "title", "content"}
mock.ExpectQuery("SELECT (.+) FROM ARTICLES"). // expectedSQL: 想定される実行クエリをregexpで指定(指定文字列が含まれるかどうかを見る)
    WithArgs(id).                                          // 想定されるプリペアドステートメントへの引数
    WillReturnRows(sqlmock.NewRows(columns).AddRow(1, "test title", "test content")) // 返戻する行情報の指定

実際のクエリ呼び出し検証

ExpectationsWereMet checks whether all queued expectations were met in order. If any of them was not met - an error is returned.

SqlMock::ExpectationsWereMet() は、呼び出した SqlMock::ExpectXXX に即する操作が、実際に DB ドライバ( sqlmock.New() から返戻された sql.DB )に順番どおりに実行されたかどうかを検証する。

// mock定義の期待操作が順序道理に実行されたか検査
if err := mock.ExpectationsWereMet(); err != nil {
    t.Fatalf("failed to ExpectationWerMet(): %s", err)
}

永続化処理(INSERT)の検証

テスト対象コード

func Create(id int, title, content string, db *sql.DB) error {
    tx, err := db.Begin()
    defer func() {
        switch err {
        case nil:
            tx.Commit()
        default:
            tx.Rollback()
        }
    }()

    if err != nil {
        return err
    }

    _, err = tx.Exec("INSERT INTO ARTICLES (ID, TITLE, CONTENT) VALUES (?, ?, ?)", id, title, content)
    if err != nil {
        return fmt.Errorf("failed to insert article: %s", err)
    }

    return nil
}

テストコード

func TestSQLMock_Insert(t *testing.T) {

    // モックDBの初期化
    // ...

    id := 1
    title := "test title"
    content := "test content"

    // dbドライバに対する操作のモック定義
    mock.ExpectBegin()
    mock.ExpectExec("INSERT INTO ARTICLES").      // 想定される実行SQLをregexpで指定(指定文字列が含まれるかどうかを見る)
        WithArgs(id, title, content).             // 想定されるプリペアドステートメントへの引数
        WillReturnResult(sqlmock.NewResult(1, 1)) // 想定されるExec関数の結果を指定
    mock.ExpectCommit()

    // テスト対象関数call
    // ...

    // mock定義の期待操作が順序道理に実行されたか検証
    // ...
}

ドライバへの想定操作と結果の定義

SELECT 検証時と異なる点は 2 つ。

  1. トランザクション呼び出しの定義 永続化処理などでトランザクション開始終了処理 Begin() Commit() RollBack() を呼ぶとき、それに合わせて SqlMock.ExpectBegin() SqlMock.ExpectCommit() SqlMock.ExpectRollback() を呼ぶ。

  2. Exec 関数呼び出しの定義 永続化処理では sql.DB::Query() ではなく sql.DB::Exec() を使用しているため、 SqlMock.ExpectExec() を使用している。また返戻値が行ではなく更新結果となるため、これを WillReturnResult() で指定し、返戻値の内容を NewResult() で定義している。

// dbドライバに対する操作のモック定義
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO ARTICLES").      // 想定される実行SQLをregexpで指定(指定文字列が含まれるかどうかを見る)
    WithArgs(id, title, content).             // 想定されるプリペアドステートメントへの引数
    WillReturnResult(sqlmock.NewResult(1, 1)) // 想定されるExec関数の結果を指定
mock.ExpectCommit()

UPDATE 処理を実装した場合も、基本検証は INSERT とほぼ同じ流れになる。

クエリマッチングルールの変更

SqlMock::ExpectQuery() SqlMock::ExpectExec() でクエリに含まれるべき文字列を指定する事ができたが、このルールは sqlmock.New()sqlmock.QueryMatcherOption() 返戻値を指定することで変更可能になる。

db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))

sqlmock.QueryMatcherOption() には以下の QueryMatcher が指定できる。

  • sqlmock.QueryMatcherRegexp : 想定される SQL 文字列を正規表現として使用し、実行クエリの文字列と照合する
  • sqlmock.QueryMatcherEqual : 想定される SQL 文字列と実行クエリを大文字・小文字含め完全一致で照合する

この QueryMatcher は QueryMatcherFunc 型で、この型にキャスト可能な関数を定義すれば独自の QueryMatcher を定義できる。

type QueryMatcherFunc func(expectedSQL, actualSQL string) error

所感

  • テストケースごとに個別にクエリ結果を設定できるのは柔軟でよい
  • sql.DB 関数呼び出し順検証、DB 周りの処理が頻繁に変更しない前提ならば便利かも
  • 逐一想定クエリを記述するのはちょっと面倒
  • RDB のテーブルスキーマやリレーションは再現できないので、実動環境と乖離しない定義を書くことが求められる1

参考文献


  1. DBMS などの機能も含めて検証したい場合、モックではなく素直に検証用 DB 含めてテストしたほうが良いかも。モックの宿命

【Golang】GoでTCPソケット通信を実装する

ソケット通信

ソケット(BSD)- Wikipedia

ソケット通信とは、プログラムから見たときのネットワーク通信を抽象化する概念をさす。通信の終端をソケットと呼ばれるオブジェクトとみなし、ソケットに対して何かしらの入力を行うと、もう一方のソケットから出力される。

このモデルにおいて、ネットワーク階層モデル(OSI,TCP/IP など)上のいわゆるネットワークレイヤ以下の詳細は隠蔽される。例えばパケット(セグメント)がどのように分割され、どのノードを経由して到達するのかなどはプログラムで制御しなくとも良い。

Go のソケット通信

Go では net パッケージがソケット通信の機能を提供している。受け手側で net.Listen() 、送る側で net.Dial() 関数を呼び出し、それぞれから取得できる Conn を介してメッセージを送受信できる。

net - The Go Programming Language

Usage

Server

サーバー側はソケットをオープンしたあと、クライアントからの入力を待ち続ける。 今回は TCP 通信で検証し、サーバー・クライアント共々同一ネットワークノード上にあるものとする。

package main

import (
    "fmt"
    "io"
    "net"
    "os"
)

type HandleConnection func(c net.Conn) error

func main() {

    handleConn := func(c net.Conn) error {
        buf := make([]byte, 1024)
        for {
            n, err := c.Read(buf)
            if err != nil {
                return err
            }
            if n == 0 {
                break
            }
            s := string(buf[:n])
            fmt.Println(s)
            fmt.Fprintf(c, "accept:%s\n", s)
        }
        return nil
    }

    if err := start(handleConn); err != nil {
        fmt.Fprintln(os.Stderr, err)
    }

}

func start(f HandleConnection) error {

    ln, err := net.Listen("tcp", "localhost:8080")
    if err != nil {
        return err
    }
    defer ln.Close()

    conn, err := ln.Accept()
    if err != nil {
        return err
    }
    defer conn.Close()

    for {
        if err := f(conn); err != nil && err != io.EOF {
            return err
        }
    }
}

Conn (ソケット)が io.Reader io.Writer インタフェース要件を満たすので、標準入出力のように扱える。もともと BSD UNIX がソケット通信をファイル読み書きのように扱いたかったという思想があり、この概念を継承している様子。

Client

クライアントは一度きりの入力とした。もちろん繋ぎっぱなしのまま連続して入力もできる。

package main

import (
    "bufio"
    "fmt"
    "net"
)

func main() {

    fmt.Println("client start.")

    err := start()
    if err != nil {
        fmt.Errorf("%s", err)
    }

    fmt.Println("client end.")

}

func start() error {

    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        return err
    }
    defer conn.Close()

    fmt.Fprintf(conn, "Hello, Socket Connection !")
    status, err := bufio.NewReader(conn).ReadString('\n')
    if err != nil {
        return err
    }
    fmt.Println(status)

    return nil
}

実行結果

Server

Hello, Socker Connection !

Client

client start.
accept:Hello, Socket Connection !

client end.

net パッケージの実装

ソケット通信の実装部分は OS が使用する物がすでにあり、Go はそれを使用していると思われる。 net パッケージの MacOS 用のロジックを追っていくと、最終的にシステムコールよりソケット通信 API を実行している様子。 (rawSyscall でシステムコールを実行できる)

syscall/zsyscall_darwin_amd64.go

//go:linkname libc_connect libc_connect
//go:cgo_import_dynamic libc_connect connect "/usr/lib/libSystem.B.dylib"

// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT

func socket(domain int, typ int, proto int) (fd int, err error) {
    r0, _, e1 := rawSyscall(funcPC(libc_socket_trampoline), uintptr(domain), uintptr(typ), uintptr(proto))
    fd = int(r0)
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

考えられるユースケース

アプリケーションレイヤのペイロードを自由に記述できるので、例えば独自のアプリケーションプロトコルを定義したり、http 通信ほど情報を詰め込みたくないが、何らかの情報を別ノードに送信したい場合などに利用できそう。

参考文献

【GraphQL】gqlgenでGraphQL serverをGoで構築する

gqlgen

gqlgen は Go の GraphQL ライブラリで、GraphQL をインタフェースとして持つ API サーバを Go で構築できる。

gqlgen はコード上に GraphQL スキーマをガシガシ書いていくライブラリとは異なり、
GraphQL ファイルに記述するスキーマ情報からコードを自動生成する。
なのでコードがごちゃごちゃしにくく、Go を知らない人でも GraphQL スキーマを書ける人なら誰でも定義を編集できるメリットがある(と個人的に考えている)。

デメリットとしては、自動生成されるコードに関してはブラックボックスになってしまうということと、
読み解いて編集したとてスキーマ更新時に再編集しなければいけないところだろうか。

Usage

プロジェクトの作成

公式のチュートリアルがあり一回通してみたが、これとは別に自分なりにプロジェクトを再構築した。

$ mkdir tmp-gqlgen; cd tmp-gqlgen
$ go mod init (Gov1.11以上であれば)

まずはパッケージを入手。

$ go get github.com/99designs/gqlgen

次にプロジェクトルートに GraphQL スキーマファイルを用意。

schema.graphql

type Query {
  user(id: ID): User!
  pet(id: ID): Pet!
}

type User {
  id: ID
  name: String
}

type Pet {
  id: ID
  name: String
}

次に gqlgen.yml を用意する。
このファイルは gqlgen でコードを自動生成する際に必要になる。

gqlgen.yml の詳しい定義はこちら

# GraphQLスキーマファイルの場所
schema:
  - ./*.graphql

# スキーマGo実装ファイルの生成場所
exec:
  filename: graph/generated/generated.go
  package: generated

# モデル構造体ファイルの生成場所
model:
  filename: graph/model/models_gen.go
  package: model

# resolver(GraphQL版controller的なもの)ファイルの生成場所
resolver:
  layout: follow-schema
  dir: graph/resolver
  package: resolver

# 不足したスキーマ構造体を自動生成する場所
autobind:
  - 'github.com/rennnosuke/tmp-gqlgen/graph/model'

gqlgen.yml が用意できたら、コードの自動生成を実行する。

$ go run github.com/99designs/gqlgen

すると yml で指定したものを含めいくつかコードが生成される。
ファイル名が気に入らなければ、yml 上で変更できる。

tmp-gqlgen
├── go.mod
├── go.sum
├── gqlgen.yml
├── graph
│   ├── generated
│   │   └── generated.go
│   ├── model
│   │   └── models_gen.go
│   └── resolver
│       ├── resolver.go
│       └── schema.resolvers.go
├── schema.graphql
└── server.go

models_gen.go

スキーマに定義した Type に対応する構造体が定義された。
コメントにあるように、自動生成ファイルはいじらないほうが吉。

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type Pet struct {
    ID   *string `json:"id"`
    Name *string `json:"name"`
}

type User struct {
    ID   *string `json:"id"`
    Name *string `json:"name"`
}

resolver.go

ベースになる resolver。
resolver は Web アプリケーションアーキテクチャとしての MVC における Controller に近く、endpoint に対応するメソッドを実装している。余談だが、GraphQL モジュール自体薄く保つべきという指針が提唱されているので、Controller 同様多くの処理は持たせず軽い Validation などに留めるのがよい。

package resolver

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct{}

schema.resolver.go

yml の resolver に指定したスキーマファイル分だけ生成される。今回は schema.graphql のみ指定したので、 schema.resolver.go ファイル1つが生成された。

これらの自動生成ファイルで定義される xxxResolver 構造体は resolver.goResolver 構造体をコンポジットする。

QueryResolver の持つメソッドは GraphQL スキーマクエリのメソッドに対応するが、実装は空(panic)になっているため、独自に編集する必要がある(ので、もちろん編集は可能)。

package resolver

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
    "context"
    "fmt"

    "github.com/rennnosuke/tmp-gqlgen/graph/generated"
    "github.com/rennnosuke/tmp-gqlgen/graph/model"
)

func (r *queryResolver) User(ctx context.Context, id *string) (*model.User, error) {
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Pet(ctx context.Context, id *string) (*model.Pet, error) {
    panic(fmt.Errorf("not implemented"))
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

スキーマの更新・再生成

一旦初期自動生成後、スキーマの変更を反映したい場合は以下を実行する。

$ go run github.com/99designs/gqlgen

これで resolverの上書きなしに、 modelexec に該当するファイルだけ更新できる。

サーバーの起動

コード自動生成の際、GraphQL API サーバーを起動する server.go も自動で生成されている。

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/rennnosuke/tmp-gqlgen/graph/generated"
    "github.com/rennnosuke/tmp-gqlgen/graph/resolver"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &resolver.Resolver{}}))

    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)

    log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

ちなみに、 Server 構造体が Go 標準 net/httpHandler インタフェース実装のハンドラ関数 Handler を持っているため、既存プロジェクトでも net/http を使用していればすぐに組み込むことができる。

server.go をそのまま起動すると API サーバーを起動できる。

$ go run server.go
2020/05/17 13:19:52 connect to http://localhost:8080/ for GraphQL playground

Request

実際にクエリを投げられるのを確認するため、Resolver メソッドを書き換える。
構造体のすべてのメンバ型がポインタなので、値を代入するとき少しもどかしい、、、

schema.resolver.go

func (r *queryResolver) User(ctx context.Context, id *string) (*model.User, error) {
    name := "Bob"
    return &model.User{
        ID:   id,
        Name: &name,
    }, nil
}

上記実装後、 go run server.go でサーバーを再起動すると user() クエリが投げられるようになる。

Query

{
  user(id:"user::1"){
    name
  }
}

Response

{
  "data": {
    "user": {
      "name": "Bob"
    }
  }
}

備考

個人の見解だが、自動生成される部分(特に exec に該当する部分)は殆ど変更の入らない部分なのと、 model resolver も自動生成 + 取得処理の外部モジュール化で十分だと思ったので、Go で GraphQL API サーバーを実装する際は gqlgen で良きかな、と思った。

graphql-goも試したが、Resolver 周りの実装が煩雑だったので心が折れた)

参考文献

gqlgen - Github

CentOS7 dockerコンテナのsystemctlを使用可能にする

TL;DR

  • CentOS7docker コンテナで systemctl が使えない
  • コンテナ起動時、--privileged /sbin/init を指定する
$ docker run -it --privileged --name centos -d centos:centos7 /sbin/init
$ docker exec -it centos /bin/bash

概要

CentOS 7 の Docker イメージは、下記コマンドにて習得できる。

$ docker pull centos:centos7

コンテナを作成してシェルを実行すると、CentOS をホスト OS であるかのようにコマンドを使用できる。

$ docker run -it -d --name centos centos:centos7
$ docker exec -it centos /bin/bash

ただし、CentOS のサービス制御コマンド systemctl を実行すると、以下のような表示が出て使用することができない。

$ Failed to get D-Bus connection: Operation not permitted

解決法

この問題を解消するためには、コンテナ起動時に --privileged オプションを指定した上で、 /sbin/init を起動する。

$ docker run -it --privileged --name centos -d centos:centos7 /sbin/init
$ docker exec -it centos /bin/bash

参考文献

F.2.4. /SBIN/INIT プログラム

Docker privileged オプションについて

CentOS7 のコンテナで systemctl を使うための方法