GoでmongoDBから出力したJSONファイルを処理するスクリプトを書いてみた

## はじめに

会社で作っているチャットボットのログをmongoDBに格納しているのですが、その対話のログデータから、前後数分以内に同じ発言をしているログを抽出して調査したいといったことがありました。そのためにスクリプトを書くことにしたのですが、どうせなら最近勉強を始めたGolangで書いてみよう！！と思い、勉強をかねてスクリプトを実装してみました。

流れとしては、mongoDBからデータをjsonで出力するコマンド`mongoexport`を使い、出力されたJSONファイルを読み込んでGoの構造体にした後、時間と発言内容を比較する。といった感じです。
`mongoexport`コマンドで出力されるのは、一行ずつJSONデータとなっているファイルなので、スクリプトで一行ずつ読み込んで処理する必要があります。

本記事では、mongoDBからexportしたJSONファイルを読み込んでGoの構造体へ変換する方法について記載していきます。
もっとこうしたほうが良いよ！これは間違っている！などございましたらご指摘お願いします:bow:

## JSONの取り扱い方法

はじめに、簡単にですがJSONデータをGoの構造体に変換する方法を紹介します。
Goの標準パッケージである`encoding/json`を使用します。このパッケージには、下記メソッドが実装されています。

https://golang.org/pkg/encoding/json/

### [json.Marshal](https://golang.org/pkg/encoding/json/#Marshal)

構造体->JSON

```golang:定義
func Marshal(v interface{}) ([]byte, error)
```

構造体をJSONへ変換するメソッドです。引数にはJSONに変換したい構造体を渡します。
構造体定義時に、JSONへの出力時のKeyの名前を指定することが出来ます。Keyの名前を指定しない場合は、構造体で定義されているKey名使用されます。

```golang:例
package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name    string
	Age     int      `json:"age"`
	Email   string   `json:"mail_address"`
	Hobbies []string `json:"hobbies"`
}

func main() {
	user := User{
		Name:    "taro",
		Age:     3,
		Email:   "taro@example.jp",
		Hobbies: []string{"fishing", "tennis"},
	}
	json, err := json.Marshal(&user)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Printf("%+v\n", user)
	fmt.Println(string(json))
}
```

```shell:出力結果
{Name:taro Age:3 Email:taro@example.jp Hobbies:[fishing tennis]}
{"Name":"taro","age":3,"mail_address":"taro@example.jp","hobbies":["fishing","tennis"]}
```

### [json.Unmarshal](https://golang.org/pkg/encoding/json/#Unmarshal)

JSON->構造体

```golang:定義
func Unmarshal(data []byte, v interface{}) error
```

JSONから構造体へ変換するメソッドです。引数には、変換したいbyte配列と変換後の構造体を格納する変数を渡します。

```golang:例
package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name    string
	Age     int    `json:"nenrei"`
	email   string // 頭文字が小文字のため"email"のJSONは読み込まれない
	Hobbies []string
}

func main() {
	j := `{
		"name": "taro",
		"nenrei": 3,
		"email": "taro@sample.jp",
		"hobbies": ["fishing", "tennis"]}`
	bytes := []byte(j)
	var user User
	// 変換に失敗したらerrorが返ってくるのでハンドリングする
	if err := json.Unmarshal(bytes, &user); err != nil {
		fmt.Println(err)
	}
	fmt.Printf("%+v\n", user) // mapのキーとバリュー両方表示するためにPrintf+vを使用
}
```

```shell:出力結果
{Name:taro Age:3 email: Hobbies:[fishing tennis]}
```

ここ2つ注目してほしい部分があります。
まずひとつ目はstruct定義の`email`で、構造体定義の頭文字が`email`と小文字になっている場合は、JSONから構造体に変換されず、代わりに`string`型の初期値である空の文字列`""`になってしまっています。
2つ目は、JSONでは`nenrei`となっているキーをGoの構造体ではAgeとして受け取っている部分です。このようにJSONで与えられるキーとは別の名前としたい場合には、`json`で指定することが出来ます。

## データの準備

