스물세번째 날: Hacking mojopaste

저자

@keedi - Seoul.pm 리더, Perl덕후, 거침없이 배우는 펄의 공동 역자, keedi.k at gmail.com

시작하며

지난 기사인 스물두번째 날: 나만의 pastebin에서는 pastebin이 무엇인지 간단히 알아보고, Mojolicious 기반의 pastebin 웹응용인 CPAN의 App::mojopaste 모듈을 사용해 개인 서버에 설치하고 실행하는 방법을 알아보았습니다. 무엇보다 Perl은 오픈소스이면서, 매우 유연한만큼, Perl 모듈들도 그 철학을 그대로 따라가 확장성이 뛰어납니다. Mojolicious 역시 마찬가지인데, Mojolicious와 App::mojopaste 모듈의 내부 구조를 적극 활용해 기능을 더욱 확장하는 해킹을 해볼까합니다. 지난 기사에서 mojopaste를 메모장처럼 사용할 수 있는 기법을 소개했는데, 이 기능에서 조금 더 발전시켜, 평문을 마크다운 형식으로 변환해 미려하게 보여주도록 확장하면 꽤 유용하겠죠? 자, 준비되셨나요? :)

준비물

필요한 모듈은 다음과 같습니다.

직접 CPAN을 이용해서 설치한다면 다음 명령을 이용해서 모듈을 설치합니다.

$ sudo cpan \
    App::mojopaste \
    File::Which \
    Text::MultiMarkdown

사용자 계정으로 모듈을 설치하는 방법을 정확하게 알고 있거나 perlbrew를 이용해서 자신만의 Perl을 사용하고 있다면 다음 명령을 이용해서 모듈을 설치합니다.

$ cpan \
    App::mojopaste \
    File::Which \
    Text::MultiMarkdown

메뉴 추가

우선 mojopaste를 실행해볼까요? 8080 포트에서 차트 기능을 활성화 시킨 상태로 실행하는 명령은 다음과 같습니다.

$ PASTE_ENABLE_CHARTS=1 mojopaste daemon -l http://*:8080
[Thu Dec 22 19:21:54 2016] [info] Listening at "http://*:8080"
Server available at http://127.0.0.1:8080
...

브라우저로 접속해서 예제로 사용할 마크다운 문서를 하나 저장하죠.

예제 문서 저장 그림 1. 예제 문서 저장 (원본)

제 경우 저장한 마크다운 문서의 아이디는 bd42c5bb8ff2가 되었군요.

예제 마크다운 문서 그림 2. 예제 마크다운 문서 (원본)

http://localhost:8080/bd42c5bb8ff2으로 접속하면 웹 응용은 뷰어 모드로 전환되어 저장한 내용을 보여주는데 이 때 상단 메뉴는 다음과 같은 기능을 보여줍니다.

새로 만들기나 현재 문서 수정 기능은 글자 그대로의 기능입니다. Raw의 경우 조금 의아할 수 있는데, 현재 보여주는 내용의 경우 상단에 헤더도 있고, 왼쪽에 줄번호도 있으며, 글 내용도 약간의 문법 강조가 되어있는 등 입력한 그대로 보여지는 것이 아닙니다. Raw 기능은 말 그대로 입력한 그대로, 즉 평문을 보여주는 것입니다. Graph는 CSV 형식일 때만 제대로 보여주니까 지금은 별로 상관이 없습니다.

우리는 새로운 기능을 추가할 것이므로 상단의 메뉴에 보이는 기존 항목과 동일하게 Graph 옆에 Markdown처럼 메뉴를 추가하면 좋겠네요. 음... 어떻게 해야할까요? Perl과 Mojolicious에 아무런 지식이 없다면 조금 당황스러울 수 있습니다. 기본적으로 Perl의 대부분의 모듈은 제법 체계적으로 이름 공간 침범이 가능하기 때문에 이런 기법을 사용할 수도 있지만, Mojolicious 자체가 워낙 확장성이 좋기 때문에 기존 웹 응용의 코드를 최대한 살리면서 필요한 부분만 덮어쓴(override)다거나, 부족한 부분을 추가할 수 있습니다. 우선 다른 모든 기능을 제외하고 메뉴를 추가하는 부분에만 집중해보죠. :) 가장 먼저 작성해야 할 코드는 다음과 같습니다.

