@nqounetです。
前回は、Heading、Paragraph、CodeBlockの3つの要素クラスを作成し、Markdownの様々な要素を区別できるようになりました。今回は、これらの要素をHTMLに変換する機能を追加してみましょう。
このシリーズについて
このシリーズは「Mooで覚えるオブジェクト指向プログラミング」シリーズを読了した方を対象に、実践的なドキュメント変換ツールを作りながらオブジェクト指向設計を深く学ぶシリーズです。
素直にHTML変換を実装してみる

要素の種類に応じてHTMLタグを生成すればいいので、こんな感じで書けそうです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| sub to_html ($element) {
my $type = $element->type;
if ($type eq 'heading') {
my $level = $element->level;
return "<h$level>" . $element->content . "</h$level>";
}
elsif ($type eq 'paragraph') {
return "<p>" . $element->content . "</p>";
}
elsif ($type eq 'code_block') {
my $lang = $element->language;
if ($lang) {
return "<pre><code class=\"language-$lang\">"
. $element->content
. "</code></pre>";
}
return "<pre><code>" . $element->content . "</code></pre>";
}
else {
return $element->content;
}
}
|
シンプルでわかりやすいですね。でも…
注意: この記事のHTML変換は学習用の最小実装です。実運用ではHTMLエスケープ(< や & の処理)や、コードブロックの末尾改行整理などが必要になります。
新しい要素を追加すると?
リスト要素(ListItem)を追加してみましょう。
1
2
3
4
5
| package ListItem;
use Moo;
extends 'Element';
sub type ($self) { return 'list_item'; }
|
さて、to_html関数を修正します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| sub to_html ($element) {
my $type = $element->type;
if ($type eq 'heading') {
my $level = $element->level;
return "<h$level>" . $element->content . "</h$level>";
}
elsif ($type eq 'paragraph') {
return "<p>" . $element->content . "</p>";
}
elsif ($type eq 'code_block') {
my $lang = $element->language;
if ($lang) {
return "<pre><code class=\"language-$lang\">"
. $element->content
. "</code></pre>";
}
return "<pre><code>" . $element->content . "</code></pre>";
}
elsif ($type eq 'list_item') { # 追加
return "<li>" . $element->content . "</li>";
}
else {
return $element->content;
}
}
|
まだ許容範囲内です。
さらに出力形式を追加すると?
HTMLだけでなく、プレーンテキストでも出力したくなりました。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| sub to_text ($element) {
my $type = $element->type;
if ($type eq 'heading') {
my $level = $element->level;
my $prefix = "=" x (7 - $level); # h1 = ======, h2 = =====, ...
return "$prefix " . $element->content;
}
elsif ($type eq 'paragraph') {
return $element->content;
}
elsif ($type eq 'code_block') {
return "---\n" . $element->content . "---";
}
elsif ($type eq 'list_item') {
return " * " . $element->content;
}
else {
return $element->content;
}
}
|
ん?なんだか同じようなif/elseをまた書いていますね。
問題が見えてきた
今の状態を整理しましょう。
flowchart TD
subgraph 変換関数
A[to_html] --> A1[if heading...]
A --> A2[elsif paragraph...]
A --> A3[elsif code_block...]
A --> A4[elsif list_item...]
B[to_text] --> B1[if heading...]
B --> B2[elsif paragraph...]
B --> B3[elsif code_block...]
B --> B4[elsif list_item...]
end
要素が4種類、出力形式が2種類で、すでに8つの条件分岐が必要です。
もしBlockquote、Link、Imageなども追加し、さらにMarkdown出力やJSON出力も追加したら?
| 要素数 | 出力形式数 | 条件分岐の総数 |
|---|
| 4種類 | 2形式 | 8個 |
| 7種類 | 4形式 | 28個 |
| 10種類 | 5形式 | 50個 |
条件分岐が爆発的に増えていきます。これはつらい。
何がまずいのか
問題点を整理します。
単一責任の原則(SRP)違反
to_html関数が、全ての要素タイプの変換処理を知っている必要があります。要素が増えるたびにto_htmlを修正しなければなりません。
開放閉鎖の原則(OCP)違反
新しい要素を追加するたびに、既存のto_html関数を変更する必要があります。既存のコードを変更せずに拡張することができません。
1
2
3
4
5
6
7
8
| # 新しい要素を追加するたびに...
elsif ($type eq 'blockquote') { # 変更!
return "<blockquote>" . $element->content . "</blockquote>";
}
elsif ($type eq 'link') { # 変更!
return "<a href=\"...\">" . $element->content . "</a>";
}
# 終わりが見えない...
|
試しに動かしてみる
とりあえず今のコードをまとめて動かしてみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| #!/usr/bin/env perl
use v5.36;
# (前回までのクラス定義は省略)
my $markdown = <<'MARKDOWN';
# タイトル
これは段落です。
```perl
my $x = 1;
```
MARKDOWN
my $parser = Parser->new();
my @elements = $parser->parse($markdown);
say "=== HTML出力 ===";
for my $elem (@elements) {
say to_html($elem);
}
say "\n=== テキスト出力 ===";
for my $elem (@elements) {
say to_text($elem);
}
|
実行結果:
1
2
3
4
5
6
7
8
9
10
11
12
| === HTML出力 ===
<h1>タイトル</h1>
<p>これは段落です。</p>
<pre><code class="language-perl">my $x = 1;
</code></pre>
=== テキスト出力 ===
====== タイトル
これは段落です。
---
my $x = 1;
---
|
動くには動きます。でも、このコードを保守していく自信はありません…
今回のポイント
今回は以下のことを学びました。
- if/elseによる変換処理の実装
- 要素と出力形式の組み合わせで条件分岐が爆発する問題
- SRP(単一責任の原則)違反の具体例
- OCP(開放閉鎖の原則)違反の具体例
今回の完成コード
以下が今回作成したコードの完成版です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
| #!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo
use v5.36;
# === Element(基底クラス) ===
package Element {
use Moo;
use experimental qw(signatures);
has content => (
is => 'ro',
required => 1,
);
sub type ($self) {
return 'element';
}
}
# === Paragraph ===
package Paragraph {
use Moo;
use experimental qw(signatures);
extends 'Element';
sub type ($self) {
return 'paragraph';
}
}
# === Heading ===
package Heading {
use Moo;
use experimental qw(signatures);
extends 'Element';
has level => (
is => 'ro',
default => 1,
);
sub type ($self) {
return 'heading';
}
}
# === CodeBlock ===
package CodeBlock {
use Moo;
use experimental qw(signatures);
extends 'Element';
has language => (
is => 'ro',
default => '',
);
sub type ($self) {
return 'code_block';
}
}
# === Parser ===
package Parser {
use Moo;
use experimental qw(signatures);
sub parse ($self, $text) {
my @elements;
my @lines = split /\n/, $text;
my $in_code_block = 0;
my $code_content = '';
my $code_lang = '';
for my $line (@lines) {
if ($line =~ /^```(\w*)/) {
if ($in_code_block) {
push @elements, CodeBlock->new(
content => $code_content,
language => $code_lang,
);
$code_content = '';
$code_lang = '';
$in_code_block = 0;
} else {
$code_lang = $1 // '';
$in_code_block = 1;
}
next;
}
if ($in_code_block) {
$code_content .= $line . "\n";
next;
}
next if $line =~ /^\s*$/;
if ($line =~ /^(#+)\s+(.+)/) {
my $level = length($1);
my $content = $2;
push @elements, Heading->new(
content => $content,
level => $level,
);
next;
}
push @elements, Paragraph->new(content => $line);
}
return @elements;
}
}
# === 変換関数(if/else地獄) ===
package main {
use experimental qw(signatures);
sub to_html ($element) {
my $type = $element->type;
if ($type eq 'heading') {
my $level = $element->level;
return "<h$level>" . $element->content . "</h$level>";
}
elsif ($type eq 'paragraph') {
return "<p>" . $element->content . "</p>";
}
elsif ($type eq 'code_block') {
my $lang = $element->language;
if ($lang) {
return "<pre><code class=\"language-$lang\">"
. $element->content
. "</code></pre>";
}
return "<pre><code>" . $element->content . "</code></pre>";
}
else {
return $element->content;
}
}
sub to_text ($element) {
my $type = $element->type;
if ($type eq 'heading') {
my $level = $element->level;
my $prefix = "=" x (7 - $level);
return "$prefix " . $element->content;
}
elsif ($type eq 'paragraph') {
return $element->content;
}
elsif ($type eq 'code_block') {
return "---\n" . $element->content . "---";
}
else {
return $element->content;
}
}
my $markdown = <<'MARKDOWN';
# タイトル
これは段落です。
```perl
my $x = 1;
```
MARKDOWN
my $parser = Parser->new();
my @elements = $parser->parse($markdown);
say "=== HTML出力 ===";
for my $elem (@elements) {
say to_html($elem);
}
say "\n=== テキスト出力 ===";
for my $elem (@elements) {
say to_text($elem);
}
}
|
次回予告
次回は、この「if/else地獄」を解消するために、変換処理を別のクラスに分離していきます。ConverterクラスとHtmlConverterを作って、処理を委譲する方法を学びましょう。
お楽しみに!