アトラシエの開発ブログ

株式会社アトラシエのブログです

rails Gem入れすぎ問題について。負債化するGemとは

こんにちは。tkotです。今回はrailsのgemについて。

オススメのgemについて紹介する記事は頻繁に取り上げられるのですが、その反対に使うと弊害が出てしまうgemや、gemを多用することのデメリットについて解説されているものはなかなか無いように思います。 今回やや古くなったRailsアプリケーションの保守を行った際に感じた問題点について紹介したいと思います。

サンプルとなる実際のGemfile

Rails3.2.xx, ruby2.0.0-pxxxで動作するアプリケーションです。これを古いと感じるか新しいと感じるかは人によると思いますが、よくあるRails4アップデート前のGemfile構成ではないでしょうか。

gem 'haml-rails'
gem 'enumerize'
gem 'devise'
gem 'mysql2'
gem 'bcrypt-ruby', '~> 3.0.0'
gem 'omniauth'
gem 'omniauth-facebook'
gem 'omniauth-twitter'
gem 'omniauth-mixi'
gem 'omniauth-yahoojp'
gem 'omniauth-google-oauth2'
gem 'simple_form'
gem 'kaminari'
gem 'execjs'
gem 'therubyracer'
gem 'attr_encrypted'
gem 'comma'
gem 'rmagick'
gem 'carrierwave'
gem 'moji'
gem 'net-sftp'
gem 'whenever', require: false
gem 'active_decorator'
gem 'friendly_id'
gem 'legato'
gem 'wicked_pdf'
gem 'wkhtmltopdf-binary'
gem 'exception_notification'
gem 'exception_notification-rake'
gem 'letter_opener'
gem 'dalli'
gem 'double-bag-ftps'
gem 'holiday_japan'
gem 'multi_db'
gem 'ruby-duration'
gem 'gravtastic'
gem 'turnout'
gem 'roadie'
gem 'twitter'

group :assets do
  gem 'sass-rails',   '~> 3.2.3'
  gem 'coffee-rails', '~> 3.2.1'
  gem 'uglifier', '>= 1.0.3'
  gem 'compass-rails'
  gem 'underscore-rails'
  gem 'turbo-sprockets-rails3'
  gem 'font-awesome-rails', tag: 'v3.2.1.3'
end

group :test do
  gem 'capybara', '2.0.3'
  gem 'capybara-email', '2.0.3'
  gem 'factory_girl_rails'
  gem 'guard'
  gem 'guard-spring'
  gem 'guard-rspec'
  gem 'spring', require: false
  gem 'simplecov', require: false
  gem 'simplecov-rcov', require: false
  gem 'ci_reporter'
  gem 'database_cleaner'
  gem 'timecop'
  gem 'rack_session_access'
  gem 'mimemagic'
  gem 'rspec-rails'
  gem 'launchy'
  gem 'poltergeist'
  gem 'rspec-console'
  gem 'fuubar'
end

group :development do
  gem 'rails-erd'
  gem 'brakeman', require: false
  gem 'rails_best_practices', require: false
  gem 'erb2haml'
  gem 'thin'
  gem 'better_errors'
  gem 'binding_of_caller'
  gem 'capistrano', '~> 2.15'
  gem 'capistrano_colors'
  gem 'quiet_assets'
  gem 'rack-mini-profiler'
  gem 'rails-footnotes'
  gem 'letter_opener_web'
  gem 'shout-bot'
end

group :test, :development do
  gem 'pry-rails'
  gem 'jasmine'
  gem 'jasmine-headless-webkit'
end

group :darwin do
  gem 'rb-fsevent'
end
gem 'jquery-rails'

情報がない状況でこれを保守していくのは相当大変です。そこで上をサンプルにどのようなGemが負債化しやすいか, Gemを負債化させないベストプラクティスについて考えてみます。

負債化の臭い

巨大である 危険度★★☆

この中で私が個人的に最も忌避すべきと思うのがDeviseです。DeviseはRailsにサインアップ・ログイン機能を簡単に実装してくれるエンジンです。 Deviseは次のようなケースで非常に面倒です。

  • ログインフォームの表示やサインアップ時に追加で処理を走らせたい時、controllerのカスタマイズが難しい
  • Userモデルの定義を少しカスタマイズしたい。例えば「(メールもしくはユーザ名)とパスワードが必須」など。
  • 複数のログイン処理をもたせたい。UserのログインとAdministratorのログイン。
  • Userのログインでも、@user.user_typeによって処理を分岐させたい。

要するに少し混み合ったものを作ろうとするだけでとたんに面倒になります。Deviseのコードを読んで内容を理解しながらメソッドをオーバーライドしないといけないようになると最悪です。 大きい機能を持っている、ビジネスロジックに深く関わるGemの使用は避けましょう。

スターが少ない 危険度★☆☆

githubのスターが少ないと開発が止まるリスクがあります。マニアックなgemの使用は慎みましょう。例えばci-reporterとか止まってますね。

