LoginSignup
17
14

More than 1 year has passed since last update.

GoならわかるWindows API

Last updated at Posted at 2021-12-04

この記事はGoアドベントカレンダーの5記事目です。
WindowsのAPIを叩いて遊んでみた記事です、あまりGoっぽくないです。

ターミナルに文字を吐く以外のことをやってみたい

何らかの処理を行い、その実行結果をターミナルに出力するのはどの言語でもprint系の関数を利用すれば簡単に行う事ができます。
ターミナルに依存せず、起動するとGUIのウインドウが立ち上がるアプリケーションはいったいどのような仕組みで動いているんだろう、と疑問に思いました。

ウインドウを自力で生成する方法を探す

普通にGUIアプリケーションを作りたいときはフレームワークに頼るでしょう。
gotronfyneqtなど様々な選択肢があります。
しかし今回は仕組みを学ぶことに意義があります。
ウインドウが生成されるための最小限のコードはどんなコードだろう、と考えてみました。

同じアプリケーションであってもウインドウの細かいデザインはOSによって異なり、また同じOSであればアプリケーションの最大化、最小化機能などのUIは統一されています。
これはウインドウ生成の役割をOSが担当しているからです。
おそらくOS側の機能にウインドウを生成するためのシステムコールが存在するだろう、という推測と、Goにはシステムコールを呼ぶための機能が備わっているとGoならわかるシステムプログラミングに書いてある事から、多分できるだろうと考えました。(この記事のタイトルはそのオマージュです)

さて、探してみたところWindows APIというものに辿り着きました。

WisdomSoftの情報も発見しました、WindowsのOSには新しくウインドウを作るためのCreateWindowというAPIがあるようです。

これらAPIはターミナルでコマンドを叩いて実行できる類の機能では無く、DLLとしてOSに搭載されている事がわかりました。
そしてGoでDLLの機能を実行するための方法がありました。
golang で型付きで DLL を呼び出す方法
mattnさんの記事ですね、強すぎる。

下記のようなイメージでウインドウを生成できるかな、というイメージが固まってきました。

main.go
package main

var (
    dll   = syscall.NewLazyDLL("欲しい機能がある.dll")
    proc  = dll.NewProc("ウインドウを生成するAPI")
)

func main() {
    i := int32(123)
    proc.Call(uintptr(unsafe.Pointer(&i)))
}

しかしCreateWindowACreateWindowExWなど似たような命令が沢山あり、引数の使い方とGoでのお作法もわかりません。
中々難しそうですね。

ここまで推測したタイミングで先駆者を発見しました。
こちらです、ありがとうnathan-osmanさん

お目当てのCreateWindowCreateWindowExWが動き、user32.dllに存在するようですね。

main.go
var (
    user32 = syscall.NewLazyDLL("user32.dll")

    pCreateWindowExW  = user32.NewProc("CreateWindowExW")
    pDefWindowProcW   = user32.NewProc("DefWindowProcW")
    pDestroyWindow    = user32.NewProc("DestroyWindow")
    pDispatchMessageW = user32.NewProc("DispatchMessageW")
    pGetMessageW      = user32.NewProc("GetMessageW")
    pLoadCursorW      = user32.NewProc("LoadCursorW")
    pPostQuitMessage  = user32.NewProc("PostQuitMessage")
    pRegisterClassExW = user32.NewProc("RegisterClassExW")
    pTranslateMessage = user32.NewProc("TranslateMessage")
)

もしお使いのPCで上記のコードが動かない場合、また同じようなノリで別のAPIを呼んでみたいが、何が使えるかわからないは、dumpbin /exportsコマンドでインストールされているuser32.dllにどのようなAPIが実装されているかを確認できます。
参考: DLL 内の関数の識別

さて、nathan-osmanさんのコードを実際に動かしてみたところ、画像のようなウインドウが生成されました、すごい。
ほとんど白い画像で見づらいと思いますが、コードを実行すると真っ白なウインドウが立ち上がります。

