[インデックス 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で広く使われています。
WriteFileとWriteConsole: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上で動作する際に、書き込み対象が通常のファイルかコンソールかを動的に判断し、それぞれに最適な書き込み方法を選択する点にあります。
-
コンソール検出:
os.NewFile関数内で、新しく作成されたos.Fileオブジェクトのファイルディスクリプタ(f.fd)に対してsyscall.GetConsoleModeを呼び出します。GetConsoleModeがエラーなく成功した場合、そのファイルディスクリプタはコンソールデバイスを指していると判断し、f.isConsoleフラグをtrueに設定します。これにより、以降の書き込み処理でコンソール固有のロジックが適用されるようになります。
-
writeConsoleメソッドの導入:os.File構造体にisConsole(bool) とlastbits([]byte) というフィールドが追加されました。lastbitsは、前回の書き込みでUTF-8の不完全なマルチバイト文字が残った場合に、その残りを保持するために使用されます。writeConsoleという新しいプライベートメソッドが導入されました。このメソッドは、os.File.Writeがコンソールに対して呼び出された場合にのみ使用されます。
-
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は一度にすべてのデータを書き込めない可能性があるため、ループを使用して残りのデータがなくなるまで書き込みを続けます。
-
不完全なUTF-8文字のハンドリング:
writeConsoleは、入力バイト列の末尾に不完全なUTF-8文字(例えば、マルチバイト文字の途中でバイト列が終わる場合)が残った場合を考慮しています。utf8.FullRune(b)を使用して、現在のバイト列が完全なruneを含んでいるかをチェックします。完全なruneでない場合は、その残りのバイトをf.lastbitsに保存し、次回のwriteConsole呼び出し時に前回の残りと結合して処理します。これにより、マルチバイト文字が途中で分割されても正しく処理されるようになります。
-
syscallパッケージの更新:src/pkg/syscall/syscall_windows.goにGetConsoleModeとWriteConsoleのGoラッパーが追加されました。src/pkg/syscall/zsyscall_windows_386.goとsrc/pkg/syscall/zsyscall_windows_amd64.goに、これらのAPIを呼び出すためのプロシージャポインタ(procGetConsoleMode、procWriteConsoleW)の定義と、対応するGo関数(GetConsoleMode、WriteConsole)の実装が追加されました。これらは、Windows APIのDLL(kernel32.dll)から関数アドレスを取得し、SyscallまたはSyscall6を使用して実際のシステムコールを実行します。
この一連の変更により、GoプログラムがWindowsコンソールに対して os.Stdout.Write や fmt.Print などで文字列を出力する際に、内部的に適切なWindows APIが選択され、UTF-8文字列が正しくUTF-16に変換されてコンソールに書き込まれるため、文字化けの問題が解消されます。
コアとなるコードの変更箇所
src/pkg/os/file_windows.go
file構造体にisConsole boolとlastbits []byteフィールドが追加。NewFile関数内でsyscall.GetConsoleModeを呼び出し、isConsoleフラグを設定するロジックを追加。writeConsoleという新しいメソッドが追加され、UTF-8からUTF-16への変換とsyscall.WriteConsoleの呼び出しを実装。writeメソッド内でf.isConsoleがtrueの場合、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
GetConsoleModeとWriteConsoleの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
procGetConsoleModeとprocWriteConsoleWの定義を追加。GetConsoleModeとWriteConsoleの実際のシステムコール呼び出しを実装。
--- 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 メソッドに集約されます。
-
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のマルチバイト文字が途中で分割されて書き込まれた場合に、その残りのバイトを一時的に保持するためのバッファです。これにより、次回の書き込み時に完全な文字として結合して処理できます。 -
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.isConsoleをtrueに設定します。 -
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形式に変換してWriteConsoleAPIで書き込みます。lastbitsを使用して、マルチバイト文字が複数のWrite呼び出しにまたがる場合でも正しく処理されるようにしています。 -
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環境下で、コンソールへの出力とファイルへの出力を透過的に、かつ正しく処理できるようになりました。
関連リンク
- Go Issue #3376: https://code.google.com/p/go/issues/detail?id=3376 (古いGoogle Codeのリンクですが、コミットメッセージに記載されています)
- Go CL 6488044: https://golang.org/cl/6488044 (Goのコードレビューシステムへのリンク)
参考にした情報源リンク
GetConsoleModefunction (Windows): https://learn.microsoft.com/en-us/windows/console/getconsolemodeWriteConsolefunction (Windows): https://learn.microsoft.com/en-us/windows/console/writeconsoleWriteFilefunction (Windows): https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-writefile- UTF-8: https://ja.wikipedia.org/wiki/UTF-8
- UTF-16: https://ja.wikipedia.org/wiki/UTF-16
- Go
unicode/utf8package: https://pkg.go.dev/unicode/utf8 - Go
unicode/utf16package: https://pkg.go.dev/unicode/utf16 - Go
syscallpackage (Windows): https://pkg.go.dev/syscall (Goのバージョンによってドキュメントの場所が変わる可能性があります)