今回サンプルで使用するデータをmongoDBに格納していきます。
db名は`smaple`。collection名は`logs`としています。

```shell
> use sample;
switched to db sample
> db.createCollection('logs');
{ "ok" : 1 }
> show dbs;
admin   0.000GB
config  0.000GB
local   0.000GB
sample  0.000GB
> show collections;
logs
```

logsコレクションにデータを追加

```shell
> db.logs.insert({name: "leg", utterance: '{"message": "明日の天気は？"}', extra: { type: "hoge" }, created_at: ISODate("2019-12-01T00:02:00+09:00")})
...
```

### mongoexportコマンド

次に`mongoexport`コマンドを使って、コレクションに格納されているデータのjsonファイルを取得します。

#### option
-d: db名
-c: コレクション名
-out: 出力ファイル名
--query: 検索条件クエリ

```shell
mongoexport -d=sample -c=logs -out=sample.json --query '{ "created_at": {"$gte" : ISODate("2019-11-01T00:00:00+00:00"), "$lte" : ISODate("2019-12-03T00:00:00+00:00")} }'
```

上記コマンドにより、下記データが出力されました。

```json:sample.json
{"_id":{"$oid":"5df5102e6909535236a55d2b"},"name":"Charlotte","utterance":"{\"message\": \"こんにちは\"}","extra":{},"created_at":{"$date":"2019-11-30T15:01:00.000Z"}}
{"_id":{"$oid":"5df510326909535236a55d2c"},"name":"leg","utterance":"{\"message\": \"明日の天気は？\"}","extra":{"type":"hoge"},"created_at":{"$date":"2019-11-30T15:02:00.000Z"}}
{"_id":{"$oid":"5df510376909535236a55d2d"},"name":"Chloe","utterance":"{\"message\": \"今日の運勢\"}","created_at":{"$date":"2019-11-30T15:03:00.000Z"}}
{"_id":{"$oid":"5df5103b6909535236a55d2e"},"name":"jummy","utterance":"{\"message\": \"こんにちは\"}","created_at":{"$date":"2019-11-30T15:04:00.000Z"}}
{"_id":{"$oid":"5df513d76909535236a55d31"},"name":"taro","utterance":"{\"message\": \"明日の天気は？\"}","created_at":{"$date":"2019-11-30T15:05:00.000Z"}}
{"_id":{"$oid":"5df513ea6909535236a55d32"},"name":"taro","utterance":"{\"message\": \"メニュー\"}","extra":{"piyo":"piyopiyo"},"created_at":{"$date":"2019-11-30T15:06:00.000Z"}}
```

*データの補足ですが、utteranceにはJSONの形で格納されていて、extraのデータ存在しなかったり、存在したとしても不規則な形で格納されているデータです。

## スクリプトの実装

今回やりたいことは、前後5分以内の同じMessage(重複したログ)があれば取得することです。
上で用意したデータの場合、Messageが`こんにちは`と`明日の天気は？`になっている2つずつのログが取得出来れば成功となります。

### 構造体の定義

まずは、JSONを読み込むための構造体を定義します。

```golang
type Id struct {
	Oid string `json:"$oid"`
}

type Utterance struct {
	Message string `json:"message"`
}

type CreatedAt struct {
	Date time.Time `json:"$date"`
}

type Log struct {
	Id         Id                     `json:"_id"`
	Name       string                 `json:"name"`
	Utterance  Utterance              `json:"utterance"`
	CreatedAt CreatedAt              `json:"created_at"`
	Extra      map[string]interface{} `json:"extra"`
}
```

ここでのポイントはUnmarshalの説明部分でも紹介した、JSONのデータでのキーとは別名で受け取る方法を利用しています。理由としては、`_id`, `$oid`, `$date`のように頭文字がアルファベットまたは数値意外の場合にはGoの構造体のキーとして使用できないためです。
また、Extraの部分はどんなデータ型がバリューとして入っているのかわからないため、interface{}としています。

このまま上記の構造体へ`json.Unmarshal`を使って変換しようとするとエラーが起きます。
それは、`Utterance`を構造体の`type Utterance struct { Message string }`として受け取ろうとしていますが、実際に読み込む値はstringとなっているためです。