#!/usr/bin/env perl

use utf8;
use strict;
use warnings;

use Mojo::Server;

use File::Which;

my $mojopaste = File::Which::which("mojopaste");

my $server = Mojo::Server->new;
my $app = $server->load_app($mojopaste);

$app->start;

아니 mojopaste가 있는데 왠 스크립트일까요? 우리는 기존 mojopaste 기능은 그대로 계승하면서 추가적으로 기능을 수정하거나 더하려고 합니다. 이 때 사용하는 것이 Mojo::Server 모듈입니다. Mojo::Server 모듈을 이용해 Mojolicious 웹 응용을 그대로 읽어 들인 후 이 웹 응용을 Mojolicious 웹 응용을 작성할 때처럼 $app에 저장할 수 있습니다. 이렇게 $app에 불러오고 나면 그 뒤로는 얼마든지 원하는 동작을 수정하거나 추가할 수 있겠죠. File::Which 모듈을 이용해 mojopaste 스크립트의 절대 경로를 찾은 후 Mojo::Server 모듈의 load_app() 메소드의 인자로 넘겨줍니다. File::Which 모듈을 사용하지 않는다면 직접 해당 스크립트의 경로를 확인해야겠죠. 이런 기법을 사용하면 시스템에 이미 존재하는 모듈과 스크립트를 지저분하게 손대지 않으면서도 사용자 영역에서 필요한 부분만 수정할 수 있어 유지보수 측면에서 유리합니다. 이제 mojopaste 대신 새로 만든 my-mojopaste.pl 스크립트를 실행해보죠.

$ PASTE_ENABLE_CHARTS=1 ./my-mojopaste.pl daemon -l http://*:8080
[Thu Dec 22 19:56:08 2016] [info] Listening at "http://*:8080"
Server available at http://127.0.0.1:8080
...

Mojolicious에서 화면에 보여지는 부분은 렌더러가 담당합니다. 렌더러가 템플릿이란 개체를 원하는 형식(HTML, 텍스트, JSON 등)으로 렌더링하면, 브라우저는 이 내용을 적절하게 화면에 뿌려주는 것이죠. 즉, 지금처럼 이미 존재하는 페이지를 수정하려면 해당 페이지 템플릿을 수정해야겠죠. 더불어 수정한 템플릿을 적용하도록 Mojolicious에게 알려주어야 하므로 렌더러에게 그 사실을 통지할 필요도 있을테구요. mojopaste에서 우리가 수정하려는 템플릿의 이름은 show.html.ep입니다. 메뉴에 FooBar, Baz 세 개의 메뉴를 추가하도록 템플릿을 수정하는 코드는 다음과 같습니다.

...
$app->start;

__DATA__
@@ show.html.ep
% if ($embed =~ /nav/) {
<nav>
  %= link_to 'New', 'pastebin', class => 'btn'
  %= link_to 'Edit', url_for('pastebin')->query(edit => $paste_id), class => 'btn'
  %= link_to 'Raw', url_for('show', paste_id => $paste_id, format => 'txt'), class => 'btn'
  %= link_to 'Graph', url_for('chart'), class => 'btn' if $enable_charts
  %= link_to 'Foo', url_for('foo'), class => 'btn'
  %= link_to 'Bar', url_for('bar'), class => 'btn'
  %= link_to 'Baz', url_for('bar'), class => 'btn'
  %= include 'powered_by'
</nav>
% }
<pre class="prettyprint linenums"><%= $error || $paste %></pre>

어라? 기대와는 다르게 웹 응용을 다시 재구동 시켜서 확인해도 메뉴가 추가되지 않았을 것입니다. 이는 이미 mojopaste를 최초 적재시키는 시점에 show.html.ep 템플릿을 읽어들여 내부 자료구조에 이미 저장하고 있기 때문에 우리가 새로 추가한 DATA 섹션의 템플릿이 전혀 영향을 끼치지 못하고 있어 나타나는 현상입니다. 이 문제를 해결하기 위해선 렌더러로 하여금 현재 우리가 지정하는 템플릿을 우선해서 읽어들이도록 해야겠죠. $app->rendererclasses 멤버에 접근해 현재 스크립트 소스 코드의 DATA 섹션을 우선시 하도록 main 클래스를 제일 앞에 추가하면 해결할 수 있습니다.

