アペフチ

Railsでの検索機能にgroonga-client-railsを使う(前編)

今日の日記はGroonga Advent Calendar 2016の18日目です。

今日はRailsアプリケーションに検索機能を付けるのに、groonga-client-rails gemを使う方法を、サンプルアプリケーションを作りながら紹介したいと思います。長くなったので……というか、準備と書く時間の見積もりを誤ったので、前後編に分けます。前編の今日はインストールからRailsのモデルと検索機能を結び付ける(言い回しが分かりにくいと思いますがあとで分かります)まで紹介し、後編では検索用のUIを作ろうと思います。

以下の環境で動作確認をしています。

  • OS … OS X El Capitan 10.11.6
  • Ruby … 2.3.3
  • Groonga … 6.1.1
  • Ruby on Rails … 5.0.0.1
  • groonga-client-rails … 0.9.4

目次

  1. groonga-client-railsとは
  2. サンプルアプリケーションについて
  3. インストール
  4. 基本機能の作成
  5. 検索機能との結び付け
    1. Groongaの起動
    2. サーチャークラスの作成
    3. モデルクラスとサーチャークラスとの結び付け
    4. データの同期
  6. おまけ - 検索して遊ぶ

groonga-client-railsとは

groonga-client-railsは、Ruby on Railsに検索用の機能を提供するgemです。バックエンドの検索エンジンにGroongaを使っています。以前、RailsにGroongaの検索機能を提供するgemとしてActiveGroongaを紹介しましたが(RailsでActiveGroongaを使う)、groonga-client-railsはこれとは別アプローチのgemです。

ActiveGroonga(が内部で使っているRroonga)はローカルのファイルシステムにGroongaのデータベースを作成し、そこにC API経由でアクセスします。このため、インストール時にCのコンパイルが走り、RailsサーバーとGroongaデータベースは同じマシン上に存在する必要がありました。スケールアウトさせる際にはGroongaデータベースの同期が課題にもなります。

groonga-client-railsが使うGroongaはリモートサーバーです。GroongaはC API経由でアクセスするほかに、HTTPサーバーとして動作し、HTTPクライアントでアクセスしてデータの投入と検索を行うこともできます(GQTPというGroonga独自のプロトコルも使えます)。この用途に向けたgroonga-clientというgemがあり、それをRailsで使いやすくしたのがgroonga-client-railsです。

groonga-client-railsでは、ActiveJobXxxJobCarrierWaveXxxUploaderのようなXxxSearcherというサーチャークラスを作って検索エンジンへのアクセスを抽象化します。サーチャーの作り方は後述しますが、モデルクラス一つと対応付けられ、モデルの(RDBMSやMongoDBなどへの)保存・更新・削除とGroongaの対応テーブルとの同期を取ったり、対応テーブルからの検索用APIを提供したりします。うまくインターフェイスを揃えればElsticsearchとかも同じクラスで扱えるかも?

サンプルアプリケーションについて

サンプルとして、SQLite3のpostsテーブルにメモを入れたり読んだりするだけの簡単なRailsアプリケーションを使って、groonga-client-railsを導入していきます。

ソースコードはこちら: KitaitiMakoto/groonga-client-rails-sample

上で「groonga-client-railsが使うGroongaはリモートサーバーです。」と書きましたが、今回はGroongaもRailsも同じローカルマシンで動かして、127.0.0.1でHTTPでアクセスさせようと思います。

なお、アプリケーション作成においてはgroonga-client-railsのテストディレクトリーを大いに参考しました。ほぼまんまです。

インストール

Groongaのインストールは公式サイトのドキュメント(2. インストール)を見ながらやってください。日本語を扱うと思うので、MeCabトークナイザーもインストールしておくといいと思います(これもドキュメントに記載があります)。

OS XであればHomebrewでインストールできます:

% brew install groonga --with-mecab
% brew install mecab-ipadic

groonga-client-railsのインストールは、いつも通りGemfileに

gem 'groonga-client-rails'

と書いて

% bundle install --path=vendor/bundle

です。

軽く確認しておきましょう。

% ./bin/rails --help | grep groonga
 groonga:sync                       # Synchronize Groonga database with model data

タスクが追加されていますね。後で実際に使ってみます。

基本機能の作成

postsを読み書きするための土台を作ります。

