Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

Goのファイルシステムfs.FSを実装するときのヒント

pkg.go.dev

fs.FSを実装するには次のインターフェースを満たす具象型を実装しなければならない。

多いね。それぞれのメソッドとインターフェースの関係は次の図で表すことができる。 例えば、FSのメソッドOpenはFileを返す。

ファイルパスのルール

FSのファイルパスのルールはfsパッケージの関数ValidPathに書いてある。 ざっと次のとおり。

  • UTF-8エンコード
  • パスの区切りは/(例えばpath/to/file
  • パスの前後に/をつけてはいけない(/aaa, aaa/は許されない)
  • パスの要素に(空文字列)や...は許されない(path//to/./dir/../or/fileは許されない)
  • ただしルートは単体の.で示す。

これらはFSを実装しやすくするためのルールだ。 Openメソッドを実装するときには入力されたファイルパスが上のルールに従っているかVaildPathを使ってチェックすると良いだろう。

func (fsys *myfs) Open(name string) (fs.File, error) {
    if !fs.ValidPath(name) {
        return nil, &fs.PathError{"open", name, fs.ErrInvalid}
    }
    ...
}

fs.ReadDirFileとfs.ReadDirFSの違い

ReadDirFileはFileにReadDirメソッドが追加されたインターフェース。 ReadDirFSはFSにReadDirメソッドが追加されたインターフェースである。 実装したファイルシステムにどちらかのインターフェースが備わっていればディレクトリの中身を読むことができる。 しかしながら好きな方どっちかを実装すればよいという訳ではない。 Goのドキュメントでは全てのディレクトリファイルはReadDirFileとして実装するべきと書いてある。 ReadDirFSはfsパッケージの関数ReadDir(上のメソッドとは区別せよ)を最適化するための補助的なインターフェースであって、ReadDirFSだからReadDirFileを実装しなくていいよねという話にはならない。

ちなみにnet/httpパッケージを使うとFSの静的ファイルサーバーを作れるが、ディレクトリファイルをReadDirFileとして実装しないとディレクトリの中身を読んでくれない。

fs.ReadDirFileの実装のしかた

仕様がちょっと複雑だ。 archive/zipパッケージにはfs.FSが実装されていてzipファイルを解凍せずに中身をファイルシステムとして読むことができる。 そこのReadDirメソッドのコードは実装するときに参考になるかもしれない。

https://cs.opensource.google/go/go/+/refs/tags/go1.19.3:src/archive/zip/reader.go;l=884

DirEntryとFileInfoの違い

DirEntryもFileInfoもファイルの情報を保持するインターフェースである。 しかしなぜ同じようなものが2つあるのか。 ReadDirもDirEntryという劣化版ではなくて、FileInfoを返せばよいではないか。と疑問を感じるかもしれない。

これはUnixファイルシステムの設計が原因である。 Unixファイルシステムにおいてディレクトリの中身(DirEntry)とファイルの情報(FileInfo)は別々の領域に格納されている。 ディレクトリの中身を読み(readdir システムコール)、中身のファイルの情報を取得する(stat システムコール)という処理をすると2回システムコールを呼ぶ必要がある。 しかしながらディレクトリの中身を探索する処理ではたいてい DirEntry を読むだけで十分なことが多く、FileInfo を毎回呼ぶのは無駄である。 だからReadDirはFileInfoではなくDirEntryを返す実装になっている。

この経緯はGoのissueを読むとわかる。

github.com

ただオリジナルのファイルシステムを実装する際にこのような背景は知ったこっちゃないだろう。 DirEntryとFileInfoは同じ構造体で実装してしまえばよい。

type info struct {
    name    string
    size    int64
    mode    fs.FileMode
    modTime time.Time
}

var _ fs.FileInfo = &info{}
var _ fs.DirEntry = &info{}

func (i *info) Name() string               { return i.name }
func (i *info) Size() int64                { return i.size }
func (i *info) Mode() fs.FileMode          { return i.mode }
func (i *info) Type() fs.FileMode          { return i.mode.Type() }
func (i *info) ModTime() time.Time         { return i.modTime }
func (i *info) IsDir() bool                { return i.mode.IsDir() }
func (i *info) Info() (fs.FileInfo, error) { return i, nil }
func (i *info) Sys() any                   { return nil }

fs.FileInfoを実装するときに注意すること

意味のない値の扱い

/dev/urandomのようなファイルでは大きさ(Size)や更新日時(ModTime)の情報は意味をなさない。 これは個人的な意見であるが、もし作ったファイルシステムを既存の Unix ソフトウェアと組み合わせるなら、更新日時をtime.Timeのゼロ値(つまりJanuary 1, year 1, 00:00:00.000000000 UTC)にしないほうがよいかもしれない。 Unixソフトウェアを組み合わせたときにバグを引き起こしてしまうかもしれないからだ。この場合はUnixエポックタイムでのゼロ秒を返すかな。

func (info) ModTime() time.Time { return time.Unix(0,0) }

POST RequestをハンドルするシンプルすぎるHTTPサーバー

github.com

Usage

postsrv [-flag] cmd [arg ...]

postsrvはとてもシンプルなHTTPサーバー。 カレントディレクトリをサーブするファイルサーバーとほとんど同じだが、/uploadはPOSTリクエストをハンドルする。 POSTリクエストのBodyが標準入力を通してcmdへ送られ、cmdの標準出力がレスポンスとなる。

Example

postsrv cat

とすれば、/uploadへPOSTしたフォームがそのままレスポンスとして返ってくるサーバーになる。 基本的にはカレントディレクトリに<form action='/upload' ...>タグを含んだindex.htmlをおいて使う。

また-mフラッグによってmultipart/form-data形式でPOSTされたファイルを扱える。ファイルが複数あるときはconcatenateされ標準入力へ送られる。

postsrv -m cat

なぜ作ったか

いま僕はJavaScriptのお勉強をしている。 このときPOSTリクエストをハンドルするサーバーをサクッと作れると便利だった。 毎回サーバーをイチから実装するのは面倒くさいので、シェルスクリプトの要領で簡単に作れるようにした。 このUnixコマンドへ入出力させるという単純だが強力な手法は応用が効きそうだなっていつも考えている。

応用

POSTされたデータをターミナルへ出力

postsrv sh -c 'cat > /dev/tty'

文字列操作

postsrv tr a-z A-Z

POSTされたテキストデータを大文字化して返す。

画像を操作する

postsrv -m convert - -negate -

画像ファイルをPOSTするとImageMagickのconvert(1)によって色が反転された画像が返ってくる。

POSTされたデータを保存して、レスポンスを返す

postsrv sh -c 'cat > $(mktemp); cat response'

POSTされたデータをランダムな名前のファイルに保存し、ファイルresponseに書いてあるレスポンスを返す。

POSIXのmanをインストール

Unixにはシステムコールやライブラリ(C言語)、コマンド(シェルスクリプト)などのインターフェースを定義したPOSIXという規格がある。移植性の高いソフトウェアの開発を容易にすることを目的としており、GNU/LinuxmacOS, BSDなどはPOSIXにだいたい準拠するように実装されている。POSIXを意識してシェルスクリプトを書けば、GNU拡張やBSD拡張に混乱しないですむ。POSIXは下のサイトから参照できる。

pubs.opengroup.org

手元のターミナルから参照できるようにすると便利だ。2つの方法でインストールできる。

troffファイルを直接ダウンロード

次のサイトからPOSIXのmanのtroffファイルをダウンロードできる。

www.kernel.org

Linux

付属しているMakefileLinux向けだ。makeでインストールしたPOSIXのman pagesはセクション0p, 1p, 3p下に置かれる。つまり

$ man 1p grep

とすればPOSIXのmanページが参照できる。

macOS

macOSのmanはこの方法では上手く行かない。理由はよくわからないが0p, 1p, 3pというセクションを認識してくれない。付属のMakefileを使わずに手動でやるか、Makefileを書き換えよう。ファイル名をgrep.1pからposix:grep.1pなどと変え、/usr/local/share/man/man{1,3,7}にインストールすればよいだろう(man0はないので変わりにman7)。そのときは

$ man 1 posix:grep

で参照できる。

パッケージマネージャーから

パッケージマネージャーからインストールすれば楽だ。

Ubuntu

aptからmanpages-posixmanpages-posix-devの2つのパッケージがあり両方インストールすれば良い。

$ man 1p grep
# or
$ man 1posix grep

で参照できる。

macOS (MacPorts)

筆者はMacPortsを使っている。posix-manpagesのパッケージをインストールすればよい。

$ man 1 posix:grep

で参照できる。

他のOS

探せばあると思う。

Unixのファイルシステムを9Pでサーブするu9fsを「ちょっと」使う

github.com

u9fsはPlan 9に付属している(のに)Unix向けのソフトウェアである。これはUnixファイルシステムPlan 9通信プロトコル9Pでサーブし、Plan 9でマウントすることを目的としている。私は初め使い方が全く分からなかったので、私と同じ人のためにメモを残す。

若者のinetd離れ

u9fsのソースコードにはソケットを操作する部分が見当たらなかったため、初めて読んだとき(こいつはどうやって通信するんだ)と悩んだ。 答えをいうとこのソフトウェアはinetdを使って通信するのだ。 私は"若い" Unixユーザーなので最近あまり使われていないinetdを知らなかったのである。 inetdはsuffixのdが示すとおり、デーモンである。 使い方はWikipediaのページを参照してほしい。

ja.wikipedia.org

要するにinetdは接続がacceptされた際に登録されたプログラムを起動し、そのプログラムの標準出入力をソケットにフックするのだ。 つまりinetdを使って通信するu9fsは標準出入力を通して9Pをサーブしているのである。 これがわかればinetdを使わなくても通信できる。

u9fsの使い方を示した日本語の記事を紹介しよう。

p9.nyx.link

oraccha.hatenadiary.org

inetdを使わずにちょっと使ってみる

この記事の本題である。ちゃんと使うにはinetdを設定する必要があるが、面倒である。 ちょっと使うだけなら次の方法できるだろう。

netcat

一番簡単なのはnetcat (nc)を使う方法である。

fifo=/tmp/tmpfifo.$$
mkfifo $fifo
./u9fs -a none -l /dev/null -n -u $(id -un) < $fifo | nc -l localhost 5640 > $fifo

あまり用いられないnamed pipeの格好の利用例でもある(ちなみにmktemp(1)に似た一時的なnamed pipeを作るコマンドmktempfifoがある)。 設定は接続時の認証は無し(-a none)、ログファイルは残さず(-l /dev/null)、u9fsを通して作ったファイルのオーナーは自分(-u $(id -un))としている。 オプションについて詳しくはmanページを参照してほしい。

そして別のターミナルから接続してみよう(9pはplan9portにあるコマンドで、9Pのクライアントである)。

$ 9p -a 'tcp!localhost!5640' ls -l
d-rwxr-xr-x M 0 root    root        106496 Jul 23 12:45 bin
d-rwxr-xr-x M 0 root    root          4096 Jul 23 12:36 boot
d-rwxrwxr-x M 0 root    root          4096 Mar  2  2020 cdrom
d-rwxr-xr-x M 0 root    root          5120 Jul 23 15:13 dev
d-rwxr-xr-x M 0 root    root         12288 Jul 23 12:45 etc
...

この方法は一度しか接続できないという問題点がある。

listen

github.com

これは私が作成した簡易版inetdである。とても単純なプログラムだ(Plan 9のユーザープログラムにこれに似たlisten1がある)。

$ listen -a 'localhost:5640' ./u9fs -a none -l /dev/null -n -u $(id -un)

と起動する。これは接続ごとにu9fsをexecするので同時に何度も接続することが可能である。

君はPythonでcatが実装できるか?

これは技術ブログでない。ただの日記だ。

皆様はPythonUnixのコマンドcatを実装できますでしょうか?それっぽいのではなく、ちゃんと標準の/bin/catと同じ動作するものを作ろうとすると意外と引っかかる部分が多いと思います。

まず最も単純な実装(cat1.py)を見てみましょう。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        print(b, end='')

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin)
    else:
        for fname in sys.argv[1:]:
            with open(fname) as f:
                cat(f)

main()

これは一応catっぽいことをします。引数にテキストファイルがあればそれらを、なければ標準入力をを標準出力に書き出します(標準的なcatは引数に-を指定すると標準入力から読み取るという仕様がありますが、今回それは無視します。そもそも現代のUnixでは/dev/fd/1を指定すればいいし)。 BUFSIZEの大きさは適当に決めてしまいました。『詳細UNIXプログラミング』という書籍が参考になるかも。

これには問題点があって、Python面倒なことにバイナリファイルとテキストファイルを区別します。 だからバイナリファイルを入力すると例外を起こしてしまいます。

$ ./cat1.py /bin/sh
Traceback (most recent call last):
  File "./cat1.py", line 21, in <module>
    main()
  File "./cat1.py", line 19, in main
    cat(f)
  File "./cat1.py", line 8, in cat
    b = f.read(BUFSIZE)
  File "/usr/lib/python3.10/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf0 in position 24: invalid continuation byte

ファイルをバイナリモードでオープンしないといけませんね。次のプログラム(cat2.py)に書き換えます。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb") as f:
                cat(f)

main()

バイナリはsys.std{in,out}.bufferを通して標準出入力します。また関数open"rb"と引数を追加してバイナリモードでopenすることを指示しなければなりません。

これでバイナリファイルも扱えるようになりました。

$ ./cat2.py /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ ./cat2.py < /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ sha256sum /bin/sh
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  /bin/sh

catが実装できた。めでたしめでたし。

と、思いきやこれで終わりではありません。 Pythonはデフォルトで標準出入力やファイルをバッファリングします。このプログラムもバッファリングしているでしょう。しかしながらGNUやらPlan 9やらのcatは出入力をバッファリングしません。Python版でもバッファリングしないように実装したいですね。

バッファリングについて解説するのは省いて、バッファリングによって/bin/catとcat2.pyが異なる動作をする例を確認してみましょう。つぎのGoのプログラム(coproc)を使います。

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"
)

