[インデックス 13650] ファイルの概要
このコミットは、Go言語の実験的なHTMLパーサーライブラリ exp/html
における内部的なリファクタリングに関するものです。具体的には、HTMLパーサーがトークナイザー(字句解析器)の内部状態を直接操作するのではなく、エクスポートされた(公開された)メソッドを介して操作するように変更されています。これにより、モジュール間の結合度が低減され、コードの保守性と堅牢性が向上しています。
コミット
commit 2b14a48d5474831ff992a180ca563a22276a2332
Author: Nigel Tao <nigeltao@golang.org>
Date: Mon Aug 20 11:04:36 2012 +1000
exp/html: make the parser manipulate the tokenizer via exported methods
instead of touching the tokenizer's internal state.
R=andybalholm
CC=golang-dev
https://golang.org/cl/6446153
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/2b14a48d5474831ff992a180ca563a22276a2332
元コミット内容
exp/html: make the parser manipulate the tokenizer via exported methods instead of touching the tokenizer's internal state.
(exp/html: パーサーがトークナイザーの内部状態を直接触るのではなく、エクスポートされたメソッドを介してトークナイザーを操作するように変更)
変更の背景
この変更の背景には、ソフトウェア設計における「カプセル化(Encapsulation)」の原則があります。Go言語の exp/html
パッケージは、HTMLドキュメントのパース(構文解析)を行うためのライブラリであり、その内部でトークナイザー(Tokenizer
)とパーサー(parser
)が密接に連携しています。
以前の実装では、パーサーがトークナイザーの内部フィールド(例: cdataOK
, rawTag
)に直接アクセスし、その状態を変更していました。これは、モジュール間の結合度を高め、以下のような問題を引き起こす可能性があります。
- 保守性の低下: トークナイザーの内部実装が変更された場合、それに依存しているパーサーのコードも変更する必要が生じ、変更の影響範囲が広がる。
- 堅牢性の低下: パーサーがトークナイザーの内部状態を不適切に操作する可能性があり、予期せぬバグにつながる。
- テストの困難さ: 内部状態に直接アクセスするコードは、単体テストが難しくなる傾向がある。
このコミットは、これらの問題を解決するために、パーサーがトークナイザーの内部状態を直接操作するのをやめ、トークナイザー自身が提供する公開メソッド(エクスポートされたメソッド)を介して操作するように変更することで、よりクリーンで保守性の高い設計を目指しています。
前提知識の解説
このコミットを理解するためには、以下の概念が役立ちます。
- HTMLパーサー: HTMLドキュメントを読み込み、その構造を解析して、プログラムが扱えるデータ構造(通常はDOMツリー)に変換するソフトウェアコンポーネントです。
- トークナイザー(字句解析器): パーサーの最初の段階で、入力された文字列(この場合はHTMLソースコード)を意味のある最小単位(トークン)に分割する役割を担います。例えば、
<p>
は開始タグトークン、Hello
はテキストトークン、</p>
は終了タグトークンといった具合です。 - カプセル化(Encapsulation): オブジェクト指向プログラミングにおける重要な原則の一つで、データ(状態)とそのデータを操作するメソッドを一つの単位(クラスや構造体)にまとめ、外部から直接データにアクセスできないようにすることです。これにより、データの整合性を保ち、モジュール間の依存関係を減らします。Go言語では、フィールド名やメソッド名が小文字で始まるものはパッケージプライベート(非公開)、大文字で始まるものはエクスポート可能(公開)となります。
- CDATAセクション: XMLやHTMLにおいて、マークアップとして解釈されたくないテキストブロックを指定するための構文です。HTML5では、主にSVGやMathMLといった外部コンテンツ内で使用されます。
<![CDATA[...]]>
の形式で記述されます。 - Raw Text (生テキスト): HTMLの特定の要素(例:
<script>
,<style>
,<textarea>
,<title>
)の内部コンテンツは、通常のHTMLマークアップとしてパースされず、そのままのテキストとして扱われます。これをRaw Textと呼びます。例えば、<script>alert("<b>Hello</b>")</script>
のalert("<b>Hello</b>")
の部分は、<b>
がタグとして解釈されず、単なるテキストとして扱われます。 - HTMLフラグメントのパース: HTMLドキュメント全体ではなく、HTMLの一部(フラグメント)をパースすることです。例えば、既存の要素の
innerHTML
をパースする場合などです。この際、パースの挙動は、そのフラグメントがどの要素の内部にあるか(コンテキスト)によって変わることがあります。
技術的詳細
このコミットの技術的な核心は、Tokenizer
構造体の内部状態を外部から直接変更することを禁止し、代わりに公開メソッドを介した操作を強制することです。
具体的には、以下の変更が行われました。
-
Tokenizer.cdataOK
フィールドの変更:Tokenizer
構造体からcdataOK
というブール型のフィールドが削除されました。- 代わりに
allowCDATA
というブール型のフィールドが追加されました。これは、CDATAセクションの認識を許可するかどうかを制御します。 - この
allowCDATA
フィールドを操作するために、AllowCDATA(allowCDATA bool)
という公開メソッドがTokenizer
に追加されました。パーサーは、このメソッドを呼び出すことで、トークナイザーのCDATAセクション処理の挙動を制御します。
-
Tokenizer.rawTag
フィールドの操作の変更:Tokenizer
構造体のrawTag
フィールドは、トークナイザーがRaw Textモードで動作しているかどうか、およびどのタグのRaw Textを処理しているかを内部的に示すために使用されます。- 以前は、パーサーが
p.tokenizer.rawTag = ""
のように直接このフィールドを空文字列に設定することで、Raw Textモードを解除していました。 - このコミットでは、
NextIsNotRawText()
という公開メソッドがTokenizer
に追加されました。このメソッドは内部でz.rawTag = ""
を実行します。これにより、パーサーはp.tokenizer.NextIsNotRawText()
を呼び出すことで、Raw Textモードの解除をトークナイザーに依頼する形になります。
-
ParseFragment
関数の変更:- HTMLフラグメントをパースする
ParseFragment
関数において、以前はcontext
ノードのタグに基づいてp.tokenizer.rawTag
を直接設定していました。 - この変更により、
NewTokenizerFragment
という新しいコンストラクタ関数が導入されました。ParseFragment
は、このNewTokenizerFragment
にcontextTag
を渡すようになりました。 NewTokenizerFragment
は、内部でTokenizer
を初期化する際に、渡されたcontextTag
に基づいてz.rawTag
を適切に設定します。これにより、フラグメントパース時のRaw Textモードの初期設定もカプセル化されました。
- HTMLフラグメントをパースする
これらの変更により、parse.go
は token.go
の内部実装に直接依存することなく、公開されたAPIを通じてのみ Tokenizer
とやり取りするようになります。これは、Go言語における「インターフェースによるプログラミング」の原則にも合致し、より疎結合な設計を実現します。
コアとなるコードの変更箇所
src/pkg/exp/html/parse.go
--- a/src/pkg/exp/html/parse.go
+++ b/src/pkg/exp/html/parse.go
@@ -402,7 +402,7 @@ func (p *parser) reconstructActiveFormattingElements() {
func (p *parser) read() error {
// CDATA sections are allowed only in foreign content.
n := p.oe.top()
- p.tokenizer.cdataOK = n != nil && n.Namespace != ""
+ p.tokenizer.AllowCDATA(n != nil && n.Namespace != "")
p.tokenizer.Next()
p.tok = p.tokenizer.Token()
@@ -1613,9 +1613,9 @@ func inSelectIM(p *parser) bool {
p.parseImpliedToken(EndTagToken, a.Select, a.Select.String())
return false
}
- // Ignore the token.
// In order to properly ignore <textarea>, we need to change the tokenizer mode.
- p.tokenizer.rawTag = ""
+ p.tokenizer.NextIsNotRawText()
+ // Ignore the token.
return true
case a.Script:
return inHeadIM(p)
@@ -1921,7 +1921,7 @@ func parseForeignContent(p *parser) bool {
if namespace != "" {
// Don't let the tokenizer go into raw text mode in foreign content
// (e.g. in an SVG <title> tag).
- p.tokenizer.rawTag = ""
+ p.tokenizer.NextIsNotRawText()
}
if p.hasSelfClosingToken {
p.oe.pop()
@@ -2046,16 +2046,7 @@ func Parse(r io.Reader) (*Node, error) {
// found. If the fragment is the InnerHTML for an existing element, pass that
// element in context.
func ParseFragment(r io.Reader, context *Node) ([]*Node, error) {
-\tp := &parser{
-\t\ttokenizer: NewTokenizer(r),\n-\t\tdoc: &Node{\n-\t\t\tType: DocumentNode,\n-\t\t},\n-\t\tscripting: true,\n-\t\tfragment: true,\n-\t\tcontext: context,\n-\t}\n-\
+\tcontextTag := ""
\tif context != nil {
\t\tif context.Type != ElementNode {
\t\t\treturn nil, errors.New("html: ParseFragment of non-element Node")
@@ -2066,10 +2057,16 @@ func ParseFragment(r io.Reader, context *Node) ([]*Node, error) {
\t\tif context.DataAtom != a.Lookup([]byte(context.Data)) {
\t\t\treturn nil, fmt.Errorf("html: inconsistent Node: DataAtom=%q, Data=%q", context.DataAtom, context.Data)
\t\t}\n-\t\tswitch context.DataAtom {\n-\t\tcase a.Iframe, a.Noembed, a.Noframes, a.Noscript, a.Plaintext, a.Script, a.Style, a.Title, a.Textarea, a.Xmp:\n-\t\t\tp.tokenizer.rawTag = context.DataAtom.String()\n-\t\t}\n+\t\tcontextTag = context.DataAtom.String()
+\t}
+\tp := &parser{
+\t\ttokenizer: NewTokenizerFragment(r, contextTag),
+\t\tdoc: &Node{
+\t\t\tType: DocumentNode,
+\t\t},
+\t\tscripting: true,
+\t\tfragment: true,
+\t\tcontext: context,
\t}
\n \troot := &Node{
src/pkg/exp/html/token.go
--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -155,8 +155,54 @@ type Tokenizer struct {
// convertNUL is whether NUL bytes in the current token's data should
// be converted into \ufffd replacement characters.
convertNUL bool
- // cdataOK is whether CDATA sections are allowed in the current context.
- cdataOK bool
+ // allowCDATA is whether CDATA sections are allowed in the current context.
+ allowCDATA bool
+}
+
+// AllowCDATA sets whether or not the tokenizer recognizes <![CDATA[foo]]> as
+// the text "foo". The default value is false, which means to recognize it as
+// a bogus comment "<!-- [CDATA[foo]] -->" instead.
+//
+// Strictly speaking, an HTML5 compliant tokenizer should allow CDATA if and
+// only if tokenizing foreign content, such as MathML and SVG. However,
+// tracking foreign-contentness is difficult to do purely in the tokenizer,
+// as opposed to the parser, due to HTML integration points: an <svg> element
+// can contain a <foreignObject> that is foreign-to-SVG but not foreign-to-
+// HTML. For strict compliance with the HTML5 tokenization algorithm, it is the
+// responsibility of the user of a tokenizer to call AllowCDATA as appropriate.
+// In practice, if using the tokenizer without caring whether MathML or SVG
+// CDATA is text or comments, such as tokenizing HTML to find all the anchor
+// text, it is acceptable to ignore this responsibility.
+func (z *Tokenizer) AllowCDATA(allowCDATA bool) {
+ z.allowCDATA = allowCDATA
+}
+
+// NextIsNotRawText instructs the tokenizer that the next token should not be
+// considered as 'raw text'. Some elements, such as script and title elements,
+// normally require the next token after the opening tag to be 'raw text' that
+// has no child elements. For example, tokenizing " <title>a<b>c</b>d</title>"
+// yields a start tag token for "<title>", a text token for "a<b>c</b>d", and
+// an end tag token for "</title>". There are no distinct start tag or end tag
+// tokens for the "<b>" and "</b>".
+//
+// This tokenizer implementation will generally look for raw text at the right
+// times. Strictly speaking, an HTML5 compliant tokenizer should not look for
+// raw text if in foreign content: <title> generally needs raw text, but a
+// <title> inside an <svg> does not. Another example is that a <textarea>
+// generally needs raw text, but a <textarea> is not allowed as an immediate
+// child of a <select>; in normal parsing, a <textarea> implies </select>, but
+// one cannot close the implicit element when parsing a <select>'s InnerHTML.
+// Similarly to AllowCDATA, tracking the correct moment to override raw-text-
+// ness is difficult to do purely in the tokenizer, as opposed to the parser.
+// For strict compliance with the HTML5 tokenization algorithm, it is the
+// responsibility of the user of a tokenizer to call NextIsNotRawText as
+// appropriate. In practice, like AllowCDATA, it is acceptable to ignore this
+// responsibility for basic usage.
+//
+// Note that this 'raw text' concept is different from the one offered by the
+// Tokenizer.Raw method.
+func (z *Tokenizer) NextIsNotRawText() {
+ z.rawTag = ""
}
// Err returns the error associated with the most recent ErrorToken token.
@@ -592,7 +638,7 @@ func (z *Tokenizer) readMarkupDeclaration() TokenType {
if z.readDoctype() {
return DoctypeToken
}
- if z.cdataOK && z.readCDATA() {
+ if z.allowCDATA && z.readCDATA() {
z.convertNUL = true
return TextToken
}
@@ -1101,8 +1147,27 @@ func (z *Tokenizer) Token() Token {
// NewTokenizer returns a new HTML Tokenizer for the given Reader.
// The input is assumed to be UTF-8 encoded.
func NewTokenizer(r io.Reader) *Tokenizer {
- return &Tokenizer{
+ return NewTokenizerFragment(r, "")
+}
+
+// NewTokenizerFragment returns a new HTML Tokenizer for the given Reader, for
+// tokenizing an exisitng element's InnerHTML fragment. contextTag is that
+// element's tag, such as "div" or "iframe".
+//
+// For example, how the InnerHTML "a<b" is tokenized depends on whether it is
+// for a <p> tag or a <script> tag.
+//
+// The input is assumed to be UTF-8 encoded.
+func NewTokenizerFragment(r io.Reader, contextTag string) *Tokenizer {
+ z := &Tokenizer{
r: r,
buf: make([]byte, 0, 4096),
}
+ if contextTag != "" {
+ switch s := strings.ToLower(contextTag); s {
+ case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "title", "textarea", "xmp":
+ z.rawTag = s
+ }
+ }
+ return z
}
コアとなるコードの解説
src/pkg/exp/html/parse.go
の変更点
-
CDATAセクションの制御:
p.tokenizer.cdataOK = n != nil && n.Namespace != ""
がp.tokenizer.AllowCDATA(n != nil && n.Namespace != "")
に変更されました。- これは、パーサーがトークナイザーの内部フィールド
cdataOK
を直接設定する代わりに、Tokenizer
型の公開メソッドAllowCDATA
を呼び出して、CDATAセクションの許可状態をトークナイザーに伝えるようになったことを意味します。
-
Raw Textモードの解除:
p.tokenizer.rawTag = ""
がp.tokenizer.NextIsNotRawText()
に変更されました。- これにより、パーサーはトークナイザーの内部フィールド
rawTag
を直接操作するのではなく、Tokenizer
型の公開メソッドNextIsNotRawText
を呼び出して、Raw Textモードを解除するよう指示するようになりました。
-
ParseFragment
の初期化:ParseFragment
関数内でparser
構造体を初期化する際、以前はp.tokenizer.rawTag = context.DataAtom.String()
のように直接rawTag
を設定していました。- 変更後、
NewTokenizerFragment(r, contextTag)
という新しいコンストラクタ関数を使用してTokenizer
を初期化するようになりました。contextTag
は、フラグメントがパースされるコンテキストのHTMLタグ(例: "div", "script")を表します。これにより、Tokenizer
の初期化ロジックがtoken.go
内にカプセル化され、parse.go
からは初期化の詳細が見えなくなりました。
src/pkg/exp/html/token.go
の変更点
-
Tokenizer
構造体のフィールド変更:cdataOK bool
フィールドが削除され、allowCDATA bool
フィールドが追加されました。これは、より明確な命名と、後述の公開メソッドによる制御のためです。
-
AllowCDATA
メソッドの追加:func (z *Tokenizer) AllowCDATA(allowCDATA bool)
が追加されました。このメソッドは、トークナイザーがCDATAセクションをテキストとして認識するかどうかを制御します。パーサーは、このメソッドを呼び出すことで、トークナイザーのCDATA処理の挙動を外部から安全に設定できます。
-
NextIsNotRawText
メソッドの追加:func (z *Tokenizer) NextIsNotRawText()
が追加されました。このメソッドは、トークナイザーに対して、次のトークンをRaw Textとして扱わないように指示します。内部的にはz.rawTag = ""
を実行し、Raw Textモードを解除します。
-
readMarkupDeclaration
の変更:if z.cdataOK && z.readCDATA()
がif z.allowCDATA && z.readCDATA()
に変更されました。これは、新しいフィールド名allowCDATA
の使用に合わせて修正されたものです。
-
NewTokenizer
の変更:NewTokenizer
関数は、直接Tokenizer
を初期化する代わりに、新しく追加されたNewTokenizerFragment(r, "")
を呼び出すようになりました。これは、通常のドキュメントパースが、コンテキストタグが空のフラグメントパースとして扱えることを示唆しています。
-
NewTokenizerFragment
の追加:func NewTokenizerFragment(r io.Reader, contextTag string) *Tokenizer
が追加されました。このコンストラクタは、HTMLフラグメントのパース用にTokenizer
を初期化します。contextTag
引数を受け取り、そのタグがiframe
,noembed
,noframes
,noscript
,plaintext
,script
,style
,title
,textarea
,xmp
のいずれかである場合、z.rawTag
をそのタグ名に設定します。これにより、これらの要素のInnerHTML
がRaw Textとして正しくパースされるように初期設定されます。
これらの変更により、Tokenizer
の内部状態は token.go
パッケージ内で完全にカプセル化され、parse.go
は Tokenizer
の公開APIを通じてのみその挙動を制御するようになりました。これは、Go言語の設計思想である「疎結合」と「カプセル化」を促進する良い例です。
関連リンク
- Go言語の
exp/html
パッケージ: https://pkg.go.dev/exp/html (このコミットは古いexp
パッケージのものであり、現在のgolang.org/x/net/html
に相当します) - HTML5仕様 (Tokenization): https://html.spec.whatwg.org/multipage/parsing.html#tokenization
- Go言語におけるカプセル化: https://go.dev/doc/effective_go#exported_names
参考にした情報源リンク
- Go言語の公式ドキュメント
- HTML5仕様書
- ソフトウェア設計原則(カプセル化、疎結合)に関する一般的な知識
- コミットメッセージと差分(diff)の内容
- Go言語のソースコード慣習