コア部分を拡張している 危険度★★★

例えばactive-record-xxxのような名前の、ActiveRecordに機能を拡張するようなgemです。

上のGemfileだと

  • simple_form
  • friendly_id

あたりが該当します。

かつてSqueelというgemがあり、

github.com

Article.where('created_at >= ?', 2.weeks.ago)

と書くところを

Article.where { created_at >= 2.weeks.ago }

こんな感じにActiveRecordの検索を直感的に書けるgemがありました。 最初この機能で作られたコードを読んだ時、「ActiveRecordのメソッドにブロック渡せたのか。知らなかったな。」と思ったんですが、コア部分を拡張して上のような機能を持たせています。 確かにArelなどで無理やりに書かれたクエリよりもわかりやすいのですが、コア部分の拡張はなるべく避けなければいけないものですし、squeelはrails5への対応の前後に開発が止まったようです。したがってsqueelで書いたコードをすべてネイティブのコードに直さないとrails5にあげられなくなっているかと思います。

標準を変更する 危険度★☆☆

上でいうと

  • haml-rails
  • rspec

あたりです。これは賛否両論だと思いますが、確かにこのコードが書かれた2013年ごろはhaml・rspecの支持派が強かったと思います。 しかし今ではslimのほうが人気ですし、rspecも以下のような議論がありrails標準のテストライブラリの良さが再評価されています。

togetter.com

もっといえばフロントエンドの関係で最近はReact・Vue.jsなどでテンプレートを描画することも多くなっています。このときにslimで書かれたコードにReactのコンポーネントを混在させたり、Rails側のviewのコードの一部をReactのコンポーネントに移そうと思った時、ピュアhtmlでないslimは見通しが悪くなります。

私はerb => haml => slimと使ってきましたが、最近は再びerbを使い、emmetなどを使ってエディタ側でタイピングの煩わしさを解消するようにしてます。

asset系のgem 危険度★★☆

bootstrap-railsのような、css・JavaScriptを提供するgemです。 rails5.1からはnpmが使えるようになるそうです。rubyのライブラリにはgemを、フロントエンドライブラリはnpmを使うという住み分けになるでしょう。

標準ライブラリでできることをgemでやる 危険度★☆☆

たとえばこういうの。

github.com

普通にNet::HTTPでできます。

負債gemをなくすには?

gem使わないでできないか考えよう

ちょっとしたことにgemを使うのは慎むべきでしょう。少し前ですが、JavaScript側のgem的な存在であるnpmで、left-padというパッケージが削除され混乱がありました。

postd.cc

依存関係は、その名の通り、コードが動くために必要になってしまうものです。より多くの依存関係があればあるほど、より多くの欠陥が生まれ得ます。エラーが生じる可能性が増えることは言うまでもありません。あなたが日々用いている依存パッケージに関して、その関数を書いたプログラマについて精査したことがありますか?

ここの内容に私も同意です。

定期的にアップデートする

gemは

$ bundle update

でアップデートできます。このような取り組みもあります。

qiita.com

どこに使っているか、gemにコメントをつけていく

使っているのか使ってないのかすぐわからないのでとりあえず残しているというケースもよくあります。そのgemが使われているかはgithubでソースコードを確認し、クラス名などでgrepすればわかるのですが、gemfileのすべてのgemにそれをやるにはけっこうな時間が必要です。

開発者用のドキュメントを作成し、Gemfileの中のgemがどういう用途でどこで使われているか、メモしておくのは有効です。

f:id:tkot:20161218025847p:plain

弊社では上のようなドキュメントをmarkdownで作成し、RAILS_ROOT/docs/gems.md として保存しています。

目先の実装コストよりも中長期の視点を。

開発者・ウェブ関係者必携。入れておきたいchromeアドオン

Chromeアドオンで開発を効率化したい

こんにちは、tkotです。私は普段のブラウジングや開発でMacのGoogle Chromeを使っています。最近はSafariのほうが電源の持ちが良いと聞き試していたんですが、いつの間にか戻っていました。

最近はクラッシュしたり妙に重くなったりして以前に比べて不満はあるものの、やはり機能面では一番充実していると思います。そこで私が使っているオススメアドオンを紹介します。

Meta SEO Inspector おすすめ度 ★★☆☆

chrome.google.com

title要素やmetaタグの有無、og:imageの設定が適切にできているかが確認できます。エンジニアやフロントエンドのコーディングを行う方、SEO担当の方にオススメです。

f:id:attracie:20161217145924p:plain

適切でないものが見つかればアイコンにエラーが表示されます。

HTML5 Outliner おすすめ度 ★★★☆

chrome.google.com

article要素やsection要素、h1~h6の見出し要素がどのようにマークアップされているかがひと目で分かります。

f:id:attracie:20161217150804p:plain

SEO上見出し構成は重要なので、ライターさんやSEO担当者、エンジニアは入れておくべきです。

