PHP用ORM Emonkak\Ormの概要

背景

データベースを利用したWebアプリケーションをインフラストラクチャ(技術的機能)の観点から単純化すると、クライアントのリクエストを入力に、データベースに格納されたデータをCRUD(Create/Read/Update/Delete)して、その結果をレスポンスとして出力するものと定義できる。

この定義からいうと、リクエストとレスポンスの入出力を管理するのがWAF(Web Application Framework)で、データベースに格納されたデータをCRUDするのがORM(Object-relational mapping)となる。 両者はWebアプリケーションを構成するインフラストラクチャのコアであり、特にORMはドメインモデルの設計にも大きな影響を与えるのでその重要性はより高い。

そのような中、ORMといえばRuby On RailsのActive Recordに代表されるような重厚長大なものが主流だ。 一方で、ドメインモデルを特定のインフラストラクチャ(ORM)に依存することを避けるという観点からは、よりシンプルで依存性の少ないORMの方が望ましい。

ドメインの関心とインフラストラクチャの関心を適切に分離して、それぞれの関心に注力することでシンプルかつ明瞭な設計を実現する。 そのために新しいORMが必要だった。

コンセプト

Emonkak\Ormはフレームワークではなくライブラリとして設計した。

ここで言うフレームワークとライブラリの違いは以下の点にある:

ここでフレームワークとしてのORMの問題を考えてみる。

よくある例としては、ドメインモデル(エンティティ)の実装にORMが提供する基底クラスを継承しなければならないということがある。 これでは、ドメインとインフラストラクチャの関心が混ざり合い、適切に分離するのが難しい。 ORMが提供するモデルとドメインモデルを別個のものとして設計するという考えもあるが、冗長な実装になりがちでデメリットの方が大きいだろう。

制御の逆転を利用したフレームワークの難点としては、単純にわかりづらいという問題がある。 どのように機能が呼び出されるのかは、フレームワークの動作を知らなければわからないからだ。

そこで、Emonkak\Ormはあくまでインフラストラクチャの実装を助けるためのライブラリであると位置付け、ドメインの設計に影響を与えないようにした。 いわゆるリポジトリの実装に使うための一つのライブラリに過ぎないということだ。

特徴

Emonkak\Ormはクエリビルダー、リレーション、オブジェクトへのマッピングと基本的な機能しか提供しない。 データベースの抽象はEmonkak\Databaseが、結果セットのシーケンス処理はEmonkak\Enumerableと別のライブラリによって提供されるものを利用する。

  • SELECT, INSERT, UPDATE, DELETE文のクエリビルダー
  • カスタマイズ可能なマッピング動作
  • 一対一、一対多、多対多のリレーション
  • アクセスされるまで読み込みが遅延される遅延リレーション
  • PSR-6 Caching Interfaceを利用したキャッシュ付きのリレーション
  • ページネーション

使い方

※すべて開発中のものなので変更可能性がある

基本的な使い方としてはクエリビルダーを作成してクエリを構築、用途に合った実行メソッドを呼ぶ形になる。

SELECT文を発行して結果セットを取得する

クエリの実行結果を得るための結果セットを取得するには getResult() メソッドを呼び出す。 getResult() 実行時点ではまだクエリは実行されておらず、foreach で走査するなどして、実際に結果を得ようとして初めてクエリは実行される。 結果セットは Emonkak\Enumerable\EnumerableInterface を実装しているので、例えば first() メソッドを呼び出すことで一件だけ取り出すことができる。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\SelectBuilder;

$users = (new SelectBuilder())
    ->from('users')
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

$user = (new SelectBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class))
    ->first();

ここで PopoFetcher というのが出てきたが、これは Emonkak\Orm\Fetcher\FetcherInterface の実装クラスで、 Emonkak\Database\PDOStatementInterface からどのように値を取得するのかを決めるものだ。 PopoFetcher の場合は指定されたクラスのインスタンスを作成して、それをPOPO(Plain Old PHP Object)としてプロパティに値を代入する。 POPOとはPOJO(Plain Old Java Object)PHP版で、マジックメソッドなどを利用しないごく普通のPHPオブジェクトを表す。

POPOの実装例:

<?php

class User
{
    private $user_id;
    private $first_name;
    private $last_name;

    public function getUserId()
    {
        return $this->user_id;
    }

    public function getFirstName()
    {
        return $this->first_name;
    }

    public function getLastName()
    {
        return $this->last_name;
    }
}

FetcherInterface の別の実装としては指定されたクラスのコンストラクター連想配列を与えてインスタンスを作成する ModelFetcher と、オブジェクトにマッピングせずに連想配列をそのまま返す ArrayFetcher の実装がある。 ModelFetcherActiveRecordタイプのORMにあるようなModelクラスを利用したい場合に役立つ。

ページネーションを利用する

指定された件数ごとにページ分けされた結果セットを取得するには paginate() メソッドを呼び出す。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\SelectBuilder;