...
my $server = Mojo::Server->new;
my $app = $server->load_app($mojopaste);
unshift @{ $app->renderer->classes }, "main";

$app->start;

__DATA__
...

어떤가요? 이제 메뉴가 잘 추가된 것 같죠? :)

메뉴 추가 그림 3. 메뉴 추가 (원본)

방금은 편의를 위해 DATA 섹션을 이용했지만, 필요하다면 템플릿 디렉터리를 지정해서 처리할 수도 있습니다. 템플릿은 다음과 같은 디렉터리 구조로 구성합니다.

$ tree templates/
templates/
└── show.html.ep

0 directories, 1 file
$

역시 앞선 예제와 마찬가지로 이번에도 메뉴의 변경 내역이 적용되지 않습니다. $app->rendererpathes 멤버에 접근해 현재 디렉터리 하부의 templates 디렉터리를 우선해서 템플릿 디렉터리로 탐색하도록 해야합니다.

...

my $mojopaste = File::Which::which("mojopaste");
my $server = Mojo::Server->new;
my $app = $server->load_app($mojopaste);

unshift @{ $app->renderer->paths }, "./templates";

$app->start;

템플릿의 수정과 렌더러의 세밀한 조작을 통해 화면에 보이는 부분은 수정에 성공했습니다. 우선 테스트 삼아 넣은 메뉴를 수정해서 Markdown 항목을 추가하죠. 편의를 위해 템플릿은 DATA 섹션을 이용합니다.

...
my $server = Mojo::Server->new;
my $app = $server->load_app($mojopaste);
unshift @{ $app->renderer->classes }, "main";

$app->start;

__DATA__
@@ show.html.ep
% if ($embed =~ /nav/) {
<nav>
    %= link_to 'New', 'pastebin', class => 'btn'
    %= link_to 'Edit', url_for('pastebin')->query(edit => $paste_id), class => 'btn'
    %= link_to 'Raw', url_for('show', paste_id => $paste_id, format => 'txt'), class => 'btn'
    %= link_to 'Graph', url_for('chart'), class => 'btn' if $enable_charts
    %= link_to 'Markdown', url_for('markdown'), class => 'btn'
    %= include 'powered_by'
</nav>
% }
<pre class="prettyprint linenums"><%= $error || $paste %></pre>

라우트 주소 추가

이제 실제로 클릭했을 때 원하는 동작이 수행되게 하는 일이 남았네요. 원래 기능이 아닌 추가하는 기능인 만큼 그래프(차트) 기능과 마찬가지로 사용자가 설정을 통해 해당 기능의 사용 여부를 선택할 수 있으면 좋을 것 같습니다. 환경 변수와 설정 양쪽 모두를 지원하도록 해보죠.

...
use Mojolicious::Lite;
use Mojo::Server;
...