EditThisCookie おすすめ度 ★☆☆☆

chrome.google.com

f:id:attracie:20161217150913p:plain

cookieを操作するのに使っています。ただ、最近のchromeは標準でcookie操作はできるようなので、インストールせず標準機能で足りるかも。

SimilarWeb おすすめ度 ★★★★

chrome.google.com

f:id:attracie:20161217151150p:plain

訪問したサイトがどのくらいのトラフィックか、similarwebのデータを使ってわかるようになります。 このアドオンがすごいのはクリックしなくてもサイトを読み込んで数秒待つとアイコンの満タン度でだいたいのアクセス数がわかるところです。 アドオンを導入している人がブラウジング中、ずっと情報を送ってるわけなのでどれだけのトラフィックをさばいているのか気になります。

このアドオンを入れていると測定値の取得以外に、常時閲覧情報が送られているのだと思います。公開前のサイトでほとんど自分しかアクセスしていなくても1Kとか2Kという数字になります。おそらくアドオンを入れてる人が訪問すると+100くらいのカウントになるのではないでしょうか。

Google Analytics Debugger おすすめ度 ★☆☆☆

chrome.google.com

f:id:attracie:20161217151738p:plain

Google Analyticsのデータ送信がうまくいってるかを確認できます。基本的にはそれだけなんですが、例えば以下のようなコードで

<a onclick="ga('send', 'event', 'cv', 'cv1', 'shiryo');">資料請求</a>

クリックなどをトラッキングする際、正しくデータが送れているかデバッグできて便利です。

React Developer Tools おすすめ度 ★☆☆☆

chrome.google.com

Reactを提供しているFacebookの公式アドオン。コンポーネントの配置やプロパティがビジュアルでわかるのでReactを使うディベロッパーには必須。

Vue.js devtools おすすめ度 ★☆☆☆

chrome.google.com

f:id:attracie:20161217153416p:plain

上のアドオンのVue.js版。こちらも公式。

iChrome おすすめ度 ★★★★

igoogleが終了し、my yahooも終了し、というタイミングで見つけたのがこのアドオンです。

chrome.google.com

f:id:attracie:20161217154844p:plain

ホーム画面にこのようにGoogle Driveやカレンダー、gmail、ニュースを並べることができます。

ポップアップブロッカー おすすめ度 ★★★☆

chrome.google.com

クリックすると広告に飛ばされるような動作をブロックしてくれます。

Block Yourself from Analytics おすすめ度 ★★★☆

chrome.google.com

Google Analyticsに自分のトラッキングを送らないように設定できます。 開発者が本番サイトを何度も利用するケースはよくあります。アクセス数が少ない場合、自分自身の滞在時間が非常に長かったりすると平均値などの数字が狂ってしまいますので、ブロックしましょう。

Vimium おすすめ度 ★★☆☆

chrome.google.com

vimのキーバインドがchromeで使えるようになります。 うれしいのはリンクにこのようにショートカットを表示できることです。

f:id:attracie:20161217161758p:plain

ウェブサイトでキーボードのfを押すと黄色いコードが出現するので、あとはクリックしたいリンクの通りコマンドを入力すると移動できます。マウスを使うシーンがぐっと減るので操作が早くなります。

逆にいらないアドオンは?

私の場合、iChromeのホームでカレンダーやメール、ニュースをすべてチェックしています。なので個別にgmailアドオンやgoogle driveアドオンは使っていません。 また、gmailはChromeにプッシュ通知が来るように設定できます。

あとはパスワード管理系のアドオンですが、これはセキュリティの観点から使用を見合わせています。

Ruby初心者にオススメ。Array・Hashの練習問題

tkotです。

弊社ではRuby・Railsを覚えたい初心者の方向けに学習用のプログラムを用意しています。基本的にはRailsで動くものを作っていく中で細かい文法やプログラミングのルールを覚えていこうという方針でやっています。

Railsはすぐ動くものができるのでとっつきやすいのですが、その分Rubyの基礎的な文法がおろそかになってしまいます。とくにArray・Hashの処理が鬼門です。

そこで次の練習問題をやってもらうことにしました。

目的

  • Array・Hashのデータ処理に慣れる。
  • Enumerableモジュールについて知る。

Q1

問題

{ a: 1, b: 2, c: 3, d: 4 }
# 上のHashから
[:a, :b, :c, :d]
# という結果を得てください。

解答

1. eachを使う方法

hash = { a: 1, b: 2, c: 3, d: 4 }
result = []
hash.each do |k,v|
  result << k
end
hash

新しい集合を返すものは上のように返したい集合を先に定義し、それを加工した上で返すという考え方ができればほとんどのケースで対応できます。

2. keysを使う方法

{ a: 1, b: 2, c: 3, d: 4 }.keys

といっても、Hashのキーだけを取り出したいなどはよくあるケースなので、上のようなショートカットメソッドがあることを知っておきましょう。valueだけ取り出したければhash.valuesで。