$userPages = (new SelectBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->paginate($pdo /** @var PDOInterface */, new PopoFetcher(User::class), 100 /** @var integer ページ分けの単位 */);

$users = $userPages->at(1);  // 1ページ目を取得
$userPages->getNumItems();  // 総アイテム数を取得
$userPages->getNumPages();  // 総ページ数を取得

更新系のクエリを実行する

結果行がない更新系のクエリを実行する際は execute() メソッドを利用する。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\UpdateBuilder;

$stmt = (new InsertBuilder())
    ->into('users', ['first_name', 'last_name'])
    ->values(['foo', 'bar'])
    ->execute($pdo /** @var PDOInterface */);

$stmt = (new UpdateBuilder())
    ->table('users')
    ->set('first_name', 'foo')
    ->set('last_name', 'bar')
    ->execute($pdo /** @var PDOInterface */);

$stmt = (new DeleteBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->execute($pdo /** @var PDOInterface */);

簡易クエリビルダー

Emonkak\Orm\Sql クラスはSQL文字列と、プレースホルダーにバインドする値のペアを表すクラスだが、簡易的なクエリビルダーとしても機能する。 また、 Sql オブジェクトを各種クエリビルダーのメソッドに渡すことで、クエリビルダー単体では実現できないような複雑なクエリも表現できる。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\Relation\Relations;
use Emonkak\Orm\SelectBuilder;
use Emonkak\Orm\Sql;

$users = (new Sql('SELECT'))
    ->append('*')
    ->append('FROM')
    ->append('users')
    ->append('WHERE')
    ->append('user_id IN')->appendValues([1, 2, 3])
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

// SQLの断片を条件式に指定することもできる
$users = (new SelectBuilder())
    ->from('users')
    ->where(new Sql('user_id = ?', [1]))
    ->getResult();

リレーションを利用する

リレーションは SelectBuilder あるいは Sql クラスの with() メソッドを利用することで行う。 with() に与えるリレーションは Emonkak\Orm\Relation\Relations が提供するファクトリメソッドで作成できる。

リレーションはネスト可能で、Emonkak\Orm\Relation\RelationInterfacewith() メソッドを使うことでネストできる。

作成できるリレーションの種類としては以下のものがある:

  • Relations::oneToOne(): 一対一
  • Relations::oneToMany(): 一対多
  • Relations::manyToMany(): 多対多
  • Relations::lazyOneToOne(): 遅延一対一
  • Relations::lazyOneToMany(): 遅延一対多
  • Relations::cachedOneToOne(): キャッシュ付きの一対一
  • Relations::cachedOneToMany(): キャッシュ付きの一対多

遅延リレーションについては別記事にて詳しい記述がある。

リレーション作成時に指定するパラメータは以下ものがある:

  • $tableName: string

    リレーション先のテーブル名。

  • $relationKey: string

    リレーション先のオブジェクトを格納するプロパティ名。

  • $outerKey: string

    リレーション元のテーブルの結合対象のカラム名

  • $innerKey: string

    リレーション先のテーブルの結合対象のカラム名

  • $connection: Emonkak\Database\PDOInterface

    リレーション先のテーブルのデータを取得するためのDBコネクション。 リレーションごとに指定するので、それぞれのリレーション先のテーブルが別のDBにあっても良い。

  • $fetcher: Emonkak\Orm\Fetcher\FetcherInterface

    リレーションのクエリの結果からどのように値を取得するかを決定する FetcherInterfaceインスタンス。 リレーションごとに指定するので、全く別のクラスか、あるいは連想配列をも混在してリレーションさせることもできる。

  • $proxyFactory: ProxyManager\Factory\LazyLoadingValueHolderFactory (Optional)

    遅延リレーションを利用する場合のプロキシオブジェクトを作成するためのファクトリ。

  • $cachePool: Psr\Cache\CacheItemPoolInterface

    キャッシュ付きのリレーションを利用する場合のキャッシュプール

  • $cachePrefix: string

    キャッシュ付きのリレーションを利用する場合のキャッシュキーのプレフィックス。

  • $lifetime: integer

    キャッシュ付きのリレーションを利用する場合のキャッシュの有効期限。

  • $builder: Emonkak\Orm\SelectBuilder (Optional)

    リレーション先のテーブルからデータを取得するためのクエリを生成するクエリビルダー。 リレーションする条件を特別に指定したい場合に利用する。

以下はユーザー(User)が投稿(Post)と、それに対するコメント(Comment)を持っているというリレーションの例だ:

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\Relation\Relations;
use Emonkak\Orm\SelectBuilder;

$hasPosts = Relations::oneToMany(
    'posts',
    'posts',
    'user_id',
    'user_id',
    $pdo /** @var PDOInterface */,
    new PopoFetcher(Post::class)
);

$hasComments = Relations::oneToMany(
    'comments',
    'comments',
    'post_id',
    'post_id',
    $pdo /** @var PDOInterface */,
    new PopoFetcher(Comment::class)
);

$users = (new SelectBuilder())
    ->with($hasPosts->with($hasComments))
    ->from('users')
    ->where('user_id', '=', 1)
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

おわりに

Web開発の世界は長らくフルスタックのフレームワークが主流で、小さなライブラリを組合せてアプリケーションを構築するという文化があまりなかった。 しかし、PHPの世界ではPRR-7 HTTP message interfaceの登場以降、様々なソフトウェアが登場し、小さなものを組合せて大きなアプリケーションを作るというUnix哲学にも通ずる考えが徐々に普及してきた。

さらに近年はDDD(Domain-Driven Design)の考えが広く浸透つつあるのもあってか、アプリケーションのアーキテクチャについてさまざまな論考が出てきている。 アーキテクチャはアプケーションの構成部品をどう分割し、どう組み合せるかということで決まる。 そのような中、構成部品の組み合わせ方に制限を加えてしまうようなフレームワークは、アーキテクチャの決定にも制限を与えてしまうだろう。

そこで、アプリケーションの要件に適合するフレームワークを、小さなのライブラリの組み合わせで独自に構築するというのが一つの考えだ。 Emonkak\Ormはこの考えを元に開発されたライブラリである。

エンティティのライフサイクルに基いた実装

エンティティとはその属性によってではなく識別子によって識別されるオブジェクトである。 属性が違っていても識別子が同一であればそのエンティティは同一のものと見なされる。 そのためエンティティは多くの場合、変更可能(Mutable)なオブジェクトとして表現される。 変更可能(Mutable)なオブジェクトは最初に生成されてから、それ以後に状態を変えることがある。 この状態遷移の流れがエンティティのライフサイクルである。

では、エンティティの状態はどのように変化するだろう。 それは、ドメインモデルを使って業務手順を記述したユースケースによって決ます。 ユースケースでは業務手順に基いてエンティティを作成し、状態を変更し、あるいは削除する。 これがエンティティのライフサイクルだ。

PHPにおけるエンティティの実装

エンティティはユースケースによってその状態を変えるが、ドメインモデルの一部であるエンティティをドメインのルールに反するような状態にしてはならない。 エンティティがドメインのルールに反する状態にあるというのは、バグに他ならないからだ。 実装レベルでエンティティがドメインのルールに反する状態にすることをできなくするのが望ましい。

基本的な方針としてエンティティの持つプロパティが、DBの列名と一致するように実装する。 プロパティは自由に参照・変更できないように private に隠蔽しておく。

class Person
{
    private $person_id;

    private $first_name;

    private $last_name;

    ...
}

コンストラクター

エンティティのコンストラクターprivate にして隠蔽しておく。 自由にインスタンスを作られては状態を適切に管理できないからだ。 インスタンスの生成は後述のFactory Methodによって行う。

private function __construct()
{
}

ゲッター

エンティティの属性値として、ただの文字列(String)や数値(Integer)ではドメインの関心を表現するオブジェクトして適切ではないことがある。 その場合はプロパティをそのまま返すのはでなく、ドメインモデル(値オブジェクト)を返すようにする。 そうでない場合はそのままプロパティを返却する。

public function getName()
{
    return new PersonName($this->first_name, $this->last_name);
}

セッター

単純なプロパティのセッターはすべて private にして隠蔽しておく。 自由に属性を設定できては状態を適切に管理できない。 外から状態の変更するメソッドはセッターではなくドメインに基いた振舞いとして別途適切に実装する。 セッターで設定される値を検証して値がエンティティの属性として適切ではなければ例外を投げるということもありうる。 この検証はあくまで属性レベルの検証なので、エンティティ全体の状態として正しいことを保証しているわけではない。

private function setFirstName($first_name)
{
    if ($first_name === null || $first_name === '') {
        throw new \InvalidArgumentException('名前は必ず設定する必要がある。');
    }
    $this->first_name = $first_name;
}

Factory Method

エンティティを生成する時は必ずFactory Methodを使用する。 コンストラターでいいと思うかもしれないが、PHPはメソッドのオーバーロードができないので別途Factory Methodを作った方が都合がいい。 このメソッドで作成されたエンティティの状態はドメインにおいて合法であることが保証される。

public static function create(Name $name)
{
    // first_name や last_name を持たない人間を作ることはできない
    $person = new Person();
    $person->setFirstName($name->firstName());
    $person->setLastName($name->lastName());
    return $person;
}

エンティティのライフライクルに基いた振舞い

これはドメインのルールに基いて、エンティティの状態を変更させるメソッドである。 これは対象とするドメインによって異なるので、その都度考える必要がある。 多くの場合ドメインの用語(ユビキタス言語)で動詞に対応するものが命令形で定義されることになる。

例:名前を変更する

public function changeName(Name $name)
{
    $this->setFirstName($name->firstName());
    $this->setLastName($name->lastName());
}

まとめ

業務手順を表すユースケースとは、エンティティのライフサイクルの記述に他ならない。 エンティティのライフサイクルはドメインに基いたものでなければならず、ユースケース実装に際してエンティティを不適切な状態にできるのは望ましくない。 これを回避するために、コンストラクターとセッターを隠蔽して、エンティティのライフサイクルに基いた振舞いだけを公開するといった手法を紹介した。 これによってエンティティを不適切な状態にすることができなくなり、ライフサイクルを適切に管理することができるようになった。

集約の実装とLazy Loading

エンティティの設計においてオブジェクト同士の関連(参照)は避けては通れないものだ。 エンティティ単体では不十分で、関連するエンティティ同士が結び付いて始めて意味を成す場面は往々にしてある。 そのような場合、エンティティを他のエンティティの参照を保持させる集約 として設計する。

しかし無闇に関連を持たせた集約を設計すればいいというわけではない。 関連をたくさん持った巨大な集約というのは扱いづらいし、双方向の関連は複雑さを生む。 必要最低限かつ、単方向の関連しか持たない集約が望ましい。 そのためには、エンティティの参照を保持するのではなく、その識別子(ID)を保持させることも検討すべきだ。 この時、エンティティの参照を得るには識別子をリポジトリに問い合わせればいい。

N + 1問題

集約とそれが参照する各エンティティがデータベース内に別々に格納されているとして、集約のエンティティを複数取得するケースではパフォーマンスの悪化が懸念される。 いわゆるN + 1問題がその典型的なケースだ。 N個の集約が与えられた時、集約自身を得るクエリ 1 つと、その関連を辿るクエリN を足したN + 1のクエリを発行してしまうという問題である。

この問題の解決方法は単純で、そもそもクエリを分けずに JOIN を利用して1 つのクエリにしてしまうか、IN句を利用して関連を辿るクエリを 1 つにまとめてしまえばいい。 いわゆるEager Loadingと呼ばれる方法だ。 N + 1問題が発生する方法が関連を参照するまで遅延読み込み(Lazy Loading)するのと対比して、この方法では即時読み込み(Eager Loading)するからだ。

Lazy Loadingと N + 1問題の回避

N + 1問題を回避するためにLazy Loadingは避けて常にEager Loadingすべきかというとそうとも言えない。 ユースケースによっては集約の一部の関連しか参照しない場合もあるかもしれないが、この時Lazy Loadingが利用できればクエリの発行自体を避けることができるのでパフォーマンスで有利になる。 しかしN + 1問題が起きると途端にパフォーマンスが悪化してしまうので、N + 1問題の起きないLazy Loadingの実装があれば使い勝手がいい。 つまり、エンティティごとに個別に読み込みを遅延するのではなく関連ごとに遅延すればいい。

emonkak/ormにはこの方式のLazy Loadingが実装されている。 ocramius/proxy-managerを利用した簡単な実装だ。

利用例:

<?php

use Emonkak\Orm\Relation\Relation;
use Emonkak\Orm\SelectQuery;

$users = (new SelectQuery())
    ->with(Relation::lazyOneToOne(
        Profile::class,
        'profiles',
        'profile',
        'user_id',
        'user_id',
        $pdo
    ))
    ->with(Relation::lazyOneToMany(
        Post::class,
        'posts',
        'posts',
        'user_id',
        'user_id',
        $pdo
    ))
    ->from('users')
    ->getResult($pdo, User::class);

foreach ($users as $user) {  // $users自体も実際に走査されるまでクエリの発行が遅延されている
    $users->posts;  // Postが格納されたArrayObjectのProxy Object
    $users->posts[0];  // 実際にアクセスするまでクエリの実行は遅延される
                       // 一度アクセスした時点ですべてのUserのPostが読み込まれる

    $users->profile;  // ProfileのProxyObject
    $users->profile->introduction;  // Post の時と同様にアクセスするまで読み込みは遅延される
}

おわりに

集約の読み込みにおいてLazy Loadingを利用することでクエリの発行自体を回避してパフォーマンスを向上させることができる場面があるが、N + 1問題が発生してしまうと逆にパフォーマンスが悪化してしまうので扱いづらい。 そこでN + 1問題の起きないLazy Loadingの実装があると使い勝手がいいのでemonkak/ormに実装した。

LINQ to ObjectのメソッドをJavaScriptの個別のモジュールとして使うためのライブラリ

LINQ to ObjectのメソッドをJavaScriptの個別のモジュールとして使うことのできるライブラリを作った。 これは元々Feedponで利用するために作ったのもので、現在はライブラリ単体として切り出している。

@emonkak/enumerable

なお、今回特に解説しないが本ライブラリはPHP版のものも存在する。

Emonkak\Enumerable

設計

この手のシーケンス処理のためのライブラリは膨大なメソッド(関数)を提供するので、そのライブラリに依存するスクリプトのサイズが大きくなりがちだ。 そのため、lodashRxJSなどのライブラリは個別のメソッドをモジュールとしても提供している。 個別に提供することで必要なものだけを読み込むようになるので、スクリプトのサイズ減らすことができるからだ。

今回作成した@emonkak/enumerableも同様で、メソッドを個別にモジュールとして提供している。 さらに、それらのモジュールが他のモジュールになるべく依存しないようにすることで、スクリプトサイズの増加を最小限に抑えている。

では、lodashとの違いはどこにあるのかというと、APILINQベースであるということがまずある。 LINQAPIは一般的なシーケンス処理の設計とは異なり、Select() Where() などSQL文を彷彿とさせる命名がなされている。 慣れていないと少々とっつきにくいが、APIとしての機能性はとても優れているように思う。 特に Join()GroupJoin() のような結合処理ができるのはLINQならではで、他のライブラリ等だとあまり提供されない。 実際に開発する動機となったのはこの2つのメソッドが必要になったからだ。

もう一つの違いとして、@emonkak/enumerableではGeneratorを使って実装されたIteratorをターゲットにしたAPIなので、シーケンス処理が遅延される。 そのため、返り値として配列が必要な場合は適宜変換が必要だ。 Generatorを使っているのでサポート環境が限られるが、モダンブラウザではサポートが進みつつある。 サポート環境を絞ればトランスパイルなしで利用できるだろう。

使用例

import select from '@emonkak/enumerable/select';

Array.from(select.call([1, 2, 3], (x) => x * 2));  // => [2, 4, 6]

This-Binding Syntaxを使うことでメソッドチェインをすることができる。

import select from '@emonkak/enumerable/select';
import where from '@emonkak/enumerable/where';
import toArray from '@emonkak/enumerable/toArray';

[1, 2, 3]
    ::where((x) => x % 2 === 0)
    ::select((x) => x * 2)
    ::toArray();  // => [4]

しかし、This-Binding Syntaxはまだ提案段階なのでトランスパイルなしでは使用することはできない。 代替手段として拡張モジュールを使用することができる。 拡張モジュールは読み込むと、Enumerable クラスにメソッドが追加される。

import Enumerable from '@emonkak/enumerable';

import '@emonkak/enumerable/extensions/select';
import '@emonkak/enumerable/extensions/where';
import '@emonkak/enumerable/extensions/toArray';

new Enumerable([1, 2, 3])
    .where((x) => x % 2 === 0)
    .select((x) => x * 2)
    .toArray();  // => [4]

@emonkak/enumerable

LDRに代わる新しいRSSリーダーFeedponを開発した

f:id:emonkak:20170929225749p:plain

はじめに

LDRが終了するということで、FeedlyをバックエンドにしたWebブラウザ上で動作する新しいRSSリーダーFeedponを開発した。 現在の所FirefoxのアドオンChromeの拡張として配布している。 他にモバイル版として、CordovaでパッケージングしたAndroidiOSアプリがあるものの、ストアでは配布していない(お金がかかるので)。 モバイル版を利用する場合は各自でビルドしてインストールする必要がある。

本稿は今回開発したFeedponの設計思想とその特徴を語るものである。

背景

自分が長年愛用していたRSSリーダーLDR(Live Dowango Reader)が2017年8月31日をもって終了と告知された。 終了の告知はLivedoor時代から数えると2度目で、今回はいよいよ本当に終了した。

実は、一度目の2014年10月の終了告知から、新しいRSSリーダーを作ることを構想し実装を進めていた。 LDRが終了するということで代替手段が必要になったが、他のサービスでは満足いくものが見付からなかったからだ。

しかし、この時はLDRの権利がドワンゴに譲渡されることになり、サービスの再開が決まった。 ここで一旦、新しいRSSリーダーの開発は止まっていた。 それを今年(2017年)に入ってから再開して、同年7月に一応の完成を迎えた。 その後すぐ、ある意味タイミング良くLDRの2度目の終了が告知され、公開に至るという流れだ。

バックエンドとフロントエンド

RSSリーダーの機能はバックエンドとフロントエンドに大別することができる。 フィードの巡回、購読や未読の状態を管理するのがバックエンド、対してフィードを読むためのUIを提供するのがフロントエンドだ。

バックエンドの機能についてはサーバーに実装されることが多い。 バックエンドがサーバー、フロントエンドがWebで提供されるのが、Webベースのサーバー・クライアント型のRSSリーダーだ。 サーバー・クライアント型は、購読・未読の情報をサーバー上に保存するので、それらの情報を複数の端末で共有できる。 また、フィードの巡回はサーバー上で自動的に行うので、ユーザーの端末に負担は掛からない。

一方、バックエンドとフロントエンドが一体となったスタンドアローン型のRSSリーダーも存在するが、現在の主流ではない。 複数端末で情報を共有できないし、巡回を自動で行なってはくれないので、使い勝手として劣るからだろう。

以上のことから、今回開発するFeedponについてはWebべースのサーバー・クライアント型を採用することにした。

バックエンドサーバー

Feedponでは外部サービスのAPI(Feedly)を利用しており、自前のバックエンドサーバーは用意していない。 ここではその理由について説明する。

ます、バックエンドサーバーの具体的機能を整理してみる。

  • 購読管理
  • 未読管理
  • フィードの巡回
  • エントリのお気に入り
  • フィードの検索

機能数は少なく、どの機能もシンプルで、実装上の工夫を挟む余地は少ない。 このことから、異なる実装のバックエンド同士を差別化するのは難しい。 差別化が難しいのであれば、サーバーを自前で用意するメリットも少ないだろう。

唯一差別化が容易な点としては、API応答時間や、フィードの巡回間隔などの、パフォーマンスに関する部分だろう。 パフォーマンスを最適化するのであれば、高スペックのサーバーと、効率的なサーバーアプリケーションの実装を用意するのはもちろんのことだが、サーバーにアクセスするユーザー数を制限するのが最も効率的だ。 今はコンテナ技術や各種自動化技術によりサーバー実装を配布することも容易なので、そのために自分専用のサーバーを立ち上げるというのはそう難しくないだろう。 しかしながら、難しくなはいといっても誰でもできるほど簡単とは言えないし、サーバーの稼働コストが掛かってしまうという難点もある。

現時点ではFeedlyをバックエンドサーバーとして利用しているが、特に不満はなく、サーバー実装を自前で用意するつもりはない。 しかし、LDRGoogle ReaderがそうであったようにFeedlyもいつかは終了してしまうかもしれない。 それに備えてバックエンドを交換可能にできるようにクライアントを実装している。 もしFeedlyのサービスが終了したとしても、別のサービスに移行してもいいし、自前で用意してもいい。 RSSリーダーのような日常的に使うツールは長く使えるべきだと考えているので、特定の外部サービスに密結合することは避けている。 LDRは10年近く使っていたので、Feedponについても同じくらいは使うことを考えている。

クライアント

クライアントはRSSリーダーの使い勝手に大きく影響するUIを提供するものである。 ではこのUIに表示するものは具体的に何かと言うと、RSSあるいはATOMの形式で配信されたXML文章である。 そして、このXML文章の中にはエントリの本文がHTML文章がとして含まれる。 このことから、RSSリーダーのUI上にはHTML文章を表示する必要があり、そのためにはブラウザエンジン(HTMLレンダラー)が必要である。

そのため、UIをネイティブ部品を使ってネイティブアプリケーションとして作成するよりも、ブラウザ上で動作するWebアプリケーションとして実装するのがシンプルだ。 ネイティブアプリケーションとして実装しても、本文の表示にはブラウザエンジンが必要になるので、ネイティブのUIにWebViewを載せる形になるからだ。 それよりも一貫してすべてWebベースで実装してしまうのがシンプルだろう。

もちろんパフォーマンスについてはネイティブの方がいくらかは優位だろうが、クロスプラットフォーム展開が容易という点からも、Webアプリケーションの方がいいと考えた。

Webアプリケーションとして実装するのであれば、LDRがそうであるように純粋なWebサービスとしてドメインを取って配信することがまず考えられる。 しかし、Feedponはバックエンド(Feedly)との通信の他、エントリの全文取得、ブックマーク数の取得など様々な箇所でクロスドメイン通信を利用するので、これは不可能だ。 これらすべての通信でCORS(Cross-Origin Resource Sharing)が利用できれば良いが、当然そうはいかない。 そのため、Webサービスとしてドメインを取って運用するという手段は取れない。

では、Webアプリケーションを動かすためのプラットフォームが必要となる。 まず最近流行しているElectronが思い付くが、これは選択肢として入れることはなかった。 何故かというと、自分の使い方だとRSSリーダーでエントリを読んでいる最中に大量のリンクを開くからだ。 リンクを開く度にElectronとブラウザを行き来するのではとても使いづらい。 同じブラウザウィンドウのタブ上で開いて欲しいのだ。 そのために、普段使っているブラウザ上で動作するのが望ましいだろう。

ブラウザ上で動作するWebアプリケーションであれば、ブラウザ拡張(Extension)がある。 幸いにもブラウザ拡張はGoogle ChromeAPIをベースに標準化されつつあるので、Chromeの拡張を作れば対応ブラウザでであればそのまま動作させることができるようになりつつある。 Feedponは始めChorme拡張として開発していたが、実際にコード改変なしでChrome拡張をFirefox拡張として動作させることができた。

モバイル対応

前述のクライアントの項はPCを対象としたものだったが、今の時代モバイル対応も必須だろう。 LDRには使いやすいスマホアプリがなく、レスポンシブの考え方がなかった時代のものなので直接Web経由で見るのも困難で、スマホ利用がしづらかったのが不満だった。

Feedponは、モバイル版もPCと全く同様のWebベースの実装にして、レスポンシブデザインで表示を切り替えるようにした。 実装が同じなのでメンテンスコストが抑えられるとともに、手間を掛けずとも同じ操作感を実現できるものもメリットだ。 もちろんモバイルならではの工夫でより使いやすくすることもできるだろうが、違和感なくモバイル版とPC版を使い分けたかったので、同じ操作感を実現する方向に舵を切った。

モバイルであれば、ネイティブアプリケーションでの実装はパフォーマンスの観点では圧倒的に有利だろう。 しかし、実装に大きな手間が掛かってしまうのであえて避けた。 また、モバイルのネイティブアプリケーションは開発環境の変化がとても大きいので、メンテナンスコストも馬鹿にできない。 対して、Web標準技術で実装すれば大きくメンテナンスの頻度を抑えることができる。 現にLDRは10年以上も稼動していたが、サービスの終了さえなければまだまだ使うことができた。 長く使うツールだからこそ、メンテンナンスの頻度は極力抑えたい。 そのためにもモバイル版もWebアプリケーションとして実装している。

モバイル版のアプリケーションのパッケージングにはApache Cordovaを利用した。 CordovaでiOSAndroid、Window Phone等様々なプラットフォームに対応しているので、マルチプラットフォーム対応も難なく可能だ。 現在の所FeedponはiOSAndroidに対応している。

UIデザイン

f:id:emonkak:20170929224156p:plain

UIデザインはLDRのものを参考にして、モダンでシンプルなデザインを心掛けた。 コンセプトとしては印刷物としても成り立つデザインということで、Vertical Rhythmにも気を使っている。

印刷物としても成り立つデザインというのは、いわゆるフラットデザインにも通ずる所がある。 フラットデザインというのはWebデザインが紙媒体のデザインに近付いていく仮定だと考えている。 これは高DPIのディスプレイが普及してきたことと無関係ではない。 DPIが限りなく印刷物に近づけばWebデザインも当然のことながら紙媒体のデザインに近付いて行くという訳だ。 そうであれば、これからのWebデザインはDTPのノウハウを活かす機会が多くなるだろう。

フラットデザインについてよく議論の対象となるのは、ボタンなどの操作可能なUI部品のデザインをどうするのかというものがある。 操作可能なUIかどうかが分かりづらいという批判がまさにそれだ。

ここでレイヤーという概念を導入して、操作可能な部品には影を付けるというアイディアがマテリアルデザインだろう。 操作可能な部品に影を付けることで、そうではない部分との違いが明確になって分かり易くなる。

Feedponの場合、RSSリーダーという日常的に使うツールなので、操作可能な部品がそう分かり易い必要はないと考えている。 少々分かりづらくても日常的に使っている内に操作を覚えて慣れてしまうからだ。 それよりも、デザインとしての一貫性と可読性を重視して、一貫してフラットなデザインにした。

レスポンシブデザイン

モバイル向けのデザインについてはレスポンシブにして、共通化を図っている。 デザインはモバイルのものがベースで、そこから微調整をしてデスクトップ向けのデザインが存在する。 デスクトップのデザインをベースにしてしまうと、ボタンなどの操作可能な部品が小さくなってしまい、ディスプレイの小さなモバイル端末でタップすることが困難になってしまうからだ。 そのため、モバイル端末での表示を考慮したデザインでは余白を大く取る傾向にある。 余白を多く取ることでモバイルでの操作性や、可読性も向上するが、一方で一画面に表示できる情報量については劣ってしまう。 デザインリニューアルで一画面に表示される情報量が減ってしまって不評を買うというのは度々ある話だ。

Feedponでは余白を比較的広く取ってはいるが、情報量が減りすぎないように微妙な所で調整している。

特徴

ここではLDRにはないFeedponならではの特徴的な機能について述べる。

文展

f:id:emonkak:20170929224219g:plain

フィードに含まれるエントリの情報にエントリの全文が含まれいないことが度々ある。 これでは使い勝手が悪いので、様々なRSSリーダーでエントリの全文を取得できるようにするユーザースクリプトが往々にして提供されている。 LDRで言えばLDR Full FeedFeedlyであればFeedly Full Feedだ。

Feedponではこの機能を初めから本体に内蔵している。 仕組みとしては前述のユーザースクリプトと同様にWedata上で管理されているSiteinfo情報を利用している。 この仕組みは古くからあるもので、今となってはWebではあまり使われないXPathを使ってエントリ本文のノードを特定している。 それこそ今だと流行りのAIを使って本文を抽出するというアプローチもありうるのかもしれないが、この古い仕組みでも十分に機能する。 むしろこの仕組みだと直接本文のあるウェブサイトに直接クライアントからアクセスするので、外部APIに本文の抽出を依頼するのに比べ、ラウンドトリップタイムを最小に抑えられるというのがメリットだ(APIでキャッシュが効いていればその限りではないが)。

キーボードショートカット

f:id:emonkak:20170929224218p:plain

Feedponでは多数のキーボードショートカットが提供される。 そしてそのすべてをカスタマイズすることが可能だ。

現時点ではデフォルトのキーボードショートカットは以下のようになっている。 マッピングVimライクな記法で行い、マルチキーストロークマッピングも可能だ。

Key Command
/ Search subscriptions
<Escape> Close sidebar
<S-Space> Scroll page up
<Space> Scroll page down
? Show help
A Select previous category
G Go to last line
R Reload stream
S Select next category
V Visit website
a Select previous subscription
b Toggle comments
c Toggle stream view
f Fetch full content
gc Clear read entries
gg Go to first line
gm Mark all entries as read
h Close entry
i Open URL
j Select next entry
k Select previous entry
l Expand entry
p Pin/Unpin entry
r Reload subscriptions
s Select next subscription
v Visit website
z Toggle sidebar

はてなブックマーク連携

f:id:emonkak:20170929224217p:plain

RSSリーダーには人それぞれ様々な使い方があると思うが、配信されるすべてのエントリを読むという人は少ないだろう。 自分の場合、エントリを読むかどうかはタイトルと、そのエントリのはてブの被ブックマーク件数で判断している。

Feedponではエントリごとにはてぶの件数が表示され、件数の多い場合は件数の表示が目立つようになるので、注目されているエントリが一目瞭然だ。 また、エントリのパーマリンクRSS用のトラッキングURLが設定されている場合、はてブの件数を正常に取得できないので、トラッキングURLを展開する機能も内蔵している。 さらに、はてブのコメントを表示する機能も内蔵しているので、コメントもシームレスに読むことができる。

もちろん、はてブを使わない人もいるだろうし、そんな人にとっては不要な機能だろう。 しかし、そもそものコンセプトはLDRの代替として「自分のために作るRSSリーダー」ということなので、この機能を入れている。 はてブの件数を、Facebookのいいねの件数にするだとか、はてブのコメントをエントリに関するツイートの一覧にするなど考えられるが、自分が使わないという理由で、はてブ決め打ちとなっている。 もしかすると、今後そのようにカスタマイズできるようにするかもしれないが今の所その必要を感じていない。

おわりに

今回LDRに代わる新しいRSSリーダーFeedponを開発した。 個人的にはとても便利に使っていて、もうこれなしではいられないというくらい毎日使っている。 そもそも自分が使うために作ったプロダクトなので万人に使い易いとは限らないものの、自分と同じように気に入る人がいればいいとの思いで公開した。

もし、何か要望や問題があった場合はGithubIssuesにて受け付けている(日本人ユーザーしかいないと思うので日本語OK)。

旅部まとめ

ライフログチャンネル旅部チャンネルにおける旅部の放送まとめ。

# Date URL Place Cast
1 2014/01/26 http://live.nicovideo.jp/watch/lv170879155 ドライブ 横山緑、力也、14
2 2014/02/28 http://live.nicovideo.jp/watch/lv170879155 名古屋 横山緑、本間智則、14、ふかみん
3 2014/05/21 http://live.nicovideo.jp/watch/lv176706778 福井 横山緑、石川典行、えりりか、力也、本間真、野田総理なっちゃん
4 2014/06/07 http://live.nicovideo.jp/watch/lv181013988 キャンプ 横山緑、石川典行、本間真、その他
5 2014/07/01 http://live.nicovideo.jp/watch/lv183430282 江ノ島 横山緑、石川典行、力也、本間真、あっきーなしゃま、野田総理、ももえり、その他
6 2014/08/07 http://live.nicovideo.jp/watch/lv185681203 韓国 横山緑、石川典行、力也
7 2014/09/27 http://live.nicovideo.jp/watch/lv192086983 沖縄 横山緑、力也、NER
8 2014/10/24 http://live.nicovideo.jp/watch/lv196711673 草津 横山緑、らみあ、力也
9 2014/11/25 http://live.nicovideo.jp/watch/lv198668560 大阪 ティロ・フィナーレ加川、本間すなお、14
10 2014/12/06 http://live.nicovideo.jp/watch/lv199282132 初島 横山緑、NER、力也
11 2015/06/19 http://live.nicovideo.jp/watch/lv223372925 浜名湖 横山緑、よっさん、力也
12 2015/07/18 http://live.nicovideo.jp/watch/lv227571405 大阪 横山緑、力也、団長
13 2015/10/03 http://live.nicovideo.jp/watch/lv236123298 福岡 横山緑、NER
14 2016/02/09 http://live.nicovideo.jp/watch/lv251659007 北海道 横山緑・石川典行
15 2016/05/22 http://live.nicovideo.jp/watch/lv260920055 鎌倉 横山緑、ハニー大木、シケキノコ
16 2016/12/20 http://live.nicovideo.jp/watch/lv284160287 沖縄 横山緑、大島薫
17 2017/03/13 http://live.nicovideo.jp/watch/lv292820296 九州・佐賀 横山緑、堀田みなみ
18 2017/04/09 http://live.nicovideo.jp/watch/lv295143445 キャンプ 横山緑、まりにゃん、AFちゃん、ロハコ、jan発進
19 2017/07/01 http://live.nicovideo.jp/watch/lv301795761 北海道 横山緑、野田草履、高田健志
20 2017/09/16 http://live.nicovideo.jp/watch/lv306534663 大阪 横山緑、NER、ティロ・フィナーレ加川、野田草履

Kensington Expert Mouse 5 USBをX11で使う方法

トラックボールの名機と呼ばれるKensington Expert Mouse 5(EM5)をX11で使おうとしたが問題が発生した。

まず、EM5には4つのボタンがあり、それぞれのボタンはデフォルトでは以下のように割り当てられている:

.---------. .---------.
| Middle  | | Back    |
`---------' `---------'
.---------. .---------.
| Left    | | Right   |
`---------' `---------'

これら4つのボタンとホイールのX、Y軸それぞれの正方向、負方向のスクロールを合わせて8ボタンのマウスの扱いになる。

この割り当てを以下のように変更した上で、左クリックと右クリック同時押しで中央クリック、進むボタンを押しながらカーソル移動をスクロールに割り当てたい。

.---------. .---------.
| Middle  | | Forward |
`---------' `---------'
.---------. .---------.
| Left    | | Right   |
`---------' `---------'

これを実現するためにxorg.confでevdevの設定を以下のようにした:

Section "InputClass"
    Identifier "Kensington USB/PS2 Expert Mouse"
    MatchIsPointer "on"
    MatchProduct "Kensington USB/PS2 Expert Mouse"
    Driver "evdev"
    Option "ButtonMapping" "1 8 3 4 5 6 7 9 2"
    Option "Emulate3Buttons" "on"
    Option "Emulate3Button" "9"
    Option "EmulateWheel" "on"
    Option "EmulateWheelButton" "8"
    Option "EmulateWheelInertia" "10"
    Option "XAxisMapping" "6 7"
    Option "YAxisMapping" "4 5"
EndSection

しかし 8ボタンマウス として認識しているため、ButtonMapping の設定値で最後の 2 が無視されて 1 8 3 4 5 6 7 9 になってしまう。 そのため中央クリックのエミュレーションでボタン2(中央ボタン)ではなく、ボタン9(進むボタン)のイベントが送信されてしまう。

マッピングxinput コマンドで確認できる:

$ xinput get-button-map "Kensington USB/PS2 Expert Mouse"
1 8 3 4 5 6 7 9

今回これを回避するためにソースを編集してボタン数を btnmap の長さ(32)で決め打ちして回避した。

パッチを当てるとマッピングは以下のようになった:

$ xinput get-button-map "Kensington USB/PS2 Expert Mouse"                                 
1 8 3 4 5 6 7 9 2 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 0 

また、以前の記事でも、中央クリックのエミュレーションをマッピングの変更とともに使用するためのパッチを書いたが、これは中央ボタンの物理ボタン番号を指定する Emulate3Button の設定が実装されて不要になった。

なお、シリアルポート版のEM5をPS/2で使う場合はパッチは必要なく、以下の設定で同じ動作が可能:

Section "InputClass"
    Identifier "ThinkPS/2 Kensington ThinkingMouse"
    MatchIsPointer "on"
    MatchProduct "ThinkPS/2 Kensington ThinkingMouse"
    Driver "evdev"
    Option "ButtonMapping" "1 8 3 4 5 6 7 2 9"
    Option "Emulate3Buttons" "on"
    Option "Emulate3Button" "8"
    Option "EmulateWheel" "on"
    Option "EmulateWheelButton" "9"
    Option "EmulateWheelInertia" "10"
    Option "XAxisMapping" "6 7"
    Option "YAxisMapping" "4 5"
EndSection