Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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)に直接アクセスし、その状態を変更していました。これは、モジュール間の結合度を高め、以下のような問題を引き起こす可能性があります。

  1. 保守性の低下: トークナイザーの内部実装が変更された場合、それに依存しているパーサーのコードも変更する必要が生じ、変更の影響範囲が広がる。
  2. 堅牢性の低下: パーサーがトークナイザーの内部状態を不適切に操作する可能性があり、予期せぬバグにつながる。
  3. テストの困難さ: 内部状態に直接アクセスするコードは、単体テストが難しくなる傾向がある。

このコミットは、これらの問題を解決するために、パーサーがトークナイザーの内部状態を直接操作するのをやめ、トークナイザー自身が提供する公開メソッド(エクスポートされたメソッド)を介して操作するように変更することで、よりクリーンで保守性の高い設計を目指しています。

前提知識の解説

このコミットを理解するためには、以下の概念が役立ちます。

  • 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 構造体の内部状態を外部から直接変更することを禁止し、代わりに公開メソッドを介した操作を強制することです。

具体的には、以下の変更が行われました。

  1. Tokenizer.cdataOK フィールドの変更:

    • Tokenizer 構造体から cdataOK というブール型のフィールドが削除されました。
    • 代わりに allowCDATA というブール型のフィールドが追加されました。これは、CDATAセクションの認識を許可するかどうかを制御します。
    • この allowCDATA フィールドを操作するために、AllowCDATA(allowCDATA bool) という公開メソッドが Tokenizer に追加されました。パーサーは、このメソッドを呼び出すことで、トークナイザーのCDATAセクション処理の挙動を制御します。
  2. Tokenizer.rawTag フィールドの操作の変更:

    • Tokenizer 構造体の rawTag フィールドは、トークナイザーがRaw Textモードで動作しているかどうか、およびどのタグのRaw Textを処理しているかを内部的に示すために使用されます。
    • 以前は、パーサーが p.tokenizer.rawTag = "" のように直接このフィールドを空文字列に設定することで、Raw Textモードを解除していました。
    • このコミットでは、NextIsNotRawText() という公開メソッドが Tokenizer に追加されました。このメソッドは内部で z.rawTag = "" を実行します。これにより、パーサーは p.tokenizer.NextIsNotRawText() を呼び出すことで、Raw Textモードの解除をトークナイザーに依頼する形になります。
  3. ParseFragment 関数の変更:

    • HTMLフラグメントをパースする ParseFragment 関数において、以前は context ノードのタグに基づいて p.tokenizer.rawTag を直接設定していました。
    • この変更により、NewTokenizerFragment という新しいコンストラクタ関数が導入されました。ParseFragment は、この NewTokenizerFragmentcontextTag を渡すようになりました。
    • NewTokenizerFragment は、内部で Tokenizer を初期化する際に、渡された contextTag に基づいて z.rawTag を適切に設定します。これにより、フラグメントパース時のRaw Textモードの初期設定もカプセル化されました。

これらの変更により、parse.gotoken.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.goTokenizer の公開APIを通じてのみその挙動を制御するようになりました。これは、Go言語の設計思想である「疎結合」と「カプセル化」を促進する良い例です。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • HTML5仕様書
  • ソフトウェア設計原則(カプセル化、疎結合)に関する一般的な知識
  • コミットメッセージと差分(diff)の内容
  • Go言語のソースコード慣習