% ./bin/rails generate scaffold post title:string body:text
Expected string default value for '--jbuilder'; got true (boolean)
      invoke  active_record
      create    db/migrate/20161217142208_create_posts.rb
      create    app/models/post.rb
      invoke    test_unit
      create      test/models/post_test.rb
      create      test/fixtures/posts.yml
      invoke  resource_route
       route    resources :posts
      invoke  scaffold_controller
      create    app/controllers/posts_controller.rb
      invoke    erb
      create      app/views/posts
      create      app/views/posts/index.html.erb
      create      app/views/posts/edit.html.erb
      create      app/views/posts/show.html.erb
      create      app/views/posts/new.html.erb
      create      app/views/posts/_form.html.erb
      invoke    test_unit
      create      test/controllers/posts_controller_test.rb
      invoke    helper
      create      app/helpers/posts_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/posts/index.json.jbuilder
      create      app/views/posts/show.json.jbuilder
      create      app/views/posts/_post.json.jbuilder
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/posts.coffee
      invoke    scss
      create      app/assets/stylesheets/posts.scss
      invoke  scss
      create    app/assets/stylesheets/scaffolds.scss
Generate posts scaffold

追加されたファイルを見てみます。

% git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   config/routes.rb

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	app/assets/javascripts/posts.coffee
	app/assets/stylesheets/posts.scss
	app/assets/stylesheets/scaffolds.scss
	app/controllers/posts_controller.rb
	app/helpers/posts_helper.rb
	app/models/post.rb
	app/views/posts/
	config/groonga_client.yml
	db/migrate/
	test/controllers/posts_controller_test.rb
	test/fixtures/posts.yml
	test/models/post_test.rb

no changes added to commit (use "git add" and/or "git commit -a")

config/groonga_client.ymlというファイルが出来ていますね(ジェネレーターのアウトプットには出て来ないので、見逃しそうでした)。中身はこうです。

default: &default
  protocol: http
  # protocol: https
  host: 127.0.0.1
  port: 10041
  # user: alice
  # password: secret
  read_timeout: -1
  # read_timeout: 3
  backend: synchronous

development:
  <<: *default

test:
  <<: *default
  port: 20041

production:
  <<: *default
  host: 127.0.0.1
  read_timeout: 10

何となく分かると思います。10041番ポートは、GroongaのHTTPサーバーを起動する時のデフォルトポートです。

Groongaについては一先ず置いておいて、RDB(SQLite3)の作成とマイグレーションをします。

% ./bin/rails db:migrate
== 20161217142208 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0014s
== 20161217142208 CreatePosts: migrated (0.0015s) =============================

http://localhost:3000/posts にアクセスすると、アプリケーションが動いているのが確認できると思います。

入力画面入力結果

検索機能との結び付け

Groongaの起動

サンプルアプリと検索機能を結び付ける前に、まずGroongaを起動しておきましょう。

% groonga --protocol http -s -n db/groonga.db

-nは「新しくデータベースを作る」というオプションで、Groongaデータベースがまだない時に一度だけ指定します。二度目以降は不要なので

% groonga --protocol http -s db/groonga.db

として起動します。

終了させるにはCtrl+CでSIGINTを送ります(デーモンモードなどについてはドキュメントを見てください)。

db/groonga.db は指定したパス(及びそれをプリフィクスとしたパス)にデータベースファイルを作成するという意味です。Railsのdbディレクトリーを指定しています。

サーバーモードのGroongaは、検索用のAPIの他に人間用の管理インターフェイスも持っています。http://localhost:10041/ にアクセスしてみてください。

Groonga管理画面

まだテーブルの作成すらしていないので、左側の「List of table」欄には何もありません。

サーチャークラスの作成

RailsアプリとGroongaとの結び付けをするサーチャークラスを作成します。サーチャークラスはモデルクラス一つにつき一つまで作成することができます1。不要なモデルについては、当然作る必要はありません。

ここではPostモデルしかないのでこれに対応するクラスを作成するのですが、他の種類のクラスの例に漏れず、まずapp/searchers/application_searcher.rbファイルにApplicationSearcherクラスを作りましょう。

class ApplicationSearcher < Groonga::Client::Searcher
end

次にPostsSearcherクラスをapp/searchers/posts_searcher.rbに作ります。

class PostsSearcher < ApplicationSearcher
  schema.column :title, {
    type: "ShortText",
    index: true,
    index_type: :full_text_search
  }
  schema.column :body, {
    type: "Text",
    index: true,
    index_type: :full_text_search
  }
  schema.column :updated_at, {
    type: "Time",
    index: true
  }
end

