はじめに
会社で作っているチャットボットのログをmongoDBに格納しているのですが、その対話のログデータから、前後数分以内に同じ発言をしているログを抽出して調査したいといったことがありました。そのためにスクリプトを書くことにしたのですが、どうせなら最近勉強を始めたGolangで書いてみよう!!と思い、勉強をかねてスクリプトを実装してみました。
流れとしては、mongoDBからデータをjsonで出力するコマンドmongoexport
を使い、出力されたJSONファイルを読み込んでGoの構造体にした後、時間と発言内容を比較する。といった感じです。
mongoexport
コマンドで出力されるのは、一行ずつJSONデータとなっているファイルなので、スクリプトで一行ずつ読み込んで処理する必要があります。
本記事では、mongoDBからexportしたJSONファイルを読み込んでGoの構造体へ変換する方法について記載していきます。
もっとこうしたほうが良いよ!これは間違っている!などございましたらご指摘お願いします
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")} }'
上記コマンドにより、下記データが出力されました。
{"_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.Open
とbufio.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[]}]
無事に想定した結果を取得することが出来ました
完成したコードの全体はイカのようになりました。
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