Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

clap, serde, rss を使って自分の好きなサイトのフィードを Rust で取得する

自分の好きなサイトのフィードを取得してリストアップしてくれるツールを簡単に作ってみました.ライブラリに関する知識を整理しておきたいので,記事として残しておきます.

f:id:yuk1tyd:20180321230809p:plain:w150
Rust で CLI ツール

先日,Rust が CLI ツールにも今後注力すると発表しました.それを受けて,せっかくなので clap を使って CLI ツールを作ってみようと思ったのが動機です.製作時間は約30分くらいでした.わざわざ JSON で返しているので,CLI ツールである必要はまったくありませんし.サーバー化して UI をつけるのもいいかもしれません.

実装自体は残念ながら個人用に作ったので,だいぶハードコーディングしています.スケールしません.サイトを追加するたびに自身でプログラムを書き換える必要があります(笑).

GitHub

github.com

実行とその結果

あらかじめフィードを取得したいサイトの情報を登録しておき,設定したサイトのキーを引数として与えると,JSON に包まれたタイトルとリンクが入った結果が返ってくるという仕様です.HackerNews と Redditはてなブックマークのホットエントリを出力できるようにしました *1

動かし方ですが,

cmdrss hacker

と入力すると,HackerNews の注目のエントリ一覧が返ってきます.

{"title":"Building a data informed product culture","link":"https://medium.com/@MyMonese/developing-a-data-informed-product-culture-ce4d74b007e4"}
{"title":"Cambridge Analytica and SCL – A Very British Coup","link":"http://www.bellacaledonia.org.uk/2018/03/20/scl-a-very-british-coup/"}
{"title":"The Next Russian Attack Will Be Far Worse Than Bots and Trolls","link":"https://lawfareblog.com/next-russian-attack-will-be-far-worse-bots-and-trolls"}
{"title":"Illegal Secrecy? The Prosecution of Phantom Secure","link":"https://lawfareblog.com/illegal-secrecy-prosecution-phantom-secure-and-its-implications-going-dark-debate"}
{"title":"Building Multi-Use Web Workers","link":"https://matthewphillips.info/programming/building-multi-use-workers.html"}
{"title":"Invoice as a service: generates PDF invoice from json","link":"https://github.com/samber/invoice-as-a-service"}
(...)

ちなみに, clap は便利で,項目を入力すれば --help 等を自動で生成してくれます.

cmdrss --help

とコマンドを打つと,

cmdrss 1.0
yuk1ty
A RSS reader runs on command line.

USAGE:
    cmdrss <feeds>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

ARGS:
    <feeds>    みたいサイト [possible values: hacker, reddit, hatena]

と返ってきます.自分で実装することもできますが,結構手間なので clap を使うと CLI ツールを作るのがちょっと楽になることがわかります.

使用したクレート

今回この CLI ツールを作成するにあたって,次のクレートを使用しました.rss 以外は,Rust でのデファクトスタンダードのようになっているクレートばかりです.目にすることも多いでしょう:

  • clap: CLI ツールを作成する際に使用します.提供される API に従うだけで,お手軽に CLI ツールを作成することができます.
  • serde, serde_json, serde_derive: 「Ser(ialize)/De(serialize)」の略ですかね.serde, serde_jsonJSON をパースする際に使用する基本的な機能を提供しています.serde_derive は,#[derive(Serialize, Deserialize)] のような文法を可能にしてくれます.大抵の場合,3つセットで使います.
  • rss: RSS フィードを取得する際に使用します.内部的には,reqwest を使って,対象の URL に対して GET をかけるという構造になっています.取得した内容は Channel という構造体に保有されます

サンプルコード

#[macro_use]
extern crate clap;
#[macro_use]
extern crate serde_derive;
extern crate rss;
extern crate serde;
extern crate serde_json;

use clap::{App, Arg};
use std::str::FromStr;
use rss::Channel;

fn main() {
    let arg_matches = App::new("cmdrss")
        .version("1.0")
        .about("A RSS reader runs on command line.")
        .author("yuk1ty")
        .arg(
            Arg::from_usage("<feeds> 'みたいサイト'")
                .possible_values(&["hacker", "reddit", "hatena"]),
        )
        .get_matches();

    let ty = value_t!(arg_matches, "feeds", Rss).unwrap_or_else(|e| e.exit());

    match ty {
        Rss::HackerNews(url) => print_list_items(create_list(&url)),
        Rss::Reddit(url) => print_list_items(create_list(&url)),
        Rss::HatenaBookMark(url) => print_list_items(create_list(&url)),
    };
}

enum Rss {
    HackerNews(String),
    Reddit(String),
    HatenaBookMark(String),
}

impl FromStr for Rss {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "hacker" => Ok(Rss::HackerNews("https://hnrss.org/newest".to_string())),
            "reddit" => Ok(Rss::Reddit(
                "https://www.reddit.com/r/programming/.rss".to_string(),
            )),
            "hatena" => Ok(Rss::HatenaBookMark(
                "http://b.hatena.ne.jp/hotentry/it.rss".to_string(),
            )),
            _ => Err("Sorry, not matched in this app."),
        }
    }
}

#[derive(Serialize, Debug)]
struct FeedItem {
    title: String,
    link: String,
}

fn print_list_items(items: Vec<FeedItem>) {
    items
        .iter()
        .for_each(|item| println!("{}", serde_json::to_string(&item).unwrap()))
}

fn create_list(url: &str) -> Vec<FeedItem> {
    let channel = Channel::from_url(url).unwrap();
    let items: Vec<FeedItem> = channel
        .items()
        .iter()
        .map(|item| FeedItem {
            title: item.title().unwrap().to_string(),
            link: item.link().unwrap().to_string(),
        })
        .collect();
    items
}

処理の流れは単純です.コマンドを叩くと,フィードにアクセスして情報を取りそれをパースします.結果を構造体に詰めたあと,JSON 形式でコマンドラインに書き出しするだけです.

コマンドを用意する

fn main() {
    let arg_matches = App::new("cmdrss")
        .version("1.0")
        .about("A RSS reader runs on command line.")
        .author("yuk1ty")
        .arg(
            Arg::from_usage("<feeds> 'みたいサイト'")
                .possible_values(&["hacker", "reddit", "hatena"]),
        )
        .get_matches();

    let ty = value_t!(arg_matches, "feeds", Rss).unwrap_or_else(|e| e.exit());

    match ty {
        Rss::HackerNews(url) => print_list_items(create_list(&url)),
        Rss::Reddit(url) => print_list_items(create_list(&url)),
        Rss::HatenaBookMark(url) => print_list_items(create_list(&url)),
    };
}

enum Rss {
    HackerNews(String),
    Reddit(String),
    HatenaBookMark(String),
}

App によってコマンドを定義し,細かいオプションをメソッドチェーンで定義するという形式です.ちなみに私は試していませんが,コマンドの定義情報自体は .yml ファイルに定義しておくことも可能なようです.便利ですね.

コマンドの実行情報自体は,get_matches() 関数が返す ArgMatchers を使って,最終的にはパターンマッチをして書くことになります.今回は enum を有効に使いたかったので clap が提供している value_t! マクロを使って enum の情報とリンクさせ,enum によるパターンマッチをできるようにしました.また今回はやりませんでしたが, cmdrss --site hacker のように引数を与えることもできます.

RSS を取得する

fn create_list(url: &str) -> Vec<FeedItem> {
    let channel = Channel::from_url(url).unwrap();
    let items: Vec<FeedItem> = channel
        .items()
        .iter()
        .map(|item| FeedItem {
            title: item.title().unwrap().to_string(),
            link: item.link().unwrap().to_string(),
        })
        .collect();
    items
}

Channel を使ってフィードの取得を行っています.

注意が必要なのですが,Channel::from_url を利用するためには,Cargo.toml にいつもとは違う設定を行う必要があります.featuresfrom_url を ON にする必要があるので,それを忘れないようにしましょう.

rss = { version = "1.0", features = ["from_url"] }

さて,Channel をそのまま to_string すると GET した結果がそのまま表示されてしまうので,取得したデータを加工する必要があります.今回は (あまり意味がないといえばないですが) FeedItem という構造体を作って,そこに記事のタイトルとリンクを詰め込みました.

そしてこの FeedItem なのですが,最終的には JSON で返したいなと思っていました.その際に何を使うのでしょうか?そうです,serde を利用するのです.ということで,最後に serde が解釈できるようにオプションをつけていきましょう.

JSON

#[derive(Serialize, Debug)]
struct FeedItem {
    title: String,
    link: String,
}

fn print_list_items(items: Vec<FeedItem>) {
    items
        .iter()
        .for_each(|item| println!("{}", serde_json::to_string(&item).unwrap()))
}

#[derive(Serialize)] を構造体に付与することによって,簡単な JSON 化を行うことができます.ドキュメント.Deserialize をつけると,JSON から構造体に変換してくれます.

serde_json::to_string を使うことで JSON から文字列に変えてくれます.特段ハマリポイントもありません.シンプルです.

まとめ

CLI ツールと言えば Python や Go のイメージが少々強く,実際 Rust の所有権や借用の解決の手間を考えると実装に時間がかかると思うかもしれません.

しかし,それは慣れ次第なのかなとも思います.私自身,簡単なツールを作る程度であれば Python で同等のものを作るときとさほど作業時間は変わらないように感じました.むしろ,コンパイラがさまざまなバグを拾い上げてくれるので,コンパイルさえ通ればプログラムは正しく動くという安心感があります.

今後 Rust によって注力されると発表されている分野なので,これからも Rust で CLI ツールを作っていきたいですね.

*1:ちなみに,RedditXML のパースに失敗して落ちます.なんでだろう.