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

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

このコミットは、Go言語の標準ライブラリ os パッケージにおいて、Windows環境でのコンソール出力(os.File.Write)の挙動を改善するためのものです。具体的には、ファイルディスクリプタがコンソールであるかどうかを検出し、その場合はWindows APIの WriteConsole 関数を使用してUTF-16エンコーディングで文字列を書き込むように変更しています。これにより、マルチバイト文字(特にUTF-8でエンコードされた文字)がWindowsコンソールで正しく表示されない問題(バグ #3376)を修正します。

コミット

commit 18601f88fda8b037726b2e45a5032f680d47f713
Author: Alex Brainman <alex.brainman@gmail.com>
Date:   Wed Sep 12 12:04:45 2012 +1000

    os: detect and handle console in File.Write on windows
    
    Fixes #3376.
    
    R=golang-dev, bsiegert, minux.ma, rsc
    CC=golang-dev
    https://golang.org/cl/6488044

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

https://github.com/golang/go/commit/18601f88fda8b037726b2e45a5032f680d47f713

元コミット内容

os: detect and handle console in File.Write on windows

Fixes #3376.

R=golang-dev, bsiegert, minux.ma, rsc
CC=golang-dev
https://golang.org/cl/6488044

変更の背景

この変更の背景には、GoプログラムがWindowsコンソールに対してマルチバイト文字(特にUTF-8でエンコードされた日本語やその他の非ASCII文字)を出力しようとした際に、文字化けが発生するという問題がありました。Goの内部では文字列はUTF-8で扱われますが、WindowsのコンソールはデフォルトでUTF-16(UCS-2)エンコーディングを期待することが多く、また、通常のファイル書き込みAPI(WriteFile)ではコンソールへの出力が正しく処理されない場合がありました。

具体的には、Goの os.File.Write メソッドが内部的に syscall.Write を呼び出し、それがWindows APIの WriteFile にマッピングされていました。しかし、WriteFile はコンソールデバイスに対してはバイト列をそのまま書き込むため、UTF-8バイト列がコンソールに渡されると、コンソールがそれを現在のコードページ(通常はASCII互換のシングルバイトエンコーディング)として解釈しようとし、結果として文字化けが発生していました。

この問題を解決するためには、ファイルディスクリプタが通常のファイルではなくコンソールデバイスを指している場合に、Windowsがコンソール出力用に提供している専用のAPIである WriteConsole を使用する必要がありました。WriteConsole はUTF-16エンコードされた文字列を受け取るため、GoのUTF-8文字列をUTF-16に変換してから書き込む必要があります。

このコミットは、Goの os パッケージがWindowsコンソールへの出力をより堅牢に、かつ正しく処理できるようにするための重要な修正です。

前提知識の解説

1. Windowsコンソールと文字エンコーディング

Windowsのコマンドプロンプト(cmd.exe)やPowerShellなどのコンソールは、歴史的に文字エンコーディングの扱いに複雑さがあります。

  • コードページ: Windowsコンソールは、通常、特定の「コードページ」に基づいて文字を解釈・表示します。これは、文字とバイト列のマッピングを定義するものです。例えば、日本語環境ではShift-JISやCP932などが使われることがあります。
  • UTF-8とUTF-16:
    • UTF-8: 可変長エンコーディングで、ASCII文字は1バイト、それ以外の文字は2バイト以上で表現されます。WebやUnix系システムで広く使われています。
    • UTF-16: 固定長または可変長エンコーディングで、多くの文字が2バイト(UCS-2)で表現されますが、サロゲートペアを使用することで4バイトで表現される文字もあります。Windowsの内部APIで広く使われています。
  • WriteFileWriteConsole:
    • WriteFile: 一般的なファイル書き込みAPIで、バイト列をそのままファイルやデバイスに書き込みます。コンソールに対して使用すると、バイト列が現在のコードページで解釈されるため、UTF-8バイト列をそのまま渡すと文字化けの原因となります。
    • WriteConsole: コンソールデバイス専用の書き込みAPIです。このAPIはUTF-16エンコードされた文字列(LPCWSTR)を受け取り、コンソールが正しく文字を表示できるように処理します。

2. Go言語の os パッケージと syscall パッケージ

  • os パッケージ: Go言語の標準ライブラリで、オペレーティングシステムとの基本的な相互作用(ファイル操作、プロセス管理など)を提供します。os.File はファイルディスクリプタを抽象化したもので、Write メソッドを通じてデータの書き込みを行います。
  • syscall パッケージ: オペレーティングシステム固有のシステムコールへの低レベルなインターフェースを提供します。Goの os パッケージは、内部的にこの syscall パッケージを利用してOSの機能にアクセスします。Windowsの場合、syscall パッケージはWindows API関数をGoから呼び出すためのラッパーを提供します。

3. GetConsoleMode 関数

Windows APIの GetConsoleMode 関数は、指定されたコンソールスクリーンバッファまたはコンソール入力バッファの現在の入力モードまたは出力モードを取得します。この関数は、ファイルハンドルがコンソールデバイスに関連付けられているかどうかを判断するためにも使用できます。GetConsoleMode が成功した場合、そのハンドルはコンソールハンドルであると判断できます。

4. unicode/utf8 および unicode/utf16 パッケージ

  • unicode/utf8: UTF-8エンコーディングされたバイト列を操作するための関数を提供します。DecodeRune はUTF-8バイト列からUnicodeコードポイント(rune)をデコードし、そのバイト長を返します。FullRune は、与えられたバイト列が完全なUTF-8文字の先頭部分であるかどうかを判断します。
  • unicode/utf16: UTF-16エンコーディングされたバイト列を操作するための関数を提供します。Encode はUnicodeコードポイント(rune)のスライスをUTF-16エンコードされた uint16 のスライスに変換します。

技術的詳細

このコミットの技術的な核心は、os.File.Write メソッドがWindows上で動作する際に、書き込み対象が通常のファイルかコンソールかを動的に判断し、それぞれに最適な書き込み方法を選択する点にあります。

  1. コンソール検出:

    • os.NewFile 関数内で、新しく作成された os.File オブジェクトのファイルディスクリプタ(f.fd)に対して syscall.GetConsoleMode を呼び出します。
    • GetConsoleMode がエラーなく成功した場合、そのファイルディスクリプタはコンソールデバイスを指していると判断し、f.isConsole フラグを true に設定します。これにより、以降の書き込み処理でコンソール固有のロジックが適用されるようになります。
  2. writeConsole メソッドの導入:

    • os.File 構造体に isConsole (bool) と lastbits ([]byte) というフィールドが追加されました。lastbits は、前回の書き込みでUTF-8の不完全なマルチバイト文字が残った場合に、その残りを保持するために使用されます。
    • writeConsole という新しいプライベートメソッドが導入されました。このメソッドは、os.File.Write がコンソールに対して呼び出された場合にのみ使用されます。
  3. UTF-8からUTF-16への変換と WriteConsole の呼び出し:

    • writeConsole メソッドは、入力されたバイトスライス b をUTF-8として解釈し、unicode/utf8 パッケージを使用してrune(Unicodeコードポイント)のシーケンスにデコードします。
    • デコードされたruneのスライスは、unicode/utf16.Encode を使用してUTF-16エンコードされた uint16 のスライスに変換されます。
    • 変換されたUTF-16バイト列は、Windows APIの WriteConsoleW 関数(syscall.WriteConsole を介して呼び出される)に渡されます。WriteConsoleW はワイド文字(UTF-16)をコンソールに書き込むための関数です。
    • WriteConsoleW は一度にすべてのデータを書き込めない可能性があるため、ループを使用して残りのデータがなくなるまで書き込みを続けます。
  4. 不完全なUTF-8文字のハンドリング:

    • writeConsole は、入力バイト列の末尾に不完全なUTF-8文字(例えば、マルチバイト文字の途中でバイト列が終わる場合)が残った場合を考慮しています。
    • utf8.FullRune(b) を使用して、現在のバイト列が完全なruneを含んでいるかをチェックします。完全なruneでない場合は、その残りのバイトを f.lastbits に保存し、次回の writeConsole 呼び出し時に前回の残りと結合して処理します。これにより、マルチバイト文字が途中で分割されても正しく処理されるようになります。
  5. syscall パッケージの更新:

    • src/pkg/syscall/syscall_windows.goGetConsoleModeWriteConsole のGoラッパーが追加されました。
    • src/pkg/syscall/zsyscall_windows_386.gosrc/pkg/syscall/zsyscall_windows_amd64.go に、これらのAPIを呼び出すためのプロシージャポインタ(procGetConsoleModeprocWriteConsoleW)の定義と、対応するGo関数(GetConsoleModeWriteConsole)の実装が追加されました。これらは、Windows APIのDLL(kernel32.dll)から関数アドレスを取得し、Syscall または Syscall6 を使用して実際のシステムコールを実行します。

この一連の変更により、GoプログラムがWindowsコンソールに対して os.Stdout.Writefmt.Print などで文字列を出力する際に、内部的に適切なWindows APIが選択され、UTF-8文字列が正しくUTF-16に変換されてコンソールに書き込まれるため、文字化けの問題が解消されます。

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

src/pkg/os/file_windows.go

  • file 構造体に isConsole boollastbits []byte フィールドが追加。
  • NewFile 関数内で syscall.GetConsoleMode を呼び出し、isConsole フラグを設定するロジックを追加。
  • writeConsole という新しいメソッドが追加され、UTF-8からUTF-16への変換と syscall.WriteConsole の呼び出しを実装。
  • write メソッド内で f.isConsoletrue の場合、f.writeConsole(b) を呼び出すように変更。
--- a/src/pkg/os/file_windows.go
+++ b/src/pkg/os/file_windows.go
@@ -10,6 +10,7 @@ import (
 	"sync"
 	"syscall"
 	"unicode/utf16"
+	"unicode/utf8"
 )
 
 // File represents an open file descriptor.
@@ -26,6 +27,10 @@ type file struct {
 	name    string
 	dirinfo *dirInfo   // nil unless directory being read
 	l       sync.Mutex // used to implement windows pread/pwrite
+
+	// only for console io
+	isConsole bool
+	lastbits  []byte // first few bytes of the last incomplete rune in last write
 }
 
 // Fd returns the Windows handle referencing the open file.
@@ -43,6 +48,10 @@ func NewFile(fd uintptr, name string) *File {
 		return nil
 	}
 	f := &File{&file{fd: h, name: name}}
+	var m uint32
+	if syscall.GetConsoleMode(f.fd, &m) == nil {
+		f.isConsole = true
+	}
 	runtime.SetFinalizer(f.file, (*file).close)
 	return f
 }
