Mojoliciousで出力時だけ別の文字コードにする
Mojoliciousを使っていて、タイトルの件でハマったので、解決方法をメモがてら記事に起こします。
前提・課題
MojoliciousはUTF-8を使用する前提で作られているようで、異なる文字コードを使うには少し手間がかかります。
Mojolicious::Plugin::Charsetというプラグインがあって、これを使えば解決しそうな感じでしたが、ちょっと問題がありました。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; plugin Charset => { charset => 'EUC-JP' }; get '/welcome' => sub { my $self = shift; $self->render('welcome'); }; app->start; __DATA__ @@ welcome.html.ep % layout 'default'; % title 'Welcome'; うぇるかむとぅもじょりしゃす! @@ layouts/default.html.ep <!doctype html><html> <head><title><%= title %></title></head> <body><%= content %></body> </html>
上記のようにひとつのファイルで完結する場合は問題ありませんでしたが、前提にあるように、テンプレートは下記のように別ファイルで管理したい。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; plugin Charset => { charset => 'EUC-JP' }; get '/welcome' => sub { my $self = shift; $self->render('welcome'); }; app->start;
default.html.ep
% layout 'default'; % title 'Welcome'; うぇるかむとぅもじょりしゃす!
welcome.html.ep
<!doctype html><html> <head><title><%= title %></title></head> <body><%= content %></body> </html>
テンプレートを別ファイルに分けると、文字コードの変換に問題が生じます。文字化けというやつです。
テンプレートをEUC-JPで制作すれば問題ありませんが、ファイルごとに文字コードが違うというのも面倒なので、できればテンプレートはutf-8のまま制作したいと思うと、何らかの方法で解決するしかありません。
Mojolicious::Plugin::Charsetを読んでみる
Mojolicious::Plugin::Charsetはシンプルなプラグインでした。
Mojolicious::Plugin::Charset
package Mojolicious::Plugin::Charset; use Mojo::Base 'Mojolicious::Plugin'; # "Shut up friends. My internet browser heard us saying the word Fry and it # found a movie about Philip J. Fry for us. # It also opened my calendar to Friday and ordered me some french fries." sub register { my ($self, $app, $conf) = @_; # Got a charset $conf ||= {}; if (my $charset = $conf->{charset}) { # Add charset to text/html content type $app->types->type(html => "text/html;charset=$charset"); # Allow defined but blank encoding to suppress unwanted # conversion my $encoding = defined $conf->{encoding} ? $conf->{encoding} : $conf->{charset}; $app->renderer->encoding($encoding) if $encoding; # This has to be done before params are cloned $app->hook(after_build_tx => sub { shift->req->default_charset($charset) } ); } } 1;
HTMLのMIMEタイプの文字コードと、レンダラのエンコード、リクエストの文字コードを、ユーザーが指定した文字コードに一括で設定するようです。
文字化けが起こる原因を調べる
テンプレートを処理している箇所は、Mojolicious::Controllerのrenderメソッドなので、それを調べます。
Mojolicious::Controller
sub render { my $self = shift; # Recursion my $stash = $self->stash; if ($stash->{'mojo.rendering'}) { $self->app->log->debug(qq/Can't render in "before_render" hook./); return ''; } # Template may be first argument my $template; $template = shift if @_ % 2 && !ref $_[0]; my $args = ref $_[0] ? $_[0] : {@_}; # Template $args->{template} = $template if $template; unless ($stash->{template} || $args->{template}) { # Default template my $controller = $args->{controller} || $stash->{controller}; my $action = $args->{action} || $stash->{action}; # Normal default template if ($controller && $action) { $self->stash->{template} = join('/', split(/-/, $controller), $action); } # Try the route name if we don't have controller and action elsif ($self->match && $self->match->endpoint) { $self->stash->{template} = $self->match->endpoint->name; } } # Render my $app = $self->app; { local $stash->{'mojo.rendering'} = 1; $app->plugins->run_hook_reverse(before_render => $self, $args); } my ($output, $type) = $app->renderer->render($self, $args); return unless defined $output; return $output if $args->{partial}; # Prepare response my $res = $self->res; $res->body($output) unless $res->body; my $headers = $res->headers; $headers->content_type($type) unless $headers->content_type; $self->rendered($stash->{status}); return 1; }
my ($output, $type) = $app->renderer->render($self, $args);
の箇所で、別のrenderメソッドを呼んでいます。Mojolicious::ControllerのPODによると、このrenderメソッドはMojolicious::Rendererのrenderメソッドのラッパーということなので、Mojolicious::Rendererのrenderメソッドをさらに調べます。
Mojolicious::Renderer
sub render { my ($self, $c, $args) = @_; $args ||= {}; # Localize extends and layout my $partial = $args->{partial}; my $stash = $c->stash; local $stash->{layout} = $partial ? undef : $stash->{layout}; local $stash->{extends} = $partial ? undef : $stash->{extends}; # Merge stash and arguments while (my ($key, $value) = each %$args) { $stash->{$key} = $value } # Extract important stash values my $template = delete $stash->{template}; my $class = $stash->{template_class}; my $format = $stash->{format} || $self->default_format; my $handler = $stash->{handler}; my $data = delete $stash->{data}; my $json = delete $stash->{json}; my $text = delete $stash->{text}; my $inline = delete $stash->{inline}; # Pick handler $handler = $self->default_handler if defined $inline && !defined $handler; my $options = { template => $template, format => $format, handler => $handler, encoding => $self->encoding, inline => $inline, template_class => $class }; # Text my $output; my $content = $stash->{'mojo.content'} ||= {}; if (defined $text) { $self->handlers->{text}->($self, $c, \$output, {text => $text}); $content->{content} = b("$output") if ($c->stash->{extends} || $c->stash->{layout}); } # Data elsif (defined $data) { $self->handlers->{data}->($self, $c, \$output, {data => $data}); $content->{content} = b("$output") if ($c->stash->{extends} || $c->stash->{layout}); } # JSON elsif (defined $json) { $self->handlers->{json}->($self, $c, \$output, {json => $json}); $format = 'json'; $content->{content} = b("$output") if ($c->stash->{extends} || $c->stash->{layout}); } # Template or templateless handler else { return unless $self->_render_template($c, \$output, $options); $content->{content} = b($output) if ($c->stash->{extends} || $c->stash->{layout}); } # Extends while ((my $extends = $self->_extends($c)) && !$json && !$data) { my $stash = $c->stash; $class = $stash->{template_class}; $options->{template_class} = $class; $handler = $stash->{handler}; $options->{handler} = $handler; $format = $stash->{format} || $self->default_format; $options->{format} = $format; $options->{template} = $extends; $self->_render_template($c, \$output, $options); } # Encoding (JSON is already encoded) unless ($partial) { my $encoding = $options->{encoding}; encode $encoding, $output if $encoding && $output && !$json && !$data; } return $output, $c->app->types->type($format) || 'text/plain'; }
コメントもヒントに、Mojolicious::Controllerにあった、$outputの値を作っているところを探すと、Templateを利用している場合は、return unless $self->_render_template($c, \$output, $options);
のところっぽいです。
Mojolicious::Rendererの_render_templateメソッドも調べてみます。
Mojolicious::Renderer
sub _render_template { my ($self, $c, $output, $options) = @_; # Renderer my $handler = $options->{handler} || $self->_detect_handler($options) || $self->default_handler; $options->{handler} = $handler; my $renderer = $self->handlers->{$handler}; # No handler unless ($renderer) { $c->app->log->error(qq/No handler for "$handler" available./); return; } # Render return unless $renderer->($self, $c, $output, $options); # Success! return 1; }
return unless $renderer->($self, $c, $output, $options);
の箇所でハンドラを呼び出しているみたいです。ハンドラはepを使っているので、Mojolicious::Plugin::EpRendererを読んでみます。
Mojolicious::Plugin::EpRenderer
sub register { my ($self, $app, $conf) = @_; # Config $conf ||= {}; my $name = $conf->{name} || 'ep'; my $template = $conf->{template} || {}; # Custom sandbox $template->{namespace} = 'Mojo::Template::SandBox::' . md5_sum(($ENV{MOJO_EXE} || ref $app) . $$) unless defined $template->{namespace}; # Auto escape by default to prevent XSS attacks $template->{auto_escape} = 1 unless defined $template->{auto_escape}; # Add "ep" handler $app->renderer->add_handler( $name => sub { my ($r, $c, $output, $options) = @_; ################### 中略 ################### # Render with epl return $r->handlers->{epl}->($r, $c, $output, $options); } ); # Set default handler $app->renderer->default_handler('ep'); } 1;
return $r->handlers->{epl}->($r, $c, $output, $options);
の箇所でeplを使ってレンダリングしてますね。
ということで、Mojolicious::Plugin::EplRendererも読んでみます。
Mojolicious::Plugin::EplRenderer
sub register { my ($self, $app) = @_; # Add "epl" handler $app->renderer->add_handler( epl => sub { my ($r, $c, $output, $options) = @_; # Template my $inline = $options->{inline}; my $path = $r->template_path($options); if (defined $inline) { utf8::encode $inline; $path = md5_sum $inline; } return unless defined $path; # Cache my $cache = $r->cache; my $key = delete $options->{cache} || $path; my $mt = $cache->get($key); # Cached $mt ||= Mojo::Template->new; if ($mt->compiled) { $$output = $mt->interpret($c) } # Not cached else { # Inline if (defined $inline) { $c->app->log->debug('Rendering inline template.'); $mt->name('inline template'); $$output = $mt->render($inline, $c); } # File else { $mt->encoding($r->encoding) if $r->encoding; return unless my $t = $r->template_name($options); # Try template if (-r $path) { $c->app->log->debug(qq/Rendering template "$t"./); $mt->name(qq/template "$t"/); $$output = $mt->render_file($path, $c); } ################### 以下略 ###################
$$output = $mt->render_file($path, $c);
の箇所でテンプレートファイルを読み込んでるっぽいです。やっとたどり着いた...
$mtはMojo::Templateなので、Mojo::Templateのrender_fileメソッドを読んでみます。
Mojo::Template
sub render_file { my $self = shift; my $path = shift; # Slurp file $self->name($path) unless defined $self->{name}; croak "Can't open template '$path': $!" unless my $file = IO::File->new("< $path"); my $tmpl = ''; while ($file->sysread(my $buffer, CHUNK_SIZE, 0)) { $tmpl .= $buffer; } # Decode and render $tmpl = decode($self->encoding, $tmpl) if $self->encoding; return $self->render($tmpl, @_); }
ここでようやくdecodeがでてきました。ふぅ。
$self->encoding
の文字コードでデコードしているようです。
$self->encoding
の値はMojolicious::Plugin::EplRendererの$mt->encoding($r->encoding)
の箇所で設定されています。
Mojo::Templateのencodingの値を設定する時の引数、$r->encoding
の$rは、ソースを読むとMojolicious::Rendererのインスタンスとわかります。Mojolicious::Rendererのencoding属性は、Mojolicious::Plugin::Charsetで設定しています。
Mojolicious::Plugin::Charset(再掲)
package Mojolicious::Plugin::Charset; use Mojo::Base 'Mojolicious::Plugin'; # "Shut up friends. My internet browser heard us saying the word Fry and it # found a movie about Philip J. Fry for us. # It also opened my calendar to Friday and ordered me some french fries." sub register { my ($self, $app, $conf) = @_; # Got a charset $conf ||= {}; if (my $charset = $conf->{charset}) { # Add charset to text/html content type $app->types->type(html => "text/html;charset=$charset"); # Allow defined but blank encoding to suppress unwanted # conversion my $encoding = defined $conf->{encoding} ? $conf->{encoding} : $conf->{charset}; $app->renderer->encoding($encoding) if $encoding; # This has to be done before params are cloned $app->hook(after_build_tx => sub { shift->req->default_charset($charset) } ); } } 1;
pluginでencodingオプションが設定されていれば、その値が使われて、設定されていなければ、charsetオプションで設定した値が使われるようです。*1
本題に戻る
ここで本題に戻ります。
上記の前提・課題をクリアしようと思った時に、文字化けが発生したのでした。ソースを読み解いたので、これで原因がわかるはずです。
Mojolicious::Plugin::Charset(再掲)
my $encoding = defined $conf->{encoding} ? $conf->{encoding} : $conf->{charset}; $app->renderer->encoding($encoding) if $encoding;
隠しオプション的ですが、以下のようにすれば、EUC-JPでデコードしてしまう事態は防げそうです。
plugin Charset => { charset => 'EUC-JP', encoding => 'UTF-8' };
Mojolicious::Plugin::Charsetのencodingオプションの値が未設定の時、Mojolicious::Rendererのencodingの値がcharsetと同じになってしまうことが問題だからです。
ただ、これにも落とし穴がありました。
Mojolicious::Renderer(再掲)
# Encoding (JSON is already encoded) unless ($partial) { my $encoding = $options->{encoding}; encode $encoding, $output if $encoding && $output && !$json && !$data; } return $output, $c->app->types->type($format) || 'text/plain';
Mojolicious::Rendererのソースで、Mojolicious::Rendererのencoding属性の値を使ってencodeしています。
Mojolicious::Plugin::CharsetでencodingオプションにUTF-8を設定することで、UTF-8のテンプレートをEUC-JPでデコードしてしまうことは防げますが、このままではUTF-8でデコードされたテンプレートをそのままUTF-8でencodeしてしまって、htmlのMIMEタイプの文字コード(EUC-JP)との不一致が起こってしまいます。
ここまでの結論
以上を満たすためには、既存のソースを修正するか、既存のソースを動的に書き換えるしか方法がなさそうです。
一番簡単な解決方法
Mojolicious::Controllerのrenderメソッドの内部の動きが問題になっているので、Mojolicious::Controllerのrenderメソッドをそのままコピペして、一部修正することで問題解決できます。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; use Encode; plugin Charset => { charset => 'EUC-JP', encoding => 'UTF-8' }; get '/welcome' => sub { my $self = shift; ## ここからMojolicious::Conrollerのrenderメソッドからのコピペ&修正 # Recursion my $stash = $self->stash; if ($stash->{'mojo.rendering'}) { $self->app->log->debug(qq/Can't render in "before_render" hook./); return ''; } # Template may be first argument my $template; $template = shift if @_ % 2 && !ref $_[0]; my $args = ref $_[0] ? $_[0] : {@_}; # Template $args->{template} = $template if $template; unless ($stash->{template} || $args->{template}) { # Default template my $controller = $args->{controller} || $stash->{controller}; my $action = $args->{action} || $stash->{action}; # Normal default template if ($controller && $action) { $self->stash->{template} = join('/', split(/-/, $controller), $action); } # Try the route name if we don't have controller and action elsif ($self->match && $self->match->endpoint) { $self->stash->{template} = $self->match->endpoint->name; } } # Render my $app = $self->app; { local $stash->{'mojo.rendering'} = 1; $app->plugins->run_hook_reverse(before_render => $self, $args); } my ($output, $type) = $app->renderer->render($self, $args); return unless defined $output; return $output if $args->{partial}; ################### 以下の行を追加 ################### $output = encode('EUC-JP', decode('UTF-8', $output)); ################### 以上の行を追加 ################### # Prepare response my $res = $self->res; $res->body($output) unless $res->body; my $headers = $res->headers; $headers->content_type($type) unless $headers->content_type; $self->rendered($stash->{status}); return 1; ## ここまでMojolicious::Conrollerのrenderメソッドからのコピペ&修正 }; app->start;
UTF-8でエンコードされてしまった$outputを再び、UTF-8でデコードし直して、改めてEUC-JPでエンコードしています。
ただ、これを各ルートパターンにこのコードを埋め込むのはどう考えても実用的ではありません。
パッチ的に動作を変更する
Sub::Installを使ってパッチ的に動作を変更してみます。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; use Encode; use Sub::Install; plugin Charset => { charset => 'EUC-JP', encoding => 'UTF-8' }; Sub::Install::reinstall_sub({ code => sub { my $self = shift; ################### 中略 ################### my ($output, $type) = $app->renderer->render($self, $args); return unless defined $output; return $output if $args->{partial}; ################### 以下の行を追加 ################### $output = encode($self->req->default_charset, decode($app->renderer->encoding, $output)); ################### 以上の行を追加 ################### # Prepare response my $res = $self->res; $res->body($output) unless $res->body; my $headers = $res->headers; $headers->content_type($type) unless $headers->content_type; $self->rendered($stash->{status}); return 1; }, into => 'Mojolicious::Controller', as => 'render', }); get '/welcome' => sub { my $self = shift; $self->render('welcome'); }; app->start;
これでもOKですが、Mojo::Templateでデコードして、Mojolicious::Rendererでエンコードして、Mojolicious::Controllerでデコードしてエンコードすることになるので、エンコードとデコードを無駄に2回呼び出すことになってしまいます。ということで、Mojolicious::Rendererのrenderメソッドを上書きすることにしました。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; use Mojo::ByteStream 'b'; # Mojolicious::Rendererのrenderメソッドで使うので読み込む use Mojo::Util 'encode'; # Mojolicious::Rendererのrenderメソッドで使うので読み込む use Sub::Install; plugin Charset => { charset => 'EUC-JP', encoding => 'UTF-8' }; Sub::Install::reinstall_sub({ code => sub { my ($self, $c, $args) = @_; ################### 中略 ################### # Encoding (JSON is already encoded) unless ($partial) { my $encoding = $c->req->default_charset; # この行を変更 encode $encoding, $output if $encoding && $output && !$json && !$data; } return $output, $c->app->types->type($format) || 'text/plain'; }, into => 'Mojolicious::Renderer', as => 'render', }); get '/welcome' => sub { my $self = shift; $self->render('welcome'); }; app->start;
実行ファイルに置いたままだと見通しが悪いので、このパッチをプラグインにまとめちゃいます。基本はMojolicious::Plugin::Charsetのを継承してSub::Installの箇所以外は親に丸投げする感じでOKかと。デフォで入っていますし。
app.pl
#!/usr/bin/env perl use utf8; use Mojolicious::Lite; use lib 'lib'; plugin MyCharset => { charset => 'EUC-JP', encoding => 'UTF-8' }; get '/welcome' => sub { my $self = shift; $self->render('welcome'); }; app->start;
Mojolicious::Plugin::MyCharset
package Mojolicious::Plugin::MyCharset; use base qw(Mojolicious::Plugin::Charset); use strict; use warnings; use Mojo::Base 'Mojolicious::Plugin'; use Mojo::ByteStream 'b'; use Mojo::Util 'encode'; use Sub::Install; Sub::Install::reinstall_sub({ code => sub { my ($self, $c, $args) = @_; ################### 中略 ################### # Encoding (JSON is already encoded) unless ($partial) { my $encoding = $c->req->default_charset; # この行を変更 encode $encoding, $output if $encoding && $output && !$json && !$data; } return $output, $c->app->types->type($format) || 'text/plain'; }, into => 'Mojolicious::Renderer', as => 'render', }); 1; ################### 以下略 ###################
これで解決。もっといい方法があるのかもしれませんが。。。
この作業を通して学んだこと
- 簡単な仕組みを利用する裏側には複雑な仕組みが動いている。
- ソースコードは目的意識を持って読もうとすればなんとか読める。
- コメントとドキュメント重要。
- Sub::Install便利。既存モジュールの動作の仕組みを調べるのにも使える。
- 問題を解決するよりも、問題の原因を突き止めるほうが数倍時間がかかる場合がある。
- perldoc -m MODULEのお陰でソースコードリーディングが捗る。*2
- MojoliciousのRenderer周りの動きがなんとなく分かった。
- オレは『納得』したいだけだ!『納得』は全てに優先するぜッ!! でないとオレは『前』へ進めねぇッ!『どこへ』も!『未来』への道も!探す事は出来ねえッ!!は、やっぱり名言。
次の記事はYAPC参加報告かなー。
おすすめ書籍
- 作者: 冨田尚樹,タナカユカリ
- 出版社/メーカー: ワークスコーポレーション
- 発売日: 2011/04/08
- メディア: 単行本(ソフトカバー)
- 購入: 14人 クリック: 1,942回
- この商品を含むブログ (19件) を見る
追記
一応、githubに置いた。削除した。