何となく分かると思いますが、schema.columnメソッドの第一引数(:title:body:updated_at)は、Groonga上の検索条件や結果に使うカラムです(Groongaもカラムとレコードでデータを管理する、テーブル型のデータベースです)。このカラムに検索語が含まれているかとか、範囲内に収まっているかとかで検索することになります。これからGroongaのテーブル上に作成するカラム名なので、RDBMSのカラム名とは違っていてもいいのですが、同じにしておく方が分かりやすいでしょう。

第二引数でそのカラムの性質を定義します。typeはGroongaのデータ型をします。大体分かると思うので、このまま進めましょう。実際に使う時には公式ドキュメント(7.4. データ型)を参照してください。

indexはインデックスを張るかどうかで、RDBMSと同様、張れば検索やソートが速くなるし、張らなければストレージやメモリーを節約できます。検索エンジンを導入するという時点で、殆どのカラムにtrueを指定することになると思います。「検索結果表示には使うけど、検索条件には使わない」という付随的な情報のカラムでだけfalseにしておきます。

index_typeは、全文検索に使うカラムでだけ:full_text_searchを指定します。それ以外の場合は無くて構いません。例えば:updated_at(日時)は範囲指定に使うことはあっても全文検索には使わないので、ここでも指定されていません。

他にvectortruefalseで指定することができるようで、Groongaのベクター型に対応すると思われます。ベクター型についても詳細はドキュメントを参照してください。「投稿に複数タグを付けられる場合の、タグ」など、配列のように複数の値が入るカラムをベクターにします(この日記のタグもベクターとしてGroonga上のカラムにしています)。

本来、Groongaのテーブルではもっと細かなチューニングができますが、groonga-client-railsでは(今の所)ライブラリーのおすすめ設定を使うことになります。

モデルクラスとサーチャークラスとの結び付け

次に、Postモデルに設定を書いてPostsSearcherと結び付けます。

class Post < ApplicationRecord
  searcher = PostsSearcher.source(self)
  searcher.title = :title
  searcher.body = ->(model) {
    model.body.gsub(/<.*?>/, "")
  }
  searcher.updated_at = true
end

この設定をすることでモデルクラスのafter_createafter_updateafter_destroyフックにコールバックが登録されて、リモートのGroongaサーバーと同期が取れるようになります(なので、これらのフックさえあればActiveRecord以外のクラスでも結び付けられます)。

ここでsearcherの属性ライター(title=body=updated_at=)は、Groongaデータベース側のカラム名を意味しています。右辺には

  • Symbol
  • TrueClass
  • NilClass
  • Procなど#callメソッドを持つオブジェクト

のいずれかを指定できます。

searcher.updated_at = true

のようにTrueClassの場合は、モデルクラス(Postpostsテーブル)の、左辺と同名の属性・カラム(updated_at)の値を取得して、その値をGroongaデータベースと同期します。なので実は

searcher.title = :title

searcher.title = true

で構いません。

Symbolは、「RDBMSのカラム名とGroongaデータベースでのカラム名が違う」といった場合に活きます。

Procの場合は見れば分かると思います。#callの戻り値がGroongaデータベースの該当カラムに挿入されます。ここではHTMLタグっぽい文字列を削除しています。

最後にnilの場合は、そのカラムはGroongaデータベースに入りません。「始めは使っていたけど後から使わなくなったカラム」なんかで使うんでしょうかね。

データの同期

さて、これでRDBMS(SQLite3)とGroongaとでデータの同期が取れるようになりました—これからのデータについては。

この時点ではRailsを再起動したりしても既存のデータが同期されません。既存データを一括同期するためには、groonga-client-railsが提供するRakeタスクを使います。

% ./bin/rails groonga:sync