Q2

問題

[ 1, 2, 3, 4]
# という配列を
[ [1], [2], [3], [4] ]
# という配列に変換

解答

1.eachを使う方法

result = []
[1,2,3,4].each do |n|
  result << [n]
end
result

2.mapを使う方法

[1,2,3,4].map { |n| [n] }

集合をある変換法則に従って別の集合に変換する時はmapというメソッドが使えます。このときもとの集合と結果となる集合の要素数は同じになります。 もとの集合と結果の集合の要素数が同じ場合はmapが使えないか検討してみるといいでしょう。

また、初心者だと

[1,2,3,4].each { |n| [n] }

ではなぜだめなんだろうという疑問があると思います。確かにeachmapも要素をループさせるので似ているのですが、map{ ... }の中身の最終的な結果を保持して、その結果群を返します。eachのほうはそういう機能はありません。 これは重要なことなので理解しましょう。

Q3

問題

[ [:a, 1], [:b, 2], [:c, 3], [:d, 4] ]
# という配列を
{ a: 1, b: 2, c: 3, d: 4 }
# に変換

解答

eachを使う場合

hash = {}
[ [:a, 1], [:b, 2], [:c, 3], [:d, 4] ].each do |ary|
  key = ary[0]
  value = ary[1]
  hash[key] = value
end
hash

each_with_objectを使う場合

[ [:a, 1], [:b, 2], [:c, 3], [:d, 4] ].each_with_object({}) do |ary, hash|
  key = ary[0]
  value = ary[1]
  hash[key] = value
end

each_with_objectは概念が難しいのですが、ループを回しながら段階的にブロックの第二引数を加工して、新しい変数を返すメソッドです。 初期値はeach_with_objectの引数である{}から始まり、はじめのループではブロックローカル変数の ary, hashにはそれぞれ [:a, 1], {}が入ります。 ループでhashを加工するので、次のループを開始すると ary, hashには [:b, 2], { a: 1 }が入っています。 最終的に加工したhashが戻り値になります。

Hashクラスから直接生成する

実はこれでもいけたりします。

Hash[[ [:a, 1], [:b, 2], [:c, 3], [:d, 4] ]]

Q4

問題

[1,2,3]
# という配列を
[2,4,6]
# に変換

解答

1. eachを使う場合

result = []
[1,2,3].each do |n|
  result << n * 2
end
result

2.mapを使う場合

[1,2,3].map { |n| n*2 }

Q5

問題

[:a, :b, :c]
# を
[ { a: 1 }, { b: 2 }, { c: 3 } ]
# に変換する

解答

1. eachを使う

結果の配列にあるvalueである1,2,3をどのようにカウントするかがポイントです。

n = 1
result = []
[:a, :b, :c].each do |key|
  hash = {}
  hash[key] = n
  n += 1
end
hash

あとは変数をハッシュのキーにするにはどうすればいいかという点ですが、上のような方法でもいけますし、

{ :"#{key}" => n }

でも可能です。

2. each_with_indexとmapを使う

[:a, :b, :c].each_with_index.map { |n, idx| { :"#{n}" => idx + 1 } }

まずループのカウンタはeach_with_indexというメソッドで取得可能です。こうするとブロックの第二引数にはループカウンタが入るようになります。 で、普通はeach_with_indexの直後にブロックを渡すんですが、これをさらにmapでチェーンすることが可能です。こうすればmapでカウンタが使えるようになります。

Q6

問題

{ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }
# を
[ :b, :d, :f ]
# に変換する(valueが偶数のkeyだけ返す)

解答

1. eachを使う場合

result  = []
{ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }.each do |key,value|
  if value % 2 == 0
    result << key
  end
end
result

2. selectを使う場合

{ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }.select { |_,v| v%2 == 0 }.keys

selectは集合の中から条件を満たしたものだけを選択して新しい集合を返してくれます。mapと違って新しく返ってくる結果は元のものとは要素数が一致するとは限りません。 これで新しいハッシュを返したあとに、Q1のようにキーだけにすればいいわけです。 ちなみにブロックローカル変数を渡す時、ブロックの中で使用しないものは_にしておくのがちょっとした慣例です。

Q7

問題

[1,2,3,4,5]
# を
120
# に変換(すべてかけ合わせる)

解答

1.eachを使う場合

result = 1
[1,2,3,4,5].each do |n|
  result = result * n
end
result

2.injectを使う場合

[1,2,3,4,5].inject(&:*)

injectは集合から一つの結果を返してくれます。 &:*という見慣れない文法が出てきましたが、これはブロックを省略して書く特殊な書き方になります。省略しない場合は

[1,2,3,4,5].inject { |n, result| result * n }

となります。これ、each_with_objectに似ているのは気づきましたか? これらはけっこう役割も似ていますしどちらでも書けるときがあります。 詳しくはこちらを見てください。

qiita.com

Q8

