[Flutter]RSSをパースして内部で利用するアプリを作るためにDart XMLパッケージを使ってみる

FlutterでXMLをパースする必要があったのでDart XMLというパッケージを試してみました。
2020.05.29

新しい技術を勉強する時に作るアプリやモジュールは人それぞれあると思います。自分の場合はフィルタ付きのRSSリーダーです。実際にはその時の気分でRSSリーダーだったりはてなフィルタだったりしますがクライアント側でRSSを利用することには変わりません。

FlutterでRSSを利用できるようにするためにはXMLをパースするモジュールが必要になります。開発が盛んなフレームワークがあるプログラミング言語だと、RSSのFeed parser が提供されていることも珍しく有りません。FlutterがあるDartも例に漏れずありましたが、はてなのRSSで欲しい情報が取れず、また任意の項目が取得できる機構も提供されていなかったので、そのモジュールのソースコードを一通り読んでDart XMLパッケージの使い方を学んでみました。

欲しい項目だけ取れるように、また不要なものは取得せずに済ませられるように今回はDart XMLパッケージを使ってはてなブックマークのRSSをパースしながら使い方を紹介できればと思います。

作業環境

Dart XMLパッケージの導入

xml | Dart Packageに記載がある通りなのですが一応こちらでも一通りやります。バージョン違いで動かなかったりしたらリンク元を参照してください。

pubspec.yamlのdependenciesに指示通りのバージョンを指定します。

dependencies:
  xml: ^4.1.0

問題なければflutter pub getを叩きます。dart製のパッケージから利用する場合はpub getを叩きます。IDEを利用されている方はGUIからボタンを押すだけこれらのコマンドを実行してくれると思います。

利用する場合は利用するdartファイル内でimportします。

import 'package:xml/xml.dart';

これで利用できる状態になります。

実装を読んだパッケージでどのように使われていたか

開発当初利用を検討したのはwebfeedというパッケージです。RSS形式、Atom形式に対応していますがはてなブックマークのRSSには対応していませんでした。RSS1.0に対応させるPRがありましたがこのライブラリは一年前以降更新がないようだったので待つより実装した方が早いと思いました。

内部実装を読んでみると主に使用しているのはfindAllEleemntsメソッドとfindElementsメソッドの2つでした。この2つを利用しやすいようにglobalに定義したメソッドでラップして各data modelのfactory constructorで利用しています。

ドキュメントでどのように使うよう記載されているか

今回の実装で関係のある部分は以下です。

  • パースの結果はXmlDocumentという型で返ってくる
  • XmlDocumentクラスのインスタンスに対してgetElement(String name)、findElements(String name)、findAllElements(String name) のメソッドでノードの探索、取得を行う。

探索、取得を行うメソッドはXmlDocument、XmlElementが継承している抽象クラスに対してextensionで定義されています。

実装とテスト

この記事では以下のXMLをparseしてユーザ定義のクラスのインスタンスを生成する所をコードにします。

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
    <channel>
        <title>サンプル</title>
        <link>https://dev.classmethod.jp/</link>
        <description>Dart XML packageのサンプル</description>
        <image>
            <url>https://cdn-ssl-devio-img.classmethod.jp/wp-content/uploads/2020/03/devio-eyecatch-960x504.jpg</url>
            <title>Developers.IOのロゴ</title>
            <link>https://dev.classmethod.jp/</link>
        </image>
        <language>en-US</language>
        <lastBuildDate>Mon, 26 Mar 2020 14:00:00 PDT</lastBuildDate>
        <generator>Custom</generator>
        <copyright>Copyright 2020, Nobuyuki Tanabe</copyright>
        <item>
            <title>[Flutter]BottomAppBarを使ってボタンをめり込ませたナビゲーション(下タブ)を作る</title>
            <link>https://dev.classmethod.jp/articles/intro_flutter_bottom_app_bar/</link>
            <guid>https://dev.classmethod.jp/articles/intro_flutter_bottom_app_bar/?guid</guid>
            <description>この記事ではFlutterでボタンを避けるようにノッチの形状になった下タブを実現できるBottomAppBarを取り上げます。</description>
            <pubDate>Mon, 26 Mar 2020 14:00:00 PDT</pubDate>
        </item>
        <item>
            <title>FlutterのWidgetをコードを動かしながら学ぶ: Opacity & AnimatedOpacity編</title>
            <link>https://dev.classmethod.jp/articles/flutter_introduce_opacity_widget/</link>
            <guid>https://dev.classmethod.jp/articles/flutter_introduce_opacity_widget/?guid</guid>
            <description>この記事ではWidget of the Weekで紹介されていたOpacity Widgetを紹介します。</description>
            <pubDate>Tue, 20 Mar 2020 10:00:00 PDT</pubDate>
        </item>
    </channel>