@@ -230,11 +239,47 @@ func (f *File) pread(b []byte, off int64) (n int, err error) {
 	return int(done), nil
 }
 
+// writeConsole writes len(b) bytes to the console File.
+// It returns the number of bytes written and an error, if any.
+func (f *File) writeConsole(b []byte) (n int, err error) {
+	n = len(b)
+	runes := make([]rune, 0, 256)
+	if len(f.lastbits) > 0 {
+		b = append(f.lastbits, b...)
+		f.lastbits = nil
+
+	}
+	for len(b) >= utf8.UTFMax || utf8.FullRune(b) {
+		r, l := utf8.DecodeRune(b)
+		runes = append(runes, r)
+		b = b[l:]
+	}
+	if len(b) > 0 {
+		f.lastbits = make([]byte, len(b))
+		copy(f.lastbits, b)
+	}
+	if len(runes) > 0 {
+		uint16s := utf16.Encode(runes)
+		for len(uint16s) > 0 {
+			var written uint32
+			err = syscall.WriteConsole(f.fd, &uint16s[0], uint32(len(uint16s)), &written, nil)
+			if err != nil {
+				return 0, nil
+			}
+			uint16s = uint16s[written:]
+		}
+	}
+	return n, nil
+}
+
 // write writes len(b) bytes to the File.
 // It returns the number of bytes written and an error, if any.
 func (f *File) write(b []byte) (n int, err error) {
 	f.l.Lock()
 	defer f.l.Unlock()
+	if f.isConsole {
+		return f.writeConsole(b)
+	}
 	return syscall.Write(f.fd, b)
 }