image.png

ウインドウを生成するコードを読んでみる

nathan-osmanさんのコードを読んでみると、OSのCreateWindow関数に渡す引数として、ウインドウの名前、クラス名等の設定などを渡しています。
Microsoftの公式ドキュメントにある通り、cやc++で動作していた世界にGoからアクセスするために、全ての引数はuintptrにキャストしています。

main.go
func createWindow(className, windowName string, style uint32, x, y, width, height int32, parent, menu, instance syscall.Handle) (syscall.Handle, error) {
    ret, _, err := pCreateWindowExW.Call(
        uintptr(0),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(className))),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(windowName))),
        uintptr(style),
        uintptr(x),
        uintptr(y),
        uintptr(width),
        uintptr(height),
        uintptr(parent),
        uintptr(menu),
        uintptr(instance),
        uintptr(0),
    )
    if ret == 0 {
        return 0, err
    }
    return syscall.Handle(ret), nil
}

引数のinstanceは、どの処理においてこの命令を実行するのかのコンテキストに相当するsyscall.Handleのようです。
このような下準備が必要で難しいですね。
何もしない真っ白なウインドウを一個作るだけの処理で250行ものコードを書くことになりました(先駆者のお陰で私はコピペするだけでしたが)。

main.go
func getModuleHandle() (syscall.Handle, error) {
    ret, _, err := pGetModuleHandleW.Call(uintptr(0))
    if ret == 0 {
        return 0, err
    }
    return syscall.Handle(ret), nil
}

ウインドウに情報を描画してみる

ウインドウが出ただけでもそれなりに嬉しいですが、適当な絵や文字を表示してみたいと思いました。
文字を描画するためのTextOut関数が用意されている事がWisdomSoftの情報からわかりました。

私のPCにはTextOutWTextOutAgdi32.dllに実装されていました。
Goのコードで動く文字セットに対してはTextOutWが良いようです。(実際に試してみました、TextOutAをコールすると文字化けしたような出力が描画されます)
下記のコードをnathan-osmanさんのコードに追記します。

main.go
var (
    gdi32    = syscall.NewLazyDLL("gdi32.dll")
    pTextOut = gdi32.NewProc("TextOutW")
)

func textOut(hwnd syscall.Handle, text string) (syscall.Handle, error) {

    ret, _, _ := pTextOut.Call(
        uintptr(hwnd),
        uintptr(0),
        uintptr(0),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))),
        uintptr(len(text)),
    )
    return syscall.Handle(ret), nil
}

このコードを呼ぶタイミングは、メインプロセスを登録している部分で宣言しているwcxのコールバック関数fnに挟み込むと良さそうです。

main.go
    fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
        switch msg {
        case cWM_CLOSE:
            destroyWindow(hwnd)
        case cWM_DESTROY:
            postQuitMessage(0)
        default:
            ret := defWindowProc(hwnd, msg, wparam, lparam)
            return ret
        }
        return 0
    }

    wcx := tWNDCLASSEXW{
        wndProc:    syscall.NewCallback(fn),
        instance:   instance,
        cursor:     cursor,
        background: cCOLOR_WINDOW + 1,
        className:  syscall.StringToUTF16Ptr(className),
    }
    wcx.size = uint32(unsafe.Sizeof(wcx))

    if _, err = registerClassEx(&wcx); err != nil {
        log.Println(err)
        return
    }

このコールバック関数はウインドウの移動、拡大縮小など様々なイベント発生時に物凄い勢いで呼ばれています。
0.1秒に一回以上の頻度で呼ばれるので、ここにprintデバッグを仕込むとターミナルの挙動が凄い事になります。

main.go
    fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
        switch msg {
        case cWM_CLOSE:
            destroyWindow(hwnd)
        case cWM_DESTROY:
            postQuitMessage(0)
        default:
            ret := defWindowProc(hwnd, msg, wparam, lparam)
            return ret
        }
        return 0
    }

