2017/10/26


printf デバッグは便利だ。技術の後退と言われようと printf でないと解決できない事はまだまだたくさんあります。

今日は net/http でクライアントが得たレスポンスの JSON を確認したいといった場合に、どうデバッグしたらいいかを書いてみたいと思う。

Go のインタフェースは大よそ io.Reader もしくは io.Writer を使う様に設計されている。こうする事でプログラムがメモリを一度に沢山確保してしまわない様にしています。

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

type Foo struct {
    ID  string `json:"id"`
    Content string `json:"content"`
}

func main() {
    resp, err := http.Get("http://example.com")
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    var foo Foo
    err = json.NewDecoder(resp.Body).Decode(&foo)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(foo.Content)
}

例えばこういうコードの、resp.Body に何が流れているのか確認したい場合、デバッグ出力する為に一旦 ioutil.ReadAll で全て読み取ったりしていないでしょうか。

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
)

type Foo struct {
    ID  string `json:"id"`
    Content string `json:"content"`
}

func main() {
    resp, err := http.Get("http://example.com")
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    b, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(string(b))

    var foo Foo
    err = json.NewDecoder(bytes.NewReader(b)).Decode(&foo)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(foo.Content)
}

デバッグ表示したいだけなのに、ちょっとコードが増えてしまった感じがしますよね。デバッグを無効にしたいときに消すコードも多い。しかも json.NewDecoder の部分にも手を入れてしまわないといけなくてなんだか嫌な感じもします。元のコードは json.NewDecoder の箇所に手を入れられるから良いですが、時には io.Reader を引数に持つ関数に渡す必要があったり、ioutil.ReadAll で全て読み取る事が出来ないストリームデータの場合には使えません。こういった場合は io.TeeReader を使います。

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
)

type Foo struct {
    ID  string `json:"id"`
    Content string `json:"content"`
}

func main() {
    resp, err := http.Get("http://example.com")
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    var r io.Reader = resp.Body
    r = io.TeeReader(r, os.Stderr)

    var foo Foo
    err = json.NewDecoder(r).Decode(&foo)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(foo.Content)
}

こうしておき、必要に応じて r = io.TeeReader(r, os.Stderr) の行をコメントアウトすれば良いのです。コメントアウトを外せばデバッグ表示になります。メモリも節約出来てお得感ありますね。


2017/10/24


たぶん逆引きが無いから探せないのかなと思ったので path/filepath にどういう機能があるのか書いておく。

パスからファイル名を得る

filepath.Base を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.Base("C:/foo/bar"))
}

この場合 bar が表示される。

パスからディレクトリ名を得る

filepath.Dir を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.Dir(filepath.Clean(`../foo\bar`)))
}

この場合、..\foo が表示される。

パスからボリューム名を得る

filepath.VolumeName を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.VolumeName(`c:/windows/notepad.exe`))
}

この場合 c: が表示される。UNIX の場合は空文字列が返る。

相対パスから絶対パスに変換する

filepath.Abs を使う。

package main

import (
    "log"
    "path/filepath"
)

func main() {
    p, err := filepath.Abs("./testdata")
    if err != nil {
        log.Fatal(err)
    }
    println(p)
}

絶対パスから相対パスに変換する

filepath.Rel を使う。

package main

import (
    "log"
    "os"
    "path/filepath"
)

func main() {
    cwd, err := os.Getwd()
    if err != nil {
        log.Fatal(err)
    }
    p, err := filepath.Rel(cwd, `c:/dev`)
    if err != nil {
        log.Fatal(err)
    }
    println(p)
}

ディレクトリ配下であれば、それ以下の部分が。ディレクトリ配下でなければ .. で上昇した結果が返る。

パスを綺麗にする

../foo\bar\baz といった汚いパスを綺麗にするには filepath.Clean を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.Clean(`../foo\bar`))
}

この場合、..\foo\bar が表示される。

シンボリックリンクのリンク元を得る

filepath.EvalSymlinks を使う。

package main

import (
    "log"
    "path/filepath"
)

func main() {
    p, err := filepath.EvalSymlinks(`c:/dirlink`)
    if err != nil {
        log.Fatal(err)
    }
    println(p)
}

Windows でも動作する。(ショートカットファイルではなくジャンクション)

パスから拡張子を得る

filepath.Ext を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.Ext(`C:\Windows\Notepad.exe`))
}

.bashrc の様にドットで始まるファイル名を渡すと、ファイル名のまま返る。

スラッシュで区切られたパスを OS のパスセパレータに直す

