読者です 読者をやめる 読者になる 読者になる

あと味

たくさん情報を食べて、たくさん発信すると、あとになって味わい深い。

Mojoliciousで出力時だけ別の文字コードにする

Mojoliciousを使っていて、タイトルの件でハマったので、解決方法をメモがてら記事に起こします。

前提・課題

  1. 制作はUTF-8を利用し、出力時はEUC-JPを使いたい
  2. テンプレートは別ファイルに分けたい

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

本題に戻る

  1. 制作はUTF-8を利用し、出力時はEUC-JPを使いたい
  2. テンプレートは別ファイルに分けたい

ここで本題に戻ります。

上記の前提・課題をクリアしようと思った時に、文字化けが発生したのでした。ソースを読み解いたので、これで原因がわかるはずです。

charsetにEUC-JPを設定し、テンプレートはutf8を使った場合
plugin Charset => { charset => 'EUC-JP' }

アプリケーションの実行ファイルの上記述によって、以下のような設定になるはずです。

  1. HTMLのMIMEタイプの文字コードEUC-JP
  2. Mojolicious::Rendererのencoding属性の文字コードEUC-JP
  3. テンプレートはUTF-8

Mojo::Templateのrender_fileメソッドで読んだように、Mojolicious::Rendererのencoding属性の文字コードで、テンプレートファイルがデコードされていました。そうすると、UTF-8のファイルをEUC-JPでデコードしちゃいますね。これは文字化けになるはずです。

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)との不一致が起こってしまいます。

ここまでの結論

  1. 制作はUTF-8を利用し、出力時はEUC-JPを使いたい
  2. テンプレートは別ファイルに分けたい

以上を満たすためには、既存のソースを修正するか、既存のソースを動的に書き換えるしか方法がなさそうです。

一番簡単な解決方法

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;
################### 以下略 ###################

これで解決。もっといい方法があるのかもしれませんが。。。

この作業を通して学んだこと

  1. 簡単な仕組みを利用する裏側には複雑な仕組みが動いている。
  2. ソースコードは目的意識を持って読もうとすればなんとか読める。
  3. コメントとドキュメント重要。
  4. Sub::Install便利。既存モジュールの動作の仕組みを調べるのにも使える。
  5. 問題を解決するよりも、問題の原因を突き止めるほうが数倍時間がかかる場合がある。
  6. perldoc -m MODULEのお陰でソースコードリーディングが捗る。*2
  7. MojoliciousのRenderer周りの動きがなんとなく分かった。
  8. オレは『納得』したいだけだ!『納得』は全てに優先するぜッ!! でないとオレは『前』へ進めねぇッ!『どこへ』も!『未来』への道も!探す事は出来ねえッ!!は、やっぱり名言

次の記事はYAPC参加報告かなー。

おすすめ書籍

Perl CPANモジュールガイド

Perl CPANモジュールガイド

追記

一応、githubに置いた。削除した。

*1:PODにencoding属性のこと一言も書いてないのに...

*2:[https://github.com/thinca/vim-ref:title=vim-ref:bookmark]使えば特に便利