```golang
type Utterance struct {
    Message string `json:"message"`
}

type Log struct {
    Utterance  Utterance `json:"utterance"` // 構造体として受け取ろうとしている
}

// 読み込むデータ: {"utterance": "{\"message\": \"こんにちは\"}"} stringとなっている
```

### UnmarshalJSONメソッドの実装

再度Unmarshal(JSON->構造体)してあげる必要があります。
このように、Unmarshal(JSON->構造体)するときにデータを加工したり、追加したいデータが場合などには`UnmarshalJSON`というメソッドを定義することが出来ます。
イメージとしては、最終的に吐き出したいデータのために途中で処理を挟むという感じです。

https://golang.org/pkg/encoding/json/#RawMessage.UnmarshalJSON

```golang
type Log struct {
	Id         Id                `json:"_id"`
	Name       string            `json:"name"`
	Utterance  Utterance         `json:"utterance"` // ここが構造体になっている
	CreatedAt CreatedAt         `json:"created_at"`
	Extra      map[string]string `json:"extra"`
}

func (log *Log) UnmarshalJSON(b []byte) error {
	// まずは、途中で使用する構造体を定義する
	// 上のstructのLogとの違いは、Utteranceだけ
	type Log2 struct {
		Id        Id                `json:"_id"`
		Name      string            `json:"name"`
		Utterance string            `json:"utterance"` // ここをstringで定義
		CreatedAt CreatedAt         `json:"created_at"`
		Extra     map[string]string `json:"extra"`
	}

	var log2 Log2                   // 新しく定義した構造体の変数を宣言
	err := json.Unmarshal(b, &log2) // json.Unmarshalを実行するときに渡されるbyteをUnmarshal(JSON->構造体)する
	if err != nil {
		return err
	}
	var utterance Utterance                                  // Utteranceの変数を宣言
	err = json.Unmarshal([]byte(log2.Utterance), &utterance) // ここがポイント！stringを構造体にする

	// 加工し終わったデータたちを、logの構造体変数へ格納していく
	log.Id = log2.Id
	log.Name = log2.Name
	log.Utterance = utterance
	log.CreatedAt = log2.CreatedAt
	log.Extra = log2.Extra

	return err
}
```

### JSONファイルの読み込み

続いて、JSONファイルを読み込んで実際に`json.Unmarshal`を使用して構造体に変換するコードを書いていきます。
mongoexportで出力したファイルは、1行に1つのJSONが保存されているので、1行ずつ読み込んで構造体にしスライスに格納すれば良さそうです。

ファイルの読み込みには`os.Open`と`bufio.NewScanner`を使います。

#### os.Open

https://golang.org/pkg/os/#Open

```golang
func Open(name string) (*File, error)
```

#### bufio.NewScanner

https://golang.org/pkg/bufio/#NewScanner

リターンされるscannerからメソッドを呼び出すことで、一行ずつ読み込む処理をすることが出来ます。

```golang
func NewScanner(r io.Reader) *Scanner
```

下記のようにReadFileという関数を定義します。
引数としてファイルの名前と、結果を格納するスライスのポインタを受け取り、ファイルを一行ずつ読み込んで構造体に変換してから、スライスへ追加していく処理を実装しています。

```golang
func ReadFile(filename string, resultLogs *[]Log) {
	fp, err := os.Open(filename)
	if err != nil {
		fmt.Println(err)
	}
	defer fp.Close()                // 先にCloseを定義しておく
	scanner := bufio.NewScanner(fp) // 読み込んだファイルのスキャナーが返ってくる
	for scanner.Scan() {            // ループを処理でScanメソッドを実行することで、一行ずつ処理できる
		txt := scanner.Text()       // Text()メソッドで一行のテキストを受け取る
		bytes := []byte(txt)
		var log Log
		if err := json.Unmarshal(bytes, &log); err != nil {
			fmt.Println(err)
		}
		*resultLogs = append(*resultLogs, log) // 構造体にした結果をスライスに格納する
	}
	if err = scanner.Err(); err != nil {
		fmt.Println(err)
	}
}
```

