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

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

このコミットは、Go言語の標準ライブラリ html パッケージ内の src/pkg/html/token.go ファイルに対する変更です。このファイルは、HTMLドキュメントをトークンに分解する「トークナイザー(字句解析器)」の核心部分を担っています。具体的には、HTMLタグの開始タグを読み込み、その内容に基づいて特定のタグ(例えば <script><style> など)が「生テキスト要素(Raw Text Elements)」であるかどうかを判定するロジックを扱っています。生テキスト要素は、その内部のコンテンツが通常のHTMLとしてパースされるのではなく、そのままのテキストとして扱われる特殊な要素です。

コミット

このコミットは、HTMLトークナイザーにおける z.rawTag の計算ロジックを整理し、最適化することを目的としています。特に、特定のHTMLタグが生テキスト要素であるかを判定する際の効率性と可読性を向上させています。

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

https://github.com/golang/go/commit/849fc19cab2c3059379b21dde019f521ce772f5c

元コミット内容

commit 849fc19cab2c3059379b21dde019f521ce772f5c
Author: Nigel Tao <nigeltao@golang.org>
Date:   Wed Nov 30 17:00:37 2011 +1100

    html: clean up the z.rawTag calculation in the tokenizer.
    
    R=andybalholm
    CC=golang-dev
    https://golang.org/cl/5440064

変更の背景

HTMLのトークナイザーは、入力されたHTML文字列を意味のある単位(トークン)に分割する役割を担います。このプロセスにおいて、<script><style> のような特定のタグは、その内部のコンテンツを通常のHTMLとしてではなく、純粋なテキストとして扱う必要があります。これらは「生テキスト要素(Raw Text Elements)」と呼ばれ、その開始タグが検出された場合、トークナイザーは特別なモードに切り替わり、対応する終了タグが見つかるまで内部のコンテンツをそのまま読み込みます。

変更前のコードでは、この「生テキスト要素」であるかどうかの判定に strings.ToLower 関数を使用してタグ名を小文字に変換し、その結果を switch 文で比較していました。しかし、このアプローチにはいくつかの非効率性がありました。

  1. 不必要な文字列変換: 毎回タグ名全体を小文字に変換することは、特に頻繁に呼び出されるトークナイザーのパスにおいて、パフォーマンスのオーバーヘッドとなる可能性があります。
  2. 冗長な比較: switch 文の各ケースで文字列全体を比較することは、最初の文字で絞り込める場合でも、より多くのCPUサイクルを消費する可能性があります。

このコミットは、これらの非効率性を解消し、z.rawTag の計算ロジックをよりクリーンで効率的なものにすることを目的としています。具体的には、タグ名の最初の文字に基づいて早期に分岐し、必要な場合にのみ文字列比較を行うことで、パフォーマンスを向上させ、コードの可読性を高めています。

前提知識の解説

HTMLトークナイザー(字句解析器)

HTMLトークナイザーは、HTMLパーサーの最初の段階であり、HTMLドキュメントの生バイトストリームを、パーサーが理解できる一連のトークン(例: 開始タグ、終了タグ、属性、テキスト、コメントなど)に変換する役割を担います。このプロセスは、ウェブブラウザがHTMLページをレンダリングする際や、HTMLを処理するツール(リンター、フォーマッターなど)が内部的にHTML構造を理解する際に不可欠です。

生テキスト要素(Raw Text Elements)

HTMLには、その内容が通常のHTMLとしてパースされるのではなく、そのままのテキストとして扱われる特殊な要素が存在します。これらは「生テキスト要素(Raw Text Elements)」と呼ばれます。代表的なものには以下があります。

  • <script>: JavaScriptコードを含む。
  • <style>: CSSスタイルシートを含む。
  • <textarea>: ユーザーが入力する複数行のテキストを含む。
  • <title>: ドキュメントのタイトルを含む。
  • <noembed>: <embed> タグをサポートしないブラウザ向けの代替コンテンツ。
  • <noframes>: フレームをサポートしないブラウザ向けの代替コンテンツ。
  • <noscript>: JavaScriptをサポートしないブラウザ向けの代替コンテンツ。
  • <plaintext>: HTML5では非推奨だが、その後のコンテンツをすべてプレーンテキストとして扱う。
  • <xmp>: HTML5では非推奨だが、整形済みテキストを表示する。
  • <iframe>: 別のHTMLドキュメントを埋め込む。