</rss>

テストは以下です。

import 'dart:core';
import 'dart:io';

import 'package:feed_parser/domain/rss_feed.dart';
import 'package:test/test.dart';
import 'package:xml/xml.dart' as xml;

void main() {
  test("parsing Invalid.xml", () {
    var xmlString = new File("test/examples/Invalid.xml").readAsStringSync();
    var doc = xml.parse(xmlString);

    try {
      new RSSFeed.parseFrom(doc);
      fail("Should throw Argument Error");
    } on ArgumentError {}
  });
  test("parsing RSS.xml", () {
    var xmlString = new File("test/examples/RSS.xml").readAsStringSync();
    var doc = xml.parse(xmlString);

    var rssFeed = new RSSFeed.parseFrom(doc);

    expect(rssFeed.title, "サンプル");
    expect(rssFeed.description, "Dart XML packageのサンプル");
    expect(rssFeed.link, "https://dev.classmethod.jp/");
    expect(rssFeed.language, "en-US");
    expect(rssFeed.lastBuildDate, "Mon, 26 Mar 2020 14:00:00 PDT");
    expect(rssFeed.generator, "Custom");
    expect(rssFeed.copyright, "Copyright 2020, Nobuyuki Tanabe");

    expect(rssFeed.image.title, "Developers.IOのロゴ");
    expect(rssFeed.image.url,
        "https://cdn-ssl-devio-img.classmethod.jp/wp-content/uploads/2020/03/devio-eyecatch-960x504.jpg");
    expect(rssFeed.image.link, "https://dev.classmethod.jp/");

    expect(rssFeed.items.length, 2);

    expect(rssFeed.items.first.title,
        "[Flutter]BottomAppBarを使ってボタンをめり込ませたナビゲーション(下タブ)を作る");
    expect(rssFeed.items.first.description,
        "この記事ではFlutterでボタンを避けるようにノッチの形状になった下タブを実現できるBottomAppBarを取り上げます。");
    expect(rssFeed.items.first.link,
        "https://dev.classmethod.jp/articles/intro_flutter_bottom_app_bar/");
    expect(rssFeed.items.first.guid,
        "https://dev.classmethod.jp/articles/intro_flutter_bottom_app_bar/?guid");
    expect(rssFeed.items.first.pubDate, "Mon, 26 Mar 2020 14:00:00 PDT");
  });
}

今回はDartでパッケージを作成してpub run test test/rss.dartコマンドを叩いてテストをします。Flutterプロジェクトを作成して使用したい場合はFlutterでのテストコマンドを叩く必要があります。

pub run testコマンドでテストが成功するとAll tests passed!と出力されます。

pub run test test/rss.dart 
00:01 +2: All tests passed!

XmlElementにから文字列を取り出すメソッドを抽象クラスのextensionに定義します。以下の2つのメソッドはそれぞれ失敗したら例外をthrow、nullを返すメソッドです。non-nullableなfieldとnullableなfield用に個別に用意します。

import 'package:xml/xml.dart';

abstract class XMLParsableBase {}

extension ParseHelper on XMLParsableBase {
  static String stringFrom(XmlElement element, String name) {
    try {
      return element.findElements(name).first.text;
    } on StateError {
      throw new ArgumentError("$name not found");
    }
  }

  static String stringFromOrNil(XmlElement element, String name) {
    try {
      return element.findElements(name).first.text;
    } catch (_) {
      return null;
    }
  }
}

次にサンプルのXMLファイルを見ると、root → rss → channelとノードを辿れてそこからimageとitemにもノードをたどれます。itemは複数あるのでfindAllElements(XMLElements(String name)を使えば良さそうです。

それをコードにします。RSSFeedクラスを定義してそのインスタンスからimage.property_nameのように要素にアクセスしたいのでそうなるようにデータ構造を定義します。

一番単純なImageクラスから。以下のように定義します。

import 'package:xml/xml.dart';

import 'xml_parsable_base.dart';

class Image extends XMLParsableBase {
  final String title;
  final String url;
  final String link;

  Image._(this.title, this.url, this.link);

  factory Image.parseFrom(XmlElement element) {
    var title = ParseHelper.stringFromOrNil(element, 'title');
    var url = ParseHelper.stringFromOrNil(element, 'url');
    var link = ParseHelper.stringFromOrNil(element, 'link');

    return new Image._(title, url, link);
  }
}

constructorはprivateにしてfactory constructor を定義してXmlElementを引数にインスタンスを生成するよう強制します。factory constructor内では抽象クラスにextensionで定義したstatic メソッドを使って文字列を取り出すようにします。できればParseHelper.とつけたくない気持ちなんですがうまい書き方あれば教えて欲しいです。

続いてItemクラスです。Imageと同じなので説明をすることはありません。

import 'package:xml/xml.dart';

import 'xml_parsable_base.dart';

class Item extends XMLParsableBase {
  final String title;
  final String description;
  final String link;
  final String guid;
  final String pubDate;

  Item._(this.title, this.description, this.link, {this.guid, this.pubDate});

  factory Item.parseFrom(XmlElement element) {
    var title = ParseHelper.stringFrom(element, 'title');
    var description = ParseHelper.stringFrom(element, 'description');
    var link = ParseHelper.stringFrom(element, 'link');
    var guid = ParseHelper.stringFromOrNil(element, 'guid');
    var pubDate = ParseHelper.stringFrom(element, 'pubDate');

    return new Item._(title, description, link, guid: guid, pubDate: pubDate);
  }
}

次にRSSFeedです。これもコードは長いもののやってることは同じです。findAllElements(String name)を使っているぐらいですね。

import 'dart:core';

import 'package:xml/xml.dart';

import 'image.dart';
import 'item.dart';
import 'xml_parsable_base.dart';

class RSSFeed extends XMLParsableBase {
  final String title;
  final String description;
  final String link;
  final List<Item> items;

  final Image image;
  final String lastBuildDate;
  final String language;
  final String generator;
  final String copyright;

  RSSFeed._(this.title, this.description, this.link, this.items,
      {this.image,
      this.lastBuildDate,
      this.language,
      this.generator,
      this.copyright});

  factory RSSFeed.parseFrom(XmlDocument document) {
    XmlElement channnelElement;

    try {
      channnelElement = document.findAllElements('channel').first;
    } on StateError {
      throw ArgumentError('channel not found');
    }

    var title = ParseHelper.stringFrom(channnelElement, 'title');
    var description = ParseHelper.stringFrom(channnelElement, 'description');
    var link = ParseHelper.stringFrom(channnelElement, 'link');

    var feeds =
        channnelElement.findAllElements('item').map((XmlElement element) {
      return Item.parseFrom(element);
    }).toList();

    Image image;

    try {
      image = Image.parseFrom(channnelElement.findElements('image').first);
    } on StateError {}

    var lastBuildDate =
        ParseHelper.stringFromOrNil(channnelElement, 'lastBuildDate');
    var language = ParseHelper.stringFromOrNil(channnelElement, 'language');
    var generetor = ParseHelper.stringFromOrNil(channnelElement, 'generator');
    var copyright = ParseHelper.stringFromOrNil(channnelElement, 'copyright');

    return RSSFeed._(
      title,
      description,
      link,
      feeds,
      image: image,
      lastBuildDate: lastBuildDate,
      language: language,
      generator: generetor,
      copyright: copyright,
    );
  }
}

実装が終わったのでpub run test test/rss.dart コマンドを叩きます。

pub run test test/rss.dart 
00:01 +2: All tests passed!

問題なくテストを通過しました。

定義したクラスは以下のようにアクセスできます。

rssFeed.title
rssFeed.image.url
rssFeed.first.pubDate

XmlDocumentへのXmlElementへのアクセスのキー指定がタイプセーフではない点、Swiftのようにプロパティを提議してprotocol にconformすればOK、といったぐらい気軽に使えない点が課題ですがそこにこだわった実装になると本筋のxmlパッケージの紹介から外れるので実際に実装中のアプリで綺麗に抽象化できたらまた記事にしてみようと思います。

まとめ

RSSのパース用ライブラリを探していたら要件に合わなかったのでxmlパッケージを使ったのでこの記事を書きましたが、軽量なライブラリでコードも読みやすいのでDartのコードに慣れていない人にはおすすめです。自分もいくらか内部の実装を読み込んで日頃のコーディングで試しています。

ラッパーを利用せずに自分でパースすると必要なプロパティのみパースすれば最小限の実装で済みます。Dart XMLパッケージを内部で使っているアプリを私的に作っているのでこのライブラリに何かあればPRチャンスと思って引き続きプライベートで実装を進めたいです。

この記事で参考にしたリンクは全て記事内で引用して紹介しています。

何か認識や記述に誤りがあるかもしれません。お気づきになられたことがあればコメントかTwitterにてご連絡いただけると幸いです。最後まで読んでいただきありがとうございました。