src/pkg/syscall/syscall_windows.go

  • GetConsoleModeWriteConsole のGoラッパー関数定義を追加。
--- a/src/pkg/syscall/syscall_windows.go
+++ b/src/pkg/syscall/syscall_windows.go
@@ -196,6 +196,8 @@ func NewCallback(fn interface{}) uintptr
 //sys	RegEnumKeyEx(key Handle, index uint32, name *uint16, nameLen *uint32, reserved *uint32, class *uint16, classLen *uint32, lastWriteTime *Filetime) (regerrno error) = advapi32.RegEnumKeyExW
 //sys	RegQueryValueEx(key Handle, name *uint16, reserved *uint32, valtype *uint32, buf *byte, buflen *uint32) (regerrno error) = advapi32.RegQueryValueExW
 //sys	getCurrentProcessId() (pid uint32) = kernel32.GetCurrentProcessId
+//sys	GetConsoleMode(console Handle, mode *uint32) (err error) = kernel32.GetConsoleMode
+//sys	WriteConsole(console Handle, buf *uint16, towrite uint32, written *uint32, reserved *byte) (err error) = kernel32.WriteConsoleW
 
 // syscall interface implementation for other packages

src/pkg/syscall/zsyscall_windows_386.go および src/pkg/syscall/zsyscall_windows_amd64.go

  • procGetConsoleModeprocWriteConsoleW の定義を追加。
  • GetConsoleModeWriteConsole の実際のシステムコール呼び出しを実装。