これらの要素の開始タグが検出されると、トークナイザーは特別な「生テキストモード」に切り替わり、対応する終了タグ(例: </script>)が見つかるまで、その間のすべての文字をテキストデータとして扱います。これにより、例えばJavaScriptコード内の <> といった文字がHTMLタグとして誤って解釈されることを防ぎます。

Go言語における文字列操作とパフォーマンス

Go言語では、文字列は不変(immutable)なバイトのシーケンスとして扱われます。strings.ToLower のような関数は、新しい文字列を生成するためにメモリを割り当て、元の文字列の各文字を変換してコピーします。これは、特に短い文字列であっても、頻繁に呼び出されるホットパスではパフォーマンスに影響を与える可能性があります。

このコミットの変更は、このような文字列操作のオーバーヘッドを最小限に抑えることを目指しています。具体的には、タグ名の最初の文字に基づいて早期に分岐することで、不要な strings.ToLower の呼び出しや、長い文字列の比較を避けることができます。これにより、トークナイザー全体の効率が向上します。

技術的詳細

このコミットの主要な変更点は、HTMLトークナイザーの Tokenizer 構造体における z.rawTag の計算ロジックの改善です。z.rawTag は、現在処理中のタグが生テキスト要素である場合に、そのタグ名を保持するために使用されます。

変更前のロジック

変更前は、readStartTag 関数内で以下のようなロジックが使用されていました。

// Several tags flag the tokenizer's next token as raw.
// The tag name lengths of these special cases ranges in [3, 9].
if x := z.data.end - z.data.start; 3 <= x && x <= 9 {
    switch z.buf[z.data.start] {
    case 'i', 'n', 'p', 's', 't', 'x', 'I', 'N', 'P', 'S', 'T', 'X':
        switch s := strings.ToLower(string(z.buf[z.data.start:z.data.end])); s {
        case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "textarea", "title", "xmp":
            z.rawTag = s
        }
    }
}

このコードは、まずタグ名の長さが3から9の範囲にあるかをチェックし、次にタグ名の最初の文字(大文字・小文字を区別しない)に基づいて switch 文で分岐していました。その後、タグ名全体を strings.ToLower で小文字に変換し、その結果を別の switch 文で既知の生テキスト要素のタグ名と比較していました。

変更後のロジック

変更後では、以下の2つの主要な改善が導入されました。

  1. startTagIn ヘルパー関数の導入: この新しい関数は、z.buf 内の現在の開始タグが、引数として渡された文字列スライス ss のいずれかとケースインセンシティブに一致するかどうかを効率的に判定します。

    func (z *Tokenizer) startTagIn(ss ...string) bool {
    loop:
        for _, s := range ss {
            if z.data.end-z.data.start != len(s) {
                continue loop
            }
            for i := 0; i < len(s); i++ {
                c := z.buf[z.data.start+i]
                if 'A' <= c && c <= 'Z' {
                    c += 'a' - 'A'
                }
                if c != s[i] {
                    continue loop
                }
            }
            return true
        }
        return false
    }
    

    この関数は、以下の最適化を含んでいます。

    • 長さの事前チェック: 比較対象の文字列 s と現在のタグの長さが異なる場合、すぐに次の文字列に移ります。
    • 文字ごとのケースインセンシティブ比較: 各文字をループで比較し、大文字の場合は小文字に変換して比較します。これにより、strings.ToLower を呼び出して新しい文字列を生成するオーバーヘッドを回避します。
    • 早期リターン: 一致するタグが見つかった場合、すぐに true を返します。
  2. readStartTag 内のロジックの簡素化と効率化: readStartTag 関数内の z.rawTag 判定ロジックは、startTagIn 関数を利用するように変更されました。

    c, raw := z.buf[z.data.start], false
    if 'A' <= c && c <= 'Z' {
        c += 'a' - 'A'
    }
    switch c {
    case 'i':
        raw = z.startTagIn("iframe")
    case 'n':
        raw = z.startTagIn("noembed", "noframes", "noscript")
    case 'p':
        raw = z.startTagIn("plaintext")
    case 's':
        raw = z.startTagIn("script", "style")
    case 't':
        raw = z.startTagIn("textarea", "title")
    case 'x':
        raw = z.startTagIn("xmp")
    }
    if raw {
        z.rawTag = strings.ToLower(string(z.buf[z.data.start:z.data.end]))
    }
    

    この新しいロジックでは、まずタグ名の最初の文字を小文字に変換し、その文字に基づいて switch 文で分岐します。各ケースでは、関連する生テキスト要素のタグ名を startTagIn 関数に渡して比較を行います。

    • 例えば、最初の文字が 's' であれば、startTagIn("script", "style") のみが呼び出され、他のタグとの比較は行われません。
    • strings.ToLower は、実際に生テキスト要素であることが確認された場合にのみ、z.rawTag に値を設定するために一度だけ呼び出されます。これにより、不要な文字列変換が大幅に削減されます。