func main() {
    if len(os.Args) < 2 {
        log.Fatal("no args")
    }

    c := exec.Command(os.Args[1], os.Args[2:]...)
    w, _ := c.StdinPipe()
    r, _ := c.StdoutPipe()
    if err := c.Start(); err != nil {
        log.Fatal(err)
    }

    nbuf := 100
    buf := make([]byte, nbuf)

    greeting := []byte("hello\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ := r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    greeting = []byte("bye\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ = r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    w.Close()
    if err := c.Wait(); err != nil {
        log.Fatal(err)
    }
}

このプログラムは引数に与えられたプログラムをfork/execで起動します。 coprocは起動したプロセスへパイプを通してhello\nをwriteしたら100bytesぶんreadし、またbye\nをwriteしたら100bytesぶんreadするといった単純なものです。 /bin/catと./cat2.pyで試してみましょう。

$ ./coproc /bin/cat
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)
$ ./coproc ./cat2.py 
input: "hello\n" (6 bytes)

/bin/catでは予想通りの結果が出力されプログラムが終了しました。 しかしながらcat2.pyは出力がされずプログラムが固まってしまいました。 なぜでしょう?

writeシステムコールは重いシステムコールと呼ばれ、実行が遅いです。 ちょっとの文字列しか出力しないprintfのような関数で毎回writeシステムコールを呼んでいたらプログラムは遅くなってしまいます。 だからPythonsys.std{in,out}.bufferやCのstdio.hは出力するデータをバッファに一時的に貯め、まとまった量を一気にwriteします。 これをバッファリングといいます。 ちなみにCのstdio.hではデータを貯める量(バッファーサイズ)をBUFSIZとして定義してあります。 readシステムコールも同様です。

すると上記の./coproc ./cat2.pyが固まってしまった理由がわかるでしょう。 coprocからパイプを通して書き込まれた6bytesのhello\nはcat2.pyのバッファーサイズよりも小さいですから、cat2.pyはwriteせずにバッファに貯めます。 その状態でcoprocがパイプをreadしても読み込むものはありません。 通常この状態になるとreadシステムコールはプログラムをブロックします。 これによってcoprocは固まってしまったのです。

一方通常の/bin/catはバッファリングしていないですから、readしたらすぐにwriteします。 catはデータの加工をしませんから、まとまった量を一気にreadして、そのままwriteしちゃえばいいんです。 この点においてcatは一行ずつreadして加工をして一行ずつwriteする他のプログラム(grepやらsedやら)と異なります。

話をもとに戻しましょう。バッファリングしない完成版Python版cat(cat3.py)は次のようになります。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.raw.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer.raw)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb", buffering=0) as f:
                cat(f)

main()

sys.std{in,out}.bufferの代わりにsys.std{in,out}.buffer.rawを通して入出力します。 またopenの引数にbuffering=0が追加しました。 これをcoprocで起動してみましょう。

$ ./coproc ./cat3.py 
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)

ようやく/bin/catと同じ動作になりました。 Pythonでcatを実装するのも簡単ではないですね。 バッファリングやらread/writeシステムコールの知識も必要だし、これまでsys.std{in,out}.buffer.rawなんか知らなかったし、Pythonも難しいですね。

