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を負債化させないベストプラクティスについて考えてみます。
よくわからない1つのgem、そのgemのソースコードを自分で書いた時の倍くらいの負債を生むと思うの。
— hito_asa (@hito_asa) 2014年3月14日
負債化の臭い
巨大である 危険度★★☆
この中で私が個人的に最も忌避すべきと思うのがDeviseです。DeviseはRailsにサインアップ・ログイン機能を簡単に実装してくれるエンジンです。 Deviseは次のようなケースで非常に面倒です。
- ログインフォームの表示やサインアップ時に追加で処理を走らせたい時、controllerのカスタマイズが難しい
- Userモデルの定義を少しカスタマイズしたい。例えば「(メールもしくはユーザ名)とパスワードが必須」など。
- 複数のログイン処理をもたせたい。UserのログインとAdministratorのログイン。
- Userのログインでも、
@user.user_type
によって処理を分岐させたい。
要するに少し混み合ったものを作ろうとするだけでとたんに面倒になります。Deviseのコードを読んで内容を理解しながらメソッドをオーバーライドしないといけないようになると最悪です。 大きい機能を持っている、ビジネスロジックに深く関わるGemの使用は避けましょう。
deviseとrails_adminは悪魔の囁き。ちょっとだけなら大丈夫さ!と思って手を出すと、アプリの色んな所が侵されて止められなくなるんだ。ダメ絶対!あなたは人間辞めますか、それともレールズ辞めますか?という気分がここ数日支配的
— joker1007 (@joker1007) July 4, 2013
スターが少ない 危険度★☆☆
githubのスターが少ないと開発が止まるリスクがあります。マニアックなgemの使用は慎みましょう。例えばci-reporter
とか止まってますね。
コア部分を拡張している 危険度★★★
例えばactive-record-xxx
のような名前の、ActiveRecordに機能を拡張するようなgemです。
上のGemfileだと
- simple_form
- friendly_id
あたりが該当します。
かつてSqueelというgemがあり、
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標準のテストライブラリの良さが再評価されています。
もっといえばフロントエンドの関係で最近はReact・Vue.jsなどでテンプレートを描画することも多くなっています。このときにslimで書かれたコードにReactのコンポーネントを混在させたり、Rails側のviewのコードの一部をReactのコンポーネントに移そうと思った時、ピュアhtmlでないslimは見通しが悪くなります。
普段JSX書いてるとSlimやHamlよりERBのほうが一貫性あるし良いという気持ちになってくる
— ホームページビルダー (@r7kamura) 2016年7月12日
私はerb => haml => slimと使ってきましたが、最近は再びerbを使い、emmetなどを使ってエディタ側でタイピングの煩わしさを解消するようにしてます。
asset系のgem 危険度★★☆
bootstrap-rails
のような、css・JavaScriptを提供するgemです。
rails5.1からはnpmが使えるようになるそうです。rubyのライブラリにはgemを、フロントエンドライブラリはnpmを使うという住み分けになるでしょう。
標準ライブラリでできることをgemでやる 危険度★☆☆
たとえばこういうの。
普通にNet::HTTP
でできます。
負債gemをなくすには?
gem使わないでできないか考えよう
ちょっとしたことにgemを使うのは慎むべきでしょう。少し前ですが、JavaScript側のgem的な存在であるnpmで、left-pad
というパッケージが削除され混乱がありました。
依存関係は、その名の通り、コードが動くために必要になってしまうものです。より多くの依存関係があればあるほど、より多くの欠陥が生まれ得ます。エラーが生じる可能性が増えることは言うまでもありません。あなたが日々用いている依存パッケージに関して、その関数を書いたプログラマについて精査したことがありますか?
ここの内容に私も同意です。
依存gemを減らしたい。減らす努力をしていかないとアプリケーションを永く保守することはできない
— null (@yuroyoro) 2016年4月1日
定期的にアップデートする
gemは
$ bundle update
でアップデートできます。このような取り組みもあります。
どこに使っているか、gemにコメントをつけていく
使っているのか使ってないのかすぐわからないのでとりあえず残しているというケースもよくあります。そのgemが使われているかはgithubでソースコードを確認し、クラス名などでgrepすればわかるのですが、gemfileのすべてのgemにそれをやるにはけっこうな時間が必要です。
開発者用のドキュメントを作成し、Gemfileの中のgemがどういう用途でどこで使われているか、メモしておくのは有効です。
例
弊社では上のようなドキュメントをmarkdownで作成し、RAILS_ROOT/docs/gems.md
として保存しています。
目先の実装コストよりも中長期の視点を。
Railsアプリを作る時、個人的に絶対避ける3大gemはdevise、activeadmin、ransack。目先のラクさと将来遭遇する面倒事のバランスがとれてない。
— Junya Ogu®a (@junya) 2015年1月23日
開発者・ウェブ関係者必携。入れておきたいchromeアドオン
Chromeアドオンで開発を効率化したい
こんにちは、tkotです。私は普段のブラウジングや開発でMacのGoogle Chromeを使っています。最近はSafariのほうが電源の持ちが良いと聞き試していたんですが、いつの間にか戻っていました。
最近はクラッシュしたり妙に重くなったりして以前に比べて不満はあるものの、やはり機能面では一番充実していると思います。そこで私が使っているオススメアドオンを紹介します。
Meta SEO Inspector おすすめ度 ★★☆☆
title要素やmetaタグの有無、og:imageの設定が適切にできているかが確認できます。エンジニアやフロントエンドのコーディングを行う方、SEO担当の方にオススメです。
適切でないものが見つかればアイコンにエラーが表示されます。
HTML5 Outliner おすすめ度 ★★★☆
article要素やsection要素、h1~h6の見出し要素がどのようにマークアップされているかがひと目で分かります。
SEO上見出し構成は重要なので、ライターさんやSEO担当者、エンジニアは入れておくべきです。
EditThisCookie おすすめ度 ★☆☆☆
cookieを操作するのに使っています。ただ、最近のchromeは標準でcookie操作はできるようなので、インストールせず標準機能で足りるかも。
SimilarWeb おすすめ度 ★★★★
訪問したサイトがどのくらいのトラフィックか、similarwebのデータを使ってわかるようになります。 このアドオンがすごいのはクリックしなくてもサイトを読み込んで数秒待つとアイコンの満タン度でだいたいのアクセス数がわかるところです。 アドオンを導入している人がブラウジング中、ずっと情報を送ってるわけなのでどれだけのトラフィックをさばいているのか気になります。
このアドオンを入れていると測定値の取得以外に、常時閲覧情報が送られているのだと思います。公開前のサイトでほとんど自分しかアクセスしていなくても1Kとか2Kという数字になります。おそらくアドオンを入れてる人が訪問すると+100くらいのカウントになるのではないでしょうか。
Google Analytics Debugger おすすめ度 ★☆☆☆
Google Analyticsのデータ送信がうまくいってるかを確認できます。基本的にはそれだけなんですが、例えば以下のようなコードで
<a onclick="ga('send', 'event', 'cv', 'cv1', 'shiryo');">資料請求</a>
クリックなどをトラッキングする際、正しくデータが送れているかデバッグできて便利です。
React Developer Tools おすすめ度 ★☆☆☆
Reactを提供しているFacebookの公式アドオン。コンポーネントの配置やプロパティがビジュアルでわかるのでReactを使うディベロッパーには必須。
Vue.js devtools おすすめ度 ★☆☆☆
上のアドオンのVue.js版。こちらも公式。
iChrome おすすめ度 ★★★★
igoogleが終了し、my yahooも終了し、というタイミングで見つけたのがこのアドオンです。
ホーム画面にこのようにGoogle Driveやカレンダー、gmail、ニュースを並べることができます。
ポップアップブロッカー おすすめ度 ★★★☆
クリックすると広告に飛ばされるような動作をブロックしてくれます。
Block Yourself from Analytics おすすめ度 ★★★☆
Google Analyticsに自分のトラッキングを送らないように設定できます。 開発者が本番サイトを何度も利用するケースはよくあります。アクセス数が少ない場合、自分自身の滞在時間が非常に長かったりすると平均値などの数字が狂ってしまいますので、ブロックしましょう。
Vimium おすすめ度 ★★☆☆
vimのキーバインドがchromeで使えるようになります。 うれしいのはリンクにこのようにショートカットを表示できることです。
ウェブサイトでキーボードの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] }
ではなぜだめなんだろうという疑問があると思います。確かにeach
もmap
も要素をループさせるので似ているのですが、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
に似ているのは気づきましたか? これらはけっこう役割も似ていますしどちらでも書けるときがあります。
詳しくはこちらを見てください。
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などを使うことができます。モジュールとは何かが分からない方はそれも一緒に理解してみるといいでしょう。
Railsではてな記法っぽい独自の記法を実装する
弊社のリフォームメディア、ミライエはRailsで開発しています。
システム的にはCMSを実装しているのですが、データ的にはmarkdownとHTMLを混在させて保存しています。これでも回っているのですが、 引用, リンク などは一定のフォーマットで入れたいという要望があります。
そこではてなブックマーク風のオリジナル記法(以下特殊記法)を導入しています。
記法のルール
引用文
[(quote_text)text: 'xxx',refer_url: 'xxx',refer_name: 'xxx']
埋め込みリンク
[(embed_content) url: 'xxx']
というふうに、
[
と]
で囲む- 先頭に
(command)
で種類を定義 - あとはrubyのハッシュ風の引数を列挙する。
これをテキストエリアの中に書いてやれば、レンダリングすると所定のHTMLに変換されてでてくるようにしています。
実装方針
基本は正規表現なのですがこういうのは適当に実装すると確実に負債化するので、以下のサイトを参考にして html-pipeline を使用しました。
特殊記法を含んだ文字列を受け取り、特殊記法部分があらかじめ定義してある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-fast
をcategories/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とは?
nasneはSonyから出ているレコーダー兼ストレージです。wifiのネットワークにつなげば、パソコンやスマホから録画予約ができてスマートフォンで再生できます。
↓これが実物
nasneはmacで再生できなかった
以前からMacで再生したいなと思っていたんですが、よくわからないですがテレビ放送のデータ形式的にwindowsばかりが対応されていて、macは実質ソフトがなかったんですね。
それが最近ようやくソフトが出ていました。
http://www.pixela.co.jp/products/tv_capture/stationtv_link/
お試し版をダウンロードして、それなりに使えたのでそのままライセンスを購入。
使っていないiMacがテレビになった
nasneは録画もできますがチューナーが入っているのでライブチューナ再生ができます。したがってテレビと同等のことができます。
もともとiPhoneやiPadで実現できたのですがやはり音量、画面サイズが小さくテレビを見ている感じではありませんでした。今回Macでnasneが使えることで、十分テレビとして機能するようになりました。
ただ、いくつか難点・注意点はあります。
1. けっこうCPUを食う
はじめ、作業に使っている母艦で再生してみたんですがけっこうファンが回ってpremiereとか重いソフトと併用するのが辛かったです。私の場合は使っていないiMacがあったのでこれを専用にしました。
2. 再生まで時間がかかる
番組表の読み込みと、チャンネルを選択したときの再生までの時間がけっこう遅いです。これはもとからiPhone板などを使っていても同じようなもっさり感だったのでnasne側とかの問題かもしれません。
チャンネル選択からの再生が遅いとテレビのザッピングのような動きはちょっとできません。(まぁそもそも番組表から選ぶのでザッピングしなくてよいのですが)
3. 音ズレする
これは致命的なのですがチャンネルによっては相性が悪いのか、音ズレすることがたまにあります。音ズレがひどいときは見ていてけっこう辛いです。
ただこれはnasneでもとから確認できていたことです。録画番組の再生だと遅れることはないので重要なものは録画しましょう。