filepath.FromSlash を使う。僕が path/filepath で一番好きな関数。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.FromSlash(`c:/users/mattn/.bashrc`))
}

この場合 c:\users\mattn\.bashrc が表示される。UNIX では何もしていない。Windows だけスラッシュがバックスラッシュに変換される。なので例えばファイルパスから URL のパスを作る時にこれを使ってくれると Windows ユーザが幸せになれる。

OS のパスセパレータで区切られたパスをスラッシュに直す

filepath.ToSlash を使う。filepath.FromSlash の逆。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.ToSlash(`c:\users\mattn\.bashrc`))
}

c:/users/mattn/.bashrc が表示される。

ファイルをマスクで検索する

filepath.Glob を使う。

package main

import (
    "log"
    "path/filepath"
)

func main() {
    files, err := filepath.Glob(`c:\Windows\*`)
    if err != nil {
        log.Fatal(err)
    }
    for _, f := range files {
        println(f)
    }
}

Windows の場合、バックスラッシュはエスケープ文字として扱われない。また ** は使えない。使いたい場合は zglob を使う。

パスの先頭に特定のディレクトリが含まれるか確認する

filepath.HasPrefix を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.HasPrefix(`c:\Windows\Notepad.exe``c:\windows`))
}

気を付けないといけないのは、この動作は strings.HasPrefix でしかない事。この関数は deprecated として扱われている。

パスが絶対パスかを確認する

filepath.IsAbs を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.IsAbs(`..\Notepad.exe`))
}

パスを結合する

filepath.Join を使う。

package main

import (
    "path/filepath"
)

func main() {
    println(filepath.Join(`c:\windows``system32``drivers``etc``hosts`))
}

OS のパスセパレータで結合される。可変個引数なので複数渡せる。

パスをディレクトリ名とファイル名に分解する

filepath.Split を使う。

package main

import (
    "path/filepath"
)

func main() {
    dir, filename := filepath.Split(`c:\windows\notepad.exe`)
    println(dir, filename)
}

c:\windows\notepad.exe に分けられる。

パスリストを分解する

PATH 環境変数の様に OS のパスリストセパレータで結合された物を分解する。filepath.SplitList を使う。

package main

import (
    "os"
    "path/filepath"
)

func main() {
    for _, p := range filepath.SplitList(os.Getenv("PATH")) {
        println(p)
    }
}

パスがパターンにマッチするか確認する

filepath.Match を使う。Glob が中で使っている物に過ぎない。

package main

import (
    "log"
    "path/filepath"
)

func main() {
    ok, err := filepath.Match(`*.exe``c:/windows/notepad.exe`)
    if err != nil {
        log.Fatal(err)
    }
    println(ok)
}

ディレクトリを下ってファイルを探索する

filepath.Walk を使う。例えば特定パス配下のディレクトリだけを探すのであれば以下の様に実行する。

package main

import (
    "log"
    "os"
    "path/filepath"
)

