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

[インデックス 13556] ファイルの概要

このコミットは、Go言語の実験的なHTMLパーシングパッケージ exp/html における、低レベルのトークナイザの挙動を改善するものです。具体的には、HTMLの終了タグに誤って付与された属性を、トークナイザが内部的に「スキップ」(実際には読み飛ばすが、メモリに保存しない)するように変更し、効率性を向上させています。

コミット

commit 1916db786fe8a9ff2aa775eb6f68c3a7ff00f2c6
Author: Nigel Tao <nigeltao@golang.org>
Date:   Fri Aug 3 09:29:16 2012 +1000

    html: make the low-level tokenizer also skip end-tag attributes.
    
    R=andybalholm
    CC=golang-dev
    https://golang.org/cl/6453071

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/1916db786fe8a9ff2aa775eb6f68c3a7ff00f2c6

元コミット内容

html: make the low-level tokenizer also skip end-tag attributes.

R=andybalholm
CC=golang-dev
https://golang.org/cl/6453071

変更の背景

HTML5の仕様では、終了タグ(例: </div>)に属性を含めることは許可されていません。しかし、現実世界のHTMLドキュメントには、誤って </div class="foo"> のように属性が付与されている場合があります。堅牢なHTMLパーサは、このような不正な構造に対しても適切に動作し、エラーを発生させることなく、仕様に沿ってこれらの属性を無視する必要があります。

このコミット以前の exp/html パッケージのトークナイザは、終了タグに属性が存在する場合、低レベルの readTag 関数でそれらの属性を一度読み込み、内部の属性リスト (z.attr) に保存していました。その後、高レベルの Token メソッドがトークンを生成する際に、それが終了タグであれば、保存された属性を明示的に破棄していました。

この二段階の処理は、不要なメモリ割り当てと処理オーバーヘッドを生じさせていました。変更の背景には、この非効率性を解消し、終了タグの属性を最初からメモリに保存しないようにすることで、パーサのパフォーマンスを向上させる目的がありました。

前提知識の解説

HTMLトークナイザ (Lexer)

HTMLトークナイザ(またはレキサ、字句解析器)は、HTMLドキュメントのテキストストリームを、意味のある最小単位である「トークン」のシーケンスに変換するプログラムの一部です。HTMLパーシングの最初の段階であり、例えば、<p> は「開始タグトークン」、</p> は「終了タグトークン」、「Hello」は「文字データトークン」といった具合に識別します。

HTMLパーシング

HTMLパーシングは、HTMLドキュメントを読み込み、その構造を解析して、ブラウザがレンダリングできるようなDOM(Document Object Model)ツリーを構築するプロセスです。トークナイザが生成したトークンストリームを基に、パーサはツリー構造を構築し、HTMLの文法規則に従って要素の親子関係などを決定します。HTML5のパーシングアルゴリズムは非常に複雑で、エラー耐性があり、不正なHTMLに対しても一貫したDOMツリーを構築するように設計されています。

Go言語の exp/html パッケージ

exp/html は、Go言語でHTML5の仕様に準拠したHTMLパーサを提供する実験的なパッケージです。このパッケージは、ウェブスクレイピング、HTMLの変換、HTMLの検証など、様々な用途で利用されます。内部的には、HTML5のパーシングアルゴリズムに厳密に従って、トークナイザとパーサが実装されています。

属性 (Attributes)

HTML要素は、その挙動や表示を修飾するために属性を持つことができます。例えば、<a href="url">href="url" は属性です。属性は通常、開始タグ内に name="value" の形式で記述されます。HTML5の仕様では、終了タグに属性を記述することは許可されていません。

技術的詳細

このコミットの核心は、Tokenizer 構造体の readTag メソッドの変更と、それに関連する呼び出し元の調整です。

  1. readTag メソッドのシグネチャ変更:

    • 変更前: func (z *Tokenizer) readTag()
    • 変更後: func (z *Tokenizer) readTag(saveAttr bool)
    • saveAttr という新しいブーリアン引数が追加されました。この引数は、読み込んだ属性を z.attr スライスに保存するかどうかを制御します。
  2. readTag 内部の属性保存ロジックの変更:

    • 変更前: if z.pendingAttr[0].start != z.pendingAttr[0].end { z.attr = append(z.attr, z.pendingAttr) }
    • 変更後: if saveAttr && z.pendingAttr[0].start != z.pendingAttr[0].end { z.attr = append(z.attr, z.pendingAttr) }
    • saveAttrtrue の場合にのみ、pendingAttr(現在読み込んでいる属性)が z.attr に追加されるようになりました。これにより、saveAttrfalse の場合は、属性が読み飛ばされ、メモリに保存されなくなります。
  3. readTag の呼び出し元の変更:

    • readStartTag: 開始タグを読み込む関数です。
      • 変更前: z.readTag()
      • 変更後: z.readTag(true)
      • 開始タグの属性は常に必要なので、saveAttrtrue が渡されます。
    • Token メソッド内の終了タグ処理: Token メソッドは、次のトークンを返す高レベルのインターフェースです。終了タグを検出した場合の処理です。
      • 変更前: z.readTag()
      • 変更後: z.readTag(false)
      • 終了タグの属性は不要なので、saveAttrfalse が渡されます。これにより、readTag は属性を読み飛ばしますが、z.attr には保存しません。
  4. Token メソッド内の冗長な属性破棄ロジックの削除:

    • 変更前は、Token メソッド内で if z.tt != EndTagToken という条件があり、終了タグでない場合にのみ属性を処理していました。これは、readTag が常に属性を保存していたため、終了タグの属性を明示的に無視するためのものでした。
    • 変更後は、readTag(false) の呼び出しによって終了タグの属性がそもそも z.attr に保存されなくなったため、この条件分岐は不要となり削除されました。これにより、コードが簡潔になり、高レベルでの属性破棄のロジックが不要になりました。

