インタラクティブにテキスト処理を実行できるツールを作った - txtmanip
外部コマンドを利用したテキスト処理をインタラクティブに実行することができます。
ログ集計などで試行錯誤したり、ワンライナーを作る練習として使えると思います。
デモ
実行している様子は次のとおりです。
cat
で標準出力に出力したcombined形式のアクセスログの内容をパイプで渡し、インタラクティブモード内でawk '{print $7}'
→ sort
→ uniq -c
→ sort -nr
→ head -5
を実行してリクエスト数上位5件のURLを出力しています。
インタラクティブモードを終了すると、インタラクティブモード中に実行したコマンドをパイプで繋げたワンライナーが出力されます。これを実行することで最終的な結果と同じ内容のものを表示することができます。
設定ファイル
TOML形式の設定ファイルに設定を記述します。
現状は、enable_commands
という項目にインタラクティブモード中に実行するコマンドリストを設定することができます。リストにないコマンドは実行することができません。
enable_commands = [ "awk", "cut", "grep", "head", "sed", "sort", "tail", "uniq", "wc", ]
実装について
termbox-go
テキストベースのユーザインタフェースを実現するために、GitHub - nsf/termbox-go を使っています。
termbox-goではSetCell
で位置と文字(rune)を指定してどこになにを描画するかを定義します。これを手続き的に書いていくと非常に分かりづらいコードになってしまうため、そうならないように表示する内容と描画する処理を分けて書くようにしました。
MainView
とそれに含まれるInputArea
(ユーザの入力が表示される部分)とTextArea
(テキスト操作の結果が表示される部分)をそれぞれ構造体として定義しています。表示する内容はそれぞれのフィールドの値として保持します。
// MainView represent main view type MainView struct { textArea TextArea inputArea InputArea height int width int } ... // InputArea represent input area type InputArea struct { text []byte error []byte cursorPos int cursorInitialPos int prompt []byte history []string historyPos int } ... // TextArea represent text area type TextArea struct { text []byte history []string }
描画は各構造体に実装した描画用のメソッドで実行するようにしました。描画用のメソッドはフィールドに保持している値をもとにSetCell
を実行していきます。
以下はTextAreaの描画用メソッドです。
func (v *MainView) DrawTextArea() { y := TextAreaPos x := 0 for _, t := range v.textArea.text { if t == byte('\n') { y++ x = 0 continue } termbox.SetCell(x, y, rune(t), ColFg, ColBg) x++ } }
また、MainView
のFlush()
メソッドをで、描画用メソッドをまとめて実行するようにしています。
これをイベント(キーの入力)ごとに実行することで、都度画面の表示内容を更新しています。
func (v *MainView) Flush() error { if err := termbox.Clear(ColBg, ColBg); err != nil { return err } termbox.SetCursor(v.inputArea.cursorPos, InputAreaPos) v.DrawBorderLine() v.DrawInputArea() v.DrawInputError() v.DrawTextArea() return termbox.Flush() }
課題
元となるテキストやヒストリーをすべて構造体のフィールドの値として保持しており、メモリ効率がよくないと感じています。
また、マルチバイト文字に対応していないので、今後このあたりを改善していこうと思っています。
まとめ
何かありましたらIssue、Pull Requestをいただけると嬉しいです。
コマンドの終了ステータスを操作するツールを作った - altercode
https://github.com/shiimaxx/altercode
コマンドの実行結果が正常(終了ステータスが0)の場合でも、特定の条件の場合は任意の終了ステータスにしたいというときに使えます。
複雑な条件でなければワンライナーやシェルスクリプトでもできるとは思いますが、練習がてらGoで書きました。
とある環境のCIパイプラインで、WARNINGなどの警告は出ているがコマンドの終了ステータスが0であるためCIをパスしてしまっているものを強制的に失敗させたいという目的があって作りました。
使い方は次のとおりです。この場合、commadの標準出力に「warning」が含まれると終了ステータスが3になります。
altercode -contain warning -exit-code 3 -- command
現状はコマンドの標準出力に特定の文字列が含まれているかをチェックすることができます。
また、複数の条件を指定したい場合は設定ファイルを利用します。詳細はREADMEをご覧ください。
実装について
オプションのパース
引数に指定するコマンドはオプションを含むこともあると思いますので、--
によってオプション部分の終了を明示できるようにしています。
--
でオプションの終了を示す方法は、Bashのビルトインコマンドでも使われています。
man bash
に以下の記載があります。
-- A -- signals the end of options and disables further option processing. Any arguments after the -- are treated as file- names and arguments. An argument of - is equivalent to --.
Goの標準パッケージでCLIツールのオプションのパースを行うflag
では、--
をオプションの終了として解釈するため特別な実装は必要ありません。
FlagSet.Parse()
でパースすると--
より前をオプションとしてパースしてくれます。
flag
パッケージのGoDocに以下の記載があります。
Flag parsing stops just before the first non-flag argument ("-" is a non-flag argument) or after the terminator "--".
引数に指定したコマンドの終了ステータスの取得
引数に指定したコマンドがエラーになり、終了ステータスが0以外の場合はそのステータスコードで終了します(コマンドの実行自体ができない場合は別です)。
そのため、コマンドの終了ステータスを取得する必要がありました。
実際のコードは次のようになっています。
out, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { fmt.Fprintf(c.errStream, string(exitErr.Stderr)) if ws, ok := exitErr.ProcessState.Sys().(syscall.WaitStatus); ok { return ws.ExitStatus() } return ExitCodeError } :
execパッケージのCmd.Output()
でコマンドを実行しています。
Cmd.Output()
は内部的にCmd.Run()
を実行しており、これはコマンドの実行に問題があった場合に*exec.ExitError
型のエラーを返します。
コマンドの実行結果に関する情報は、*exec.ExitError
型に埋め込まれている*os.ProcessState
のSys()
メソッドで取得します。その後、取得したものをsyscall.WaitStats
型にキャストし、ExitStatus()
メソッドを呼び出すことで終了ステータスを取得できます。
まとめ
ちょっとしたツールの紹介でした。
2019年は「コードを書く → アウトプットする」というサイクルをもっと増やそうと思っていまして、それの第1弾でした。
参考文献
GoでDialogsを使ったSlack Appを作る
このエントリは Go Advent Calendar 2018 12日目の記事です。
SlackにはInteractive frameworkという仕組みがあります。これによりButtonやMenuなどのインターフェースをユーザに提供することができます。
このInteractive frameworkのひとつとしてDialogsがあります。Dialogsは、Text、TextArea、Selectの要素からなるフォームを構築し、ユーザに入力してもらうことで、より複雑なワークフローを実現するSlack Appを作るのに便利な仕組みになっています。
このエントリでは、Dialogsを使ったSlack AppをGoで書くときの流れを紹介します。
サンプル
デリバリーのコーヒーを注文するCoffeebotを題材とします(実際に注文はしません)。
以下の流れで利用します。
@coffee order
でBotを呼び出しOrder Coffee
ボタンをクリック- コーヒーの注文フォームを入力
- コーヒーの種類を選択
- カスタマイズの内容を入力(オプション)
- 配達希望時間を入力
- 注文受付のメッセージを受け取る
なお、Coffeebotは以下のPythonのサンプル用のAppをカスタマイズして実装しています。
https://github.com/slackapi/python-dialog-example
また、Goで実装するにあたり、以下のリポジトリのコードをベースとして使わせていいただいています。
https://github.com/tcnksm/go-slack-interactive
Coffeebot自体のコードはこちらのリポジトリにあります。
https://github.com/shiimaxx/slack-coffeebot
Slack Appの作成
ドキュメントに手順がありますので詳しい説明は省略します。
https://api.slack.com/slack-apps
次の2点は実施しておく必要があります。
- Appに紐づくBot Userの作成
- Interactive Componentsの有効化
Bot Userは、Slackチャンネル上におけるユーザへのインタフェースとして利用します。
Interactive ComponentsはButtonやDialogを利用するために有効にする必要があります。
また、ユーザがButtonやDialogを入力した際のPOSTリクエストを受け付けるRequest URLも指定します。Request URLのドメイン名とエンドポイントは実際のアプリケーションにあわせて設定してください。
Slackライブラリ
Slackライブラリはnlopes/slack
を利用しています。
2018/12/12現在の最新リリースは、v0.4.0です。このリリースではDialogsに関するAPIはEXPERIMENTALですのでご注意ください。
また、v0.4.0以降に導入された一部structを利用したかったため、サンプルアプリケーションでは2018/12/12時点でのmasterブランチのHEAD を利用しています。
Dialogsを使ったSlack Appの処理フロー
Slack Appは次の流れで処理を行うようにします。
- ユーザからInteraction Message、もしくはSlash CommandsによるPOSTリクエストを受ける
dialog.open
APIを呼び出して、POSTリクエストのJSONデータに含まれるtrigger_id
とフォームをユーザに提供する- ユーザによるフォーム入力の完了後のPOSTリクエストを受ける
- フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する
ユーザからInteraction Message、もしくはSlash CommandsによるPOSTリクエストを受ける
Dialogsを呼び出すためには、Button, Menuもしくは/coffee
のようなSlash Commandsをトリガーにする必要があるため、まずはこれらのPOSTリクエストを受け付けるようにします。
サンプルアプリケーションのCoffeebotではButtonがトリガーとなります。
POSTリクエストはSlack AppのInteractive Componentsを有効化する際に設定したRequest URL宛に送られます。
Request URLにはDialogフォームの入力が完了した際にもPOSTリクエストが送られてきますが、これはButton, MenuともSlash CommandsともJSONデータの構造が異なります。
nlopes/slack
では、slack.InteractionCallback
という、ButtonとDialogのどちらのPOSTリクエストのJSONデータでもパースできるようになっているstructが定義されていますのでこれを利用します。
JSONデータがButtonによるものかDialogによるものかの判別は、type
フィールドの値で行なえます。
コードは次のようになります。
var message slack.InteractionCallback if err := json.Unmarshal([]byte(jsonStr), &message); err != nil { log.Print("json unmarshal message failed: ", err) w.WriteHeader(http.StatusInternalServerError) return } // : if message.Type == "interactive_message" { // Order Coffeeボタンの入力を受け付けてフォームを表示する } else if message.Type == "dialog_submission" { // フォームの入力を受け付けて何かする }
dialog.open
APIを呼び出して、POSTリクエストのJSONデータに含まれるtrigger_id
とフォームをユーザに提供する
trigger_id
はslack.InteractionCallback
型のmessage
のフィールドにあるので、これを使えばOKです。
フォームはslack.Dialog
として定義します。Coffeebotでは次のようにしています。
func makeDialog(userID string) *slack.Dialog { return &slack.Dialog{ Title: "Request a coffee", SubmitLabel: "Submit", CallbackID: userID + "coffee_order_form", Elements: []slack.DialogElement{ slack.DialogInputSelect{ DialogInput: slack.DialogInput{ Label: "Coffee Type", Type: slack.InputTypeSelect, Name: "mealPreferences", Placeholder: "Select a drink", }, Options: []slack.DialogSelectOption{ { Label: "Cappuccino", Value: "cappuccino", }, { Label: "Latte", Value: "latte", }, { Label: "Pour Over", Value: "pourOver", }, { Label: "Cold Brew", Value: "coldBrew", }, }, }, slack.DialogInput{ Label: "Customization orders", Type: slack.InputTypeTextArea, Name: "customizePreference", Optional: true, }, slack.DialogInput{ Label: "Time to deliver", Type: slack.InputTypeText, Name: "timeToDeliver", Placeholder: "hh:mm", }, }, } }
dialog.open
APIの呼び出しはslack.Client
のOpenDialogContext
(もしくはOpenDialog
)メソッドでできます。trigger_id
とフォーム(dialog
)を引数にします。
if err := s.slackClient.OpenDialogContext(context.TODO(), message.TriggerID, dialog); err != nil { log.Print("open dialog failed: ", err) w.WriteHeader(http.StatusInternalServerError) return }
ユーザによるフォーム入力の完了後のPOSTリクエストを受ける
必要に応じて入力内容のチェックをするとよいでしょう。Coffeebotでは、注文フォームで次の項目を入力します。
- コーヒーの種類を選択
- カスタマイズの内容を入力(オプション)
- 配達希望時間を入力
「コーヒーの種類を選択」は選択式なのでチェックは必要ありません。「カスタマイズの内容を入力」もTextAreaによる自由記入欄なのでチェックは不要でしょう。
「配達希望時間」については、時刻以外の入力があったり、現在時刻より前の時間を指定されると正常にオーダーを受け付けられないので、チェックしておいたほうがよさそうです。
Coffeebotでは次のような関数を用意してチェックしています。(厳密ではなかったり冗長なところもありますがご了承ください。)
var errInvalidInput = errors.New("invalid input") var errTimePreference = errors.New("time should be after 30 minutes ago") func validateTime(t string) error { const format = "15:04" parsedTime, err := time.Parse(format, t) if err != nil { return errInvalidInput } var jst = time.FixedZone("UTC+9", 9*60*60) now := time.Now().In(jst) addLocationJST := func(t time.Time) time.Time { return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), jst) } parsedTime = addLocationJST(parsedTime) if parsedTime.Before(now.Add(time.Minute * 30)) { return errTimePreference } return nil }
入力チェックで不正な値があった場合は、エラー内容をレスポンスで返す必要があります。
レスポンスは次のような形式のJSONデータで返します。また、ステータスコードは200 OK
にします。
{ "errors": [ { "name": "email_address", "error": "Sorry, this email domain is not authorized!" }, { "name": "username", "error": "Uh-oh. This username has been taken!" } ] }
Coffeebotの例です。
t := message.Submission["timeToDeliver"] if err := validateTime(t); err != nil { log.Print("validate error: ", err) type validateError struct { Name string `json:"name"` Error string `json:"error"` } type validateErrorResponse struct { Errors []validateError `json:"errors"` } w.Header().Add("Content-type", "application/json") json.NewEncoder(w).Encode(&validateErrorResponse{ []validateError{{Name: "timeToDeliver", Error: err.Error()}}, }) return }
正しくレスポンスを返すことで、次のようにフォームにエラーメッセージを表示することができます。
フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する
フォームの入力内容が問題ない場合は、200 OK
の空レスポンスによってユーザに伝える必要があります。これは3秒以内にレスポンスするか、アプリケーション側の処理に時間を要する場合はresponse_url
を利用してレスポンスを遅延して返すこともできます。
Coffeebotでは時間のかかりそうな処理は新たに起動したgoroutineで実行して、レスポンスは即時返すようにしています。このあたりはGoならではだと思います。
go func() { time.Sleep(time.Second * 5) // 注文の処理を想定したスリープ attachment := slack.Attachment{ Text: ":white_check_mark: Order received!", CallbackID: s.botID + "coffee_order_form", } options := slack.MsgOptionAttachments(attachment) if _, _, err := s.slackClient.PostMessage(message.Channel.ID, options); err != nil { log.Print("[ERROR] Failed to post message") } return }() w.Header().Add("Content-type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "")
まとめ
Dialogsを活用すればSlack上だけで色々なことが完結させられてよさそうですね。
今回は入力を受け付けてレスポンスするだけの簡単なアプリケーションでしたが、ステートを持たせるようなものを作る場合は色々と気にする点が増えるのでもう少し複雑になります。そのあたりは公式ドキュメントをご覧いただければと思います。
参考文献
Goにおけるエラーのコンテキストとエラーハンドリング
このエントリは Gopher道場 Advent Calendar 2018 5日目の記事です。
Goではエラーをerror
型の値として扱います。error
はビルトインのインタフェース型として次のように定義されています。
type error interface { Error() string }
https://golang.org/pkg/builtin/#error
Goではインタフェース宣言時に定義したメソッドリストを実装することでインタフェースを実装します。
つまり、任意の型にError() string
をメソッドとして実装すれば、独自のエラー型のerror
として扱うことが可能になります。
標準パッケージ net
のエラー型
例えば標準パッケージのnet
では、OpError
, DNSError
などの様々な独自のエラー型が定義されています。
これらのエラー型は、エラー情報を格納するための構造体フィールドがあり、エラーに関するコンテキストを持つことができます。
また、net
パッケージのエラー型はnet.Error
というインタフェース型を実装するかたちで定義されており、error
インタフェースの埋め込みの他に、エラーの性質を判別するためのTimeout() bool
, Temporary() bool
というメソッドを提供します。
type Error interface { error Timeout() bool // Is the error a timeout? Temporary() bool // Is the error temporary? }
https://golang.org/pkg/net/#Error
エラーにコンテキストとエラーの性質を判別するメソッドをもたせることによって、エラーよって処理を変えるエラーハンドリングが可能になります。
また、ネットワークエラーをあらわすエラーをnet.Error
インタフェースで集約しているため、次のように比較的スッキリしたコードで記述できます。
if err != nil { if nerr, ok := err.(net.Error); ok && nerr.Temporary() { // リトライする処理 } else { log.Fatal(err) } }
既存のエラーにコンテキストを加える
例として、次のようなWebリクエストを実行した結果を返す関数があるとします。
func SomeFunc(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", errors.New("HTTP status error: ". resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) defer resp.Body.Close() if err != nil { return "", err } return string(body[:]), nil }
この関数を別パッケージから呼び出す際には次のようなコードになると思います。
resp, err := SomeFunc("http://example.com") if err != nil { log.Fatal(err) }
上記のプログラムの追加の要件として、HTTPステータスが500 Internal Server Error
の場合にリトライ処理を実装するとします。
呼び出し元のコードでリトライ処理を行うためには、返されたエラーからHTTPステータスをチェックする必要があります。
SomeFunc()
ではresp.StatusCode != http.StatusOK
でHTTPステータスのチェックを行い200 OK
でない場合はエラーを返していますが、HTTPステータスの情報はエラーメッセージにしか含まれていません。
エラーメッセージの内容をチェックしてエラーハンドリングする処理を書くこともできますが、あまりよいやり方ではなさそうです。
また。SomeFunc()
の戻り値にHTTPステータスを追加することもできますが、APIが壊れるためこれもよくないでしょう。
このような場合に先程のnet
パッケージで定義されていたエラー型のようにコンテキストをもたせる方法が有効です。
先程のコードを書き換えてみます。
具体的には、net.Error
にならってTemporary()
メソッドをもつHTTPError
インタフェースと、それを実装するエラー型httpStatusError
を定義します。httpStatusError
はフィールドにHTTPステータスコードをもたせます。
type HTTPError interface { error Temporary() bool } type httpStatusError struct { statusCode int } func (e *httpError) Error() string { return fmt.Sprint("HTTP status error: ", e.stautsCode) } func (e *httpError) Temporary() bool { return e.stautsCode == http.StatusInternalServerError }
HTTPステータスのチェックでは、httpStatusError
型のエラーを返すようにします。
func SomeFunc(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", &httpStatusError{stautsCode: resp.statusCode} } // 以下略 }
呼び出し元では次のようにエラーハンドリングすることが出来ます。
resp, err := SomeFunc("http://example.com") if err != nil { if herr, ok := err.(HTTPError); ok && herr.Temporary() { log.Errorf("retry...") // リトライ処理 } log.Fatal(err) }
まとめ
インタフェースによってエラーを判別できるようにしておくことで、呼び出し側が特定のエラー型に依存することを避けられます。