LDRがなくなるとのことがなくなったけど俺用フィードリーダー作った

mizchiさん(LDRがなくなるとのことで俺用フィードリーダー作った - mizchi's blog)のパクリです。はい。

f:id:ishiduca:20141115134146p:plain

実際LDRがなくなると、いずれは他のフィードリーダーも終わっていくんじゃないの? という不安がある。
なので、ローカルで動くものを持っておくのも仕方がないことなのかもしれないとモヤモヤしていたところで、mizchiさんのオレオレフィードリーダーが出てきた。
ごちゃごちゃ悩んでないでとりあえず作るべき。

ishiduca/feedr · GitHub

パクる

LDRに準拠したキーバインドとかマウス操作に対応させないとか、僕にとって重要でパクれるところはパクった。

既読管理の方法は違っていて、インメモリではなくleveldbにアウトソースしてる(下記参照)
ブラウザ上では未読/既読の管理はしてない

localStorageではLDRでいうところのピンのような機能を持たせることに。npmライブラリの localstorage-down 使ってみた。
localstorage-down は ユーザが触れるapiのところがleveldbのapiと同じlevelupを使うのでバックエンドとフロントエンドで同じapiでいい感じ。

leveldbはポータブルに使えるので今回の要件にはマッチしてたので使ってみた。

実装の方針

opmlファイルの読み込みからフィードエントリーをクライアントへ届けるまでStreamのパイプで直列化できるんじゃないの? という発想から後先考えずに mkdir -p feedr/{lib,node_modules,t} したので、ストリームインターフェイスのモジュールを書く。機能毎に小分けにしたストリームを書く。

Streamについて

わりと手間がかかる
でもStream1より実装者がコードを書く量が少ないのはいい
.onpipe時にwritableに例外が投げられると.unpipeされるので例外処理重要!

同時接続数の制御

セマフォ的な役割をこなすストリームで全体の同時接続数と同一ドメインでの同時接続数を制御してる。
任意の接続数に達した時点で上流のストリームの流れを止める方法が思いつかなかったので実装にしてみたけど、今からしてみれば上限数に達した時点でパイプ接続を一旦切ればよかったな、と

まとめは特にない。

Tatsumaki::HTTPClient で Pixivにログインする

メモ的に書きます。ので、やっつけです。

AnyEvent::HTTP で pixivにログインしようとする際に recurse を指定しておかないと、/login.php にデータをポストした後、自動的に Loation ヘッダが示すURLに移動しようとします。
が、Content-Lenghtなど を保持したまま移動しようしているようで、そのままでは上手く移動できません。なので、recurse => 0 指定して、レスポンスが帰って来たら Locationが示すURLに移動するなどします。

が、Tatsumaki::HTTPClient では recurse 指定する方法がありません。なので Tatsumaki::HTTPClient を拡張して使えるようにしてみます。

package Tatsumaki::HTTPClient::Custom;
use Tatsumaki;
use AnyEvent::HTTP;
use Moo;
use MooX::late;
extends qw(Tatsumaki::HTTPClient);

has jar => (is => 'rw', isa => 'HashRef', default => sub { +{version => 1} });
has recurse => (is => 'rw', isa => 'Num', default => $AnyEvent::HTTP::MAX_RECURSE);
has agent => (is => 'rw', isa => 'Str', default => sub { join '/', __PACKAGE__, $Tatsumaki::VERSION });

sub request {
    my($self, $request, $cb) = @_;
    my $jar = $self->jar; # add
    my $headers = $request->headers;
    $headers->{'user-agent'} = $self->agent;
    ### delete $headers->{'content-length'}; ###

    my %options = (
        timeout => $self->timeout,
        headers => $headers,
        body    => $request->content,
        cookie_jar => $jar, # add
        recurse => $self->recurse, #add
    );

    AnyEvent::HTTP::http_request $request->method, $request->uri, %options, sub {
        my($body, $header) = @_;
        my $res = HTTP::Response->new($header->{Status}, $header->{Reason}, [ %$header ], $body);
        $self->jar($jar); # add
        $cb->($res);
    };
}

package main;
use strict;
use warnings;
use AE;

my $login_php = 'http://www.pixiv.net/login.php';
my %query = (mode => 'login', pixiv_id => 'foo', pass => 'bar');
my $client = Tatsumaki::HTTPClient::Custom->new(recurse => 0);
my $cv = AE::cv;

$client->post($login_php => \%query, sub {
    my $response = shift;

    ### Tatsumaki::HTTPClient::Custom::request で delete $headers->{'content-length'} しておくと
    ### 以降のリクエストは AnyEvent::HTTP がめんどうみてくれる
    $client->get($response->header('location'), sub {
        my $response = shift;
        # some work ...
        $cv->send;
    });
});

$cv->recv;

ただ、これだとたるいので、Tatsumaki::HTTPClient::Custom::request の $headers->{'user-agent'} = $self->agent; の後に delete $headers->{'content-length'} しておけば、recurse => 0 する必要も、再度 get リクエストする必要もないので、たるい時にはそれでいいかもしれないです。

引数にオブジェクト(ハッシュ)を想定していて、想定していない型の値を渡してもエラーが投げられないこともある。

function F (opt) {
    this.a = opt.a
}

function test (opt) {
    try {
        console.log(new F(opt))
    } catch (err) {
        return console.error(err)
    }
}

test()      // [TypeError: Cannot read property 'a' of undefined]
test(null)  // [TypeError: Cannot read property 'a' of null]
test('foo') // {a: undefined}
test(1)     // {a: undefined}
test(function () {}) // {a: undefined}
test(false) // {a: undefined}
test([])     // {a: undefined}
test({a: 'abc'}) // {a: 'abc'}

文字列、数値、boolean、関数 でもエラーを投げないので気が置けない

filed使うとスタティックファイルを送るサーバが簡単に準備できるのでいいですね

※ 追記しました (2013.09.01)

WebWorker とか HTML5のFile API のテストをしたいときに使います

https://github.com/ishiduca/node-static-server

$ npm install https://github.com/ishiduca/node-static-server/tarball/master -g

すると `static-server` コマンドができるので


$ ROOT=$PWD/public PORT=3030 static-server
$ static-server --root=$PWD/public --port=3030

とかすると $PWD/public/ 以下のファイルを読み込むサーバが localhost:3030 に立ち上がります。


追記 2013.09.01

時たま XMMLHttpRequest などでリクエストをサーバー側に投げて返ってくるレスポンスを確認したいというような場合があるので、ミドルウェアの形でアプリを載せられるようにしてみました

$ static-server -p 3000 -r $PWD/public -m $PWD/xhr.js

上記の例だと、port が 3000 ルートディレクトリが $PWD/public ミドルウェアが $PWD/xhr.js になります

ミドルウェアはこんな感じで書きます。
xhr.js

module.exports = function () {
    var url = require('url')

    return function responseXHR (req, res, next) {
        if (url.parse(req.url).pathname !== '/xhr')
            return next() // 次のミドルウェア か sendStaticFile に任せる

        var data = ''
        req.on('data', function (chunk) { data += chunk })
        req.on('end', function () {
            var parsed
            try {
                parsed = JSON.parse(data)
            } catch (err) {
                res.writeHead(500, {'content-type': 'application/json'})
                res.end(JSON.stringify({error: 1, message: err.message})
                return console.error(err)
            }

            var responseData = something( parsed )

            res.writeHead(200, {'content-type': 'application/json'})
            res.end(JSON.stringify(responseData)
        })
    }
}

ミドルウェアは複数指定できますが、指定した順に実行されるので、指定する順番を考えて指定します

new (stream.Transform) してみた

夏コミが終わったので、node.js のバージョンを v0.10.15 にした。
ので、Stream2 を使えるようになりました。
なので、早速(というか今更)Stream2 を触り始めてる

stream.Transform

stream.Readable と stream.Writable は Stream1 からあるので(API変わってるけど)、今回は Transformを使ってみる

まず簡単に

  • ReadableStream: 100ミリ秒毎に 10 > 9 ... 0 とカウントダウンしていく
  • WritableStream: 標準ストリームにプリントする
  • TransformStream: ReadableStream が発行するデータ(カウント)を (10 - カウント)に変換して出力する

を実装する


(transf01.js)

var util   = require('util')
var stream = require('stream')

var rs = stream.Readable()
rs.count = 10
rs._read = function () {
    this.count >= 0
        ? setTimeout(this.countdown.bind(this), 100)
        : this.push(null)
}
rs.countdown = function () {
    this.push(String(this.count--))
}


function Transf () {
    stream.Transform.call(this)
}
util.inherits(Transf, stream.Transform)

Transf.prototype._transform = function (chunk, enc, done) {
    this.push(String((10 - Number(chunk)) + '\n'))
    done()
}


process.stdout.on('error', process.exit)
process.on('exit', function () {
    console.error('process.exit')
})

//rs.pipe(process.stdout)
rs.pipe(new Transf).pipe(process.stdout)

結果

0
1
2
3
4
5
6
7
8
9
10
process.exit

この例だと MyTransform.ptototpe._flush を使ってないので、_flush を使ってみる。

  • ReadableStream: http.ServerRequest
  • WritableStream: http.ServerResponse
  • TransformStream: リクエストヘッダとリクエストデータをパースし、JSON形式に変換するストリーム

(transf02.js)

var util   = require('util')
var stream = require('stream')

function Transf () {
    stream.Transform.call(this)

    this.body = ''
    this.headers = null

    this.once('pipe', this.oncePipe.bind(this))
}
util.inherits(Transf, stream.Transform)

Transf.prototype.oncePipe = function (req) {
    req.setEncoding('utf8')
    this.headers = req.headers
}

Transf.prototype._transform = function (chunk, enc, done) {
    this.body += chunk
    done()
}
Transf.prototype._flush = function (done) { // 
    try {
        this.push(JSON.stringify({
            headers: this.headers
          , body:    JSON.parse(this.body)
        }, null, 4))
    } catch (err) {
        console.error(err)
        this.emit('error', err)
    }

    done()
}


var http = require('http')
http.createServer(function (req, res) {
    var transf = new Transf
    req.pipe(transf).pipe(res)

    transf.on('error', function (err) {
        res.statusCode = 500
        res.end(err.name + ': ' + err.message)
    })
}).listen(1337)

結果

$ curl localhost:1337 -d '{"foo": "bar", "hoge": {"hello": "world"}}'
...
{
    "headers": {
        "user-agent": "curl/7.21.2 (x86_64-apple-darwin10.6.0) libcurl/7.21.2 OpenSSL/1.0.0d zlib/1.2.5 libidn/1.20",
        "host": "localhost:1337",
        "accept": "*/*",
        "content-length": "42",
        "content-type": "application/x-www-form-urlencoded"
    },
    "body": {
        "foo": "bar",
        "hoge": {
            "hello": "world"
        }
    }
}

# 文法違反のリクエスト送ってみる
$ curl localhost:1337 -d '["foo": "bar"]'
...
SyntaxError: Unexpected token :

"_transform は writable.write の直前にフックする"、"_flush は writable.end の直前にフックする" とイメージしてみたけど、あってるんだろうか?

もうちょっとドキュメント読んでみないとまずい印象

console.logの出力結果のテストってどうやってるんだろう?

実際にはテストしやすいように出力する部分と出力する内容を分離すればいいんだけど、直接出力する場合ってどうやってるんだろうか?

var QUnit = require('path/to/qunit-helper').QUnit;

var response = {};
response.log = function log () {
    console.log(
        (new Date).toUTCString()
      , this.method.toUpperCase()
      , this.pathname
      , this.statusCode.toString()
    );
};


QUnit.module('response.log()', {
    setup: function () {
        this.test = function (method, pathname, statusCode) {
            console.log = function () {
                var arg = arguments;
                equal(arg[0], (new Date).toUTCString(), arg[0]);
                equal(arg[1], method.toUpperCase(),  arg[1]);
                equal(arg[2], pathname, arg[2]);
                equal(arg[3], statusCode.toString(),  arg[3]);
            };

            response.method   = method;
            response.pathname = pathname;
            response.statusCode = statusCode;

            response.log();
        }
    }
});
test('response.log', function () {
    this.test('get', '/log', 200);
});