5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

NextremerAdvent Calendar 2019

Day 15

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

Last updated at Posted at 2019-12-14

はじめに

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

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

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

JSONの取り扱い方法

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

json.Marshal

構造体->JSON

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

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

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))
}
出力結果
{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

JSON->構造体

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

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

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を使用
}
出力結果
{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としています。

> 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コレクションにデータを追加

> 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: 検索条件クエリ

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")} }'

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

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を読み込むための構造体を定義します。

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となっているためです。

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

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

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

UnmarshalJSONメソッドの実装

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

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.Openbufio.NewScannerを使います。

os.Open

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

bufio.NewScanner

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

func NewScanner(r io.Reader) *Scanner

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

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を構造体にする

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

const FILE_NAME = "sample.json"

func main() {
	logs := make([]Log, 0)
	ReadFile(FILE_NAME, &logs)
	for _, v := range logs {
		fmt.Printf("%+v\n", v)
	}
}
出力結果
$ 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分先の時間を保持するようになっています。

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()
	}
}
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)
	}
出力結果
$ 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:

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

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

5
2
0

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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?