問題

{ taro: { result: true, score: 50 }, jiro: { result: false, score: 30 }, saburo: { result: true, score: 10 }, shiro: { result: false, score: 100 } }
# を
60
# に変換する(resultがtrueの人物のscoreを合計する)

解答

eachを使う場合

n = 0
hash = { taro: { result: true, score: 50 }, jiro: { result: false, score: 30 }, saburo: { result: true, score: 10 }, shiro: { result: false, score: 100 } }
hash.each { |k, v| n += v[:score] if v[:result] }
n

select, map, injectを使う

hash = { taro: { result: true, score: 50 }, jiro: { result: false, score: 30 }, saburo: { result: true, score: 10 }, shiro: { result: false, score: 100 } }
hash.select { |_,v| v[:result] }.map { |_,v| v[:score] }.inject(&:+)

まとめ

これくらいはRuby使いなら当たり前なんですが、意外と練習問題が存在しなかったのでまとめてみました。上で示したように、たいていはeachを使って実装できます。eachを基本としながら少しずつmapやselectに慣れていって、injectやeach_with_objectが自然と使えるようになってくると良いでしょう。

これらはEnumerableというモジュールで提供されていて、ArrayやHashのどちらでも使えますし、一定の条件を満たせば自分で作ったクラスでもmapなどを使うことができます。モジュールとは何かが分からない方はそれも一緒に理解してみるといいでしょう。

qiita.com

Railsではてな記法っぽい独自の記法を実装する

弊社のリフォームメディア、ミライエはRailsで開発しています。

miraie.me

システム的にはCMSを実装しているのですが、データ的にはmarkdownとHTMLを混在させて保存しています。これでも回っているのですが、 引用, リンク などは一定のフォーマットで入れたいという要望があります。

そこではてなブックマーク風のオリジナル記法(以下特殊記法)を導入しています。

記法のルール

引用文

[(quote_text)text: 'xxx',refer_url: 'xxx',refer_name: 'xxx']

埋め込みリンク

[(embed_content) url: 'xxx']

というふうに、

  • []で囲む
  • 先頭に(command)で種類を定義
  • あとはrubyのハッシュ風の引数を列挙する。

これをテキストエリアの中に書いてやれば、レンダリングすると所定のHTMLに変換されてでてくるようにしています。

実装方針

基本は正規表現なのですがこういうのは適当に実装すると確実に負債化するので、以下のサイトを参考にして html-pipeline を使用しました。

qiita.com

特殊記法を含んだ文字列を受け取り、特殊記法部分があらかじめ定義してあるHTMLに変換されて返ってくるようにするのがゴールです。

lib/mitsumo/processors/article_text_processor.rb

まずhtml-pipelineの使い方として、変換クラスを実装します。今回はArticleモデルのtextというカラムを変換する責務を持つので、ArticleTextProcessorという名前にします。

module Mitsumo
  module Processors
    class ArticleTextProcessor
      DEFAULT_FILTERS = [
        Mitsumo::Processors::Filters::MitsumoNotation,
        Mitsumo::Processors::Filters::Redcarpet,
      ].freeze

      DEFAULT_CONTEXT = {
      }.freeze

      def initialize(context = {})
        @context = DEFAULT_CONTEXT.merge(context)
      end

      def call(input, context = {})
        HTML::Pipeline.new(filters, @context).call(input, context)
      end

      def filters
        @filters ||= DEFAULT_FILTERS
      end
    end
  end
end

Mitsumoというのはプロジェクトネームです。

lib/mitsumo/processors/filters/mitsumo_notation_filter.rb

次にフィルタとして使っているMitsumoNotationFilterを実装します。1つ1つのフィルタが所定の変換ルールを持ち、ArticleTextProcessorは一連の変換ルールを束ねる役割を持ちます。