この変更により、HTMLの終了タグに不正な属性が含まれていても、トークナイザはそれらの属性を正しく消費(読み飛ばし)しつつ、不要なメモリ割り当てやデータコピーを回避できるようになりました。これは、HTML5パーシングの堅牢性と効率性の両方を向上させるための重要な改善です。

コアとなるコードの変更箇所

src/pkg/exp/html/token.go ファイルが変更されています。

readStartTag 関数 (行 691-692)

--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -691,7 +691,7 @@ loop:
 // readStartTag reads the next start tag token. The opening "<a" has already
 // been consumed, where 'a' means anything in [A-Za-z].
 func (z *Tokenizer) readStartTag() TokenType {
-	z.readTag()
+	z.readTag(true)
 	if z.err != nil && len(z.attr) == 0 {
 		return ErrorToken
 	}

readTag 関数のシグネチャとコメント、および内部ロジック (行 724-748)

--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -724,9 +724,11 @@ func (z *Tokenizer) readStartTag() TokenType {
 	return StartTagToken
 }
 
-// readTag reads the next tag token. The opening "<a" or "</a" has already been
-// consumed, where 'a' means anything in [A-Za-z].
-func (z *Tokenizer) readTag() {
+// readTag reads the next tag token and its attributes. If saveAttr, those
+// attributes are saved in z.attr, otherwise z.attr is set to an empty slice.
+// The opening "<a" or "</a" has already been consumed, where 'a' means anything
+// in [A-Za-z].
+func (z *Tokenizer) readTag(saveAttr bool) {
 	z.attr = z.attr[:0]
 	z.nAttrReturned = 0
 	// Read the tag name and attribute key/value pairs.
@@ -742,8 +744,8 @@ func (z *Tokenizer) readTag() {
 		z.raw.end--
 		z.readTagAttrKey()
 		z.readTagAttrVal()
-		// Save pendingAttr if it has a non-empty key.
-		if z.pendingAttr[0].start != z.pendingAttr[0].end {
+		// Save pendingAttr if saveAttr and that attribute has a non-empty key.
+		if saveAttr && z.pendingAttr[0].start != z.pendingAttr[0].end {
 			z.attr = append(z.attr, z.pendingAttr)
 		}
 		if z.skipWhiteSpace(); z.err != nil {

Token 関数内の終了タグ処理 (行 945-947)

--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -945,7 +947,7 @@ loop:
 				continue loop
 			}
 			if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' {
-				z.readTag()
+				z.readTag(false)
 				z.tt = EndTagToken
 				return z.tt
 			}

Token 関数内の属性処理ロジックの簡素化 (行 1078-1090)

--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -1078,15 +1080,10 @@ func (z *Tokenizer) Token() Token {
 		t.Data = string(z.Text())
 	case StartTagToken, SelfClosingTagToken, EndTagToken:
 		name, moreAttr := z.TagName()
-		// Since end tags should not have attributes, the high-level tokenizer
-		// interface will not return attributes for an end tag token even if
-		// it looks like </br foo="bar">.
-		if z.tt != EndTagToken {
-			for moreAttr {
-				var key, val []byte
-				key, val, moreAttr = z.TagAttr()
-				t.Attr = append(t.Attr, Attribute{"", atom.String(key), string(val)})
-			}
+		for moreAttr {
+			var key, val []byte
+			key, val, moreAttr = z.TagAttr()
+			t.Attr = append(t.Attr, Attribute{"", atom.String(key), string(val)})
 		}
 		if a := atom.Lookup(name); a != 0 {
 			t.DataAtom, t.Data = a, a.String()

コアとなるコードの解説

このコミットの主要な変更点は、Tokenizer 構造体の readTag メソッドに saveAttr というブーリアン引数を導入したことです。

  1. readTag(saveAttr bool) の導入:

    • この関数は、タグ名とそれに続く属性(もしあれば)を読み込む低レベルの処理を担当します。
    • saveAttrtrue の場合、読み込んだ属性は z.attr スライスに格納されます。
    • saveAttrfalse の場合、属性は入力ストリームから読み飛ばされますが、z.attr には追加されません。これにより、不要なメモリ割り当てとデータコピーが回避されます。
  2. readStartTag() からの呼び出し:

    • 開始タグ(例: <div class="container">)の場合、属性はDOMツリー構築に必要不可欠です。そのため、readStartTag 関数は z.readTag(true) を呼び出し、属性が正しく保存されるようにします。
  3. 終了タグ処理からの呼び出し:

    • HTML5の仕様では、終了タグ(例: </div>)に属性は存在しません。しかし、不正なHTML(例: </div id="footer">)が存在する可能性を考慮し、トークナイザはこれらの不正な属性も読み飛ばす必要があります。
    • Token メソッド内で終了タグが検出された場合、z.readTag(false) が呼び出されます。これにより、readTag は不正な属性を読み飛ばして入力ストリームのポインタを進めますが、それらの属性を z.attr に保存しないため、メモリ効率が向上します。
  4. Token メソッドの簡素化:

    • 以前は、Token メソッド内で if z.tt != EndTagToken という条件分岐があり、終了タグの場合には属性を返さないように明示的に制御していました。
    • readTag(false) の導入により、終了タグの属性はそもそも z.attr に保存されなくなったため、この条件分岐は不要になりました。これにより、Token メソッドのコードがよりシンプルになり、低レベルのトークナイザが属性のフィルタリングを適切に行うようになりました。

この一連の変更により、exp/html パッケージは、HTML5の厳密な仕様に準拠しつつ、不正なHTML入力に対してもより効率的かつ堅牢に動作するようになりました。

関連リンク

参考にした情報源リンク