Go言語の習作で「劣化curl」コマンドを作ってみた

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

よく訓練されたアップル信者、都元です。先日はcli-initというツールをご紹介しました。

今回はそれに引き続き、Goでちょっとしたプログラミングをしてみます。簡単にですが、curlコマンドの劣化版を作ってみようと思います。

仕様

  • 1回のコマンドで実行で1回のHTTPリクエストを行う。
  • 対応するメソッドは GET, POST, PUT, DELETE の4種類。
  • サブコマンドで get, post, put, delete を選択して使い分ける。
  • HTTPレスポンスのbodyを標準出力に書き出す。
  • --dataオプションで、POST, PUT等のリクエストbodyを文字列で指定できる。
  • 環境変数DEBUGが空でない場合、リクエストとレスポンスの詳細を標準エラー出力に書き出す。
$ dcurl get http://example.com/foo
$ dcurl post http://example.com/bar --data hogehoge
$ DEBUG=true dcurl put http://example.com/bar --data hogehoge

目指すところはこんな感じ。いい感じで劣化してますね。ヘッダ指定? ナニソレ。

実装

とりあえず、前回ご紹介したcli-initを使う手順に従って、dcurlというプロジェクトを作りました。cli-initコマンドの実行時には-s get,post,put,deleteと指定。

まず各コマンドの実行内容としては、HTTPメソッド名が違うだけなので、メソッド名を指定してdoRequestメソッドに処理委譲して終了。

func doGet(c *cli.Context) {
  doRequest(c, "GET")
}

func doPost(c *cli.Context) {
  doRequest(c, "POST")
}
// 略

引数の取り扱い

まず、postサブコマンドでは--dataっていうオプション(go界隈ではフラグって言うみたいですね)を使いますよ、という宣言が必要です。下記のFlagsに設定している内容がコレです。

その他、ノリでShortNameとかUsageも埋めます。

var commandPost = cli.Command{
  Name:      "post",
  ShortName: "p",
  Usage:     "Make POST request",
  Description: `
`,
  Flags:  []cli.Flag{
    cli.StringFlag{
      Name:  "data",
      Usage: "Body data",
    },
  },
  Action: doPost,
}

ちなみに、HTTPリクエストでbody部を持つのはPOSTとPUTくらいだよなぁ、と思っていたんですが、一応「GETやDELETEにbodyがあってはならない、という訳ではない *1」ようなので、とりあえずどのメソッドでもbody指定できるようにしちゃいます。

そのため、別途変数 *2DefaultFlagsにフラグを宣言し…

var DefaultFlags = []cli.Flag{
  cli.StringFlag{
    Name:  "data",
    Usage: "Body data",
  },
}

それぞれこんな感じにしました。

var commandGet = cli.Command{
  Name:      "get",
  ShortName: "g",
  Usage:     "Make GET request",
  Description: `
`,
  Flags:  DefaultFlags,
  Action: doGet,
}

var commandPost = cli.Command{
  Name:      "post",
  ShortName: "p",
  Usage:     "Make POST request",
  Description: `
`,
  Flags:  DefaultFlags,
  Action: doPost,
}
// 略

そもそも各コマンドに対してフラグ設定をするのではなく、下記のように(commands.go内ではなくmainメソッド内で)グローバルなオプションとして設定してしまう方法もあります。でもこの方法で--dataを設定した場合、help等のサブコマンドに対しても--dataが指定できてしまうので、本当にグローバルなオプション以外は避けたほうが良いでしょう。

  app.Flags = []cli.Flag{
    cli.StringFlag{
      Name:   "profile",
      Value:  "default",
      Usage:  "profile name",
    },
  }

このように設定した上で、--dataに指定した文字列を取得するのはctx.String("data")でOK。文字列ではなく数値や真偽値を期待している場合はctx.Int("...")ctx.Bool("...")を使います。グローバルオプションの場合はctx.GlobalString("profile")という感じで取り出します。

さて、ここまで準備ができたら、次にリクエスト先のURLを得る方法です。とりあえず引数が足りなかったらエラー終了させます。os.Exitはプロセスの終了メソッドです。コードの頭の方にimport "os"が必要です。

func getUrl(ctx *cli.Context) string {
  if len(ctx.Args()) < 1 {
    log.Fatal("require URL" + string(len(ctx.Args())))
    os.Exit(1)
  }
  return ctx.Args()[0]
}

もうちょっとキレイに書けるかもしれないっすね。もしかしたら、エラー処理は外に任せるほうがいいのかもしれません。この方がGo流でしょうか。

import "errors"
// ...
func getUrl(ctx *cli.Context) (string, err) {
  if len(ctx.Args()) < 1 {
    return nil, errors.New("require URL")
  }
  return ctx.Args()[0], nil
}

HTTPリクエストを行う部分

あとは、Go標準の"net/http"パッケージを使ってHTTPリクエストを実行します。

  body := strings.NewReader(ctx.String("data"))
  req, _ := http.NewRequest(method, url, body)

  reqDump, _ := httputil.DumpRequestOut(req, true)
  debug("request = ", string(reqDump))

  client := new(http.Client)
  res, err := client.Do(req)
  if err != nil {
    log.Fatal(err)
  }

  resDump, _ := httputil.DumpResponse(res, true)
  debug("response = ", string(resDump))

  byteArray, _ := ioutil.ReadAll(res.Body)
  fmt.Println(string(byteArray))

上記実装にあたってはGo net/httpパッケージの概要とHTTPクライアント実装例 - Qiitaを参考にさせて頂きました。

デバッグログ出力の部分は、cli-initで作成したスケルトンに下記のような定義があったので、これをそのまま使わせてもらいました。logパッケージでは基本的に標準エラー出力に出すんですね。

func debug(v ...interface{}) {
  if os.Getenv("DEBUG") != "" {
    log.Println(v...)
  }
}

まとめ

さて、習作としてこんなものを作ってみました。本当に簡単に作れますね。

プロジェクト全体はこちらをご覧ください。 → dai0304/dcurl at 0.1.0

今後、このプロジェクトをごにょごにょと触っていくかもしれませんので、本エントリーの参考としてはv0.1.0のタグを参照すると良いとおもいます。

脚注

  1. とは言え、あまり推奨はされていないようです。
  2. 配列だったので、定数としては定義できませんでした。定数的な用途ですが。