- 投稿日:2019-10-21T22:26:58+09:00
messagen: 制約に基づくメッセージ生成ツール
TL;DR
messagenはyamlかgolangで定義したテンプレートと制約に基づいてメッセージを生成するツールです。
具体的に何ができるのかは、利用例をご覧ください✌️Intro
皆さんは診断メーカーをご存知でしょうか。Twitterで以下のようなツイートを一度は見かけたことがあると思います。
にぼしはメシをこぼしました#meshiyosoihttps://t.co/r31VmMbEqM
— にぼし (@mpppk) October 20, 2019ところでこの診断メーカー、遊んだことがある方は多いと思いますが、診断を作ったことはありますか?
診断メーカーの診断作成画面では、以下のように「診断結果基本テキスト」から「リスト」を参照して作っていく仕組みになっています。messagenは診断メーカーのように、テンプレートとリストからメッセージを生成するツールです。YAMLかGo言語で定義を記述することで、メッセージを生成します。
例えば、診断メーカーのサンプルである好きなフルーツを表示する例は、messangenでは以下のように書くことができます。Definitions: - Type: Root Templates: - |- {{.User}}さんにオススメの果物は 「{{.Fruit}}」 です - Type: Fruit Templates: ["りんご", "みかん", "バナナ"]$ messagen -f def.yaml --state User=Bob Bobさんにオススメの果物は 「りんご」 です診断メーカーと異なるのは、選択されるリスト(messagenでは定義と呼びます)に対して制約をかけられる点です。例えば、Bobはりんごが嫌いなので、それ以外から選ばなければならないとします。この場合、以下のように制約をかけることで、UserがBobの場合りんごが選択されなくなります。(詳細な記法については後ほど説明します。)
Definitions: - Type: Root Templates: - |- {{.User}}さんにオススメの果物は 「{{.Fruit}}」 です - Type: Fruit Templates: ["みかん", "バナナ"] - Type: Fruit Templates: ["りんご"] # UserがBobである場合、りんごは選択されない Constraints: {"User!": "Bob"}messagenで具体的にどのようなことができるのか知りたい方は利用例を先にご覧ください。
背景
messagenが診断メーカーや他のツールと比較してより多機能であることは、より複雑であることも意味します。「制約」は、この複雑さを許容可能にするほど重要な概念なのでしょうか?
この点についてご説明するために、私がmessagenを作り始めた背景についてご紹介します。私はスタバ警察botというのを作っています。これはリプライで送られてきた画像がスタバかどうかを判定し、結果に応じてメッセージを返すというものです。
ピピーッ❗️?⚡️スタバ警察です❗️??❗️ オオッこのツイート???...間違いなくスタバ❗️❗️???
— スタバ警察 (@sutaba_police) August 19, 2019
市民の協力に感謝するッッッ??❗❗ https://t.co/nXsfuHtdxxこのメッセージは、判定結果に応じたテンプレートを元に、微妙に言い回しを毎回ランダムに生成しています。まさに診断メーカーと同じような機能が必要です。しかし加えて、次のような機能も必要であることがわかりました。
- 排他的なメッセージの組み合わせを定義できる
「一人称」の名前は「名前」だ。
というメッセージで、一人称が「俺」の場合、名前は男性名だけが選ばれてほしいことがあります。(誰でも好きな一人称を使えば良いと思いますが、あくまで例です)- デバッグモードの時はメッセージをガラッと変える、とかもやりたくなります
選択される単語の確率を変えたい
- 10回に1回しか選ばれないレアメッセージとかですね
生成されたメッセージのバリデーション
- Twitterだと生成したメッセージの文字数が140文字を超えると困ります
動的な値の代入ができる
- スタバ警察だと、画像の判定確率をメッセージに含めたくなります
言語からライブラリとして利用できる
- スタバ警察はGo言語で実装されているので、メッセージ生成ライブラリもGo言語から呼び出したいです
このような要求を満たすため、messagenが生まれました。
詳説 messagen
冒頭のYAML定義について詳しく見てみましょう。
Definitions: - Type: Root Templates: - |- {{.User}}さんにオススメの果物は 「{{.Fruit}}」 です - Type: Fruit Templates: ["りんご", "みかん", "バナナ"]messagenでは、定義(Definition)の集合でメッセージを生成します。
定義はTypeとTemplatesを持ちます。TypeはそのDefinitionを参照する際のIDのようなものです。
TemplatesはそのDefinitionが選ばれた時に実際に生成するメッセージです。複数ある場合はランダムに選択されます。(正確にはPickerにより制御されるのですが、詳細は後ほど)
もしTemplate内部で他のDefinition Typeを参照している場合、先にそのDefinitionからメッセージを生成します。つまりDefinitionのツリー構造を深さ優先で解決していくわけです。上記の例では、「Root」というTypeを持つ定義のTemplateで「User」と「Fruit」Typeが参照されています。Fruit定義は3つのTemplateを持っているので、この中からランダムに一つが選ばれます。Userはどこにも定義がないので、実行時に与える前提です。
選択された定義とそれにより生成されたメッセージの集合をStateと呼びます。messagenコマンドで上記yamlを実行すると、ランダムにメッセージが表示されます。
stateフラグを用いることで初期Stateを指定できます。ここではUserだけを与えていますが、カンマ区切りで複数与えることも可能です。$ messagen -f def.yaml --state User=Bob Bobさんにオススメの果物は 「りんご」 ですまた、messagenはgolangのライブラリとしても使えます。
この場合、上記の例は次のように書くことができます。func main() { rand.Seed(42) generator, _ := messagen.New() definitions := []*messagen.Definition{ { Type: "Root", Templates: []string{"{{.User}}さんにオススメの果物は\n「{{.Fruit}}」\nです"}, }, { Type: "Fruit", Templates: []string{"りんご", "みかん", "バナナ"} }, } initialState := map[string]string{"User": "Bob"} generator.Add(definitions...) messages, _ := generator.Generate("Root", initialState, 1) print(messages[0]) }定義の重み付け
Weight
キーでその定義が選択される確率を設定できます。同じTypeを持つ定義は幾つでも定義できるので、特定の定義だけ選ばれる確率を変えることができます。definitions := []*messagen.Definition{ { Type: "Fruit", Templates: []string{"りんご", "みかん", "バナナ"} }, { Type: "Fruit", Templates: []string{"ドラゴンフルーツ"} Weight: 0.1 // 1/10の重みになる。デフォルトは1なので、この例では0.1/(1+0.1)の確率で選ばれる }, }定義ごとの制約
messagen最大の特徴が冒頭でも述べた定義の制約(Constraints)です。
各定義には、現在のStateを元に選ばれても良いかどうかの制約を与えることができます。
例えば、一人称に応じて名前を出し分ける例は以下になります。Definitions: - Type: Root Templates: ["{{.Pronoun}}の名前は{{.Name}}。"] - Type: Pronoun Templates: ["あたし", "俺"] - Type: Name Templates: ["太郎", "次郎"] Constraints: {"Pronoun": "俺"} - Type: Name Templates: ["花子", "佳子"] Constraints: {"Pronoun": "あたし"}この例では、一人称(Pronoun)に応じて、選択されるNameが決定されます。
例えば、上から3つ目のNameでは、ConstraintsでPronounが"俺"であることが要求されているため、"あたし"の場合は選択されません。Constraints Operator
Constraintsでは、キーの最後に記号を付けることで特殊な挙動をさせることができます。
Operator 意味 ? キーが存在しなくても選択可能になる + ?に加えて、存在しなかった場合はキーと値を追加する ! 指定された値でない場合に選択可能になる / 値を正規表現として解釈し、マッチすれば選択可能になる :[Priority] プライオリティを指定する(後述) 例えば、先ほどの例で一人称のバリエーションを増やしたいとします。素朴には以下のように、Pronounを正規表現でマッチさせることができます。
Definitions: - Type: Root Templates: ["{{.Pronoun}}の名前は{{.Name}}です"] - Type: Pronoun Templates: ["あたし", "わたくし", "俺", "わし"] - Type: Name Templates: ["太郎", "次郎"] Constraints: {"Pronoun/": "俺|わし"} - Type: Name Templates: ["花子", "佳子"] Constratins: {"Pronoun/": "あたし|わたくし"}が、一人称がもっと増えてくると破綻の予感がします。
そこで代わりに+
operatorを使ってみます。Definitions: - Type: Root Templates: ["{{.Pronoun}}の名前は{{.Name}}です"] - Type: Pronoun Templates: ["あたし", "わたくし"] Constraints: {"Gender+": "女"} - Type: Pronoun Templates: ["俺", "わし"] Constraints: {"Gender+": "男"} - Type: Name Templates: ["太郎", "次郎"] Constraints: {"Gender": "男"} - Type: Name Templates: ["花子", "佳子"] Constratins: {"Gender": "女"}こうすると、一人称を増やしても他の定義には影響を与えません。
制約の優先度
食べ物の説明文を考えます。
Definitions: - Type: Root Templates: ["この{{.Food}}は{{.Description}}です"] - Type: Food Templates: ["ケーキ", "ハンバーグ", "オムライス"] - Type: Description Templates: ["ふわふわ", "クリームたっぷり"] Constraints: {"Food": "ケーキ"} - Type: Description Templates: ["美味しそう"]$ messagen run -f def.yaml このケーキは美味しそうです一番下のDescriptionは制約がないため、どんなFoodが選ばれても使われる可能性があります。
しかし、ケーキ専用のDescirptionを下から2番目の定義として作っているので、ケーキが選ばれたら必ずこの定義を使いたいとします。このような時はConstraints priorityを使います。Definitions: - Type: Root Templates: ["この{{.Food}}は{{.Description}}です"] - Type: Food Templates: ["ケーキ", "ハンバーグ", "オムライス"] - Type: Description Templates: ["ふわふわ", "クリームたっぷり"] Constraints: {"Food:1": "ケーキ"} # キーの:1がConstraints Priority - Type: Description Templates: ["美味しそう"]
Type:[PriorityNum]
と書くと、その制約にマッチした場合にプライオリティ分の値が加算されます。候補の定義のうち、最大のプライオリティを持つ定義が選択されます。(同じ値の場合は、Weightに従ってランダムに選ばれます)
プライオリティを明示しなかった場合は0として扱われます。
上記の例でFoodとしてケーキが選ばれた場合、上のDescriptionはプライオリティ1、下はプライオリティ0なので、必ず上のDescriptionが選ばれます。定義の解決順
食べ物とその味の表現を表すテンプレートを考えます。
Definitions: - Type: Root Templates: ["{{.Adjective}}な{{.Food}}"] - Type: Adjective Templates: ["ふわふわ", "クリームたっぷり"] Constraints: {"FoodGenre+": "ケーキ"} - Type: Adjective Templates: ["ジューシー", "ボリューミー"] Constraints: {"FoodGenre+": "肉"} - Type: Food Templates: ["いちごケーキ", "モンブラン", "チーズケーキ", "ガトーショコラ"] Constraints: {"FoodGenre": "ケーキ"} - Type: Food Templates: ["ハンバーグ", "ステーキ"] Constraints: {"FoodGenre": "肉"} Weight: 0.5 # ここで指定しても、先にAdjectiveが選ばれるため意味がないケーキは4種類あるのに対して、肉は2種類しかないので、肉の登場確率を
Weight
を0.5にしました。しかし、テンプレート内の定義はデフォルトでは出てきた順に解決されるので、先にAdjectiveがそれぞれ同じ確率で選ばれてしまいます。肉の登場確率を下げるには、肉に関連するAdjectiveへWeightを与える必要があります。しかし、Weight
を与えたい定義と実際にWeight
を書く定義が離れていると混乱しますし、もっと複雑な定義になると破綻しそうです。
定義の解決順をOrder
で指定すれば、この問題を解決することができます。Definitions: - Type: Root Templates: ["{{.Adjective}}な{{.Food}}"] Order: ["Food"] # Foodから解決される. 省略されたTypeはTemplateの前から解決される - Type: Adjective Templates: ["ふわふわ", "クリームたっぷり"] Constraints: {"FoodGenre+": "ケーキ"} - Type: Adjective Templates: ["ジューシー", "ボリューミー"] Constraints: {"FoodGenre+": "肉"} - Type: Food Templates: ["いちごケーキ", "モンブラン", "チーズケーキ", "ガトーショコラ"] Constraints: {"FoodGenre": "ケーキ"} - Type: Food Templates: ["ハンバーグ", "ステーキ"] Constraints: {"FoodGenre": "肉"} Weight: 0.5 # 先にFoodが選ばれるのでOKエイリアス
一つの定義から複数のメッセージを生成したい場合があります。
Definitions: - Type: Root Templates: ["「{{.Adjective}}」かつ「{{.Adjective}}」な{{.Food}}"] - Type: Adjective Templates: ["ふわふわ", "クリームたっぷり"] - Type: Food Templates: ["いちごケーキ", "モンブラン", "チーズケーキ", "ガトーショコラ"]しかし、定義から生成されたメッセージはStateに保存され、以降は同じ値が参照されます。なので、上記の例では、2つのAdjectiveは必ず同じ値になります。
$ messagen run -f def.yaml 「ふわふわ」かつ「ふわふわ」なモンブランそこで、エイリアスを用いて同じ定義から複数のメッセージを取り出します。
AllowDuplicate
で同じ定義から重複した値が生成されることを許可するかどうかを決められます。Aliases: Definitions: - Type: Root Templates: ["「{{.Adjective}}」かつ「{{.AnotherAdjective}}」な{{.Food}}"] Alias: AnotherAdjective: {"Type": "Adjective", "AllowDuplicate": false} - Type: Adjective Templates: ["ふわふわ", "クリームたっぷり"] - Type: Food Templates: ["いちごケーキ", "モンブラン", "チーズケーキ", "ガトーショコラ"]$ messagen run -f def.yaml 「ふわふわ」かつ「クリームたっぷり」なモンブランPicker & Validator
messagen組み込みの制約では表現できないメッセージ生成ルールを適用したい場合があります。例えば、特定の単語を含むテンプレートを優先して利用する、twitterに投稿するために文字数を140文字以下にするなどです。
これらを実現するために、picker / validatorという仕組みがあります。pickerは定義やテンプレートを選択する際の挙動を制御するための関数で、以下のようなシグネチャになっています。
type TemplatePicker func(def *DefinitionWithAlias, state *State) (Templates, error) type DefinitionPicker func(defs *Definitions, state *State) ([]*Definition, error)それぞれ与えられた定義とStateから定義やテンプレートのフィルタ/並び替えを行い、戻り値として返します。
TemplatePicker
TemplatePickerは、ある定義が持つテンプレートリストから、どれをどの順番で取り出すかを制御するための関数です。
例えば"ん"を含むテンプレートを選択しないTemplatePickerは以下のように実装できます。func IgnoreNTemplatePicker(def *DefinitionWithAlias, state *State) (Templates, error) { var newTemplates Templates for _, template := range def.Templates { msg, _, err := template.ExecuteWithIncompleteState(state) if err != nil { return nil, err } if !strings.Contains(string(msg), "ん") { newTemplates = append(newTemplates, template) } } return newTemplates, nil }
ExeuteWithIncompleteState
は、テンプレートが要求するTypeがStateに存在しなくても空文字として評価してメッセージを生成するメソッドです。例えば{{.A}} and {{.B}}!
というテンプレートでStateが{"A": "V1"}
だった場合、ExeuteWithIncompleteState
の結果はV1 and !
となります。詳細はmessagenのgodoc(準備中)をご覧ください。DefinitionPicker
DefinitionPickerはあるDefinition Typeをもつ定義からどれをどの順番で取り出すかを制御するための関数です。
例えば、messagen内部で、ある定義が制約を満たしているかどうかのチェックは、以下のようなDefinitionPickerとして実装されています。func ConstraintsSatisfiedDefinitionPicker(definitio ns *Definitions, state *State) ([]*Definition, error) { var newDefinitions Definitions for _, def := range *definitions { if ok, err := def.CanBePicked(state); err != nil { return nil, err } else if ok { newDefinitions = append(newDefinitions, def) } } return newDefinitions, nil }
def.CanBePicked
は、stateが制約を満たしているかどうかチェックする関数です。TemplateValidator
TemplateValidatorは、あるTemplateに対するstateがvalidかどうかを判定し、booleanを返す関数です。falseを返した場合、現在着目しているテンプレート以降の探索を打ち切ります。
TemplatePickerに似ていますが、stateが変更されるたびに呼び出されるので、適切に実装することで木構造を探索する際の枝刈りを行うことができます。例えばmessagenには、以下のような合計文字数をチェックするvalidatorのgeneratorであるMaxStrLenValidator
があらかじめ用意されています。func MaxStrLenValidator(maxLen int) TemplateValidator { return func(template *Template, state *State) (bool, error) { incompleteMsg, _, err := template.ExecuteWithIncompleteState(state) if err != nil { return false, err } return utf8.RuneCountInString(string(incompleteMsg)) <= maxLen, nil } }利用例
ここではmessagenがどのようなメッセージ生成を行えるのかを紹介します。
りょうくんグルメっぽい例
本家の方はちゃんと食べ物に合う表現を使っておられると思いますが、ここではジャンルに合った形容詞ならなんでも良いというルールで作ってみます。
Definitions: - Type: Root Templates: - |- まじでこの世の全ての{{.FoodCategory}}好きに教えてあげたいんだが {{.Location}}には全ての人間を虜にする禁断の{{.FoodName}}がある これが{{.FoodDescription}}で超絶美味いからぜひ全国の{{.FoodCategory}}好き、 {{.FoodCategory}}を愛する者たち、{{.FoodCategory}}を憎む者たち、全ての{{.FoodCategory}}関係者に伝われ OrderBy: ["FoodName"] - Type: FoodName Templates: ["サーロインステーキ", "リブステーキ"] Constraints: {"FoodCategory+": "肉", "Location+": "品川のhogeビル"} - Type: FoodName Templates: ["fugaハンバーグ"] Constraints: {"FoodCategory+": "肉", "Location+": "丸の内のfugaビル"} - Type: FoodName Templates: ["いちごケーキ", "モンブラン", "ガトーショコラ"] Constraints: {"FoodCategory+": "ケーキ", "Location+": "表参道のpiyoビル"} - Type: FoodDescription Templates: ["ジューシー", "ボリューミー"] Constraints: {"FoodCategory": "肉"} - Type: FoodDescription Templates: ["{{.CakeDescription}}かつ{{.AnotherCakeDescription}}"] Constraints: {"FoodCategory": "ケーキ"} Alias: AnotherCakeDescription: {"Type": CakeDescription, "AllowDuplicate": false} - Type: CakeDescription Templates: ["ふわふわ", "濃厚", "クリームたっぷり"]$ messagen run -f gurume.yaml まじでこの世の全てのケーキ好きに教えてあげたいんだが 表参道のpiyoビルには全ての人間を虜にする禁断のモンブランがある これが濃厚かつふわふわで超絶美味いからぜひ全国のケーキ好き、 ケーキを愛する者たち、ケーキを憎む者たち、全てのケーキ関係者に伝われガチャを作る例
診断メーカーでよく見るやつです。本家ではレアリティに応じて同じ文章をコピペしまくると言う荒技が用いられますが、messagenではWeightを用いてシンプルに表現できます。
Definitions: - Type: Root Templates: - |- フルーツ10連ガチャの結果↓ {{.Item}} {{.Item2}} {{.Item3}} {{.Item4}} {{.Item5}} {{.Item6}} {{.Item7}} {{.Item8}} {{.Item9}} {{.Item10}} Aliases: Item2: &item {"Type": "Item", "AllowDuplicate": true} Item3: *item Item4: *item Item5: *item Item6: *item Item7: *item Item8: *item Item9: *item Item10: *item - Type: Item Templates: ["SR[ドラゴンフルーツ]", "SR[マンゴー]"] Weight: 0.1 - Type: Item Templates: ["R[メロン]", "R[スイカ]"] Weight: 0.4 - Type: Item Templates: ["N[りんご]", "N[みかん]", "N[バナナ]"]$ messagen run -f gatya.yaml フルーツ10連ガチャの結果↓ N[みかん] N[みかん] N[バナナ] R[メロン] N[バナナ] R[メロン] N[りんご] R[メロン] SR[マンゴー] N[りんご]キャッチコピーを生成する例
コピーメカ/キャッチコピー自動作成サイトをリスペクトした例です。本家同様、与えられたジャンルに応じて適切なキャッチコピーを生成します。
Definitions: - Type: Root Templates: - "無人島に持って行きたい{{.Product}}" - "{{.Product}}を{{.IfAction}}、自信が生まれる" - "{{.Product}}を{{.Action}}前に知っておいて欲しいこと" - Type: Action Templates: ["食べる"] Constraints: {"Genre": "Food"} - Type: Action Templates: ["買う"] Constraints: {"Genre": "Other"} - Type: IfAction Templates: ["食べれば"] Constraints: {"Genre": "Food"} - Type: IfAction Templates: ["買えば"] Constraints: {"Genre": "Other"}$ messagen run -f copy.yaml --state Product=ラーメン,Genre=Food ラーメンを食べれば、自信が生まれるポケモン名でいろは歌を作る例
制約によるメッセージ生成の極端な例として、こちらのブログで紹介されているような、ポケモン名でいろは歌を作る例を考えます。つまり、46文字を一度ずつだけ使ったポケモン名の列挙です。ただし、濁音/半濁音は取り除き(
ガ
→カ
)、長音は無視します。また、今回はズルをして、ケルディオ
のオ
をヲ
としています。Definitions: - Type: Root Templates: - {{P1}} {{P2}} {{P3}} {{P4}} {{P5}} {{P6}} {{P7}} {{P8}} {{P9}} {{P10}} {{P11}} {{P12}} {{P13}} Alias: P1: &pokemon_alias {"Type": "Pokemon", "AllowDuplicate": false} P2: *pokemon_alias P3: *pokemon_alias P4: *pokemon_alias P5: *pokemon_alias P6: *pokemon_alias P7: *pokemon_alias P8: *pokemon_alias P9: *pokemon_alias P10: *pokemon_alias P11: *pokemon_alias P12: *pokemon_alias - Type: Pokemon Templates: ["フシギダネ", "ヒトカゲ", "ゼニガメ", "ヌオー", "ソーナノ", "キャタピー", "トランセル", "バタフリー", "ビードル", "コクーン", "スピアー", "ペロリーム", "チョボマキ", "ハブネーク", "ワタッコ", "ミュウ", "エレザード", "モンジャラ", "ケルディヲ", ]次にValidatorを定義します。
func IrohaTemplateValidator(template *messagen.Template, state *messagen.State) (bool, error) { incompleteMsg, _, err := template.ExecuteWithIncompleteState(state) if err != nil { return false, err } return !HasDuplicatedRune(NormalizeKatakanaWord(string(incompleteMsg))), nil }
HasDuplicatedRune
で、文字の重複がないかをチェックしています。また、NormalizeKatakanaWord
で、濁音のハンドリング等を行っています。コード全体を記載すると長くなってしまうので、ご興味がある方はソースをご覧ください。このvalidatorを適用したmessagenを実行します。
func main() { opt := &messagen.Option{ TemplateValidators: []messagen.TemplateValidator{IrohaTemplateValidator}, } generator, _ := messagen.New(opt) config, _ := messagen.ParseYamlFile("examples/iroha/pokemon.yaml") generator.AddDefinition(config.Definitions...) msg, _ := generator.Generate("Root", nil, 1) fmt.Println(msg) }実行すると以下のようになります。
$ go run main.go [ワタッコ ペロリーム チョボマキ ミュウ モンジャラ ケルディヲ ゼニガメ ハブネーク ソーナノ ヌオー スピアー エレザード]同じ文字を一度ずつ46文字使ったポケモン名の列挙ができていることがわかります。
(なお、実際にはもっと効率的ないろは歌生成アルゴリズムが存在します。もし高速にいろは歌を生成したくて仕方がないという奇特な方がいらっしゃれば、高速いろは歌生成CLIツールもご覧ください?)
スタバ警察の例
最後に、messagenを作るきっかけとなったスタバ警察の例です。スタバ警察では以下の要求がありました。
- デバッグモードではデバッグメッセージを表示したい
- 画像の判定結果の確信度に応じてメッセージを出し分けたい
- (Twitterのbotなので)合計文字数を140字以下にしたい
- これは
TemplateValidator
で紹介したMaxStrLenValidator
で実現できます- 低確率で出現するメッセージを作りたい
以下はこれらを実現するyamlです。
Definitions: - Type: Root Templates: - |- ピピーッ❗️?⚡️スタバ警察です❗️??❗️ {{.TweetCheck}}{{.SutabaDescription}}スタバ❗️❗️{{.GoodEmoji}} {{.LastMessage}} Constraints: {"Class": "Sutaba"} - Type: Root Templates: - |- ピピーッ❗️?⚡️スタバ警察です❗️??❗️ アナタのツイート?は❌スタバ法❌第{{.RuleNum}}条? 「スタバぢゃないツイートをスタバなうツイート?してゎイケナイ❗️」 に違反しています?今スグ消しなサイ❗️❗️❗️❗️✌️?? Constraints: {"Class": "Other", "Confidence/": "High|Medium"} - Type: Root Templates: ['{"class": "{{.Class}}", "confidence": "{{.Confidence}}"}'] Constraints: {"Debug:1": "on"} - Type: TweetCheck Templates: - "{{.Exclamation}}このツイート{{.ThinkingEmoji}}{{.ThinkingEmoji}}{{.ThinkingEmoji}}..." - Type: TweetCheck Templates: ["アアーーー❗️なんだこれはーーー❗️❗️"] Weight: 0.5 - {"Type": "Exclamation", "Templates": ["ムムッ", "ヤヤッ", "オオッ"]} - {"Type": "ThinkingEmoji", "Templates": ["?", "?"]} - {"Type": "GoodEmoji", "Templates": ["?", "?"]} - Type: SutabaDescription Templates: ["完全に", "間違いなく"] Constraints: {"Confidence": "High"} - Type: SutabaDescription Templates: ["おそらく", "多分"] Constraints: {"Confidence": "Medium"} - Type: LastMessage Templates: - "この調子でグッドなスタバツイートを心がけるようにッ❗️??" - "市民の協力に感謝するッッッ??❗"$ messagen run -f sutaba.yaml -s Confidence=High,Class=Sutaba,Debug=off ピピーッ❗️?⚡️スタバ警察です❗️??❗️ オオッこのツイート???...間違いなくスタバ❗️❗️? 市民の協力に感謝するッッッ??❗ $ messagen run -f sutaba.yaml -s RuleNum=999,Confidence=High,Class=Other,Debug=off ピピーッ❗️?⚡️スタバ警察です❗️??❗️ アナタのツイート?は❌スタバ法❌第999条? 「スタバぢゃないツイートをスタバなうツイート?してゎイケナイ❗️」 に違反しています?今スグ消しなサイ❗️❗️❗️❗️✌️?? $ messagen run -f sutaba.yaml -s Confidence=High,Class=Other,Debug=on {"class": "Sutaba", "confidence": "High"}おわり
messagenについて、背景、利用方法、利用例をご紹介しました。
もしご興味があればぜひ使って見てください?
まだドキュメントが足りない部分もあるので、不明点などあればTwitterでお気軽にご質問ください!
- 投稿日:2019-10-21T19:22:47+09:00
CircleCIでGitHubのPull Request時のみGoプロジェクトのチェックを実行する
やりたかったこと
GitHubのPull Request時にGoプロジェクトのビルド前チェックを行いたかったのでCircleCIで実現しました。
要件としては下記です。
- errcheckやらlint等のチェックを実施
- migrationを実施
- DB処理を行うため、MySQLとRedisのイメージも利用
- 処理時間の最大限短縮
- 必要な外部ライブラリを取得済みのカスタムイメージを利用(イメージはプライベートリポジトリに保持)
- vendorはキャッシュを利用
- 処理結果をslackに通知
方針策定
migrationの確認はDBを立ち上げなくてはならず、なおかつDBの起動を待って処理を行わなくてはなりません。そのため、dockerizeコマンドをstepsに組み込みます。
今回は例としてMySQLとRedisのDockerイメージを使用しています。また、チェック処理やmigrationには外部ライブラリの利用が必要となり、それらのインストールに時間がかかってしまいます。
処理時間の短縮を図るため、ライブラリを予めインストールしたカスタムイメージの利用とvendorのキャッシュ化を行います。設定手順
GitHubをCircleCI連携するためにはプロジェクト直下に.circleciディレクトリを作成し、config.ymlファイルを配置する必要があります。
また、今回は必要なライブラリをインストール済みのカスタムイメージを利用するため、Dockerfileを用意する必要があります。このDockerfileの配置に関しても公式が配置箇所を指定しているのでそれに従います。├── adapter ├── domain ├── usecase ├── hogehoge └── .circleci/ ├── config.yml └── images └── go └── Dockerfile1. Dockerfile用意
今回はGo言語での開発のため、予めライブラリを
go get
したカスタムイメージを作成して利用することにします。
depも利用しているのでバージョン指定をしてdepのインストールも行います。
CircleCi純正のDockerイメージをベースに、カスタムしました。.circleci/images/go/DockerfileFROM circleci/golang:1.11 ENV GOPATH /go ENV DEP_VERSION 0.5.0 RUN curl -L -s https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-linux-amd64 -o $GOPATH/bin/dep RUN chmod +x $GOPATH/bin/dep RUN go get -u golang.org/x/tools/cmd/goimports RUN go get -u github.com/kisielk/errcheck RUN go get -u golang.org/x/lint/golint RUN go get -u bitbucket.org/liamstask/goose/cmd/goose RUN go get -u github.com/client9/misspell/cmd/misspell2. DockerHubにアップロード
CircleCIからDockerfileを利用する場合、用意したDockerfileをDockerHubにアップロードする必要があります。
方法としては直接アップロードする方式とGitHub等と連携して自動で更新する方法があるようです。今回は直接アップロードを行います。
また、下記操作をする前に予めリポジトリは任意の名前で作成してください。$ cd path-to-project # プロジェクト直下へ $ docker build .circleci/images/go $ docker login $ docker push hogehoge/foo:1.0.0 # リポジトリ名称とタグは任意3. CircleCIプロジェクト設定
CircleCIをGitHubに連携する方法に関してはこちらの記事がとても参考になりました。
今回は割愛します。
サンプルのconfig.ymlを作成するところまで進めてください。4. config.yml用意
config.ymlはCircleCIのyml書式に従って記述します。
作成したconfig.ymlはコミットしてmasterブランチまで反映させてください。.circleci/config.ymlversion: 2 jobs: check: environment: - GOPATH: /go - APP_ENV: circleci working_directory: /go/src/github.com/path-to-project docker: - image: hogehoge/foo:1.0.0 auth: username: hogeuser password: $DOCKERHUB_PASSWORD - image: circleci/mysql:5.7-ram environment: MYSQL_ROOT_PASSWORD: rootpw MYSQL_DATABASE: test MYSQL_USER: circleci MYSQL_PASSWORD: circleci - image: circleci/redis:5.0-alpine steps: - checkout - run: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s - run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120s - restore_cache: keys: - v1-vendor-{{ checksum "Gopkg.lock" }} - run: name: ensure command: | if [ ! -d vendor ]; then dep ensure -vendor-only fi - save_cache: key: v1-vendor-{{ checksum "Gopkg.lock" }} paths: - vendor - run: goose -env circleci up - run: go test -v ./... - run: find . -type f -name '*.go' -not -path "./vendor/*" -print0 | xargs -0 goimports -l | xargs -r false - run: find . -type f -name '*.go' -not -path "./vendor/*" -print0 | xargs -0 misspell -error - run: # その他もろもろの確認処理 workflows: version: 2 setup-install-check: jobs: - check5. CircleCIプロジェクト設定の調整
上記の設定のみでは要件が満たせないため、プロジェクトの設定を調整します。
実行タイミングの設定
初期設定ではすべてのコミットに対してCircleCIが実行されます。
今回はPull Request時のみにCircleCIを実行したいため、設定を変更します。CircleCIプロジェクトからSetting > Advanced Settings に進み、「Only build pull requests」をOnに設定。
これによって、PR時にのみ実行されるようになりますが、defaultブランチに対してのコミットは常に実行される点、注意です。
※defaultブランチはリポジトリごとに設定可能です。GitHubのSettingsから確認をしてみてください。Slack通知の設定
CircleCIの実行結果をSlackに通知する設定を入れます。
CircleCIプロジェクトからSetting > Chat Notifications に進み、Slackの「Webhook URL」を設定。
SlackのWebhook URLは画面内のCircleCI Integrationリンクから遷移して取得することが可能です。
環境変数の設定
DockerHubにカスタムイメージを置く際、セキュリティの関係でプライベートリポジトリを使う必要がある場合があります。カスタムイメージを取得する際に、CircleCIがユーザ情報を保持する必要があるのですが、config.ymlに記載するのは良くないでしょう。
そのため、環境変数にカスタムイメージ取得用アカウントのパスワードを持たせ、config.ymlから呼び出しています。CircleCIプロジェクトからSetting > Environment Variables に進み、Add Variableをクリック。
出てきたダイアログに環境変数名とその値を記載してAdd Variableをクリック。
config.yml説明
ポイントだけ少し補足説明します。
Docker周り
今回はプライベートリポジトリにアップロードしているカスタムイメージを利用するため、最初の
image
(実体はGo用のイメージ)に対してauth
を設定しています。
$DOCKERHUB_PASSWORD
は前項で設定した環境変数の値が適応されます。MySQLイメージのenvironmentに各設定値を入力することで、その設定で起動されます。
MYSQL_DATABASE
を設定することでtestという名前のDBスキーマも作ることができます。docker: - image: hogehoge/foo:1.0.0 auth: username: hogeuser password: $DOCKERHUB_PASSWORD - image: circleci/mysql:5.7-ram environment: MYSQL_ROOT_PASSWORD: rootpw MYSQL_DATABASE: test MYSQL_USER: circleci MYSQL_PASSWORD: circleci - image: circleci/redis:5.0-alpineDB起動待機
今回はredisとmysqlのDockerイメージを利用するが、それらの起動を待つために下記の処理を行っています。
- run: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s - run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120sキャッシュの利用
depを利用して取得したライブラリはvendorに格納されるため、vendorをキャッシュ化しています。
depはライブラリの依存関係がGopkg.lockに反映されるため、Gopkg.lockのチェックサム値をキーとしてキャッシュを保存します。処理としては下記の流れです。
- キャッシュが存在したら取得する(restore_cache)
- キャッシュが存在しなかった(vendorがリストアされなかった)場合、dep ensureを行う。
- vendorをキャッシュに保存する(save_cache)
なお、dep ensureの際にGopkg.lockが更新され、キャッシュ保存時にキー値が変わってしまう恐れがあるため -vendor-onlyをつけています。
- restore_cache: keys: - v1-vendor-{{ checksum "Gopkg.lock" }} - run: name: ensure command: | if [ ! -d vendor ]; then dep ensure -vendor-only fi - save_cache: key: v1-vendor-{{ checksum "Gopkg.lock" }} paths: - vendor※ 保持しているキャッシュを削除したい場合は、v1-vendor-の数値を書き換えるらしいです。
おわりに
Goのサンプルが少なめだったので参考になれば嬉しいです。
- 投稿日:2019-10-21T17:24:14+09:00
Goでtar.gzを出力する
勉強用にtar.gzのサンプルを作りました。ソースコード自身をtar.gzに格納圧縮し、そのバイト列を標準出力に表示し、output.tar.gzに保存し、終了します。bytes.Buffer、gzip.Writer、tar.Writerは全てio.Writerインターフェースを実装しているので順番に噛み合わせてtar.gzされたバイト列を出力できます。
ソース
tar.gz.gopackage main import ( "archive/tar" "bytes" "compress/gzip" "fmt" "io" "os" ) func writefile(w *tar.Writer,name string){ if file, err := os.Open(name) ; err == nil{ fi, _:=file.Stat() hdr := new(tar.Header) hdr.Size=fi.Size() hdr.Name=fi.Name() w.WriteHeader(hdr) io.Copy(w, file) file.Close() } } func main() { var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) writefile(tw,"tar.gz.go") tw.Close() gz.Close() data:=buf.Bytes() fmt.Print(data) if file, err := os.Create("output.tar.gz") ; err == nil{ file.Write(data) file.Close() } }補足
- 1ファイルならtarする必要がないよね
- そのとおりです。実際はwritefile()を複数呼んでファイルを追加します。
- 直接ファイルに書き込めばいいのでは?
- 圧縮後の中身を見たかったので一度バイト列に出力しました。
- 投稿日:2019-10-21T12:50:36+09:00
GoとGAEでWebhookを使ったプライベートなShopifyアプリを作る
今回はShopifyアプリをGoogle Cloud PlatformのGAE(Google App Engine)を使ってGoで書いてみました。
Shopifyとは?
Shopifyとは、カナダ産のEC(e-Commerce)のSaaSです。ひと昔前風に言うと、GMOメイクショップや、フューチャーショップ、Baseの様なEC向けのASPです。
現在、カナダだけではなく、北米はもとより世界中で利用されており、昨年くらいからアジアオセアニア地域でのマーケティングにお力を入れ始めた、ECプラットフォーム界の巨人です。
グローバルに展開しているサービスなので、世界中のローカルの決済等が利用でき、越境EC向けのECプラットフォームです。標準機能も素晴らしいのですが、Shopifyアプリというサードパーティーのアプリでその機能を拡張できる点も魅力的な点です。
すでに世界中で様々なShopifyアプリが開発されており、日本国内でも増えてきています。
Shopifyアプリとは?
じゃあ、具体的にShopifyアプリってどうなってんの?というと、基本的にShopifyのWebAPIを叩くsomethingです。
APIには伝統的なRESTのAPIと最近流行りのGraphQLがあります。
Shopify内の何かしらのイベントに対応したEventかWebhookでキックされる感じです。
APIとアプリにはいくつか種類があって、全体的なAPIと、管理画面上の要素をごにょごにょするAPIとかフロント用のAPIとか実店舗や倉庫向けのPOSアプリ用のAPIとかもありますよくわかりません。とりあえず今回の要件に必要な分だけ調べました。Shopifyアプリには、大きく分けて公開されてて誰でも利用することができるPublic Appsと、その店舗専用みたいなPrivate Appsがあります。
Private Apps
ShopifyのAppストアに出さないそのショップ専用のアプリ。Public Appと違って、認証周りが楽。トークンとか取得しなくて良い。
https://{API_key}:{password}@{shop}.myshopify.com/admin/api/2019-10/shop.json
みたいにBasic認証でリクエストして処理できる。今回使ったのはこれPublic Apps
ShopifyのAPPストアからインストールできる。APIの接続には認証とトークンが必要。一般的なAppsといったらこっち。
App Storeに公開しなくても使える。その場合はPrivate Appsっぽい扱いができる。Private Appじゃないメリットは後述のEmbedded SDKとかが使えること。管理画面系のアプリはPublicにしないとできない。自分が作ったアプリを広く売りたいなら通常のShopify App
Public AppsはShopifyの利用者に向け販売できます。だいたいのアプリがサブスクリプション方式なので、定期的な収入が欲しい方は作ってみてはいかがでしょうか?
英語など多言語に対応すれば市場はものすごく大きいので、ポテンシャルは高いです。ただ、現状だと日本語と日本にしか対応していないアプリは厳しそうです。実際、現在日本語で提供されているアプリも、決済系かロジ系が多く、アプリじゃなくて自社サービスと連携させるアプリを出して、自社サービスで利益を出すタイプが多い様です。Embedded apps
なぜこれがPublic AppsとPrivate Appsと同じレベルで説明されているのかわからないけど、上記のPublic Appsで使える管理画面系のアプリのためのSDKがあり、これらを使う様なアプリはEmbedded appsと呼ばれる。
- Embedded App SDK
- 管理画面に埋め込む系の何かを作る時用のSDK
POS App SDK
- iPadとかのタブレットなどで使えるShopify POSアプリがあり、それ用のSDK。今は非推奨。iOS9以降は対応してないっぽい。
- Shopify App Bridge
- Embedded App SDK、POS SDKに代わる新しいしくみ。JavaScriptのライブラリで、管理画面やShopify POS Appのフロントエンド向け。
どの言語でShopify Appを作るか?
ぶっちゃけどれでも良い。APIとのやり取りがメインなので、何だったらCでもアセンブラでも良い。
RESTかGraphQL叩ければ良い。ただ、いろんな言語でShopify API用のライブラリが出てるのでそれらを使うと楽。公式に推してるのはRuby on Rails
https://github.com/Shopify/shopify_app
最初、Railsで書いてたけど、Railsが動くApp Serverを立てるのとそのメンテが面倒だったので途中でやめた。たぶんHerokuとか使えば良いんだろうけど、嫌いなのでテンション下がってやめた。今回はクライアントにとってもHerokuとRailsを使うメリットはあまり無かった。Shopify自体がRubyで書かれていて、UAがRubyでAPPにアクセスしてくる。
Shopifyって中は何で書いてるんだろう?って思ってたんですが、Wenhookに関してはアクセスしてくるUAがRubyとなってるので、中でも結構Rubyが使われてるのかも。 Matzすごい
Ruby以外の言語のライブラリ
Ruby on Railsで書くのを止めた段階で候補はGoかPHPしかなかったんだけど、ざっくり調べて見た。
- Go
- go-shopify(今回使ったやつ)
- PHP
- Shopify公式 (メンテされてない)
- Laravel Shopify App
- Symfony Shopify Bundle (最近コミットされてなくて怪しい)
- node
- Java
- Shopify SDKちゃんとメンテされてるっぽい
- Shopify4J - Caffeinated Shopify BindingsAndroid用(メンテされてない)
- Elixir
世界中で使われてるSaaSなだけあって、とにかくいっぱいある。ただ、日本語対応とか日本向けはまだまだ少ないので、日本人的には辛い。日本人頑張らねば
PHPが公式のリポジトリにあるにも関わらずメンテされていなかったので、何となく避けた。Laravelのライブラリは開発が盛んで盛り上がっているみたいだったので、少し興味を持ったけど今回はLaravel使うほどのものでもなかったのでGoでベタ書きすることにした。結局100行ちょいのmain.go
1ファイルで終わった。go-shopifyを使ってgolangでShopify Appを書く
goでShopify Appを書く場合、go-shopifyを使うと色々と便利。大体のShopifyのオブジェクトの構造体が準備されてるし、
.Create()
で追加したり色々できる。
とりあえずgo get
してインポートする。$ go get github.com/bold-commerce/go-shopify
package main import ( "encoding/json" "fmt" "io/ioutil" "log" "net/http" "os" "strings" "time" goshopify "github.com/tao-s/go-shopify"//これ )go-shoipfyの基本的な使い方
まずはAPIの認証。 Private AppとPublic Appで少し違う
Public AppのShopify APIの認証
API Key
とAPI Secret
をセットしてトークンを取得する// Create an app somewhere. app := goshopify.App{ ApiKey: "abcd", ApiSecret: "efgh", RedirectUrl: "https://example.com/shopify/callback", Scope: "read_products,read_orders", }
API Key
とAPI Secret
はShopifyのパートナーページのアプリ管理で取得できる。
次にトークン取得
// Create an oauth-authorize url for the app and redirect to it. // In some request handler, you probably want something like this: func MyHandler(w http.ResponseWriter, r *http.Request) { shopName := r.URL.Query().Get("shop") authUrl := app.AuthorizeURL(shopName)//shopNameはストアの管理画面のドメイン。 http.Redirect(w, r, authUrl, http.StatusFound)//アプリ認証画面にリダイレクトさせる } // 認証画面からの戻りの画面 func MyCallbackHandler(w http.ResponseWriter, r *http.Request) { // Check that the callback signature is valid if ok, _ := app.VerifyAuthorizationURL(r.URL); !ok {//戻りの検証 http.Error(w, "Invalid Signature", http.StatusUnauthorized) return } query := r.URL.Query() shopName := query.Get("shop") code := query.Get("code") token, err := app.GetAccessToken(shopName, code)//トークンを取得。ここで取得したトークンはDBとかに保存しとく // Do something with the token, like store it in a DB. }トークンが取得できたらAPI叩ける。
// Create an app somewhere. app := goshopify.App{ ApiKey: "abcd", ApiSecret: "efgh", RedirectUrl: "https://example.com/shopify/callback", Scope: "read_products", } // Create a new API client client := goshopify.NewClient(app, "shopname", "token")//取得したshopnameとトークンを渡してクライアントを初期化 // Fetch the number of products. numProducts, err := client.Product.Count(nil)//APIをCallするPrivate AppのShopify APIの認証
Private Appの場合は、APIキー取得するとこが違う。Public Appはパートナーページだったけど、Pricvate Appはショップのアプリ管理から取得する。
取得した** API Key*とパスワード*で初期化。トークンは空文字ですぐクライアント作れる。
このプライベートアプリがどのAPIにアクアセスできるか?とかはストアのプライベートアプリの管理画面で設定する。// Create an app somewhere. app := goshopify.App{ ApiKey: "apikey", Password: "apipassword",//API Secretじゃなくてpassword } // Create a new API client (notice the token parameter is the empty string) client := goshopify.NewClient(app, "shopname", "")アプリの起動はWebhookで
アプリの起動方法は2種類あって、あらかじめshopifyのEventに登録しておく方法と、Webhookを使う方法。プライベートアプリならWebhookが楽。
Webhookの設定は、ストアの管理画面の通知メニューから設定する。
ここでWebhookの送信先とかリクエストの検証用キーとかが設定できる。
Private Appはトークンいらない代わりにWebhookリクエストの検証が必要。じゃないとショップ以外の悪意ある第三者からのリクエストも処理しちゃう。
func ValidateWebhook(httpRequest *http.Request) (bool) { shopifyApp := goshopify.App{ApiSecret: "ratz"} return shopifyApp.VerifyWebhookRequest(httpRequest) }Webhookを起点にShopify APIを叩く
Webhookのパース
今回は受注のタイミングで支払いステータスを変更したいって要件だったので、注文作成のWebhookを設定した。その際、作成された注文の情報がWebhookのリクエストに入ってくるので、必要な情報だけを取得する。
go-shopifyでもできるんだけど、諸事情によりここだけ手作業でやった。b, err := ioutil.ReadAll(r.Body)//リクエストのbodyをパース defer r.Body.Close() if err != nil { panic(err) } var jsonData Webhook//jsonの形式に合わせたstructを初期化 if err := json.Unmarshal(b, &jsonData); err != nil {//jsonをパースして構造体に値を入れる。 panic(err) } //Webgook Webhookの構造体 type Webhook = struct { ID json.Number `json:id`//これがID。受注番号とは違う。 Gateway string `json:gateway` FinancialStatus string `json:financial_status`//支払いステータス 読み取り専用 }Shopify Admin APIを叩いてTransactionを追加する
Shopifyの受注データの支払いステータスは読み取り専用で直接変更できない。変更するためにはTransactionという支払いデータを追加して、そのステータスを
success
に設定する必要がある。具体的にはOrderのIDを使ってtransactionを追加する。ntr := goshopify.Transaction{ ParentID: &tr[0].ID,//親のトランサクションID、受注時に正成される Status: "success", Kind: "sale",//必須項目 Amount: tr[0].Amount, Gateway: tr[0].Gateway, Currency: tr[0].Currency, OrderID: orderID, SourceName: "external", Source: "external",//ここが今回のハマりポイント } rtr, err := client.Transaction.Create(orderID, ntr) //API叩いて追加ここで今回最大にハマったのが、この
Source
ってパラメータ。なんと公式ドキュメントに書いてないw
なのに必須要素で、'external'に指定しないとTransactionが追加できない。公式のドキュメントにも載ってないのでgo-shopifyにも定義されてないw
仕方が無いのでgo-shopifyをいじって、Source
を設定できる様にして解決。そのPRこれで無事、受注ステータスをShopify Appから変更できる様になりました。いやー、最後の最後でハマった...
環境はまたGAE使いました
Goの簡単な環境作るならこれがGoogle Cloud PlatformのApp Engineが一番楽ですね。運用も楽だし、速い、安い。
[PR]株式会社クロスキューブではShopifyアプリの開発も請け負っております!
最後に、弊社株式会社クロスキューブでは、Shopifyアプリ開発やShopifyを使ったECサイト構築のお仕事も大募集中です!
何かShopifyアプリでお困りの方がいらっしゃいましたらぜひお問合せ願います。
- 投稿日:2019-10-21T12:09:28+09:00
Google Drive Apiの始め方
Google DriveをGoでいじる
目標: GoのDriverでapiをいじれるようにする。
動機: Goの勉強がてらGCPにも慣れたかった。
前提: GoがインストールされているMacOS Mojave 10.14.5。GoogleAccountを持っている。Google Drive Apiの公式の通りに進めると...
あれ、でもなんか安全じゃないけど見に行けるボタンみたいなのあったよなーと思い、開発者ツールから
euZgNe
にかかっているdisplay: none
を取り除き、アクセスしました。前まで普通に表示されていたと思うのですが、なぜかを知っている方がいたら教えていただきたい。。
あとは許可ボタンを押し続ける作業です。この時、サンプルコードで設定されているドライバーのスコープ(これはそのアプリケーションのGoogle Driveへのアクセス権限を表すスコープの意味)がreadonlyになっているのでアップロードしたいとか権限を調整したい時にはここを見て
google.ConfigFromJSON(b, drive.DriveScope)
の部分を変更してください。
このとき、tokenは1スコープに対して1tokenの関係なので(多分)、readonlyでtoken.jsonを作っていたならそれは削除してください。サンプルコードではスコープの変更によってtoken.jsonを使い分けができるようには作られていないので削除する必要があります。まとめ
自分の躓いたポイントで諦める人が出ないことを祈って初めてQiitaで簡単に書きましたが、書くのって難しい。。
- 投稿日:2019-10-21T12:09:28+09:00
Google Drive Apiの始め方(つまづきポイントメイン)
Google DriveをGoでいじる
目標: GoのDriverでapiをいじれるようにする。
動機: Goの勉強がてらGCPにも慣れたかった。
前提: GoがインストールされているMacOS Mojave 10.14.5。GoogleAccountを持っている。Google Drive Apiの公式の通りに進めると...
- quickstart.goを実行した際に、URLで認証を行う必要があるのですが、自分が実行した際に以下のような感じでアクセスができない状態になってしまいました。(なんていうのか知らん)
あれ、でもなんか安全じゃないけど見に行けるボタンみたいなのあったよなーと思い、開発者ツールから
euZgNe
にかかっているdisplay: none
を取り除き、アクセスしました。前まで普通に表示されていたと思うのですが、なぜかを知っている方がいたら教えていただきたい。。
あとは許可ボタンを押し続ける作業です。この時、サンプルコードで設定されているドライバーのスコープ(これはそのアプリケーションのGoogle Driveへのアクセス権限を表すスコープの意味)がreadonlyになっているのでアップロードしたいとか権限を調整したい時にはここを見て
google.ConfigFromJSON(b, drive.DriveScope)
の部分を変更してください。
このとき、tokenは1スコープに対して1tokenの関係なので(多分)、readonlyでtoken.jsonを作っていたならそれは削除してください。サンプルコードではスコープの変更によってtoken.jsonを使い分けができるようには作られていないので削除する必要があります。まとめ
自分の躓いたポイントで諦める人が出ないことを祈って初めてQiitaで簡単に書きましたが、書くのって難しい。。
- 投稿日:2019-10-21T10:30:35+09:00
Goでプロセス監視のTUIツールを作ったら便利だった件
ども、バナナとナタデココにハマっているゴリラです。つまり食物繊維大好きゴリラ。
最近なぜかプロセスをkillすることが多くて、毎度コマンド打つの面倒だったので2日くらいかけてTUIツールを作ってみました。
今日はそのツールの紹介と実装の話をしていきます。
どんな感じ?
こんな感じ。
対応OS
- Mac OS(Catalinaで動いたことは確認できている)
- Linux
一応Windowsでも動くはずですが、動作確認していないのでダメだったらごめんなさい。
Windowsで動いたとしてもプロセスの詳細情報は見れないですが、今後対応予定なので、お待ち頂ければと思います。画面構成
画面は全部で以下の3つがあります。
- processes(現在動いているプロセス一覧)
- process info(選択したプロセスの詳細情報)
- process tree(選択したプロセスのツリー)
画面に加えて、プロセス一覧を絞り込むための入力欄があります。自分のPCでは大体400〜くらいのプロセスが動いているので、絞り込みの機能は必須でした。
使い方
作ったばっかりなので、最低限の機能(個人的に)しか実装していなくて
- プロセスの絞り込み
- プロセスの強制終了(SIG_KILL)
くらいしかないです。プロセスの矯正終了はプロセス一覧とプロセスツリーのパネルで
K
を押下すると確認ダイアログがポップするので、Killを選択するとプロセスを強制終了します。実装のお話
今回の実装にあたり、主に以下のライブラリを使いました。
プロセス周り
プロセス一覧は
go-ps
で取得し、pidを使ってps
コマンドで詳細情報を取っています。
プロセスを取得するにはシステムコールを使用しますが、OSごとに取れる情報が異なる上、システムコールのインターフェイスも異なります。
システムコールのこと調べても良くわからなかった上、OSの差分吸収はとても面倒だったので最低限pid、ppidとコマンドを取得できるgo-psを使いました。欲を言えば、もっとプロセスの細かい情報を取得したかったのですが、そこか諦めて外部コマンドに頼ることにしました。。
システムコールを学ぶにはC言語から学んだ方が良いらしいので、ちょっと勉強しようかな…話は戻して、pidとppidが取れるので、どのプロセスが子プロセスを持っているのか判定できます。
そこでプロセスツリーを作れると思い、そのインターフェイスを実装しました。プロセスの構造体はとてもシンプルで次になっています。
type Process struct { Pid int PPid int Cmd string Child []Process }抜粋ですが
Child
に子プロセスを次のようにして格納しています。
ちなみに、pidが0のプロセスはシステムコールで取得できますが、こちらはps
コマンドで情報を取れない上、
すべてのプロセスの大本なので表示する意味がないためskipするようにしています。pids := make(map[int]Process) for _, proc := range processes { // skip pid 0 if proc.Pid() == 0 { continue } pids[proc.Pid()] = Process{ Pid: proc.Pid(), PPid: proc.PPid(), Cmd: proc.Executable(), } } // add child processes for _, p := range processes { if p.Pid() == p.PPid() { continue } if proc, ok := pids[p.PPid()]; ok { proc.Child = append(proc.Child, pids[p.Pid()]) pids[p.PPid()] = proc } }プロセスの詳細情報はこんな感じで
ps
コマンドを使って取ってきてそのまま表示させています。func (p *ProcessManager) Info(pid int) (string, error) { // TODO implements windows if runtime.GOOS == "windows" { return "", nil } if pid == 0 { return "", nil } buf := bytes.Buffer{} cmd := exec.Command("ps", "-o", "pid,ppid,%cpu,%mem,lstart,user,command", "-p", strconv.Itoa(pid)) cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Run(); err != nil { return "", err } return buf.String(), nil }
-o
を使用することでフォーマットを自由にカスタマイズできるので、必要そうな情報だけピックアップしました。
どんな情報が取れるかはman ps
のkeywordの部分を参照して下さい。
-p
はプロセスIDを指定することでそのプロセスIDの情報だけ取得できます。
こういった出力をカスタマイズできるコマンドはGoでツールを作るときにありがたいです。ちなみに、Windowsはpsコマンドがない(はず…)ので一旦TODOにしています。Windows機購入したので届いてから対応しようと思います。
画面周り
画面周りは
tview
を使いました。以前DockerのTUI Clientを作ったときはgocui
というライブラリを使っていましたが、こちらはメンテされていない上、ダイアログといったコンポーネント画面を作るのはめんどくさいです。一応、コンポーネントを簡単に作れるgocui-componentというライブラリを作ったのですが、
tview
が圧倒的に高機能かつ便利だったので採用しました。tviewの優れたところは
Grid
やFlex
といったレイアウトを簡単に作れるところです。
TUIはウェブと違って結構画面周りはめんどくさいので、こういった画面のサポートが強力なライブラリはすごくありがたいです。抜粋ですが、画面のレイアウトはこんな感じで実装しています。
infoGrid := tview.NewGrid().SetRows(0, 0). AddItem(g.ProcInfoView, 0, 0, 1, 1, 0, 0, true). AddItem(g.ProcessTreeView, 1, 0, 1, 1, 0, 0, true) grid := tview.NewGrid().SetRows(1, 0). SetColumns(30, 0). AddItem(g.FilterInput, 0, 0, 1, 1, 0, 0, true). AddItem(g.ProcessManager, 1, 0, 1, 1, 0, 0, true). AddItem(infoGrid, 1, 1, 1, 1, 0, 0, true) g.Pages = tview.NewPages(). AddAndSwitchToPage("main", grid, true)
grid
を作って、AddItem
を使って画面をgrid
に格納する事ができます。SetColumns
は縦幅を定義する関数で、縦に3つ分けたいなら3つの画面のそれぞれの縦幅を渡せばあとはよしなにやってくれます。めっちゃ簡単。
更に、grid
をgrid
に格納することもできるので、縦2分割して右側を横2分割する、といったレイアウトも簡単につくれます。とても便利。まだぼくも良くわかっていないところもあるので、きちんと理解したら
tview
の使い方に関する記事を書こうと思います。作ってみた感想
プロセスやシステムコール周りについて調べて少しだけ理解した(つもり)ので良い収穫でした。
OSのような低レイヤー周りはやっぱり奥が深くて難しいですが、面白いです。
最近30日でOSを作る本が結構良いらしいので、ちょっと買って勉強しようかなと思います。そして、このツールは意外と便利なのでぜひ使ってみてください。感想お待ちしています。
余談
Macのシステムコールについて調べたら良さげの資料を見つけたので、一応共有しておきます。