Featured image of post 第7回-単語数カウントとリンク抽出を追加しよう - PerlとMooでドキュメント変換ツールを作ってみよう

第7回-単語数カウントとリンク抽出を追加しよう - PerlとMooでドキュメント変換ツールを作ってみよう

単語数をカウントするWordCounterと、リンクを抽出するLinkExtractorを追加します。変換だけでなく分析系の操作も同じ仕組みで実現でき、複数のVisitorが共存できることを確認しましょう。

@nqounetです。

前回は、TextConverterを追加して、OCP(開放閉鎖の原則)を体験しました。今回は、変換以外の操作も追加してみましょう。

このシリーズについて

このシリーズは「Mooで覚えるオブジェクト指向プログラミング」シリーズを読了した方を対象に、実践的なドキュメント変換ツールを作りながらオブジェクト指向設計を深く学ぶシリーズです。

変換だけじゃない

複数のVisitorが同じ要素構造で動作する様子

これまでは「変換」操作(HTML変換、テキスト変換)を追加してきました。でも、ドキュメントに対して行いたい操作は変換だけではありません。

  • 単語数をカウントしたい
  • リンクを抽出したい
  • 見出しだけを抜き出して目次を作りたい

こうした「分析」操作も、同じ仕組みで追加できます。

WordCounterを作る

まず、ドキュメント全体の単語数(日本語の場合は文字数)をカウントするVisitorを作りましょう。

 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
package WordCounter;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo

use Moo;
use experimental qw(signatures);

has total_chars => (
    is      => 'rw',
    default => 0,
);

sub visit_heading ($self, $element) {
    my $chars = length($element->content);
    $self->total_chars($self->total_chars + $chars);
    return $chars;
}

sub visit_paragraph ($self, $element) {
    my $chars = length($element->content);
    $self->total_chars($self->total_chars + $chars);
    return $chars;
}

sub visit_code_block ($self, $element) {
    # コードブロックは文字数に含めない
    return 0;
}

sub get_total ($self) {
    return $self->total_chars;
}

1;

このVisitorは、各要素を訪問するたびに文字数を累積していきます。

注意: 今回は「単語数」ではなく「文字数」をカウントしています。英語の単語数などを正確に数えるには、トークナイズのルールが別途必要です。

LinkExtractorを作る

次に、ドキュメント内のリンク(URLっぽい文字列)を抽出するVisitorを作りましょう。

 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
package LinkExtractor;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo

use Moo;
use experimental qw(signatures);

has links => (
    is      => 'rw',
    default => sub { [] },
);

sub visit_heading ($self, $element) {
    $self->_extract_links($element->content);
    return;
}

sub visit_paragraph ($self, $element) {
    $self->_extract_links($element->content);
    return;
}

sub visit_code_block ($self, $element) {
    # コードブロック内のURLは無視
    return;
}

sub _extract_links ($self, $text) {
    # URLっぽい文字列を抽出
    while ($text =~ m{(https?://[^\s<>\"]+)}g) {
        push $self->links->@*, $1;
    }
}

sub get_links ($self) {
    return $self->links->@*;
}

1;

このVisitorは、テキスト内からURLパターンにマッチする文字列を抽出していきます。

注意: ここでは「URLっぽい文字列」を正規表現で拾っているだけで、Markdownのリンク記法([text](url))を厳密に解析しているわけではありません。必要に応じてパーサー側の強化を検討してください。

使ってみよう

複数のVisitorを同じドキュメントに適用してみましょう。

 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
#!/usr/bin/env perl
use v5.36;
use lib '.';
use Parser;
use HtmlConverter;
use TextConverter;
use WordCounter;
use LinkExtractor;

my $markdown = <<'MARKDOWN';
# ドキュメント変換ツール

このツールはMarkdownをHTMLに変換できます。
詳細は https://example.com/docs を参照してください。

## 使い方

公式サイト https://example.com からダウンロードできます。

```perl
my $parser = Parser->new();
```

上記のコードで使えます。
MARKDOWN

my $parser = Parser->new();
my @elements = $parser->parse($markdown);

# 文字数カウント
my $counter = WordCounter->new();
for my $elem (@elements) {
    $elem->accept($counter);
}

# リンク抽出
my $extractor = LinkExtractor->new();
for my $elem (@elements) {
    $elem->accept($extractor);
}

say "=== 文字数 ===";
say "総文字数: " . $counter->get_total . " 文字";

say "\n=== 抽出されたリンク ===";
for my $link ($extractor->get_links) {
    say "  - $link";
}

実行結果:

1
2
3
4
5
6
=== 文字数 ===
総文字数: 122 文字

=== 抽出されたリンク ===
  - https://example.com/docs
  - https://example.com

3種類のVisitorが共存している

現在、以下のVisitorが同じ要素構造に対して動作しています。

	flowchart LR
    subgraph Elements["ドキュメント要素"]
        H[Heading]
        P[Paragraph]
        C[CodeBlock]
    end
    
    subgraph Visitors["操作(Visitor)"]
        V1[HtmlConverter]
        V2[TextConverter]
        V3[WordCounter]
        V4[LinkExtractor]
    end
    
    H --> V1
    H --> V2
    H --> V3
    H --> V4
    
    P --> V1
    P --> V2
    P --> V3
    P --> V4
    
    C --> V1
    C --> V2
    C --> V3
    C --> V4

要素クラスを変更することなく、新しい操作をいくらでも追加できます。

各Visitorの役割まとめ

Visitor役割状態の保持
HtmlConverterHTML変換なし
TextConverterテキスト変換なし
WordCounter文字数カウントtotal_chars
LinkExtractorリンク抽出links

ConverterはStateless(状態なし)で、CounterやExtractorはStateful(状態あり)です。どちらのパターンも同じ仕組みで実現できています。

今回のポイント

今回は以下のことを学びました。

  • 変換以外の操作(分析)も同じ仕組みで追加できる
  • 状態を持つVisitor(カウンター、コレクター)の実装
  • 複数のVisitorが同じ要素構造に対して動作できる

今回の完成コード

以下が今回作成したコードの完成版です。

  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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
#!/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 accept ($self, $visitor) {
        die "accept must be implemented by subclass";
    }
}

