[インデックス 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形式に変換してWriteConsole
APIで書き込みます。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のコードレビューシステムへのリンク)
参考にした情報源リンク
GetConsoleMode
function (Windows): https://learn.microsoft.com/en-us/windows/console/getconsolemodeWriteConsole
function (Windows): https://learn.microsoft.com/en-us/windows/console/writeconsoleWriteFile
function (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/utf8
package: https://pkg.go.dev/unicode/utf8 - Go
unicode/utf16
package: https://pkg.go.dev/unicode/utf16 - Go
syscall
package (Windows): https://pkg.go.dev/syscall (Goのバージョンによってドキュメントの場所が変わる可能性があります)