これを実行してGroonga管理画面(http://localhost:10041/)にアクセスし「List table」をクリックすると、postsなど今定義したテーブルが出来ているのが分かります。

既存のデータがGroongaに同期されてpostsテーブルなどが作成されている

postsの「Detail」ボタンを押すと、既存のレコードが同期されているのが分かります。

通常のモデルの操作でデータが同期されるのも見ておきましょう。Railsのscaffoldで出来たUIで、データを追加します。

Railsアプリでレコードを追加する

管理画面を見ると、Groongaにも対応するデータが入っています。

追加したデータがGroongaに同期されている

おまけ - 検索して遊ぶ

今日はここまで。次回は検索用のUIを導入しようと思います。

が、効果を実感しにくい、地道な作業ばかりだったので、少しだけ遊んでみましょう。今日の作業分で、コンソールで検索ができるようになっています。

% ./bin/rails console

検索ではPostsSearcher#searchメソッドから開始して(whereのように)検索クエリーを組み立て、result_setでGroongaへアクセスして結果を取得します。

searcher = PostsSearcher.new
searcher.search.result_set
=> #<Groonga::Client::Searcher::Select::ResultSet:0x007fd7c4b0f7e0 @response=#<Groonga::Client::Response::Select:0x007fd7c4b1d318 @command=#<Groonga::Command::Select:0x007fd7c4adf388 @command_name="select", @arguments={:table=>"posts", :match_columns=>"title, body"}, @original_format=nil, @original_source=nil, @path_prefix="/d/", @slices={}, @drilldowns=[], @labeled_drilldowns={}>, @header=[0, 1481988639.462895, 0.0001811981201171875], @body=[[[2], [["_id", "UInt32"], ["_key", "ShortText"], ["body", "Text"], ["title", "ShortText"], ["updated_at", "Time"]], [1, "Post-2", "Railsで検索機能を追加する選択肢の一つに、ActiveGroongaで、ActiveRecordのように追加する方法がありますが、これはローカルのデータベースファイルにアクセスするため、スケーラビリティ等に不安がありました。groonga-client-railsなら、リモートのGroongaサーバーにHTTPやGQTPでアクセスできるので、スケーラビリティや保守性をDBやアプリケーションサーバーと別々に考えることができます。", "groonga-client-railsについて", 1481954320.0], [2, "Post-3", "ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。", "ActiveGroongaについて", 1481955601.0]]], @n_hits=2, @records=[{"_id"=>1, "_key"=>"Post-2", "body"=>"Railsで検索機能を追加する選択肢の一つに、ActiveGroongaで、ActiveRecordのように追加する方法がありますが、これはローカルのデータベースファイルにアクセスするため、スケーラビリティ等に不安がありました。groonga-client-railsなら、リモートのGroongaサーバーにHTTPやGQTPでアクセスできるので、スケーラビリティや保守性をDBやアプリケーションサーバーと別々に考えることができます。", "title"=>"groonga-client-railsについて", "updated_at"=>2016-12-17 14:58:40 +0900}, {"_id"=>2, "_key"=>"Post-3", "body"=>"ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。", "title"=>"ActiveGroongaについて", "updated_at"=>2016-12-17 15:20:01 +0900}], @slices={}, @drilldowns=[], @raw="[[0,1481988639.462895,0.0001811981201171875],[[[2],[[\"_id\",\"UInt32\"],[\"_key\",\"ShortText\"],[\"body\",\"Text\"],[\"title\",\"ShortText\"],[\"updated_at\",\"Time\"]],[1,\"Post-2\",\"Railsで検索機能を追加する選択肢の一つに、ActiveGroongaで、ActiveRecordのように追加する方法がありますが、これはローカルのデータベースファイルにアクセスするため、スケーラビリティ等に不安がありました。groonga-client-railsなら、リモートのGroongaサーバーにHTTPやGQTPでアクセスできるので、スケーラビリティや保守性をDBやアプリケーションサーバーと別々に考えることができます。\",\"groonga-client-railsについて\",1481954320.0],[2,\"Post-3\",\"ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。\",\"ActiveGroongaについて\",1481955601.0]]]]">>
searcher.search.query('Qiita').result_set
=> #<Groonga::Client::Searcher::Select::ResultSet:0x007fd7c66d14b0 @response=#<Groonga::Client::Response::Select:0x007fd7c66d1f00 @command=#<Groonga::Command::Select:0x007fd7c40cb400 @command_name="select", @arguments={:table=>"posts", :match_columns=>"title, body", :query=>"Qiita"}, @original_format=nil, @original_source=nil, @path_prefix="/d/", @slices={}, @drilldowns=[], @labeled_drilldowns={}>, @header=[0, 1481988733.769218, 0.1913049221038818], @body=[[[1], [["_id", "UInt32"], ["_key", "ShortText"], ["body", "Text"], ["title", "ShortText"], ["updated_at", "Time"]], [2, "Post-3", "ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。", "ActiveGroongaについて", 1481955601.0]]], @n_hits=1, @records=[{"_id"=>2, "_key"=>"Post-3", "body"=>"ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。", "title"=>"ActiveGroongaについて", "updated_at"=>2016-12-17 15:20:01 +0900}], @slices={}, @drilldowns=[], @raw="[[0,1481988733.769218,0.1913049221038818],[[[1],[[\"_id\",\"UInt32\"],[\"_key\",\"ShortText\"],[\"body\",\"Text\"],[\"title\",\"ShortText\"],[\"updated_at\",\"Time\"]],[2,\"Post-3\",\"ActiveGroongaは「SQLite3でActiveRecordを使う」のに似ています。詳しくは過去のQiitaの記事などを見てください。\",\"ActiveGroongaについて\",1481955601.0]]]]">>

後編ではこれを使って検索UIを組み立てます。

追記。

後編を書きました:Railsでの検索機能にgroonga-client-railsを使う(後編)

  1. 正確には二つ以上作れますが、あまり意味がないと思います。 

Polymerを2.0 Previewに上げた

この日記ではPolymerを使っているのだけど、1.xから2.0 Previewに上げた。その時にやったことやはまったことなど。

目次

  1. 前提
    1. 参考ドキュメント
  2. ライブラリーのアップグレード
    1. Paper Elements
  3. コードの修正
    1. webcomponentsjsなど
    2. paper-header-panel
    3. dom-module
    4. 動的に挿入される要素のスタイリング
    5. paper-cardとpaper-buttonのエラー

前提

まず、Polymerを使っていると言うけど、Webコンポーネント用ライブラリー(フレームワーク)としての他、Googleが提唱するUIデザインであるマテリアルデザイン用のコンポーネント(Paper Elements)なども使っていた。そのうちPaper Cardではまったりしたのでまずここで断っておく。

また、自分で作ったコンポーネントは一つだけで、フッターに置いてある検索ボックス用のblog-searchという要素のみ。これの書き換えについても触れる。

参考ドキュメント

https://www.polymer-project.org/2.0/docs/about_20
Polymer 2.0になって変わったことの概要。
https://www.polymer-project.org/2.0/docs/upgrade
Polymer 1.xから2.0へアップグレードする時のガイド。
https://codelabs.developers.google.com/codelabs/polymer-2-carousel/
Polymer 2.0でコンポーネントを作るチュートリアル。

ライブラリーのアップグレード

https://github.com/KitaitiMakoto/apehuci/commit/8bd0c722cdeea984948ee4ebc89f5a3dfd5c74ca

Paper Elements

概要ページのインストールの項目にあるように、Polymerは

bower install --save Polymer/polymer#2.0-preview

でアップグレードできる。 この時に選択肢が出てくるが、Polymerは2.0、webcomponentsjsは1.0を選んでおけばよい。

各コンポーネントをインストールするにも同様に、各コンポーネントの2.0-previewブランチをインストールすればいい。これまではpaper-elementsという全体をまとめたコンポーネントがあって、それをインストールするだけで全Paper Elementがインストールできたのだけど、なぜか2.0-previewブランチはないので、自分が使っている物を個別にインストール必要があった。めんどくさい(イシューは上がってる、と思ったのだけど、今探したら無いな、幻か知ら)。

あと、

bower install --save paper-input#2.0-preview

みたいに、既存の物を2.0-previewにアップグレードすればいいコンポーネントと、

bower install --save PaperElements/paper-card#2.0-preview

みたいに、GitHubのオーガニゼーションも明示しないといけないコンポーネントがある。Polymer/paper-cardを見てみて、2.0-previewブランチがなければPolymerElements/paper-cardを見る、ということをしなくてはならず、これもめんどくさい。

webcomponentsjsなど

webcomponentsjsなどは、パッケージその物はPolymerアップグレードの時に一緒に自動でアップグレードされるのだけど、Polymerが使うファイルが変わっていることがある。例えばwebcomponents.min.jsというファイルを使っていたのがwebcomponents-lite.jsに変わっていたりするので、コンソールのエラーメッセージを見ながら参照先を変えていく。

コードの修正

アップグレードに伴って、いくつか既存のコードを修正する必要があった。大体はアップグレードガイドに従えばいいが、幾つか嵌った所。

paper-header-panel

コンポーネントをアップグレードしたら画面が真っ白になってしまった。

この日記の大部分はpaper-header-panelの中に入っているのだけど、これの使い方が変わっていて、そのせいだった。子要素を配置するためのslot(従来はcontent)にname属性で名前が付くようになって、ユーザー(=僕)が配置する子要素にも、対応する名前を付けておかなければいけなくなっていた。一応、paper-header-panelのソースコードコメントにはそのことが書いてある: paper-header-panel.html#L32-L37

これに合わせてテンプレートを修正した(slot属性を足しているのに注目):

Before:

<paper-header-panel mode=waterfall-tall>
  <paper-toolbar>
    <!-- ... -->
  </paper-toolbar>
  <main>
    <!-- ... -->
  </main>
  <footer>
    <!-- ... -->
  </footer>
</paper-header-panel>

After:

<paper-header-panel mode=waterfall-tall>
  <paper-toolbar slot=header>
    <!-- ... -->
  </paper-toolbar>
  <main slot=content>
    <!-- ... -->
  </main>
  <footer slot=content>
    <!-- ... -->
  </footer>
</paper-header-panel>

dom-module

カスタム要素を定義するのに使うdom-moduleという要素(メタ要素?)をPolymerは提供している。カスタム要素の定義は本来JavaScriptでやるのだけど、dom-moduleを使うとHTMLを使ってある程度宣言的にできて、僕はこのアプローチを気に入っている。

自分で作ったカスタム要素であるblog-searchはこのdom-moduleで定義しているのだけど、その時に、dom-moduleis属性を付けていた。

<dom-module is=blog-search id=blog-search>
  <!-- ... -->
</dom-module>

isというのは、その要素(ここではdom-module)を更に拡張したバージョンの要素を使う、という時に使う物で、例えばオートコンプリート機能を追加した検索インプットを作って

<input type=search is=auto-complete>

などとして使う(ちなみにブラウザーベンダー間で要不要の意見が割れているらしいので、この仕様はなくなるかも)。

dom-moduleを使う時にはis属性は不要なので、ここでは使い方が間違っているのだけど、問題なく動いていた。単に無視されていたのだろうと思う。2.0 Previewに上げても同様に動いていたのだけど、Chromeで全く動かない(blog-searchの定義自体が失敗している)ことに気が付いた。isを外すと動いたので、ChromeがネイティブでWebコンポーネントに対応しているのと関係していそうだが調べていない(他のブラウザーでは、現在のところ、Webコンポーネントの多くの機能がpolyfillやshimで動いている)。

追記

isは、Polymer 1.xの頃には必要(正確にはnameまたはisが必要)だったので、isを使っていたのは正しかった。単に、2.0にする時に外し、idを付与する必要がるということだった。

動的に挿入される要素のスタイリング

この日記の検索機能では、XMLHttpRequestgroonga-httpd (NginxのGroongaモジュール)から検索結果を取得し、DOMツリー内に挿入している(参考:日記に検索機能をつけた)。Groongaが検索キーワードにkeywordというクラスを付けてくれるので、赤い太字になるようスタイリングしていた。

.keyword {
  color: red;
  font-weight: bolder;
}

Chromeでスタイリングされなくなった。これは、ChromeがShadow DOMを実装していて、Polymerもそのネイティブ実装を活かしている、つまり外部でのスタイル宣言がShadow DOM内に影響を及ぼしていないからだと思う。Webコンポーネントの大きな特長がこのカプセル化なのでむしろ歓迎すべき変更。というわけで、喜んで、Shadow DOMに閉じたスタイリングに変更した。

<dom-module id=blog-search>
  <template>
    <style>
      /* ... */

      .keyword {
        color: var(--keyword-color, red);
        font-weight: var(--keyword-font-weight, bolder);
      }
    </style>
  </template>
  <!-- ... -->
</dom-module>

すると、今度はFirefoxでスタイルが外れてしまった。

FirefoxではShadow DOMが実装されておらずshimを使っている。具体的にはHTMLのstyle要素に、Shadow DOM内に閉じているのとある程度同等のスタイル宣言をして、それを持ってスコープ付きのスタイリングとしている。

<html>
  <head>
    <!-- ... -->
    <style>
      /* ... */

      .keyword.blog-search {
        color: var(--keyword-color, red);
        font-weight: var(--keyword-font-weight, bolder);
      }
    </style>
    <!-- ... -->

このスタイル宣言を活かすために行われるのが、「要素名と同じクラスを追加する」という処理だ。

 <blog-search>
  <!-- ... -->
  <paper-input ... class="style-scope blog-search" ...>
    <!-- ... -->
  </paper-input>
  <!-- ... -->
</blog-search>

classblog-searchが追加されている)

