twgというtwitter clientを作った

導入

突然ですが、バックエンドやサーバーサイドでよく使う技術ってありますか。ちなみに、ここで言うバックエンドは主にWebアプリの裏側の意味で、サーバーサイドは、サーバーの保守とか管理の意味ですかね。曖昧ですが。

私は、これらの領域では、主にShell ScriptとGo(Go lang)を使うことが多いかなって思います。

Shell Scriptは、これは非常に優れた言語、とは言えないかもしれませんが、非常に優れたプログラミング技術であることは間違いありません。その理由の一つとして挙げられるのが、実現可能性ではないでしょうか。実現したいことが素早く簡単に実現できてしまう、Shell Scriptには、そういった力があると個人的には思っています。

ですが、Shell Scriptは書いて動かしたら、それでおしまいみたいな感じになってしまうことが多いんですよね。なので、これで書いたものは、継続的に開発するとかは基本ないです。それに、やる気でないですよ。

そのため、最近はもっぱらバックエンドやサーバーサイドで細々と使うCLIツール群はGoで書くようにしています。

これがすごくいいんですよね。Goという言語はすごくいい。

ということで、今回は、Goの魅力と、自分がどんな感じでGoで書いたプログラムを使っているのかを書いていきたいなーと思います。

TwitterクライアントをGoで作ってみた時の話

この間、twgというtwitter clientを作りました。

twgというのは、twitter goの略でつけた名前です。

first commit

gitに残ってる最初のコミットは、酷いですねー。正直、最小構成をcommitしとけばよかったなと思います。今後はそんな感じでやっていきたい…。

話を戻しますが、まずは作り始めた時の最小構成を改めて再現してみたいと思います。

main.go

package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"io/ioutil"
	"encoding/json"
	"path/filepath"
	"github.com/urfave/cli"
	"github.com/ChimeraCoder/anaconda"
	"github.com/skratchdot/open-golang/open"
	"github.com/mrjones/oauth"
)

func Action(c *cli.Context) {
	if c.Args().Get(0) == "" {
		RunOAuth()
		return
	}
	return
}

func App() *cli.App {
	app := cli.NewApp()
	app.Name = "twg"
	app.Usage = "$ twg"
	app.Version = "0.0.0"
	app.Author = "syui"
	return app
}

var ckey string
var cskey string

type Oauth struct {
	AdditionalData struct {
		ScreenName string `json:"screen_name"`
		UserID     string `json:"user_id"`
	} `json:"AdditionalData"`
	Secret string `json:"Secret"`
	Token  string `json:"Token"`
}

func RunOAuth() {
	ckey := "CONSUME_KEY"
	cskey := "CONSUME_SECRET_KEY"
	var o Oauth
	anaconda.SetConsumerKey(ckey)
	anaconda.SetConsumerSecret(cskey)
	dir := filepath.Join(os.Getenv("HOME"), ".config", "twg")
	dirConf := filepath.Join(dir, "user.json")
	if e := os.MkdirAll(dir, os.ModePerm); e != nil {
		panic(e)
	}
	_, e := os.Stat(dirConf)
	flag.Parse()
	c := oauth.NewConsumer(
		string(ckey),
		string(cskey),
		oauth.ServiceProvider{
			RequestTokenUrl:   "https://api.twitter.com/oauth/request_token",
			AuthorizeTokenUrl: "https://api.twitter.com/oauth/authorize",
			AccessTokenUrl:    "https://api.twitter.com/oauth/access_token",
		})

	requestToken, u, err := c.GetRequestTokenAndUrl("oob")
	if err != nil {
	    log.Fatal(err)
	}

	fmt.Print("\ninput pin: ")
	open.Run(u)

	verificationCode := ""
	fmt.Scanln(&verificationCode)
	accessToken, err := c.AuthorizeToken(requestToken, verificationCode)
	if err != nil {
		log.Fatal(err)
	}
	outputJSON, e := json.Marshal(&accessToken)
	if e != nil {
		panic(e)
	}
	ioutil.WriteFile(dirConf, outputJSON, os.ModePerm)

	file,e := ioutil.ReadFile(dirConf)
	json.Unmarshal(file, &o)
	if e != nil {
		os.Exit(1)
	}

	client, err := c.MakeHttpClient(accessToken)
	if err != nil {
		log.Fatal(err)
	}

	response, err := client.Get(
		"https://api.twitter.com/1.1/statuses/home_timeline.json?count=1")
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()
	bits, err := ioutil.ReadAll(response.Body)
	fmt.Println("The newest item in your home timeline is: " + string(bits))
	return
}

func main() {
	app := App()
	app.Action = Action
	app.Run(os.Args)
}

コードにconsume_key, consume_secret_keyを入れた後、これをビルドして、できたバイナリを実行してみます。

$ go build
$ ./main