引数msgで、ウインドウがどのようなタイミングでコールバックを呼んでいるかを識別できます。
cWM_DESTROYは名前の通りウインドウを破棄する際のイベントでしょう。

main.go
const (
    cWM_DESTROY = 0x0002
    cWM_CLOSE   = 0x0010
)

調査の結果WM_PAINT0x000fとして定義されている事がわかりました。
定数に追加します。

main.go
const (
    cWM_DESTROY = 0x0002
    cWM_CLOSE   = 0x0010
    cWM_PAINT   = 0x000F
)

これでコールバック関数にウインドウの描画タイミングの挙動を実装する事ができます。
...がTextOutWが正常に動く使い方を見出すのに苦戦。
最終的にこれも先駆者のコードを発見しました、ありがとうCXさん。

main.go
    fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
        switch msg {
        case cWM_CLOSE:
            destroyWindow(hwnd)
        case cWM_DESTROY:
            postQuitMessage(0)
    case cWM_PAINT:
        var ps PAINTSTRUCT
        hdc := beginPaint(hwnd, &ps)
        _, err := textOut(hdc, "Hello Window API")
        if err != nil {
            panic(err)
        }
        endPaint(hdc, &ps)
        return 0
        default:
            ret := defWindowProc(hwnd, msg, wparam, lparam)
            return ret
        }
        return 0
    }

textOutだけでなくbeginPaintendPaintが増えました。
beginPaintendPaintはどの領域を描画するか、という情報をポインタでやり取りするので、そのための構造体も必要になります。
画面描画は、究極的には画面の1ピクセルを任意の色に変更するシステムコールの繰り返しです。
何かある度に変更が無い箇所も含めて画面に映る全てを再描画していては無駄な処理が多すぎます。
そこで、再描画を行う範囲を絞ることで処理の効率化を計ることができます。

下記のコードを追加します。

main.go
type tRECT struct {
    Left   int32
    Top    int32
    Right  int32
    Bottom int32
}

type tPAINTSTRUCT struct {
    hdc         syscall.Handle
    fErace      uint32
    rcPaint     tRECT
    fRestore    uint32
    fIncUpdate  uint32
    rgbReserved byte
}

const(
    pBeginPaint       = user32.NewProc("BeginPaint")
    pEndPaint         = user32.NewProc("EndPaint")
)

func beginPaint(hwnd syscall.Handle, p *PAINTSTRUCT) syscall.Handle {

    ret, _, _ := pBeginPaint.Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(p)),
    )
    return syscall.Handle(ret)
}

func endPaint(hwnd syscall.Handle, p *PAINTSTRUCT) syscall.Handle {

    ret, _, _ := pEndPaint.Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(p)),
    )
    return syscall.Handle(ret)
}

背景色が真っ白だと寂しいので色を適当に変えてみます。

background: cCOLOR_WINDOW + 1,だったコードを
background: cCOLOR_WINDOW + 2,にしてみました。

main.go
    wcx := tWNDCLASSEXW{
        wndProc:    syscall.NewCallback(wndProc),
        instance:   instance,
        cursor:     cursor,
        background: cCOLOR_WINDOW + 2,
        className:  syscall.StringToUTF16Ptr(className),
    }

これで背景色がグレーになり、ウインドウに文字を出力する事ができました。
これだけの事に随分と苦労し、動いた時は感動しました。

image.png

これにてHello World完了です。

まとめ、GUIアプリケーションって凄い

ここまで実装しましたが、これではWindowsでしか動かないですし、全てのWindows端末で動く保証はありません。
LinuxやMacでも動いて、文字だけでなく画像も表示したい、とか考えだすとキリが無いですね。
OSのAPIを愚直に利用する事で、普段利用しているアプリケーションフレームワークがどれだけ多くの事をやってくれるかわかりました。
是非皆さんも独自の機能を作ってみたり、linuxでもチャレンジしてみてください。

断片的なコード片のような書き方ばかりになってしまったので、全部まとめて整理したものをgithubに残します。

17
14
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
14