plugin 'config' if $ENV{MOJO_CONFIG};
$app->defaults( enable_markdown => app->config('enable_markdown')
        // $ENV{PASTE_ENABLE_MARKDOWN} );

if ( $app->defaults('enable_markdown') ) {
    $app
        ->routes
        ->get('/:paste_id/markdown')
        ->to( cb => sub { ... } )
        ->name('markdown')
        ;
}

$app->start;

__DATA__
@@ show.html.ep
...
  %= link_to 'Markdown', url_for('markdown'), class => 'btn' if $enable_markdown
...

기존 mojopaste의 기본값을 그대로 활용하기 위해 defaults() 메소드와, 설정 값을 활용하기 위해 plugin() 헬퍼와 config 플러그인을 사용합니다. 이 기능을 사용하려면 Mojolicious::Lite 모듈을 적재해야합니다. 마크다운 기능이 활성화 되었을 때만 라우트를 등록하도록 if (...) 구문으로 둘러싸고 템플릿 쪽에도 마찬가지로 마크다운 활성화 여부를 체크하도록 합니다.

실제 마크다운 기능이 활성화 되었을 때 동작할 코드는 다음과 같습니다.

if ( $app->defaults('enable_markdown') ) {
    $app->routes->get('/:paste_id/markdown')->to(
        cb => sub {
            my $c = shift;

            $c->delay(
                sub { $c->paste( $c->stash('paste_id'), shift->begin ) },
                sub {
                    my ( $delay, $err, $paste ) = @_;
                    if ( $err or !$paste ) {
                        $c->no_such_paste( $err || 'Could not find paste' );
                    }
                    else {
                        $c->res->headers->header(
                            'X-Plain-Text-URL' => $c->url_for( format => 'txt' )->userinfo(undef)->to_abs );
                        $c->stash( embed => $c->param('embed') ) if $c->param('embed');
                        $c->render( layout => 'mojopaste', paste => $paste );
                    }
                },
            );
        }
    )->name('markdown');
}

마크다운을 보여주기 위해 추가한 라우트 컨트롤러에 적절한 템플릿도 추가해야겠죠.

__DATA__
@@ show.html.ep
...
@@ markdown.html.ep
% if ($embed =~ /nav/) {
<nav>
  %= link_to 'New', 'pastebin', class => 'btn'
  %= link_to 'Edit', url_for('pastebin')->query(edit => $paste_id), class => 'btn'
  %= link_to 'Data', url_for('show', paste_id => $paste_id), class => 'btn'
  %= include 'powered_by'
</nav>
% }
% if ($error) {
<pre class="prettyprint linenums"><%= $error %></pre>
% }
% else {
%   use Text::MultiMarkdown;
<article>
<%== Text::MultiMarkdown::markdown($paste) %>
</article>
% }

마크다운 기능을 활성화 시켜서 실행하는 명령은 다음과 같습니다.

$ PASTE_ENABLE_CHARTS=1 PASTE_ENABLE_MARKDOWN=1 ./my-mojopaste.pl daemon -l http://*:8080
[Thu Dec 22 20:36:06 2016] [info] Listening at "http://*:8080"
Server available at http://127.0.0.1:8080
...

이제 http://.../bd42c5bb8ff2/markdown으로 접속하면 현재 문서를 마크다운으로 인지한 후 적절하게 구조화된 HTML로 렌더링된 화면을 확인할 수 있습니다. :)

마크다운 렌더링 그림 4. 마크다운 렌더링 (원본)

그림 파일 적재

어이쿠! 그런데 그림 파일을 링크했는데 제대로 보이지 않는군요. 외부 링크일 경우 문제될 것이 없지만 내부 링크일 경우 지금 상태에서는 보여줄 방법이 없습니다. 정적 파일의 경우 mojopaste에서 따로 막고 있는 부분은 없기 때문에 Mojolicious의 기본 정적 파일 처리 기능을 적극 활용해서 문제를 해결할 수 있습니다. 코드는 다음과 같습니다.

...
my $server = Mojo::Server->new;
my $app = $server->load_app($mojopaste);
unshift @{ $app->renderer->classes }, "main";
push @{ $app->static->paths }, "./public";
...

$app->static을 이용해 웹 응용의 스태틱 개체의 paths 속성에 접근해서 현재 디렉터리 하부의 public 디렉터리를 추가적으로 열람하도록 수정합니다. unshift를 이용해서 우선적으로 열람하도록 해도 상관없으나, 템플릿 때와는 달리 딱히 우선해서 적용해야 하는 것은 아니므로 push를 이용해서 정적 파일 열람 디렉터리 목록에 추가합니다. 이제 현재 디렉터리를 다음과 같이 구성합니다.

$ tree public/
public/
└── imgs
    ├── 2016-12-22-1.png
    ├── 2016-12-22-1_r.png
    ├── 2016-12-22-2.png
    └── 2016-12-22-2_r.png

1 directory, 4 files
$

짠! 다시 접속하면 그림 파일도 잘 보이는 것을 확인할 수 있습니다.

정적 파일 렌더링 그림 5. 정적 파일 렌더링 (원본)

크리스마스 선물

이제 여러분은 간단한 메모장으로써 역할을 할 수 있는 사설 pastebin은 물론, 기록한 내용을 마크다운 변환을 통해 구조화된 HTML로 확인도 할 수 있습니다. 이 정도면 기능적으로는 다 마무리 되었지만, 이왕이면 다홍치마겠죠? 약간의 자바스크립트와 CSS를 활용해 mojopaste를 다홍색으로 물들여보죠. :)