### JSONを構造体にする

あとは関数を呼び出す側を書けば、各行の構造体が格納されたスライスが作成されます。

```golang
const FILE_NAME = "sample.json"

func main() {
	logs := make([]Log, 0)
	ReadFile(FILE_NAME, &logs)
	for _, v := range logs {
		fmt.Printf("%+v\n", v)
	}
}
```

```shell:出力結果
$ go run main.go
{Id:{Oid:5df5102e6909535236a55d2b} Name:Charlotte Utterance:{Message:こんにちは} CreatedAt:{Date:2019-11-30 15:01:00 +0000 UTC} Extra:map[]}
{Id:{Oid:5df510326909535236a55d2c} Name:leg Utterance:{Message:明日の天気は？} CreatedAt:{Date:2019-11-30 15:02:00 +0000 UTC} Extra:map[type:hoge]}
{Id:{Oid:5df510376909535236a55d2d} Name:Chloe Utterance:{Message:今日の運勢} CreatedAt:{Date:2019-11-30 15:03:00 +0000 UTC} Extra:map[]}
{Id:{Oid:5df5103b6909535236a55d2e} Name:jummy Utterance:{Message:こんにちは} CreatedAt:{Date:2019-11-30 15:04:00 +0000 UTC} Extra:map[]}
{Id:{Oid:5df513d76909535236a55d31} Name:taro Utterance:{Message:明日の天気は？} CreatedAt:{Date:2019-11-30 15:05:00 +0000 UTC} Extra:map[]}
{Id:{Oid:5df513ea6909535236a55d32} Name:taro Utterance:{Message:メニュー} CreatedAt:{Date:2019-11-30 15:06:00 +0000 UTC} Extra:map[piyo:piyopiyo]}
```

構造体にすることが出来ました！

### 比較処理の実装

最後に、前後5分以内で同じMessageがあれば取得する処理の実装です。
下記のように時間を比較する関数を返すFactory関数を作ります。比較もとの時間を渡すことで、比較先の時間を渡して時間以内であれば`true`を返す関数を作成してくれます。このFactory関数はクロージャになっており、比較もと時間の5分前と5分先の時間を保持するようになっています。

```golang
const TIME_RANGE = 5

func compareTimeFactory(baseTime time.Time) func(target time.Time) bool {
	beforeTime := baseTime.Add(-TIME_RANGE * time.Minute)
	afterTime := baseTime.Add(TIME_RANGE * time.Minute)

	return func(targetTime time.Time) bool {
		return beforeTime.Unix() <= targetTime.Unix() &&
			targetTime.Unix() <= afterTime.Unix()
	}
}
```

```golang
results := make([][]Log, 0)
	for i := 0; i < len(logs); i++ {
		baseLog := logs[i]
		compareTime := compareTimeFactory(baseLog.CreatedAt.Date)
		duplicateLogs := make([]Log, 0)
		duplicateLogs = append(duplicateLogs, baseLog)

		for ii := 0; ii < len(logs); ii++ {
			targetLog := logs[ii]
			// 自分自身は比較しない
			if baseLog.Id == targetLog.Id {
				continue
			}
			if compareTime(targetLog.CreatedAt.Date) && baseLog.Utterance.Message == targetLog.Utterance.Message {
				duplicateLogs = append(duplicateLogs, targetLog)
				// 重複を発見したら、そのlogを空のstructにして2重で結果に吐かれないようにする
				logs[ii] = Log{}
			}
		}

		// 重複したログがある場合に結果の箱にいれる
		if len(duplicateLogs) > 1 {
			results = append(results, duplicateLogs)
		}
	}

	for _, v := range results {
		fmt.Printf("%+v\n", v)
	}
```

