Help us understand the problem. What is going on with this article?

Goで自分のqiita投稿記事一覧を見れるコマンドラインツールを作ってみた。

Goで自分のqiita投稿記事一覧を見れるコマンドラインツールを作ってみた。

Go言語の勉強をしたので、Cliアプリケーションを作成してみます。
何番煎じ目か知れませんが、QiitaのAPIを叩いて自分のqiitaの投稿記事一覧を見れるコマンドラインツールを作ってみます。
これが出来た暁には、qiitaを書く意欲がもりもりと湧いてくる筈である。

作ったもの

$ qi myqi

と打つと、
スクリーンショット 2019-11-12 17.21.51.png
と出力されるというコマンドラインツール。
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ファイルが作成されます。
このファイルがnpmpackage.jsonみたいなもののようだ。

buildすると新たに、go.sumファイルが作成される。
このファイルはbuildした実行ファイルの依存パッケージのlockする為にバージョンが記載されている。
npmpackage-lock.jsonみたいなもののようだ。

次にCLIフレームワークurfave/cligo getしておきます。

$ go get github.com/urfave/cli

ディレクトリ構成

今回はmyqiコマンドだけしか実装しないが、他にも好きなキーワードで最新記事や、人気記事を取ってきたり、様々なコマンド実装をしていきたいので、cmdパッケージへ分けていきたいと思います。

qi
├── cmd
│   └── myqi.go
├── go.mod
├── go.sum
└── main.go

全体のコード

まず全体のコードを載せ、続いてコードの説明をしていきたいと思います。

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)
}
myqi.go
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に取得したトークンを以下のように記述しています。

.bash_profile
# qiita_api
export QIITA_TOKEN='取得したqiitaのアクセストークンを入れてください'

別パッケージのimportは絶対パス指定で取得する

別のcmdディレクトリに作成した、cmdパッケージのimportは絶対パス指定にしないとならない。
modulesを使用した時と,していない時で、記述方法が違い、古い情報のまま相対パスで記述するとパスを見つけられずエラーがでる。https://qiita.com/yujiteshima/items/8dc2f782f27f147a1e3e

main.go·抜粋
import (
    "os"

    "github.com/yujiteshima/qiita/cmd" // 絶対パスで指定する。

    "github.com/urfave/cli"
)

初期化する

goには、オブジェクト指向プログラミングに見られる「コンストラクタ」機能は無いですが、
「型のコンストラクタ」というパターンの利用が多く使われている。
型のコンストラクタはNew + 型名とする事が多い。また、型のコンストラクタは対象の型のポインタ型を返すように定義するのが望ましく、urfave/cliにおいてもそのようになっている。

main.go·抜粋
func main(){

app := cli.NewApp()

}

app.Commandsにコマンドmyqiを登録する。

main.go·抜粋
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内で、
1. FectchMyQiitaData()を実行して、データを取得する。
2. 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へパースする時に使用する。
cmd.go·抜粋
package cmd

import (
    "encoding/json" 
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
)

jsonをパースする為に構造体を定義する

encoding/jsonを用いて構造体のデータにパースする。
encoding/jsonは構造体にTagでjson:キー名を定義しておくと、キー名を出力するjsonのキー名として使ってくれます。
書籍のスターティングGOのChapter5の構造体とインターフェイスが分かりやすかったです。

cmd.go·抜粋
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を定義するだけです。

cmd.go·抜粋
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が使えるようになった。

まとめ

やはり、何か作って見ると、理解が深まると感じた。

今回のアプリケーションの今後の拡張としては、

  1. さらに検索コマンドを増やしすこと。
  2. テストを書く。
  3. ページビューを取得する為に何回もAPIにアクセスする事になる事の改良。

3のページビューを取得する為に何回もAPIにアクセスする事になる事の改良については、FaaSCloudStorage等のサービスを使って定期実行してページビューも取得済のデータをキャッシュさせたものを叩きに行くか、コマンドラインツールから、コマンドでデータをリフレッシュさせれて、そのデータを叩きにいったり出来るようにしてみたい。

たくさんGoを使って何か作って理解を深めていきたいと思います。

リファクタリングした方が良い所や、まずい書き方あれば指摘お願いします。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away