--- a/src/pkg/syscall/zsyscall_windows_386.go
+++ b/src/pkg/syscall/zsyscall_windows_386.go
@@ -104,6 +104,8 @@ var (
 	procRegEnumKeyExW                    = modadvapi32.NewProc("RegEnumKeyExW")
 	procRegQueryValueExW                 = modadvapi32.NewProc("RegQueryValueExW")
 	procGetCurrentProcessId              = modkernel32.NewProc("GetCurrentProcessId")
+	procGetConsoleMode                   = modkernel32.NewProc("GetConsoleMode")
+	procWriteConsoleW                    = modkernel32.NewProc("WriteConsoleW")
 	procWSAStartup                       = modws2_32.NewProc("WSAStartup")
 	procWSACleanup                       = modws2_32.NewProc("WSACleanup")
 	procWSAIoctl                         = modws2_32.NewProc("WSAIoctl")
@@ -1197,6 +1199,30 @@ func getCurrentProcessId() (pid uint32) {
 	return
 }
 
+func GetConsoleMode(console Handle, mode *uint32) (err error) {
+	r1, _, e1 := Syscall(procGetConsoleMode.Addr(), 2, uintptr(console), uintptr(unsafe.Pointer(mode)), 0)
+	if int(r1) == 0 {
+		if e1 != 0 {
+			err = error(e1)
+		} else {
+			err = EINVAL
+		}
+	}
+	return
+}
+
+func WriteConsole(console Handle, buf *uint16, towrite uint32, written *uint32, reserved *byte) (err error) {
+	r1, _, e1 := Syscall6(procWriteConsoleW.Addr(), 5, uintptr(console), uintptr(unsafe.Pointer(buf)), uintptr(towrite), uintptr(unsafe.Pointer(written)), uintptr(unsafe.Pointer(reserved)), 0)
+	if int(r1) == 0 {
+		if e1 != 0 {
+			err = error(e1)
+		} else {
+			err = EINVAL
+		}
+	}
+	return
+}
+
 func WSAStartup(verreq uint32, data *WSAData) (sockerr error) {
 	r0, _, _ := Syscall(procWSAStartup.Addr(), 2, uintptr(verreq), uintptr(unsafe.Pointer(data)), 0)
 	if r0 != 0 {

コアとなるコードの解説

このコミットの最も重要な変更は、src/pkg/os/file_windows.go 内の File 構造体と write メソッド、そして新しく追加された writeConsole メソッドに集約されます。

  1. File 構造体の拡張:

    type file struct {
        // ... 既存のフィールド ...
        isConsole bool
        lastbits  []byte // first few bytes of the last incomplete rune in last write
    }
    

    isConsole フラグは、この File オブジェクトがWindowsコンソールデバイスを表しているかどうかを示します。lastbits は、UTF-8のマルチバイト文字が途中で分割されて書き込まれた場合に、その残りのバイトを一時的に保持するためのバッファです。これにより、次回の書き込み時に完全な文字として結合して処理できます。

  2. NewFile でのコンソール検出:

    func NewFile(fd uintptr, name string) *File {
        // ...
        f := &File{&file{fd: h, name: name}}
        var m uint32
        if syscall.GetConsoleMode(f.fd, &m) == nil {
            f.isConsole = true
        }
        runtime.SetFinalizer(f.file, (*file).close)
        return f
    }
    

    NewFile は、既存のファイルディスクリプタ(fd)から os.File オブジェクトを作成する際に呼び出されます。ここで、syscall.GetConsoleMode(f.fd, &m) を呼び出し、戻り値が nil (エラーなし) であれば、f.fd がコンソールハンドルであると判断し、f.isConsoletrue に設定します。

  3. writeConsole メソッド:

    func (f *File) writeConsole(b []byte) (n int, err error) {
        n = len(b) // 元のバイト列の長さを記録
        runes := make([]rune, 0, 256) // UTF-8からデコードされたruneを格納するスライス
    
        // 前回の書き込みで残った不完全なUTF-8バイトがあれば、現在のバイト列の先頭に追加
        if len(f.lastbits) > 0 {
            b = append(f.lastbits, b...)
            f.lastbits = nil
        }
    
        // バイト列をUTF-8としてデコードし、runeのスライスに変換
        // utf8.UTFMax はUTF-8の最大バイト長 (4)
        // utf8.FullRune(b) は、bが完全なUTF-8文字の先頭部分であるかを確認
        for len(b) >= utf8.UTFMax || utf8.FullRune(b) {
            r, l := utf8.DecodeRune(b) // UTF-8からruneをデコード
            runes = append(runes, r)   // デコードされたruneを追加
            b = b[l:]                  // 処理済みのバイトを削除
        }
    
        // 不完全なUTF-8文字が残っていれば、lastbitsに保存
        if len(b) > 0 {
            f.lastbits = make([]byte, len(b))
            copy(f.lastbits, b)
        }
    
        // デコードされたruneをUTF-16にエンコードし、WriteConsoleWで書き込む
        if len(runes) > 0 {
            uint16s := utf16.Encode(runes) // runeスライスをUTF-16 (uint16) スライスに変換
            for len(uint16s) > 0 {
                var written uint32
                // syscall.WriteConsole は Windows API の WriteConsoleW を呼び出す
                err = syscall.WriteConsole(f.fd, &uint16s[0], uint32(len(uint16s)), &written, nil)
                if err != nil {
                    return 0, nil // エラーが発生したら0バイト書き込みとして終了
                }
                uint16s = uint16s[written:] // 書き込まれた分をスライスから削除
            }
        }
        return n, nil // 元のバイト列の長さを返す
    }
    

    このメソッドは、Goの内部でUTF-8として扱われるバイト列 b を受け取り、Windowsコンソールが期待するUTF-16形式に変換して WriteConsole APIで書き込みます。lastbits を使用して、マルチバイト文字が複数の Write 呼び出しにまたがる場合でも正しく処理されるようにしています。

  4. write メソッドでの分岐:

    func (f *File) write(b []byte) (n int, err error) {
        f.l.Lock()
        defer f.l.Unlock()
        if f.isConsole {
            return f.writeConsole(b) // コンソールなら writeConsole を呼び出す
        }
        return syscall.Write(f.fd, b) // 通常のファイルなら syscall.Write (WriteFile) を呼び出す
    }
    

    os.File.Write が呼び出されると、まず f.isConsole フラグをチェックします。true であれば、新しく追加された writeConsole メソッドを呼び出してコンソール固有の処理を行います。false であれば、これまで通り syscall.Write(Windows APIの WriteFile に対応)を呼び出して通常のファイル書き込みを行います。

これらの変更により、GoプログラムはWindows環境下で、コンソールへの出力とファイルへの出力を透過的に、かつ正しく処理できるようになりました。

関連リンク

参考にした情報源リンク