@@ markdown.html.ep
% if ($embed =~ /nav/) {
<nav>
  %= link_to 'New', 'pastebin', class => 'btn'
  %= link_to 'Edit', url_for('pastebin')->query(edit => $paste_id), class => 'btn'
  %= link_to 'Data', url_for('show', paste_id => $paste_id), class => 'btn'
  %= include 'powered_by'
</nav>
% }
% if ($error) {
<pre class="prettyprint linenums"><%= $error %></pre>
% }
% else {
%   use Text::MultiMarkdown;
%   my $markdown = Text::MultiMarkdown::markdown($paste);
%   #$markdown =~ s/<pre><code>/<pre class="prettyprint linenums"><code>/gms;
%   $markdown =~ s{<pre><code>#!\w*}{<pre class="prettyprint linenums">}gms;
%   $markdown =~ s{</code></pre>}{</pre>}gms;
<article>
<%== $markdown %>
</article>
<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js" integrity="sha256-/SIrNqv8h6QGKDuNoLGA4iret+kyesCkHGzVUUV0shc=" crossorigin="anonymous"></script>
<script type="text/javascript">
(function() {
  $(function() {
    $('head').append( $('<link rel="stylesheet" type="text/css" />').attr('href', '//fonts.googleapis.com/earlyaccess/notosanskr.css') );

    $('article, article a')
      .css('font-family', '"Noto Sans", "Noto Sans KR", sans-serif')
      ;
    $('article h1')
      .css('background', '#628e9c')
      .css('color', 'white')
      .css('font-size', '3em')
      .css('padding', '60px 0 60px 0')
      .css('text-align', 'center')
      .css('text-shadow', '-1px -1px 0 #444, 1px -1px 0 #444, -1px 1px 0 #444, 1px 1px 0 #444')
      ;
    $('article li')
      .css('font-size', '0.9em')
      .css('margin', '0 80px')
      .css('padding', '5px')
      ;
    $('article p')
      .css('padding', '0 20px')
      ;
    $('article p img')
      .css('border', 'solid black 1px')
      .css('display', 'block')
      .css('height', '380px')
      .css('margin', '0 auto')
      .css('width', '500px')
      ;
    $('article code')
      .css('background-color', '#fffbcc')
      .css('padding', '4px')
      ;
    $('article pre.prettyprint')
      .css('border', 'dotted black 1px')
      .css('font-size', '0.9em')
      .css('margin', '0 80px')
      .css('padding', '10px')
      .css('white-space', 'pre-wrap')       /* Since CSS 2.1 */
      .css('white-space', '-moz-pre-wrap')  /* Mozilla, since 1999 */
      .css('white-space', '-pre-wrap')      /* Opera 4-6 */
      .css('white-space', '-o-pre-wrap')    /* Opera 7 */
      .css('word-wrap', 'break-word')       /* Internet Explorer 5.5+ */
      ;
    $('article pre.prettyprint li')
      .css('font-family', 'Monospace')
      .css('font-size', '0.9em')
      .css('margin', '0 0 0 14px')
      .css('padding', '0')
      ;
    $('article p img').parent()
      .css('text-align', 'center')
      ;
    $('article table + p')
      .css('text-align', 'center')
      ;
  });
}).call(this);
</script>
% }

호오~ 이제 정말 끝난 것 같군요. ;-)

pastebin은 다홍치마를 입고... 그림 6. pastebin은 다홍치마를 입고... (원본)

정리하며

지금까지 여러분만의 pastebin을 구축할 수 있게 도와주는 mojopaste를 이용해 메뉴를 추가하기 위해 템플릿을 덮어쓰고(override)나, 원하는 기능을 추가하기 위해 컨트롤러와 템플릿을 추가하는 과정을 살펴보았습니다. 사실 "mojopaste를 해킹하자!"라는 모토로 시작한 기사지만 정확히는 Mojolicious 웹 프레임워크의 해킹에 가깝습니다. 단순히 Mojolicious 웹 응용을 만드는 과정에 비해 조금 난이도가 있습니다만, 이런 과정을 통해 Mojolicious에 대해 더욱 자세하고 정확하게 이해하는 계기가 되었으면 합니다. 무엇보다 이런 해킹이 얼마든지 공식적으로 가능하다는 점이, Perl과 그 생태계가 프로그래머에게 안겨주는 큰 자유가 아닐까요? ;-)

EOT

blog comments powered by Disqus