多分、認証のためブラウザが開くと同時に、PINの入力を求められると思いますが、端末に戻ってPINコードを入力すると、twitter apiのhome_timelineで得られる出力が表示されると思います。

コードの整理

で、私の場合は、機能をどんどんと追加して作っていくわけですが、やる気があったり、または、今後もメンテナンスする予定があったりする場合には、コード整理を行うことがあります。

実際に、上記のような感じで作っていくと、後々酷くなってしまうのですよね。

その時々で適当につけた変数名がバラバラだったり、関数が重複していたりなど。

ということで、これを整理しようとすると、こんな感じになります。

まず、コマンド名でmainファイルを作ります。ちなみに、goのimportはGOPATHを参照します。つまり、gitlab.com/syui/twgの部分は都度ご自身の環境のものを書き換えてください。大体はecho $GOPATH/src以下にフォルダを置いて、そのPATHを使えばOKですかね。私の場合は、twgというアプリで、かつgitlabにuploadするので、~/go/src/gitlab.com/syui/twgmain.goにあたるファイルを置きます。今回は、コマンド名であるtwg.goですけどね。

./twg.go

package main

import (
	"os"
	"github.com/urfave/cli"
	"gitlab.com/syui/twg/cmd"
)

func App() *cli.App {
	app := cli.NewApp()
	app.Name = "twg"
	app.Usage = "$ twg"
	app.Version = "0.0.1"
	app.Author = "syui"
	return app
}

func Action(c *cli.Context) error {
	if c.Args().Get(0) == "" {
		cmd.Action(c)
	}
	return nil
}

func main() {
	app := App()
	app.Action = Action
	app.Run(os.Args)
}

次に、pathをimportしてそこにfuncを置いていきます。あと、cmdというフォルダの使い方が慣習的に間違ってるけど、まあいいか。

./cmd/cmd.go

package cmd

import (
	"github.com/urfave/cli"
	"gitlab.com/syui/twg/oauth"
)

func Action(c *cli.Context) error {
	oauth.RunOAuth()
	return nil
}

./oauth/oauth.go

package oauth

import (
	"flag"
	"fmt"
	"log"
	"os"
	"io/ioutil"
	"encoding/json"
	"path/filepath"
	"github.com/ChimeraCoder/anaconda"
	"github.com/skratchdot/open-golang/open"
	"github.com/mrjones/oauth"
)

type Oauth struct {
	AdditionalData struct {
		ScreenName string `json:"screen_name"`
		UserID     string `json:"user_id"`
	} `json:"AdditionalData"`
	Secret string `json:"Secret"`
	Token  string `json:"Token"`
}

func RunOAuth() {
	ckey := "CONSUME_KEY"
	cskey := "CONSUME_SECRET_KEY"
	var o Oauth
	anaconda.SetConsumerKey(ckey)
	anaconda.SetConsumerSecret(cskey)
	dir := filepath.Join(os.Getenv("HOME"), ".config", "twg")
	dirConf := filepath.Join(dir, "user.json")
	if e := os.MkdirAll(dir, os.ModePerm); e != nil {
		panic(e)
	}
	_, e := os.Stat(dirConf)
	flag.Parse()
	c := oauth.NewConsumer(
		string(ckey),
		string(cskey),
		oauth.ServiceProvider{
			RequestTokenUrl:   "https://api.twitter.com/oauth/request_token",
			AuthorizeTokenUrl: "https://api.twitter.com/oauth/authorize",
			AccessTokenUrl:    "https://api.twitter.com/oauth/access_token",
		})

	requestToken, u, err := c.GetRequestTokenAndUrl("oob")
	if err != nil {
	    log.Fatal(err)
	}

	fmt.Print("\ninput pin: ")
	open.Run(u)

	verificationCode := ""
	fmt.Scanln(&verificationCode)
	accessToken, err := c.AuthorizeToken(requestToken, verificationCode)
	if err != nil {
		log.Fatal(err)
	}
	outputJSON, e := json.Marshal(&accessToken)
	if e != nil {
		panic(e)
	}
	ioutil.WriteFile(dirConf, outputJSON, os.ModePerm)

	file,e := ioutil.ReadFile(dirConf)
	json.Unmarshal(file, &o)
	if e != nil {
		os.Exit(1)
	}

	client, err := c.MakeHttpClient(accessToken)
	if err != nil {
		log.Fatal(err)
	}

	response, err := client.Get(
		"https://api.twitter.com/1.1/statuses/home_timeline.json?count=1")
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()
	bits, err := ioutil.ReadAll(response.Body)
	fmt.Println("The newest item in your home timeline is: " + string(bits))
	return
}

解説がだいぶ長くなってしまいましたが、やってることは単純で、フレームワークを使って、コマンドラインツールを作ります。

そして、引数がない場合の処理として、twitterのapi認証を行い、home_timelineを出力します。