func main() {
    root := `c:\windows\system32\drivers\`
    err := filepath.Walk(root, func(p string, info os.FileInfo, err errorerror {
        if info.IsDir()  {
            println(p)
        }
        return nil
    })
    if err != nil {
        log.Fatal(err)
    }
}

探索を中断するには関数内でエラーを返す。もし特定のディレクトリ配下の探索をやめたいのであれば filepath.SkipDir を return で返してあげる。

なお物理ファイルの操作に path/filepath ではなく path を使うと爆発します。ちなみに、なぜここまで口をすっぱく言っているのかと言うと、UNIX で実装した物を Windows に持ってくると動かないからです。それどころかセキュリティ issue にもなり得る。

package main

import (
    "io"
    "net/http"
    "os"
    "path"
)

func main() {
    cwd, _ := os.Getwd()

    http.HandleFunc("/"func(w http.ResponseWriter, r *http.Request) {
        if ok, err := path.Match("/download/*", r.URL.Path); err != nil || !ok {
            http.NotFound(w, r)
            return
        }
        name := path.Join(cwd, r.URL.Path)
        f, err := os.Open(name)
        if err != nil {
            http.NotFound(w, r)
            return
        }
        defer f.Close()
        io.Copy(w, f)
    })
    http.ListenAndServe(":8080"nil)
}

何かをダウンロードさせるのにこういったコードを書いてしまうと、以下の様なリクエストでディレクトリトラバーサルが発生する。(正しくは http.ServeFile を使ってね)

http://localhost:8080/download/..%5cmain.go

これは Go のライブラリが悪い訳じゃない。こんなコードを書いた人が悪い。この辺は「みんなの Go 言語」にも書かれている。

みんなのGo言語[現場で使える実践テクニック] みんなのGo言語[現場で使える実践テクニック]
松木雅幸, mattn, 藤原俊一郎, 中島大一, 牧大輔, 鈴木健太
技術評論社 / (2016-09-09)
 
発送可能時間:


2017/09/05


8/31、patch 8.0.1026 で Linux 版 Vim のとある挙動が修正されました。

patch 8.0.1026: GTK on-the-spot input has problems - vim/vim@5c6dbcb - GitHub
https://github.com/vim/vim/commit/5c6dbcb03fa552d7b0e61c8fcf425147eb6bf7d5
この修正は、Linux 版 Vim の IME (Input Method Editor) の挙動を修正する物ですが、この1つのパッチの為に多くの開発者が動いたという話です。

Vim と IME と僕

このパッチ、多い時で月に100個以上もリリースされる大量のパッチの1つに過ぎないのですが、実はこのパッチには思い入れがあり記事にせずにいられませんでした。この問題が修正されるまでに至った経緯を知っている人間には、色々な思い出が脳裏を掛けめぐるのです。

これまでの UNIX 版 Vim の IME の挙動は、正直言って使い物になるとは言えませんでした。

これは vim-dev が悪い訳でも、我々マルチバイト圏の開発者が悪い訳でもなく、Vim というモードのあるテキストエディタに IME を連携させようと戦ってきた中で、如何にしても実現出来ない問題があった、という事なのです。

UNIX の IME

僕が vim-dev にパッチを送り始めたのは、おそらく Vim に IME の実装が入ったちょっと後だったと思います。Windows に至っては香り屋さんの多大な貢献もあり既に動作は安定し始めていました。その頃の UNIX の IME の実装は、Athena や Motif、GTK1 の GUI 上に XIM の実装が行われていました。今では当然の様にある GTK IM Module がまだ無かった頃ですね。とは言っても X11 XIM (X11R6) はその頃には既に実装も枯れていて、必要と思われる機能は十分に揃っていました。

入力エージェントの選択肢と言えば kinput2 くらいしか無く、有償であれば ATOK、その他で無償で使えた物と言えば VJE Delta くらいだったかもしれません。ここは記憶違いもあるかもしれません。特殊な入力方法を X11 上に提供する skkinput が世に出回ったのもその頃だったかもしれません。エンジンの方は Wnn、Canna、Sj3 などありました。そうは言ってもエージェントとエンジンが明確に区別されている訳ではなく、kinput2 に Wnn のパッチを当てた kinput2-wnn、kinput2 に canna のパッチを当てた kinput2-canna、といった具合にそれぞれに向けたパッチがあったという UNIX らしい提供方式でした。

今から思えば UNIX の日本語入力はお世辞にも使いやすいとは言えませんでした。今の様に当然の様に日本語入力できる訳でなく、自分でインストールして設定しなければならないのです。端末から日本語を入力するには skkfep の様に常駐して FEP を提供する物を使うか、kterm (この言葉知ってるともうオッサンと呼んでいい) から kinput2 を使う方式が一般的でした。CUI 単独で IME を使う方法は皆無で、あの頃みんなどうやって日本語を入力してたんだろうと今思えば不思議なくらいでした。

そんな頃 2ch で skk.vim という Vim script だけで SKK スタイルの入力を行うプラグインが話題に上がり、Vim 界隈で一世を風靡しました。これは後に tyru さんが skk.vim を作り直して eskk.vim をリリースする事になります。

しかし SKK は癖があります。また skk.vim の様なプラグインで実現する入力機構は他の Vim プラグインと干渉してエラーになったり画面が崩れる事も多く、それを自力で回避できるユーザでないと使いこなせないのが実状でした。その頃だったでしょうか、nvi-m17n という nvi のマルチリンガル対応版が canna と直接喋る事が出来ていて、Vim 使いとしては少し悔しい思いをしたのを覚えています。どうしても CUI で日本語入力したかった僕は、vim から Wnn や canna、POBox、SKK が使える様にするパッチ im_custom という物を作って配り始めました。しかしパッチという提供方法は Vim 本体の変更に追従しなければいけません。いつしか追従する気力を失った僕は、im_custom を最新版の Vim に追従するのを辞めてしまいました。

ON/OFF 問題

その頃の Vim の日本語入力も進展が無かったという訳ではなく、我々マルチバイト圏の開発者が試行錯誤を繰り返していました。その頃の Vim の日本語入力で一番辛かったのが IME の ON/OFF 問題でした。Vim は ESC でインサートモードから抜けますが、その際に IME が ON だった場合は一旦 IME をオフにしてから ESC (CTRL-[) を押す必要がありました。そうしないとノーマルモードで IME が ON の状態になったままとなり、jjj とタイプしても IME に食われて反応しないのです。Windows の IME に関しては ImmSetOpenStatus という Win32 API を使って IME の ON/OFF が実装され、シームレスなモードの行き来が行える様になっていました。しかし XIM には IME を ON/OFF させる仕組みがありません。その頃の Vim 使いは文句を言いながら IME の ON/OFF を繰り返していたに違いありません。

ところで Vim の IME がデフォルトでアクティブになる問題をご存じでしょうか。これは僕が vim-dev に参加するよりも前から実装されていた動作でした。Windows の GVim で以下を実行し、インサートモードに移るといきなり IME がアクティブになっているのです。

gvim --clean -u NONE -U NONE -N

この挙動は iminsert というオプションを使ってある程度制御が可能です。ただ、このオプションのデフォルト値が IME が使える場合には 2、つまり IM がオンという動作です。インサートモードに入ると IME が ON になるのです。デフォルト値が 2 になった経緯は我々日本人ではなく、最初に XIM の実装を始めた韓国の開発者による物でした。これは彼らの入力方法と日本人の入力方法が異なる事に起因しています。その頃に調べた所によると彼らの使う IME はアクティブになった後もローマ字が打てるらしいのです。なので iminsert が 2 であっても良いという事でした。丁度 skkinput が大文字でタイプを始めないと IME がアクティブ状態にならないのと同じと思って貰えると分かりやすいと思います。色々と葛藤はありましたが、結局のところ日本のユーザは vimrc で iminsert を 0 に設定して貰う事になっています。

我々日本人と他の IME ユーザの入力方法の違いはそれだけではありません。例えば日本人の IME 操作は、単語もしくは文節までを一度平仮名で仮入力を行い、その一部または全体を漢字に変換しながら確定して行きます。しかし中国や韓国の IME は文字単位です。特に韓国の IME ではアルファベット文字を幾らかタイプして1文字を入力し、都度確定していくらしいのです。つまりプリエディット(仮入力)が1文字という事です。

さて、話を IME の ON/OFF に戻します。これらの問題に香り屋さん取り組み、そして X11 のキーボードイベントをルートウィンドウに投げる事で IME を強制オフにするというワークアラウンド実装「imactivatekey」を思い付き、パッチとして取り込まれました。これはワークアラウンドながら実に良く動作しました。Linux デスクトップの IME 問題が一気に解決した様に見えました。

しかしそんな穏やかな日々を壊す物が現れます。GTK2 でした。GTK2 は IM Module を介して日本語入力を行います。そして GTK IM Module には IME を ON/OFF する機能が無いのです。今まで iminsert や imactivatekey というオプションで IME の ON/OFF が制御出来ていていたにも関わらず、もはやどうしようも無くなってしまったのです。uim に特化するのであれば vim から uim-fep を制御するパッチなんていうのもありました。

CustomizeUim - uim/uim-doc-ja Wiki - GitHub
https://github.com/uim/uim-doc-ja/wiki/CustomizeUim#vim%E3%81%A7%E4%BD%BF%E3%81%86

我々日本人開発者も、この頃はもう打つ手がないというのが正直な所でした。

仮入力問題

Vim の日本語入力にはもう一つ大きな問題がありました。それは仮入力の問題です。UNIX の IME には4つの入力方式がありました。

  • オーバーザスポット
  • オンザスポット
  • オフザスポット
  • ルートウィンドウ

それぞれの入力方式については以下のドキュメントに分かりやすく書かれています。

Seamonkey インプットメソッド仕様書

Seamonkey インプットメソッド仕様書 著者: Tague Griffith このドキュメントの内容: インプットメソッドの編集方式 プラットフォームのプロトコル エディターの関数 参考文献 イ...

https://www.mozilla-japan.org/projects/intl/input-method-spec.html

Windows の IME で一般的に使われるのはこのうちオンザスポットとオーバーザスポットです。メモ帳の様にキャレット上に仮入力がオーバーレイされているのがオーバーザスポット、MS Word の様に後続する文字も一緒に動きながら仮入力を行うのがオンザスポットです。Vim の実装上、本来であればオーバーザスポットが相応しいはずです。Windows 版の Vim もオーバーザスポットです。しかしながら GTK2 版においてはなぜか初回の実装からオンスポットになっており、しかもオンザスポットでしか入力出来ない様になっていました。

そしてまぁこのオンザスポット版にはたくさんバグがありました。そりゃそうです。IME からのコールバックで文字を自ら描画しないといけないのですから、IME と Vim で整合性を取らないといけません。しかも Vim 側の実装方式も良く無かった。Vim の基本実装は、文字入力と画面表示が明確に分けて実装されています。この文字入力部分に IME から貰った仮入力文字を流し込み、例えば「かんじ」という入力から「漢字」という仮入力に変換する際、「"かんじ" の3文字を消しながら "漢字" を挿入」といった実装になっていたのです。この方式を実装するにはいろいろな所に手を入れないといけません。もし仮入力をキャンセルするのであれば、現在の仮入力を全て「カーソル左」と「デリートキー」で削除しないといけないのです。しかもこの入力が map の影響を受けてしまっており、例えばカーソルキーに別のキーを割り当てていると IME の仮入力が目も当てられない状況になるです。

僕は問題が見付かる度にパッチを送りました。バグが見つかる度に ifdef XIM のコードが増えて行き、次第に Vim の開発者 Bram 氏も二つ返事でパッチを取り込んでくれなくなってしまいました。これがバージョン7の頃だったと思います。我々 vim-jp も何時しか vim-dev に XIM 関連のパッチを送る事が無くなってしまいました。

日本人にとって、Vim にとって、きちんと日本語入力を行う為には、もはやオーバーザスポットしかあり得なかったのです。 中平さんが作ってくれたオーバーザスポットのパッチは我々の一つの望みでもあったのですが、Bram 氏は取り込んでくれませんでした。

この時点で、Linux の GVim で問題なく日本語を入力する術が無くなってしまったのです。

そんな半ばあきらめムードだったのですが、一筋の光明が現れます。

terminal、お前だったのか

Vim に terminal が実装され出し、Vim のコミット量が一気に増えました。

vim-dev

そしてふと、こんな事を思いついたのです。

今のオンザスポット入力方式だと、後続のテキストが動いてしまうので今の terminal の実装では画面が壊れちゃうよなー

そんな思いで中平さんのパッチについて GitHub Issues で突いてみたのです。

Mapping backspace while typing Korean - Issue #1215 - vim/vim - GitHub
https://github.com/vim/vim/issues/1215

まぁ何時もの様に Bram 氏から返事は返ってこないだろうなと思っていた所、思いもよらぬリプライが。

Bram「それは中平が書いたパッチを k-takata が改良した奴の事かい?」

これは心が躍りました。急いで「そうだよ!」と返事を返しました。半信半疑でどきどきしながら待ちました。

そして先日の朝、patch 8.0.1026 として、1つのパッチが取り込まれました。早朝このコミットに気付いた僕は早く最新の Vim をコンパイルしたくて溜まらないくらいに心が躍ったのを覚えています。

数ある中の1コミットかもしれませんが、多くの日本人開発者が悩んで実装して壊されて、それでも戦ってきた歴史が、僕の脳裏には蘇ってきたのです。

そして時代も良い方向に動き始めました。現在の Linux デスクトップで日本語入力と言えば fcitx が一般的です。そして fcitx はコマンドから IME を ON/OFF 出来るのです。

Vimからfcitxを使う - Qiita

近頃の ibus や uim の使い勝手がイマイチなこともあり、Linux 上での Input Method として fcitx が最近ポピュラーになってきました。 我らが SKK では、その他のIn...

http://qiita.com/sgur/items/aa443bc2aed6fe0eb138

この記事の中に書かれている設定を行えば、ESC で日本語をオフに出来る様になります。

気付いたら、僕らを長年苦しめ続けて来た Linux 版 Vim の 日本語入力問題は、解決し始めていたのです。もちろん Bram 氏が動いてくれたのは、あの GitHub Issue が理由でもなく、terminal が直接の理由でもない事は覚えて覚えておかなければなりません。過去に繰り返しパッチを送り続けてくれた日本人 Vim 開発者の功績あってこそ、きちんとした日本語が入力出来る今の Vim がある事を。


追記 2017/09/16

提案したところ、なんと iminsert/imseach の値が 0 になりました!

patch 8.0.1114: default for 'iminsert' is annoying - vim/vim@4cf56bb - GitHub
https://github.com/vim/vim/commit/4cf56bbc85f77846aeb378cfb071677336dfad6d