Ruby でシェルのようなものを作ってみると、Ruby とシェルやシステムコールの理解ができて、研修の課題とかにいいんじゃないかと10年くらい前に思ってたのを、ふと思い出したので書いてみます。
基本
シェルの動作を簡単に説明すると次のような感じです。
- プロンプトを出力
- 標準入力からコマンドラインを読み込む
- 読み込んだコマンドを実行する
- コマンドの終了を待つ
- 1 に戻る
これをそのまま Ruby で書いてみます。
while true print '-> ' # プロンプト表示 cmd = gets or break # コマンド入力 cmd.chomp! # 末尾の改行削除 pid = Process.fork do # 子プロセス生成 Process.exec [cmd, cmd] # コマンド実行 end Process.waitall # 子プロセスの終了待ち end
Process.exec
の引数に cmd
ではなく [cmd, cmd]
を渡しています。引数が一つだけで文字列の場合は Process.exec
はシェルを経由する可能性があるためです。これからシェルを作ろうとしているのにシェル経由でコマンドを実行してしまっては面白くもなんともないので、シェルを経由しないようにしています。
実行してみます。作成したスクリプトは sheru というファイル名にしてあります。
% ruby sheru -> date 2013年 10月 8日 火曜日 23:26:03 JST > ls sheru text.md -> ← 終了は Ctrl-D
うまく動いているようです。
EOF
ちなみに Ctrl-D で標準入力が終了するのは、コマンドが Ctrl-D のコードを読み込んで特別扱いしているのではなく、端末で Ctrl-D が EOF(End Of File)に割り当てられているからです。
stty コマンドで割り当てを確認することができます。
% stty -a speed 38400 baud; rows 24; columns 80; line = 0; intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?; swtch = M-^?; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0; -parenb -parodd cs8 hupcl -cstopb cread -clocal -crtscts -ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc ixany imaxbel iutf8 opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0 isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke
stty コマンドの出力の2行目の eof = ^D
というのがそうです。ちなみに Cntrl-C でコマンドが中断するのも intr = ^C
が設定されているためです。
Ctrl-D 以外のキーを割り当ててみましょう。
% stty eof ^X % stty -a speed 38400 baud; rows 24; columns 80; line = 0; intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^X; eol = M-^?; eol2 = M-^?; ...
Ctrl-X を割り当ててみました。これ以降この端末では Ctrl-X で入力が終了するようになります。他の端末には影響しません。
% ruby sheru -> ^D ← Ctrl-D を入力しても普通の文字として扱われる -> ← 終了するには Ctrl-X を入力
ややこしいので元に戻しておきます。
% stty eof ^D
コマンドラインのパース
入力を元にコマンドをちゃんと動かすことができました。ところがコマンドに引数を指定するとエラーになってしまいます。
% ruby sheru -> ls -l ./sheru:7:in `exec': No such file or directory - ls -l (Errno::ENOENT) from ./sheru:7:in `block in <main>' from ./sheru:6:in `fork' from ./sheru:6:in `<main>' ->
引数つきの ls
コマンドではなく、ls -l
というコマンドを探して、そのようなコマンドは無いのでエラーになっています。
入力文字列をコマンドと引数に分解して exec
に渡す必要があります。
シェルの行のパースは実はいろいろ複雑なのですが、ここでは単純に空白で区切るだけにしておきます。
while true print '-> ' # プロンプト表示 line = gets or break # コマンド入力 line.chomp! # 末尾の改行削除 cmd, *args = line.split(/\s+/) # 空白で入力を区切る pid = Process.fork do # 子プロセス生成 Process.exec [cmd, cmd], *args # コマンド実行 end Process.waitall # 子プロセスの終了待ち end
% ruby sheru -> ls -l 合計 8 -rwxr-xr-x 1 tommy tommy 472 10月 8 23:36 sheru -rwxr-xr-x 1 tommy tommy 472 10月 8 23:36 text.md ->
ちゃんと引数を扱うことができました。
ワイルドカード
ファイル名にワイルドカードを使ってみましょう。
% ruby sheru -> ls * ls: * にアクセスできません: そのようなファイルやディレクトリはありません ->
ls
コマンドが *
をそのままファイル名として扱ってしまって、ファイルがないと言ってます。
ワイルドカードをファイル名に展開しているのは各コマンドではなく、シェルの役割なのです。
シェルのワイルドカードもいろいろありますが、簡単のためにここでは *
の一文字だけをワイルドカードとして扱うことにします。
def parse_line(line) line.split(/\s+/).map{|arg| if arg == '*' # 引数が '*' の場合は Dir.glob('*') # ファイル名に展開する else arg end }.flatten # '*' の展開が配列になっているのでフラット化 end while true print '-> ' # プロンプト表示 line = gets or break # コマンド入力 line.chomp! # 末尾の改行削除 cmd, *args = parse_line line # 入力のパース pid = Process.fork do # 子プロセス生成 Process.exec [cmd, cmd], *args # コマンド実行 end Process.waitall # 子プロセスの終了待ち end
コマンドラインのパース処理が複雑になってきたのでメソッドを分けました。
% ruby sheru -> ls * sheru text.md ->
リダイレクト
標準入力、標準出力、標準エラー出力は、それぞれプロセスのファイル記述子 0, 1, 2 につけられた名前です。 端末から実行された場合は通常はそれぞれ端末に結び付けられています。 なので、端末から入力された文字がコマンドの標準入力から読み込まれ、コマンドの標準出力に書かれたデータは端末に出力されるのです。
一般的なシェルは >
で標準出力をファイルに書き出すことができます。それも実装してみましょう。
簡単のために >filename
の形式だけをサポートします。>
の後に空白文字は要りません。
def parse_line(line) @stdout = nil # 標準出力リダイレクト先 line.split(/\s+/).map{|arg| if arg == '*' # 引数が '*' の場合は Dir.glob('*') # ファイル名に展開する elsif arg =~ /\A>(.*)/ # 引数が '>' で始まる場合は @stdout = $1 # 続く文字列をリダイレクトファイル名とする nil # この引数は無視 else arg end }.compact.flatten # '*' の展開が配列になっているのでフラット化 end while true print '-> ' # プロンプト表示 line = gets or break # コマンド入力 line.chomp! # 末尾の改行削除 cmd, *args = parse_line line # 入力のパース pid = Process.fork do # 子プロセス生成 if @stdout # リダイレクト先が指定されていた場合は STDOUT.reopen(@stdout, 'w') # 標準出力をファイルに変更 end Process.exec [cmd, cmd], *args # コマンド実行 end Process.waitall # 子プロセスの終了待ち end
リダイレクトには $stdout
ではなく STDOUT
を使用します。
$stdout
は Ruby のメソッドでしか有効ではありません。標準出力(=ファイル記述子1番)のファイルを変更するには STDOUT を変更する必要があります。
なお、Ruby の exec
にはこれを簡単に行う方法が用意されていて、次のように記述できます。
Process.exec [cmd, cmd], *args, :out=>@stdout
実行結果
% ruby sheru -> ls >/tmp/xxx -> cat /tmp/xxx sheru text.md ->
パイプ
一般的なシェルは、コマンドライン中に |
があると、その前後に書かれた二つのプロセスを同時に動かして、前のプロセスの標準出力と後ろのプロセスの標準入力を接続します。
pipe システムコールを使用すると2つのファイル記述子が返されます。これがパイプです。 このファイル記述子はファイルシステム上のファイルには関連づいていないのでファイル名はありません。 名前無しパイプとも呼ばれます。
パイプは一方に書き込むともう片方から読み込むことができます。なので、一つを前のプロセスの標準出力に設定して、もう一つを後ろのプロセスの標準入力に設定することで、パイプ処理が実現できます。
以下のプログラムでは、ややこしくなるので、先に実装したリダイレクト処理は削除しています。
# [[cmd1, arg, ...], [cmd2, arg, ...], ...] を返す def parse_line(line) cmd = [] cmds = [cmd] line.split(/\s+/).each do |arg| if arg == '*' # 引数が '*' の場合は cmd.concat Dir.glob('*') # ファイル名に展開する elsif arg == '|' # パイプの場合は cmd = [] # 次から新しいコマンド cmds.push cmd else cmd.push arg end end cmds end while true print '-> ' # プロンプト表示 line = gets or break # コマンド入力 line.chomp! # 末尾の改行削除 cmds = parse_line line pipes = Array.new(cmds.count-1){IO.pipe} # コマンド接続分のパイプを用意 pipes = [nil, pipes.flatten.reverse, nil] # [nil, W, R, W, R, ..., nil] に並び替え .flatten cmds.each do |cmd, *args| r, w = pipes.shift 2 # パイプを2つ取り出し pid = Process.fork do # 子プロセス生成 STDIN.reopen r if r # 読み込みパイプにリダイレクト STDOUT.reopen w if w # 書き込みパイプにリダイレクト Process.exec [cmd, cmd], *args # コマンド実行 end r.close if r # 親プロセスでは不要なので w.close if w # パイプをクローズ end Process.waitall # 子プロセスの終了待ち end
実行結果
% ruby sheru -> seq 100 | grep 0 | head -3 10 20 30 ->
おわりに
シェルと呼ぶにはプアすぎる実装ですが、シェルがどのようにしてコマンドを実行しているのかの例くらいにはなったのではないかと思います。
システムコールは C で記述すると結構面倒なことが多いのですが、Ruby を使うととても簡単に記述することができます。Ruby は素晴らしいですね。
システムコールをRuby から使う方法についてもっと詳しく知りたい人は、「なるほどUnixプロセス」という書籍がオススメです。