```shell:出力結果
$ go run main.go
[{Id:{Oid:5df5102e6909535236a55d2b} Name:Charlotte Utterance:{Message:こんにちは} CreatedAt:{Date:2019-11-30 15:01:00 +0000 UTC} Extra:map[]} {Id:{Oid:5df5103b6909535236a55d2e} Name:jummy Utterance:{Message:こんにちは} CreatedAt:{Date:2019-11-30 15:04:00 +0000 UTC} Extra:map[]}]
[{Id:{Oid:5df510326909535236a55d2c} Name:leg Utterance:{Message:明日の天気は？} CreatedAt:{Date:2019-11-30 15:02:00 +0000 UTC} Extra:map[type:hoge]} {Id:{Oid:5df513d76909535236a55d31} Name:taro Utterance:{Message:明日の天気は？} CreatedAt:{Date:2019-11-30 15:05:00 +0000 UTC} Extra:map[]}]
```

無事に想定した結果を取得することが出来ました:tada:

完成したコードの全体はイカのようになりました。

```golang:コード
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"time"
)

const FILE_NAME = "sample.json"
const TIME_RANGE = 5

type Id struct {
	Oid string `json:"$oid"`
}

type Utterance struct {
	Message string `json:"message"`
}

type CreatedAt struct {
	Date time.Time `json:"$date"`
}

type Log struct {
	Id        Id                `json:"_id"`
	Name      string            `json:"name"`
	Utterance Utterance         `json:"utterance"`
	CreatedAt CreatedAt         `json:"created_at"`
	Extra     map[string]string `json:"extra"`
}

func (log *Log) UnmarshalJSON(b []byte) error {
	type Log2 struct {
		Id        Id                `json:"_id"`
		Name      string            `json:"name"`
		Utterance string            `json:"utterance"`
		CreatedAt CreatedAt         `json:"created_at"`
		Extra     map[string]string `json:"extra"`
	}

	var log2 Log2
	err := json.Unmarshal(b, &log2)
	if err != nil {
		return err
	}
	var utterance Utterance
	err = json.Unmarshal([]byte(log2.Utterance), &utterance)

	log.Id = log2.Id
	log.Name = log2.Name
	log.Utterance = utterance
	log.CreatedAt = log2.CreatedAt
	log.Extra = log2.Extra

	return err
}

func readFile(filename string, resultLogs *[]Log) {
	fp, err := os.Open(filename)
	if err != nil {
		fmt.Println(err)
	}
	defer fp.Close()
	scanner := bufio.NewScanner(fp)
	for scanner.Scan() {
		txt := scanner.Text()
		bytes := []byte(txt)
		var log Log
		if err := json.Unmarshal(bytes, &log); err != nil {
			fmt.Println(err)
		}
		*resultLogs = append(*resultLogs, log)
	}
	if err = scanner.Err(); err != nil {
		fmt.Println(err)
	}
}

func compareTimeFactory(baseTime time.Time) func(target time.Time) bool {
	beforeTime := baseTime.Add(-TIME_RANGE * time.Minute)
	afterTime := baseTime.Add(TIME_RANGE * time.Minute)

	return func(targetTime time.Time) bool {
		return beforeTime.Unix() <= targetTime.Unix() &&
			targetTime.Unix() <= afterTime.Unix()
	}
}

func main() {
	logs := make([]Log, 0)
	readFile(FILE_NAME, &logs)

	results := make([][]Log, 0)
	for i := 0; i < len(logs); i++ {
		baseLog := logs[i]
		compareTime := compareTimeFactory(baseLog.CreatedAt.Date)
		duplicateLogs := make([]Log, 0)
		duplicateLogs = append(duplicateLogs, baseLog)

		for ii := 0; ii < len(logs); ii++ {
			targetLog := logs[ii]
			if baseLog.Id == targetLog.Id {
				continue
			}
			if compareTime(targetLog.CreatedAt.Date) && baseLog.Utterance.Message == targetLog.Utterance.Message {
				duplicateLogs = append(duplicateLogs, targetLog)
				logs[ii] = Log{}
			}
		}

		if len(duplicateLogs) > 1 {
			results = append(results, duplicateLogs)
		}
	}

	for _, v := range results {
		fmt.Printf("%+v\n", v)
	}
}

```

## 参考にした記事

https://www.takedajs.com/entry/2016/04/30/105738
https://qiita.com/kitoko552/items/d7178915a4792d1e3e85