元々HTML内に書かれているタグであれば、このようにPolymerが自動でクラスを追加してくれるのだが、検索結果のように、動的に挿入される要素ではそうはいかない。仕方がないので、挿入する時に自分でクラスを追加するようにした。

// ...
snippetHtml: article[3].join("<br>").
  replace('<span class="keyword">', '<span class="keyword style-scope blog-search">'),
// ...

文字列処理なので乱暴だが、この場合はセキュリティホールにはならない(はず)。

「Firefoxはカスタムプロパティには対応しているんだからそっち使ってくれればいいんだけどなあ」とちょっと釈然としないが、まあ、過渡期ということで仕方ないのだろう。

paper-cardとpaper-buttonのエラー

paper-cardpaper-buttonが(例によって)Chromeでだけ動かない。

Uncaught DOMException: Failed to construct 'CustomElement': The result must not have attributes

というエラーが出てしまっている。

ググるとStack Overflowがヒットして(Failed to execute 'createElement' on 'Document': The result must not have children)、見るとcreated(新しくはconstructor)コールバックの使い方が、新しいCustom Elements仕様としては不正なようだ。しようがないのでフォークしてここをreadyコールバックに書き換えて対応とした。イシューも上げた(2.0-preview throw an error Uncaught DOMException: Failed to construct 'CustomElement': The result must not have attributes #90)。

readyはCustom Elements標準にはなく、Polymer独自のコールバック。1.xの時代から存在していて、2.0でも残るようなので(Lifecycle changes)、blog-searchでも使っていたがそのままにしてある。

追記

これはpaper-cardpaper-buttonではなく、vulcanizeの問題だということが分かった(PolymerElements/paper-card/issues/90)。どうもJavaScriptのclass構文で定義した物ををvulcanizeがうまく扱えないということみたい。なので2.0でclass構文使う際には注意されたい。


こんなところかな。あとは公式のアップグレードガイドに従えばいいことばかりだった。

コミットログを見るとコードレベルで何をやっているかが分かると思う: https://github.com/KitaitiMakoto/apehuci/commits/master

アドカレ #コルクおすすめ2016 二日目 おすすめファンタジーまんが五選

これはアドベントカレンダー「年末年始おすすめ作品 BY.CORK Advent Calendar 2016」の二日目です。

本当は二日目の僕が書く必要はないと思うのですが、初日がまさかのツイッター投稿(https://twitter.com/boogie_go/status/804517137784008704)だったので、「アドベントカレンダーとは何か」ということも一緒にご説明しますね。

アドベントカレンダーとは

アドベントカレンダーは、元々は、クリスマスまでの日数を数える日めくり(?)カレンダーだそうです(参考:ウィキペディア)。

ただ、ここでのアドベントカレンダーは日本の(主にウェブ・スマホアプリの)エンジニアの慣習を指していて、「12月1日からスタートし、クリスマスまで、ある話題を決めて、それについて持ち回りでブログ記事などを書いていく」という物です。一箇所に色んなサービスや会社の知見が集まるので読む側としては毎日楽しみだし、後から見ても、一通り情報が手に入るので便利です。

これを、僕が所属するコルクで、(技術情報でなく)おすすめ作品でやろう、ということになりました。経緯を把握していないんだけど、たぶん、「コルクはクリエイターのエージェンシーだが、ITにも力を入れている(入れていきたい)」というところからIT業界のノウハウとか取り入れようとしているので、そこからの派生なのかな。内輪褒めになっちゃうけど、みんな僕よりもずっと色々な物を見ているし、さすが編集担当なんかはそれを言語化するのもうまいので、僕自身が楽しみ。

僕の担当分「おすすめファンタジーまんが五選」は次の通り。

諸星大二郎「妖怪ハンター」シリーズ

考古学者とされる稗田礼二郎が、フィールドワークで訪れた先々で、色々な怪異を体験するという話。シリーズ名に「ハンター」が含まれていますが、特にハントはしません。

怪異の元となっているのが宗教。舞台が日本ではあるのですが、特に土地土地の信仰を重視しているところが面白い。「神道はこういう宗教で、こういう神様がいて……」「仏教のこの宗派はこういう教えなので……」みたいなことは焦点ではなく、その上で、「この土地では古来の巨石への信仰と仏教がこう結び付いて……」など「生きた信仰」を描き出し、その可視化として、化け物が出てきます。(そして倒さず、去るのを待ちます……。)

それまでは大体ライトファンタジー、それも異世界ファンタジーばかり読んでいたので、ファンタジー観が変わりました。と同時に、宗教や信仰というのは、本来(?)小さなコミュニティの物であって、現代でも適応して形を変えていく物なんだ、と、世界観まで変わる体験でした。諸星大二郎を読んでから、神社を巡るのが楽しくなりました。

五十嵐大介『はなしっぱなし』

これも現代の日本を舞台にしたファンタジーですが(いわゆる現代ファンタジーとは違う)、よりライトと言うか、「事件」ではなくて、日常的な「気が付かないけれど起こっていること」というのを描いています。話に山や谷があまりないのですが、そのファンタジー世界に浸ることができる短編集です。

五十嵐大介は、読むと感性の一部を譲り受けることができるような作家で、読んだ後しばらくは日常の見方が変わる、本当に彼が見ているように見える気分になるようなパワーを持った人だと思います。理屈先行の僕はその減退が割と早いので、一時期は毎日『はなしっぱなし』を読んでファンタジー的感覚を補充していました。

ニール ゲイマン「サンドマン」シリーズ

どう紹介したものか……。

主人公は「サンドマン」「モルフェウス」などの呼び名を持つ超常の存在ドリーム。文字通り夢(そして眠り)を司る存在で、宇宙の始まりから存在し、人間が滅んでも(夢を見る知性体がいる限り)あり続ける存在です(同様の存在が他にも何人がいて、その一つ、ドリームの姉であるデス(もちろん、死を司る)とは仲がいいのが読んでいて感じられます)。例えば、物語の冒頭で、魔術師に彼が捕えられてしまうのですが、そうすると世界の眠りがめちゃくちゃになり、ある人は永久に夢を見続けることに、また別の人は眠ることができなくなったりしてしまうほどで、本当に夢その物なのです。

まんがはその彼のアドベンチャーを描いた物。始め二巻は捕えられてから出るまでと、捉えられている間に奪われてしまった三つのアイテムを見付け出すまで。次の二巻は、「渦」と呼ぶ、夢に関するおかしな現象が、アメリカのある少女を中心に(というか、彼女が渦その物となって)国中を巻き込む規模で起こり、その解決まで。第五巻は完全に独立した短編集で、猫が夢を見る話などがある。そして、六巻以降は、残念ながら邦訳されていない。

上手く感想を言葉にできないのだけど、伏線の扱いが上手とかも技術的な点もありますが、とにかくその発想と、夢に対する解釈の深さに圧倒されっぱなしのまんがでした。

萱島雄太『西遊少女』

[disclosure]僕はかつて、『西遊少女』が連載されていたパブーの開発をしていました。

有名な『西遊記』をモチーフに、少女になった三蔵、沙悟浄、八戒、悟空が天竺を目指す……向かう話。主人公の三蔵はそもそも天竺に行きたくなくて、何とか離脱しようとするところを他の連中が頑張って連れ戻すスラップスティックです。パブーでは非公開になっており、電脳マヴォで公開されています。

当時(2012年)としては非常に珍しい、ウェブの縦スクロールを意識した、というか、その特徴を使ってのびのびと遊んでいるまんが。

単に「スクロールを活かす」というのだと「あーはいはい『実験的』まんがね、はいはい」となりがちだと思うんですが、『西遊少女』はお話自体が面白いので、そういう「技術に偏った奴らが何かやってる」というネガティブイメージを生まないという意味でもおすすめです。パブーの頃は第九話で連載がストップしてしまったのですが、マヴォでは最後まで描かれるんだろうか……。

曽田正人『テンプリズム』

[disclosure]僕は現在、『テンプリズム』の編集を担当しているコルクに所属しています。

曽田正人初めての異世界ファンタジー。伝説の力を有するツナシが、魔力と呼ばれる力を持ち、どんどん国を拡大して世界を侵略していっているグゥの国と戦う物語。

曽田正人と言えば僕は『昴』から入り、それ以外の作品も読んでいった、という流れなのですが、どれも共通するのは、主人公が天才であること。特に、自分の力で成長することのできる天才。ファンタジーになってもそれは健在なのですが、変わったのが天才性が目に見えるようになったことです。

作中で「オロメテオールの力」と呼ばれるその力はツナシの眼に宿り、自分の意図ではその発動を制御できない。思わぬ時にその力が使えたり、望んだ時に使えなかったりする。こう書くと分かりやすいですが、結構、これまでの主人公の天才性と似ていますね。そして、ここが、さすが曽田正人という感じなんですが、「目に見える」「名前がある」という、「自分の外にある物である」と認識する条件が満たされているので、天才 vs 天才性という軸で主人公を眺める楽しみも出てきています。その上で、「それでも自分なのだし、責任を引き受ける」という決断を描くまでの成長があるところが、ファンタジーにして生まれた曽田正人の新しさだと感じました。