awkでpstreeを実装

タイトル通り。

github.com

[init]-+-[systemd-journald]
       |-[systemd-udevd]
       |-[systemd-oomd]
       |-[systemd-resolved]
       |-[systemd-timesyncd]
       |-[acpid]
       |-[avahi-daemon]---[avahi-daemon]
       |-[@dbus-daemon]
       |-[irqbalance]
       |-[python3]
       |-[polkitd]
       |-[power-profiles-daemon]
       |-[snapd]
       |-[accounts-daemon]
       |-[cron]
       |-[switcheroo-control]
       |-[systemd-logind]
       |-[udisksd]
       |-[wpa_supplicant]
       |-[ModemManager]
       |-[NetworkManager]-+-[dhclient]
       |                  `-[dhclient]
       |-[python3]
       |-[sshd]
       |-[gdm3]---[gdm-session-worker]---[gdm-x-session]-+-[Xorg]
       |                                                 `-[gnome-session-binary]
       |-[colord]
       |-[nvidia-persistenced]
       |-[rtkit-daemon]
       |-[upowerd]

こういったヴィジュアルな出力をするツールを作るのは楽しい。また連想配列getlineによるコプロセスの起動、関数定義などといったawkの様々な機能を使いこなすので、awk入門にも向いてる。

sedのコマンドとして使える英単語

sedの置換表現s/foo/bar/のセパレータは/でなくても良い。\\n以外の1byteキャラクターなら何でも良い。だからsequenceも使える。

$ echo _qu_ | sed sequence
_nc_

他に使える英単語は?次のシェルスクリプトを使って探そう。

#!/bin/sh

words=/usr/share/dict/words

for c in a b c d e f g h i j k l m n o p q r s t u v w x y z
do
    grep -E "^s${c}[^${c}]+${c}[^${c}]*${c}\$" "$words"
done

Ubuntuで実行した結果は次の通り

sanatoria
sanitaria
sarcomata
savanna
secede
secrete
secretive
segregate
selective
selvedge
sentence
sentience
sentimentalize
septette
sequence
serenade
serene
serpentine
serviceable
serviette
settee
severance
severe
sewerage
sextette
stateliest
statement
stealthiest
stoutest
straightest
straightjacket
straitjacket
strategist
streetlight
stretchiest
strictest
structuralist