# === Paragraph ===
package Paragraph {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    sub accept ($self, $visitor) {
        return $visitor->visit_paragraph($self);
    }
}

# === Heading ===
package Heading {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has level => (
        is      => 'ro',
        default => 1,
    );

    sub accept ($self, $visitor) {
        return $visitor->visit_heading($self);
    }
}

# === CodeBlock ===
package CodeBlock {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has language => (
        is      => 'ro',
        default => '',
    );

    sub accept ($self, $visitor) {
        return $visitor->visit_code_block($self);
    }
}

# === 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;
    }
}

# === HtmlConverter ===
package HtmlConverter {
    use Moo;
    use experimental qw(signatures);

    sub visit_heading ($self, $element) {
        my $level = $element->level;
        return "<h$level>" . $element->content . "</h$level>";
    }

    sub visit_paragraph ($self, $element) {
        return "<p>" . $element->content . "</p>";
    }

    sub visit_code_block ($self, $element) {
        my $lang = $element->language;
        if ($lang) {
            return "<pre><code class=\"language-$lang\">" 
                   . $element->content 
                   . "</code></pre>";
        }
        return "<pre><code>" . $element->content . "</code></pre>";
    }
}

# === TextConverter ===
package TextConverter {
    use Moo;
    use experimental qw(signatures);

    sub visit_heading ($self, $element) {
        my $level = $element->level;
        my $prefix = "=" x (7 - $level);
        return "$prefix " . $element->content;
    }

    sub visit_paragraph ($self, $element) {
        return $element->content;
    }

    sub visit_code_block ($self, $element) {
        my $content = $element->content;
        $content =~ s/\n$//;
        return "---\n" . $content . "\n---";
    }
}

# === WordCounter ===
package WordCounter {
    use Moo;
    use experimental qw(signatures);

    has total_chars => (
        is      => 'rw',
        default => 0,
    );

    sub visit_heading ($self, $element) {
        my $chars = length($element->content);
        $self->total_chars($self->total_chars + $chars);
        return $chars;
    }

    sub visit_paragraph ($self, $element) {
        my $chars = length($element->content);
        $self->total_chars($self->total_chars + $chars);
        return $chars;
    }

    sub visit_code_block ($self, $element) {
        return 0;
    }

    sub get_total ($self) {
        return $self->total_chars;
    }
}

# === LinkExtractor ===
package LinkExtractor {
    use Moo;
    use experimental qw(signatures);

    has links => (
        is      => 'rw',
        default => sub { [] },
    );

    sub visit_heading ($self, $element) {
        $self->_extract_links($element->content);
        return;
    }

    sub visit_paragraph ($self, $element) {
        $self->_extract_links($element->content);
        return;
    }

    sub visit_code_block ($self, $element) {
        return;
    }

    sub _extract_links ($self, $text) {
        while ($text =~ m{(https?://[^\s<>\"]+)}g) {
            push $self->links->@*, $1;
        }
    }

    sub get_links ($self) {
        return $self->links->@*;
    }
}

# === メイン処理 ===
package main {
    my $markdown = <<'MARKDOWN';
# ドキュメント変換ツール

このツールはMarkdownをHTMLに変換できます。
詳細は https://example.com/docs を参照してください。

## 使い方

公式サイト https://example.com からダウンロードできます。

```perl
my $parser = Parser->new();
```

上記のコードで使えます。
MARKDOWN

    my $parser = Parser->new();
    my @elements = $parser->parse($markdown);

    my $counter = WordCounter->new();
    for my $elem (@elements) {
        $elem->accept($counter);
    }

    my $extractor = LinkExtractor->new();
    for my $elem (@elements) {
        $elem->accept($extractor);
    }

    say "=== 文字数 ===";
    say "総文字数: " . $counter->get_total . " 文字";

    say "\n=== 抽出されたリンク ===";
    for my $link ($extractor->get_links) {
        say "  - $link";
    }
}

次回予告

最終回では、私たちがこのシリーズで作り上げてきたものの正体を明かします。実は、この設計は「Visitorパターン」という有名なデザインパターンだったのです!

パターンの正式な定義、構成要素の対応表、そしてこのパターンが威力を発揮する場面について解説します。

お楽しみに!

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。