必要な機能(関数)は、github.com/ChimeraCoder/anacondaを使いましたので、こちらのコードを読んで判断していく感じです。

最初、このパッケージは認証のために使うだけで、各種機能を自前で用意していた時代もありましたが、コード整理の時、コードを短くする一環として、anacondaが用意してくれてる関数を素直に使うようにしました。それでもいくつか不足してたり、探しきれなかったりした部分でわざわざ自前でやってしまったこともありましたが。

ちなみに、自前でやる場合は、twitter apiの出力をgojsonで持ってきて、それを使うと良いです。

$ cat ~/.config/twg/verify.json | jq . | gojson

で、かなり省略してますが、それをtypeに定義して、コードはこんな感じで。

type UserVerifyCredentials struct {
	Verified       bool        `json:"verified"`
}

func GetVerifyCredentials(c *cli.Context) error {
	var o UserVerifyCredentials
 	file,err := ioutil.ReadFile("~/.config/twg/verify.json")
 	json.Unmarshal(file, &o)
 	fmt.Println(o.Verified)
 	return nil
}

あと、issueで指摘していただいたのですが、多分、issueがなかったら自分ではやらなかったであろう修正をいくつか行ったりもしました。ありがたい。私は、きっかけがないとめんどくさがってやらないので…。

気付いてはいたんですが、デフォルトでは、出力が割とあれなんですよね。Twitter Webからみて正確ではないと言うかそんな感じなんです。

なので、基本的にはv.Set("tweet_mode", "extended")したものを使います。モードをextendedに指定してるわけですね。

出力を色々と調整したサンプルコードを見てみてください。

package search

import (
	"fmt"
	"net/url"
	"github.com/urfave/cli"
	"gitlab.com/syui/twg/oauth"
	"gitlab.com/syui/twg/color"
)

func Search(c *cli.Context) error {
	mes := c.Args().First()
	api := oauth.GetOAuthApi()
	v := url.Values{}
	v.Set("tweet_mode", "extended")
	if len(c.Args()) == 0 {
		s := c.Args().First()
		v.Set("count",s)
	} else if len(c.Args()) == 2 {
		s := c.Args()[1]
		v.Set("count",s)
	} else {
		v.Set("count","10")
	}
	searchResult, _ := api.GetSearch(mes, v)
	for _ , tweet := range searchResult.Statuses {
		tweeturl := tweet.Entities.Urls
		retweet := tweet.RetweetedStatus
		if retweet != nil {
		      rname := "@" + tweet.Entities.User_mentions[0].Screen_name
		      fmt.Println(color.Cyan(tweet.User.ScreenName), "RT", color.Red(rname), retweet.FullText)
		} else {
		      fmt.Println(color.Cyan(tweet.User.ScreenName), tweet.FullText)
		}
		if  len(tweeturl) != 0 {
			fmt.Println(color.Blue(tweeturl[0].Expanded_url))
		}
	}
	return nil
}

こんな感じで、カラフルかつWebに合わせる感じにしました。

どのように使うか

こういったものを一度作っておくと、出力とかは調整できますので、サーバーサイドとかで利用できたりします。

例えば、Mastodonの投稿をTwitterに流したりだとかですけど、Goはビルドしたものがワンバイナリなのがいいですよね。また、それぞれのOSに対応したマルチビルドも簡単ですし、サーバーに置くにしても管理しやすいんですよ。

私は基本的には、Dockerにバイナリを置いて、Shell Scriptと組み合わせ、それを使うことが多いです。

なぜDockerに置くのかと言うと、CIなどを通して、実行しやすいんですよね。例えば、なぜCIを通すのかと言うと、TravisにはCron Jobという機能があって、定期的にdocker imageを実行できるんです。

で、DockerはPrivate Imageを作れますので、そこに置いて、Travisでそのイメージをpullし、実行します。私は、GitHubでPrivateリポジトリが作れないんですよね。有料版を使ってないので。特に、Twitter Tokenなどを保存したコード、設定ファイルを使わなければならないとかだと、DockerのPrivate Imageに置いて、それを実行する方法が使えます。

私は、基本的にサーバーレスのほうが好きで、自分のサーバーで動かすのもいいけど、Dockerに詰めて、CIとかHerokuとかで回すみたいな仕組みを採用することがあるんですよね。ずっと動かし続けるやつとかは、Herokuがいいです。1日に1回の処理でいい場合とかだと、CIのほうがいいですね。

このようにDocker-Go-ShellScriptとかの連携は、サーバーサイドとかで色々と便利な気がします。まあ、基本的にはサーバーレスを目指すんですけどね。しかし、サーバーレスを目指すにしても、サーバーで動くようにしなければならないんで、とりあえずDockerにつめて、それを動かせるようにするみたいな感じですかね。

Goはいいですねー。