[インデックス 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
メソッドの変更と、それに関連する呼び出し元の調整です。
-
readTag
メソッドのシグネチャ変更:- 変更前:
func (z *Tokenizer) readTag()
- 変更後:
func (z *Tokenizer) readTag(saveAttr bool)
saveAttr
という新しいブーリアン引数が追加されました。この引数は、読み込んだ属性をz.attr
スライスに保存するかどうかを制御します。
- 変更前:
-
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) }
saveAttr
がtrue
の場合にのみ、pendingAttr
(現在読み込んでいる属性)がz.attr
に追加されるようになりました。これにより、saveAttr
がfalse
の場合は、属性が読み飛ばされ、メモリに保存されなくなります。
- 変更前:
-
readTag
の呼び出し元の変更:readStartTag
: 開始タグを読み込む関数です。- 変更前:
z.readTag()
- 変更後:
z.readTag(true)
- 開始タグの属性は常に必要なので、
saveAttr
にtrue
が渡されます。
- 変更前:
Token
メソッド内の終了タグ処理:Token
メソッドは、次のトークンを返す高レベルのインターフェースです。終了タグを検出した場合の処理です。- 変更前:
z.readTag()
- 変更後:
z.readTag(false)
- 終了タグの属性は不要なので、
saveAttr
にfalse
が渡されます。これにより、readTag
は属性を読み飛ばしますが、z.attr
には保存しません。
- 変更前:
-
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
というブーリアン引数を導入したことです。
-
readTag(saveAttr bool)
の導入:- この関数は、タグ名とそれに続く属性(もしあれば)を読み込む低レベルの処理を担当します。
saveAttr
がtrue
の場合、読み込んだ属性はz.attr
スライスに格納されます。saveAttr
がfalse
の場合、属性は入力ストリームから読み飛ばされますが、z.attr
には追加されません。これにより、不要なメモリ割り当てとデータコピーが回避されます。
-
readStartTag()
からの呼び出し:- 開始タグ(例:
<div class="container">
)の場合、属性はDOMツリー構築に必要不可欠です。そのため、readStartTag
関数はz.readTag(true)
を呼び出し、属性が正しく保存されるようにします。
- 開始タグ(例:
-
終了タグ処理からの呼び出し:
- HTML5の仕様では、終了タグ(例:
</div>
)に属性は存在しません。しかし、不正なHTML(例:</div id="footer">
)が存在する可能性を考慮し、トークナイザはこれらの不正な属性も読み飛ばす必要があります。 Token
メソッド内で終了タグが検出された場合、z.readTag(false)
が呼び出されます。これにより、readTag
は不正な属性を読み飛ばして入力ストリームのポインタを進めますが、それらの属性をz.attr
に保存しないため、メモリ効率が向上します。
- HTML5の仕様では、終了タグ(例:
-
Token
メソッドの簡素化:- 以前は、
Token
メソッド内でif z.tt != EndTagToken
という条件分岐があり、終了タグの場合には属性を返さないように明示的に制御していました。 readTag(false)
の導入により、終了タグの属性はそもそもz.attr
に保存されなくなったため、この条件分岐は不要になりました。これにより、Token
メソッドのコードがよりシンプルになり、低レベルのトークナイザが属性のフィルタリングを適切に行うようになりました。
- 以前は、
この一連の変更により、exp/html
パッケージは、HTML5の厳密な仕様に準拠しつつ、不正なHTML入力に対してもより効率的かつ堅牢に動作するようになりました。
関連リンク
- Go言語の
exp/html
パッケージのドキュメント:- https://pkg.go.dev/golang.org/x/net/html/atom (現在の
golang.org/x/net/html
パッケージの一部として統合されています)
- https://pkg.go.dev/golang.org/x/net/html/atom (現在の
- HTML5 Parsing Algorithm:
- https://html.spec.whatwg.org/multipage/parsing.html#tokenization (WHATWG HTML Living Standard)
参考にした情報源リンク
- Go CL 6453071: このコミットに対応するGoのコードレビューシステム (Gerrit) のチェンジリスト。
- HTML5仕様 (WHATWG): HTMLの最新の標準仕様。
- Go言語の公式ドキュメント: