420 lines
14 KiB
Markdown
420 lines
14 KiB
Markdown
|
+++
|
||
|
date = "2018-07-25"
|
||
|
tags = ["twitter"]
|
||
|
title = "twgというtwitter clientを作った"
|
||
|
slug = "twitter"
|
||
|
+++
|
||
|
|
||
|
## 導入
|
||
|
|
||
|
突然ですが、バックエンドやサーバーサイドでよく使う技術ってありますか。ちなみに、ここで言うバックエンドは主に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
|
||
|
|
||
|
```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`を入れた後、これをビルドして、できたバイナリを実行してみます。
|
||
|
|
||
|
```sh
|
||
|
$ 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/twg`に`main.go`にあたるファイルを置きます。今回は、コマンド名である`twg.go`ですけどね。
|
||
|
|
||
|
> ./twg.go
|
||
|
|
||
|
```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
|
||
|
|
||
|
```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
|
||
|
|
||
|
```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](https://github.com/ChimeraCoder/anaconda)を使いましたので、こちらのコードを読んで判断していく感じです。
|
||
|
|
||
|
最初、このパッケージは認証のために使うだけで、各種機能を自前で用意していた時代もありましたが、コード整理の時、コードを短くする一環として、`anaconda`が用意してくれてる関数を素直に使うようにしました。それでもいくつか不足してたり、探しきれなかったりした部分でわざわざ自前でやってしまったこともありましたが。
|
||
|
|
||
|
ちなみに、自前でやる場合は、twitter apiの出力を`gojson`で持ってきて、それを使うと良いです。
|
||
|
|
||
|
```sh
|
||
|
$ cat ~/.config/twg/verify.json | jq . | gojson
|
||
|
```
|
||
|
|
||
|
で、かなり省略してますが、それを`type`に定義して、コードはこんな感じで。
|
||
|
|
||
|
```go
|
||
|
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`に指定してるわけですね。
|
||
|
|
||
|
出力を色々と調整したサンプルコードを見てみてください。
|
||
|
|
||
|
```go
|
||
|
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はいいですねー。
|