Goで自分のqiita投稿記事一覧を見れるコマンドラインツールを作ってみた。
Go言語の勉強をしたので、Cliアプリケーションを作成してみます。
何番煎じ目か知れませんが、QiitaのAPIを叩いて自分のqiitaの投稿記事一覧を見れるコマンドラインツールを作ってみます。
これが出来た暁には、qiitaを書く意欲がもりもりと湧いてくる筈である。
作ったもの
$ qi myqi
と打つと、
と出力されるというコマンドラインツール。
githubはこちらです。:https://github.com/yujiteshima/qi
使用するフレームワーク
urfave/cli
というCLIアプリケーションフレームワークを使用してみる。
urfaveの使い方は公式のDocumentが一番分かりやすかったです。https://github.com/urfave/cli/blob/master/docs/v1/manual.md
ディレクトリきったり、go mod init
したりする。
Go 1.11から採用されているmodules
を使用する。
gopathの外でも開発が可能であるので、home
直下にworkspace_golang
というディレクトリを作って、
その中に今回のプロジェクトのディレクリを作成しました。
golangはbuildした時の実行ファイル名がデフォルトではディレクトリ名になるので、
コマンドの名前を付けると思って考えます。
「qiitaの記事を取得するコマンドだから········qi
だ。」
というような感じで考えました。悩みますが、勉強が全然進まなくなりますので、さっと考えて進めていきます。
$ mkdir workspace_golang
$ cd workspace_golang
$ mkdir qi <- 好きな名前を付ける、オプションを付けずに`build`するとフォルダ名がコマンド名になる
次にモジュールの初期化を行います。
$ cd qi
$ go mod init github.com/username/qi
これでプロジェクトフォルダにgo.mod
ファイルが作成されます。
このファイルがnpm
のpackage.json
みたいなもののようだ。
buildすると新たに、go.sum
ファイルが作成される。
このファイルはbuildした実行ファイルの依存パッケージのlockする為にバージョンが記載されている。
npm
のpackage-lock.json
みたいなもののようだ。
次にCLIフレームワークurfave/cli
をgo get
しておきます。
$ go get github.com/urfave/cli
ディレクトリ構成
今回はmyqi
コマンドだけしか実装しないが、他にも好きなキーワードで最新記事や、人気記事を取ってきたり、様々なコマンド実装をしていきたいので、cmdパッケージへ分けていきたいと思います。
qi
├── cmd
│ └── myqi.go
├── go.mod
├── go.sum
└── main.go
全体のコード
まず全体のコードを載せ、続いてコードの説明をしていきたいと思います。
package main
import (
"os"
"github.com/username/qi/cmd"
"github.com/urfave/cli"
)
func main() {
app := cli.NewApp()
app.Name = "qiitasearch"
app.Usage = "search qiita articles"
app.Version = "0.1.0"
app.Commands = []cli.Command{
{
Name: "myqi",
Usage: "qiita + mine : you get yours articles",
Action: func(c *cli.Context) error {
qiitaToken := os.Getenv("QIITA_TOKEN")
datas := cmd.FetchQiitaData(qiitaToken)
cmd.OutputQiitaData(datas)
return nil
},
},
}
app.Run(os.Args)
}
package cmd
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
)
// jsonをパースする為の構造体を定義する
type Data struct {
ID string `json:id`
Url string `json:"url"`
Title string `json:"title"`
LikesCount int `json:"likes_count"`
PageViewsCount int `json:"page_views_count"`
}
func FetchQiitaData(accessToken string) []Data {
baseUrl := "https://qiita.com/api/v2/items?query=user:yujiteshima"
// 様々な検索条件をかけるときはbaseUrlをv2/までにして他を変数で定義してurl.Parseで合体させる
endpointURL, err := url.Parse(baseUrl)
if err != nil {
panic(err)
}
b, err := json.Marshal(Data{})
if err != nil {
panic(err)
}
var resp = &http.Response{}
// qiitaのアクセストークンがない場合はAuthorizationを付与しない
// 2パターン作っておく。
// accessトークンは環境変数に入れておく。自分の場合は.bash_profileにexport文を書いている。
if len(accessToken) > 0 {
// QiitaAPIにリクエストを送ってレスポンスをrespに格納する。
resp, err = http.DefaultClient.Do(&http.Request{
URL: endpointURL,
Method: "GET",
Header: http.Header{
"Content-Type": {"application/json"},
"Authorization": {"Bearer " + accessToken},
},
})
} else {
fmt.Println("***** Access Token 無しでQiitaAPIを叩いています アクセス制限に注意して下さい*****")
resp, err = http.DefaultClient.Do(&http.Request{
URL: endpointURL,
Method: "GET",
Header: http.Header{
"Content-Type": {"application/json"},
},
})
}
defer resp.Body.Close()
if err != nil {
panic(err)
}
b, err = ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var datas []Data
if err := json.Unmarshal(b, &datas); err != nil {
fmt.Println("JSON Unmarshal error:", err)
return nil
}
/*********一覧取得では、ページビューがnilになるので個別で取りに行ってデータを得る*****************/
for i, val := range datas {
article_id := val.ID
baseUrl := "https://qiita.com/api/v2/items/"
endpointURL2, err := url.Parse(baseUrl + article_id)
if err != nil {
panic(err)
}
b, err := json.Marshal(Data{})
if err != nil {
panic(err)
}
resp, err = http.DefaultClient.Do(&http.Request{
URL: endpointURL2,
Method: "GET",
Header: http.Header{
"Content-Type": {"application/json"},
"Authorization": {"Bearer " + accessToken},
},
})
if err != nil {
panic(err)
}
b, err = ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
fmt.Println("JSON Unmarshal error:", err)
return nil
}
datas[i].PageViewsCount = int(m["page_views_count"].(float64))
}
return datas
}
// データの出力
func OutputQiitaData(datas []Data) {
fmt.Println("************************自分のQiita投稿一覧******************************")
for _, val := range datas {
fmt.Printf("%-15v%v%v\n", "ID", ": ", val.ID)
fmt.Printf("%-15v%v%v\n", "Title", ": ", val.Title)
fmt.Printf("%-12v%v%v\n", "いいね", ": ", val.LikesCount)
fmt.Printf("%-9v%v%v\n", "ページビュー", ": ", val.PageViewsCount)
fmt.Printf("%-15v%v%v\n", "URL", ": ", val.Url)
fmt.Println("-------------------------------------------------------------------------")
}
}
main.goでしている事
urfave/cli
を使用して、コマンドラインツールのインターフェイスを定義する。
urfave/cli
では、複雑なインターフェイスも定義できるが、今回は、
$ qi myqi
とうてば自身のqiita
記事が出力されるようにしたいと思います。
他のコマンドもゆくゆくは作りたいので最低限、コマンドを1つとるツールとしていきます。
Qiitaのトークンの取得
qiitaのトークンを取得しておき、環境変数に入れておきます。qiitaのトークンの取得の仕方は、この記事を書いている2019/11時点では、Qiitaのページ右上の自分のアカウントのアイコンから、
設定 -> 左側のメニューのアプリケーション -> 新しくトークンを発行する
となっています。
自分の環境の場合は、.bash_profile
に取得したトークンを以下のように記述しています。
# qiita_api
export QIITA_TOKEN='取得したqiitaのアクセストークンを入れてください'
別パッケージのimportは絶対パス指定で取得する
別のcmdディレクトリに作成した、cmdパッケージのimportは絶対パス指定にしないとならない。
modules
を使用した時と,していない時で、記述方法が違い、古い情報のまま相対パスで記述するとパスを見つけられずエラーがでる。https://qiita.com/yujiteshima/items/8dc2f782f27f147a1e3e
import (
"os"
"github.com/yujiteshima/qiita/cmd" // 絶対パスで指定する。
"github.com/urfave/cli"
)
初期化する
go
には、オブジェクト指向プログラミングに見られる「コンストラクタ」機能は無いですが、
「型のコンストラクタ」というパターンの利用が多く使われている。
型のコンストラクタはNew + 型名
とする事が多い。また、型のコンストラクタは対象の型のポインタ型を返すように定義するのが望ましく、urfave/cli
においてもそのようになっている。
func main(){
app := cli.NewApp()
}
app.Commandsにコマンドmyqi
を登録する。
app.Commands = []cli.Command{
{
Name: "myqi",
Usage: "qiita + mine : you get yours articles",
Action: func(c *cli.Context) error {
qiitaToken := os.Getenv("QIITA_TOKEN")
datas := cmd.FetchQiitaData(qiitaToken)
cmd.OutputQiitaData(datas)
return nil
},
},
}
Nameに登録するコマンドを文字列で指定する。
Usageには使い方を登録する。helpで使われる。
Actionに実際の処理を定義する。
Action内で、
- FectchMyQiitaData()を実行して、データを取得する。
- OutputQiitaData()で出力する。
以上がmain.go
での処理です。続いて、cmdパッケージの説明をします。
cmdパッケージ
cmdパッケージには、構造体Data
の定義とFetchMyQiitaData関数とOutputQiitaData関数をを定義している。
importしているパッケージ
まず各種パッケージをimport
する。
- "encoding/json" : QiitaAPIから取得したjsonを
go
のデータにパースする時に使用する。 - "fmt" : 標準出力に使用する。
- "io.ioutil" : QiitaAPIから取得したデータを読み込む時に使用する。
- "net/http" : QiitaAPIを叩きに行く時に使用する。
- "net/url" : 文字列からurlへパースする時に使用する。
package cmd
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
)
jsonをパースする為に構造体を定義する
encoding/json
を用いて構造体のデータにパースする。
encoding/json
は構造体にTag
でjson:キー名
を定義しておくと、キー名を出力するjsonのキー名として使ってくれます。
書籍のスターティングGOのChapter5の構造体とインターフェイス
が分かりやすかったです。
type Data struct {
ID string `json:id`
Url string `json:"url"`
Title string `json:"title"`
LikesCount int `json:"likes_count"`
PageViewsCount int `json:"page_views_count"`
}
FetchMyQiitaData()
を定義する。
FetchMyQiitaData
は引数としてaccessToken
を受け取ってHeader
にセットしている。
この他にもサブコマンドに引数を受け取ってここで使用することも出来る。キーワードを引数で受け取って検索するコマンドを作るときに使える。
accessToken無しでリクエストを送った時には、accessToken無しでアクセスしているとメッセージを出すようにしておきました。
json
で受け取ったデータをパースする為に、標準パッケージのencoding/json
を使いました。Unmarshal
メソッドを使って、jsonをGoの構造体データのスライスにパースする。とても簡単に出来ます。
OutputQiitaData()
を定義する
あとは出力する為のOutputQiitaData
を定義するだけです。
func OutputQiitaData(datas []Data) {
fmt.Println("************************自分のQiita投稿一覧******************************")
for _, val := range datas {
fmt.Printf("%-15v%v%v\n", "ID", ": ", val.ID)
fmt.Printf("%-15v%v%v\n", "Title", ": ", val.Title)
fmt.Printf("%-12v%v%v\n", "いいね", ": ", val.LikesCount)
fmt.Printf("%-9v%v%v\n", "ページビュー", ": ", val.PageViewsCount)
fmt.Printf("%-15v%v%v\n", "URL", ": ", val.Url)
fmt.Println("-------------------------------------------------------------------------")
}
}
forループで出力する。
見やすくフォーマットしておく。
ページビューが取れない問題発生
しかし、ここで一度出力して確認すると、ページビューが0となってしまっている。
アクセストークンが外れてしまっているのか等様々調べた結果、
"https://qiita.com/api/v2/items?query=user:yujiteshima"
このように一覧を取得する際には、ページビューはnull
になって返ってくる。つまり、個別の記事検索でしか、ページビュ0は取得できないという事でした。
個別のページからページビューを取得する
ページビューの表示を諦めそうになりますが、ページビュー欲しいです。個別のページから全て取得していきます。
for i, val := range datas {
//fmt.Println("id:", val.ID)
article_id := val.ID
baseUrl := "https://qiita.com/api/v2/items/"
endpointURL2, err := url.Parse(baseUrl + article_id)
if err != nil {
panic(err)
}
b, err := json.Marshal(Data{})
if err != nil {
panic(err)
}
resp, err = http.DefaultClient.Do(&http.Request{
URL: endpointURL2,
Method: "GET",
Header: http.Header{
"Content-Type": {"application/json"},
"Authorization": {"Bearer " + accessToken},
},
})
if err != nil {
panic(err)
}
b, err = ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
fmt.Println("JSON Unmarshal error:", err)
return nil
}
datas[i].PageViewsCount = int(m["page_views_count"].(float64))
}
取得した一覧で得たid
を使ってループで回してpage_view_count
を取得していき、0が入ってしまっているpage_view_count
を更新していきます。
これで、出力してみて完成です。
qiディレクトリで、go install
します。
$ go install
これで、qiディレクリで無くても、コマンドqi
が使えるようになった。
まとめ
やはり、何か作って見ると、理解が深まると感じた。
今回のアプリケーションの今後の拡張としては、
- さらに検索コマンドを増やしすこと。
- テストを書く。
- ページビューを取得する為に何回もAPIにアクセスする事になる事の改良。
3のページビューを取得する為に何回もAPIにアクセスする事になる事の改良については、FaaS
とCloudStorage
等のサービスを使って定期実行してページビューも取得済のデータをキャッシュさせたものを叩きに行くか、コマンドラインツールから、コマンドでデータをリフレッシュさせれて、そのデータを叩きにいったり出来るようにしてみたい。
たくさんGo
を使って何か作って理解を深めていきたいと思います。
リファクタリングした方が良い所や、まずい書き方あれば指摘お願いします。