変更によるメリット

  • パフォーマンスの向上: strings.ToLower の呼び出し回数を最小限に抑え、文字ごとの比較をインラインで行うことで、特にトークナイザーが頻繁に呼び出されるシナリオでのパフォーマンスが向上します。
  • コードの可読性と保守性: startTagIn という専用のヘルパー関数を導入することで、生テキスト要素の判定ロジックがより明確になり、コードが整理されました。
  • 効率的な分岐: 最初の文字による早期分岐により、不要な文字列比較をスキップし、必要な比較のみを実行します。

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

変更は src/pkg/html/token.go ファイルに集中しています。

  1. startTagIn 関数の追加: func (z *Tokenizer) startTagIn(ss ...string) bool が追加されました。この関数は、現在の開始タグが引数 ss のいずれかの文字列とケースインセンシティブに一致するかを判定します。

  2. readStartTag 関数の変更: func (z *Tokenizer) readStartTag() TokenType 内の z.rawTag を計算するロジックが大幅に変更されました。変更前の if x := ... ブロックが削除され、新しい switch c { ... } ブロックに置き換えられました。

コアとなるコードの解説

startTagIn 関数

// startTagIn returns whether the start tag in z.buf[z.data.start:z.data.end]
// case-insensitively matches any element of ss.
func (z *Tokenizer) startTagIn(ss ...string) bool {
loop:
	for _, s := range ss {
		// 長さが一致しない場合は次の候補へ
		if z.data.end-z.data.start != len(s) {
			continue loop
		}
		// 文字列を1文字ずつ比較(大文字・小文字を区別しない)
		for i := 0; i < len(s); i++ {
			c := z.buf[z.data.start+i]
			// 大文字であれば小文字に変換
			if 'A' <= c && c <= 'Z' {
				c += 'a' - 'A'
			}
			// 比較対象の文字と一致しない場合は次の候補へ
			if c != s[i] {
				continue loop
			}
		}
		// 全ての文字が一致すればtrueを返す
		return true
	}
	// どの候補とも一致しなかった場合はfalseを返す
	return false
}

この関数は、z.buf 内に格納されている現在のタグ名(z.buf[z.data.start:z.data.end])が、可変長引数 ss で渡された文字列のいずれかとケースインセンシティブに一致するかどうかを判定します。ループ内で各候補文字列 s と現在のタグ名を比較し、長さが異なる場合はスキップします。その後、文字ごとに比較を行い、大文字を小文字に変換して一致をチェックします。これにより、strings.ToLower を使わずに効率的なケースインセンシティブ比較を実現しています。

readStartTag 関数内の z.rawTag 判定ロジック

	c, raw := z.buf[z.data.start], false
	// タグ名の最初の文字を小文字に変換
	if 'A' <= c && c <= 'Z' {
		c += 'a' - 'A'
	}
	// 最初の文字に基づいて分岐
	switch c {
	case 'i':
		raw = z.startTagIn("iframe")
	case 'n':
		raw = z.startTagIn("noembed", "noframes", "noscript")
	case 'p':
		raw = z.startTagIn("plaintext")
	case 's':
		raw = z.startTagIn("script", "style")
	case 't':
		raw = z.startTagIn("textarea", "title")
	case 'x':
		raw = z.startTagIn("xmp")
	}
	// 生テキスト要素であると判定された場合のみz.rawTagを設定
	if raw {
		z.rawTag = strings.ToLower(string(z.buf[z.data.start:z.data.end]))
	}

この部分では、まず現在のタグ名の最初の文字 c を取得し、それを小文字に変換します。次に、この c を基に switch 文で分岐します。例えば、c's' であれば、z.startTagIn("script", "style") のみが呼び出され、現在のタグが "script" または "style" であるかを効率的にチェックします。startTagIntrue を返した場合(つまり、生テキスト要素であると判定された場合)にのみ、strings.ToLower を使用してタグ名全体を小文字に変換し、z.rawTag に設定します。これにより、不要な文字列変換と広範な文字列比較を回避し、パフォーマンスを向上させています。

関連リンク

参考にした情報源リンク