module Mitsumo
  module Processors
    module Filters
      class MitsumoNotationFilter < HTML::Pipeline::TextFilter

        def call
          doc = Nokogiri::HTML.fragment(@text)

          doc.children.each do |node|
            if node.text =~ Mitsumo::NotationParser::NOTATION_REGEXP
              html = node.text.gsub(/(#{Mitsumo::NotationParser::NOTATION_REGEXP})/) do
                NotationRenderer.new($1, @context).html.strip
              end
              node.replace Nokogiri::HTML.fragment(html)
            end
          end
          @text = doc.to_s
        end
      end
    end
  end
end

このクラスの処理の流れは

  • 流れてきたテキストをnokogiriのオブジェクトにする
  • テキストノードに特殊記法を含んでいるDOMを見つける
  • 特殊記法から引数を抽出し、NotationRendererに渡してHTMLを返却させる
  • 最終的にgsubによって特殊記法がNotationRendererが返すHTMLにすり替わる

という流れになっています。

lib/mitsumo/notation_regexp.rb

次に特殊記法を表す正規表現です。

module Mitsumo
  module NotationRegexp
    SPACE_OR_BR = /\s+/
    ARG_VALUE1 = /'(\\'|[^'])*?'/ #シングルクオートで囲まれたもの
    ARG_VALUE2 = /"(\\"|[^"])*?"/ #ダブルクオートで囲まれたもの
    ARG_VALUE3 = /\d+/ #数値
    ARG_VALUE = /#{ARG_VALUE1}|#{ARG_VALUE2}|#{ARG_VALUE3}/
    ARG_KEY = /[a-z_]+?:/
    ARG_KEY_BRACKET = /([a-z_]+?):/
    ARG_SET = /#{ARG_KEY}#{SPACE_OR_BR}*#{ARG_VALUE}/ #キーと値のセット

    ARG_SET_BRACKET = /#{ARG_KEY_BRACKET}#{SPACE_OR_BR}*(#{ARG_VALUE})/

    NOTATION_TYPE_LIST = [
      'quote_text',
      'quote_image',
      'image',
      'comment',
      'text',
      'link',
      'header',
      'ref_article',
      'summary',
      'portfolio_item',
      'thumbnail',
      'embed',
    ].freeze

    NOTATION_TYPE = /\(#{Regexp.union(NOTATION_TYPE_LIST)}\)/ #カッコに囲まれた文字列

    ARGUMENT_LIST = /#{ARG_SET}(#{SPACE_OR_BR}*,#{SPACE_OR_BR}*#{ARG_SET})*/

    NOTATION_REGEXP = /\[#{SPACE_OR_BR}*#{NOTATION_TYPE}#{SPACE_OR_BR}*#{ARGUMENT_LIST}#{SPACE_OR_BR}*\]?/
    NOTATION_REGEXP_BRACKETS = /\[#{SPACE_OR_BR}*(#{NOTATION_TYPE})#{SPACE_OR_BR}*(#{ARGUMENT_LIST})#{SPACE_OR_BR}*\]?/
  end
end

そこそこ複雑ですが、 rubyのハッシュと一緒で

  • key: 'value'の形式でカンマ区切り
  • valueには文字列か数値が来る。数値の場合はクォーテーションは不要
  • key: valueのあとに改行が来ても良い

という定義になります。

_BRACKETSというカッコ付きのものは、正規表現でマッチした部分を抽出するのに使います。

lib/mitsumo/notation_renderer.rb

次に特殊記法を用意したHTMLに変換するコードです。

例えば

[(embed_content) id: 100]という特殊記法であれば、 app/views/notations/embed_content.html.erb を呼び出します。

module Mitsumo
  class NotationRenderer
    include NotationRegexp
    include NotationParseHelper
    include ActionView

    def initialize(text, context = {})
      @text = text
      @context = context
    end

    def html
      @text =~ NOTATION_REGEXP_BRACKETS

      type = remove_brackets($1)
      args = parse_argument_list($2)
      args = args.merge(
        context: @context,
        notation: @text,
      )

      if type == 'comment'
        ''
      else
        renderer.render(template: "notations/#{type}", locals: args)
      end
    end

    private

    def renderer
      context = Rails.configuration.paths['app/views']
      @renderer ||= ViewRenderer.new(context)
    end

    class ViewRenderer < ActionView::Base
      include Rails.application.routes.url_helpers
      include ImageHelper

      def default_url_options
        Rails.application.routes.default_url_options
      end
    end
  end
end

ViewRendererはコントローラー以外でこのようにテンプレートを処理したいときに必要になります。

lib/mitsumo/notation_parse_helper.rb

最後に上の処理の中で使っている関数をモジュールとして定義しています。

module Mitsumo
  module NotationParseHelper
    include NotationRegexp

    def remove_brackets(type)
      type.gsub(/\A\(|\)\z/, '')
    end

    def parse_argument_list(arg_list)
      args = {}

      arg_list.scan(ARG_SET_BRACKET) do |arg_pattern|
        key = arg_pattern[0].to_sym
        value = arg_pattern[1]

        case value
          when ARG_VALUE1
            value = value.
              gsub(/\A'|'\z/, '').
              gsub(/\\'/, '')
          when ARG_VALUE2
            value = value.
              gsub(/\A"|"\z/, '').
              gsub(/\\"/, '')
          when ARG_VALUE3
            value = value.to_i
        end
        args[key] = value
      end

      args
    end

    def make_arguments_list(hash)
      hash.to_a.map { |k, v|
        if v.is_a?(Integer)
          "#{k}: #{v}"
        else
          "#{k}: '#{v}'"
        end
      }.join(",\n")
    end
  end
end

あとは用意してあるviewでローカル変数として特殊記法の引数を受け取れるようになります。

どういうときに嬉しいの?

[(quote) text: '引用文', url: 'http://www.yahoo.co.jp'] と書いた時に

<div class='quote-content'>
  <blockquote><%= text %></blockquote>
  <p class='referer-url'><%= url %></p>
</div>

に変換するような処理が簡単に書けます。

難点

この機能の難点は実装上のハードルがけっこう高いこと、正規表現の理解が難しいこと、そして書き手のユーザーにとっても使用がやや難しい点にあります。

例えば

[(quote) text: '引用文'、url: 'http://www.yahoo.co.jp']

のように本来半角のカンマで区切るべきものを全角にしてしまうと処理できなくなってしまいます。

最近の傾向として一般ユーザーがライティングするようなブログやメディアサイトはcontenteditableを使ったWYSIWYGに回帰しています。

シンプルなデコレータで記事内のページングを実装する

シンプルなデコレータで記事内のページングを実装する

ブログのような記事の内容をページングしたいとき、どうやって実装するか?というお話です。 ページングといえば検索結果のページングと、東洋経済のようなサイト(こういうやつ http://toyokeizai.net/articles/-/80257) で記事の中でページングするものがあると思います(他にもいろんな種類があると思います)。

検索結果ページングの場合はモデルの集合に対してoffsetやlimitを使って別の集合にして取り出す実装になるはずです。デファクトスタンダードとも言えるKaminariもだいたいそういうロジックです。

ただ、記事の中でページを作るとなるとどう実装するかは選択肢があると思います。

1. ページごとのモデルを作る

例えば@article.bodiesのような関連を作って、記事の本文はArticleBodyクラスが担うという実装です。

idx = params[:page] ? (params[:page].to_i - 1) : 0
@body = @article.bodies[idx]

まぁこれでもいいのですが関連モデルのソートを書かないといけないしなんだかそこまでやるのはめんどくさいなという印象です。

2. 特殊な区切り文字で区切る

特殊な区切り文字を使った実装です。

例えば

hogehoge
fugafuga
[(paging)]
piyopiyo

という文字列は、[(paging)]ごとに改ページになるような実装。今作っているサービスではもともとmarkdownとか特殊タグで記述するものが多く、こちらのほうが今の実装に馴染んでいたので今回はこの方法を試してみました。

この方法で実装するなら、例えばページング用のオブジェクトに表示コンテンツに関する責務を担わせるのがスジが良さそうですが、たまたまArticleDecoratorという表示用のデコレータがあったのでこれに合わせました。

まぁ現実問題これでいいと思うのですが、もう少しオブジェクトの責務について厳格に考えるのであればやはりArticlePagerというようなインスタンスを対応させて、out_of_range?などのメソッドはそちらにdelegateしたほうがいい気もします。

このデコレータはViewの為に簡単なDecoratorをつくるにアイデアをもらっています。DraperやActiveDecoratorでもいいんですが標準ライブラリで実装できてますしモデルにArticle#decorateみたいなメソッドを生やせばいいだけなので私はこっちのほうが好きです。

class ArticleDecorator < SimpleDelegator
  PAGING_NOTATION = "[(paging)]"

  attr_reader :current_page

  def initialize(*args)
    @current_page = 1
    super(*args)
  end

  def current_page=(page)
    @current_page = page.to_i
  end

  def out_of_range?
    @current_page > total_pages
  end

  def text_html
    processor.call(text)[:output].to_s
  end

  def text_plain
    processor.call(text)[:output].text
  end

  def text_html_on_page
    idx = @current_page - 1
    text_on_page = text.split(PAGING_NOTATION)[idx]
    processor.call(text_on_page)[:output].to_s
  end

  def total_pages
    text.split(PAGING_NOTATION).length
  end

  def last_page?
    total_pages == current_page
  end

  def meta_title
    "#{title}"
  end

  def meta_title_on_page
    if @current_page == 1
      meta_title
    else
      "#{meta_title} #{@current_page}ページ"
    end
  end

  def meta_description
    original_meta_description || "#{text_plain.truncate(60)}"
  end

  def short_description(length: 60)
    if original_meta_description
      original_meta_description.truncate(length)
    else
      text_plain.truncate(length)
    end
  end

  def og_title
    meta_title
  end

  def og_description
    meta_description
  end

  def next_page
    @current_page + 1
  end

  def prev_page
    @current_page - 1
  end

  def has_next_page?
    total_pages > @current_page
  end

  def has_prev_page?
    @current_page > 1
  end

  private

  def processor
    @processor ||= Mitsumo::Processors::ArticleTextProcessor.new(
      photos: self.photos,
      embed_contents: self.embed_contents,
    )
  end
end

これで使うときは

@article.current_page = params[:page] || 1
@body_html = @article.text_html_on_page

metaタグ関係

SEO上1記事を表示の上で分割する際にどのようにmetaタグを指定するかはいくつかの説があるようです。

全ページをまとめたスーパーページを用意

全ページをまとめたページを用意して各ページからはcanonicalでスーパーページに正規化する方法です。 これはgoogleの推奨ですが、ランディングページがスーパーページになるのか1ページ目になるのかよくわからなかったので止めました。

prev,nextタグ

以下のようなlinkタグでページ関係を明示する方法です。

<link href="/page/1" rel="prev" />
<link href="/page/3" rel="next" />

この場合ページは別個のものとして扱い、URL(canonical)はページごとに正規化します。 これは2ページ目以降をnoindexにするか否かはページの作り方によるみたいですが、noindexの指定はしてないほうが多い気がします。

1ページ目にcanonical

2ページ目以降をcanonicalで1ページ目に正規化する方法です。これはgoogleでは非推奨のようですが実際はこういう実装も多いみたいです。 この場合は2ページ目以降はfollow,noindexにするほうが多いみたいです。

railsで煩雑なリダイレクトの管理

ウェブサービスを運用していると設計の変更や記事・ページの削除が頻繁にあります。

例えば今まで:author_name/articles/:idというURLで提供していたページがあったとして、これをシンプルにarticles/:idで提供するとします。

このとき何も考えずにURLを変更すると、もともとのURLに付与されていた検索エンジンからの評価が引き継がれません。

このような設計変更からくるURLの変化は301転送をかけてあげる必要があります。

これをrailsで実現するとこうなります。

 get ':author_name/articles/:id' => redirect("/articles/:id")

何も指定しないと301転送になります。

ところで改修によるリダイレクトならともかく、categories/break-fastcategories/breakfastにするとか、非常に細かいことでURLを変更したいと思うとconfig/routes.rbに毎回書いていくのはかなりだるいです。

そこで今回Railsで簡単にリダイレクトを管理する方法を紹介します。

Redirectionモデルを作る

オススメはRedirectionというモデルを作り、転送前URL(before), 転送後URL(after)を管理画面などから入稿できるようにすることです。

Routing

これであとはconfig/routes.rbの先頭にこのように書きます。

Rails.application.routes.draw do
  Redirection.all.each do |redirection|
    get "#{redirection.before}" => redirect("#{redirection.after}"), as: nil
  end
   ....
end

before, afterにはワイルドカードも使えるので、URLを正規化するような転送も可能です。

注意点

例えばarticles/1を削除したときにarticlesに転送したいとき(これを404にすべきかいなかは置いておきます), リダイレクトより先にarticles/:idにマッチングすると転送できません。したがって転送コードはルーティングの先頭に書きます。

あとはリダイレクトのレコード数が多すぎるとルーティングの処理に時間がかかるのですが、今のところ30,40程度では問題なさそうです。(仮に問題があれば古いリダイレクトは削除すればよいでしょう)

ただしルーティングは起動時に一度展開されてあとはキャッシュされるので管理画面からRedirectionモデルを追加してもサーバの再起動が必要です。

その他

この方法の問題はアプリケーション側で転送を行うのでAPサーバに処理が集中してしまう点でしょう。現在運用しているサービスがあまりスペック的に問題がないのでできていますが、そうでない場合はapacheで正規表現のリダイレクトを地道にするしかないのかも・・・。

nasneでiMacをテレビ化する

nasneとは?

nasneSonyから出ているレコーダー兼ストレージです。wifiのネットワークにつなげば、パソコンやスマホから録画予約ができてスマートフォンで再生できます。

↓これが実物

nasnemacで再生できなかった

以前からMacで再生したいなと思っていたんですが、よくわからないですがテレビ放送のデータ形式的にwindowsばかりが対応されていて、macは実質ソフトがなかったんですね。

それが最近ようやくソフトが出ていました。

http://www.pixela.co.jp/products/tv_capture/stationtv_link/

お試し版をダウンロードして、それなりに使えたのでそのままライセンスを購入。

使っていないiMacがテレビになった

nasneは録画もできますがチューナーが入っているのでライブチューナ再生ができます。したがってテレビと同等のことができます。

もともとiPhoneiPadで実現できたのですがやはり音量、画面サイズが小さくテレビを見ている感じではありませんでした。今回Macnasneが使えることで、十分テレビとして機能するようになりました。

ただ、いくつか難点・注意点はあります。

1. けっこうCPUを食う

はじめ、作業に使っている母艦で再生してみたんですがけっこうファンが回ってpremiereとか重いソフトと併用するのが辛かったです。私の場合は使っていないiMacがあったのでこれを専用にしました。

2. 再生まで時間がかかる

番組表の読み込みと、チャンネルを選択したときの再生までの時間がけっこう遅いです。これはもとからiPhone板などを使っていても同じようなもっさり感だったのでnasne側とかの問題かもしれません。

チャンネル選択からの再生が遅いとテレビのザッピングのような動きはちょっとできません。(まぁそもそも番組表から選ぶのでザッピングしなくてよいのですが)

3. 音ズレする

これは致命的なのですがチャンネルによっては相性が悪いのか、音ズレすることがたまにあります。音ズレがひどいときは見ていてけっこう辛いです。

ただこれはnasneでもとから確認できていたことです。録画番組の再生だと遅れることはないので重要なものは録画しましょう。