サーバの起動
この節ではPOPサーバが起動するまでのコードを解説します。
オプションの解析
最初は順当にプログラムの頭から見ていくことにしましょう。Rubyプログラムには、Cのmain関数やJavaのMainクラスのような決まった開始地点はありませんが、私はいつもmainメソッドを自分で書くことにしています。
では、mainメソッドの冒頭を以下に示します。
def main mode = :inetd port = POPd.default_port parser = OptionParser.new parser.banner = "Usage: #{File.basename($0)} [--standalone] [--port=NUM]" parser.on('-i', '--inetd', 'Enables inetd mode (default)') { mode = :inetd } parser.on('-s', '--standalone', 'Enables standalone server') { mode = :standalone } parser.on('-p', '--port=NUM', "Port number to listen to (default: #{port})") {|num| port = num.to_i } parser.on('--help', 'Prints this message') { puts parser.help exit 0 } begin parser.parse! rescue OptionParser::ParseError => err $stderr.puts err.message $stderr.puts parser.help exit 1 end : : end
このコードではコマンドラインオプションを解析しています。POPdでは、optparseライブラリ(OptionParserクラス)を使ってコマンドラインオプションを解析します。
OptionParserでコマンドラインオプションを解析するには、次のような手順を踏みます。
OptionParser.newメソッドでOptionParserオブジェクトを作るOptionParser#onメソッドでコマンドラインオプションを登録するOptionParser#parse!メソッドで実際にARGVを解析する
OptionParser#onメソッドにはいろいろと機能があるのですが、上記のコードに登場している基本的な使い方だけマスターしておけばとりあえずは十分でしょう。OptionParser#onメソッドの主な使用パターンは以下のとおりです。
| パターン | 解説 |
| parser.on('-i', '説明') { …… } | ショートオプション「-i」を登録する。 第2引数はオプションの説明で、ヘルプメッセージを生成するときに使われる。 |
| parser.on('-i', '--inetd', '説明') { …… } | ショートオプション「-i」と、それと同等の効果を持つロングオプション「--inetd」を登録する。第3引数はオプションの説明で、ヘルプメッセージを生成するときに使われる。 |
| parser.on('--inetd', '説明') { …… } | ロングオプション「--inetd」を登録する。第2引数はオプションの説明で、ヘルプメッセージを生成するときに使われる。 |
| parser.on('--port=NUM', '説明') {|num| …… } | 引数を持つロングオプション「--port」を登録する。引数はブロック引数numに渡ってくる。第2引数はオプションの説明で、ヘルプメッセージを生成するときに使われる。 |
また、OptionParser#banner=メソッドで登録した文字列はヘルプメッセージの冒頭に出力されます。ヘルプメッセージはOptionParser#helpメソッドで得られます。
初めて見たときには、OptionParserを使ったコードはややこしく見えるかもしれません。最初のうちは、動くコードをコピー&ペーストして自分のプログラムに応用していくのがいいでしょう。使っていればそのうち慣れてしまいます。
サーバの起動
続いてサーバを起動するコードを見ていきます。mainメソッドの続きを以下に示します。
accounts = POPd::AccountTable.new(POPd::Maildir)
logger = Syslog.open('popd', Syslog::LOG_PID, Syslog::LOG_DAEMON)
popd = POPd.new(accounts, logger)
この部分はPOPdオブジェクトを設定に合わせて作成しています。POPdクラスはサーバ全体を表現するクラスです。ここで例えば、POPd::Maildirを適切なクラスに変更してやれば、別のメールボックス形式にも対応できるように設計してあります。
また、POPd#initializeは以下のとおり、accountsとloggerをインスタンス変数に代入するだけです。
class POPd def initialize(accounts, logger) @accounts = accounts @logger = logger end
mainメソッドのコード2行目のSyslog.openでは、OSに用意されているロギングインターフェイスに接続しています。第1引数は自プログラム名、第2引数はログ形式の指定、第3引数は自プログラムの分類です。上記のようにSyslog.openを呼び出すと、書き出されるログは以下のような形式になります。
Oct 29 11:12:11 tukumo popd[7947]: connection from 192.168.1.34
Syslog.openメソッドの詳細については、本稿では説明しません。リファレンスマニュアルのSyslogモジュールの項を見てください。
さて、mainメソッドの続きに戻りましょう。mainメソッドの末尾、サーバを起動する部分を以下に示します。
case mode when :standalone daemon { popd.listen port } when :inetd popd.inetd else raise 'must not happen' end
変数modeが:standaloneならスタンドアロンモード(デーモンモード)、modeが:inetdならinetdモードで起動します。
以下、スタンドアロンモードの場合とinetdモードの場合に分けて説明していきます。
デーモンになる
スタンドアローンモードの場合、特に知識が必要になる個所は2点あります。以下の2点です。
- デーモンになる方法
- TCP接続を受け付ける方法
1点目のデーモンになるコードは、daemonメソッドで実装されています。2点目のTCP接続を受け付けるコードは、POPd#listenメソッドで実装されています。
まずdaemonメソッドから見ていきましょう。daemonメソッドは自プロセスをデーモンにするメソッドです。以下にdaemonメソッドのコードを示します。
def daemon return yield if $DEBUG fork { Process.setsid fork { Dir.chdir '/' STDIN.reopen '/dev/null', 'r' STDOUT.reopen '/dev/null', 'w' STDERR.reopen '/dev/null', 'w' trap(:INT, 'IGNORE') trap(:TERM, 'EXIT') trap(:HUP, 'IGNORE') yield } exit! } exit! end
このメソッドがやることは4つあります。
forkメソッドとProcess.setsidメソッドを呼び出す- ルートディレクトリ(/)にchdirする
- 標準入出力を「/dev/null」にリダイレクトする
- INTシグナル(Ctrl-Cによる割り込み)、TERMシグナル、HUPシグナルのハンドラを登録する
まず、1.のforkメソッドとsetsidメソッドが一番重要です。このメソッドはそれぞれfork()システムコールとsetsid()システムコールに対応しており、fork()→setsid()→fork()の順番で呼び出すことによってプロセスをデーモン化します。
次に、2.3.4.はサーバプログラムとしての作法です。
ルートディレクトリにchdirするのは、ファイルシステムをアンマウントできなくなる問題を防ぐためです。UNIXでは、ファイルシステムに含まれるディレクトリがプロセスのカレントディレクトリになっていると、そのファイルシステムをアンマウントできないという仕様があります。そこで例えば、システム管理者がうっかりして、マウントしたCD-ROMファイルシステムからサーバを起動したと考えてみましょう。この場合、サーバのカレントディレクトリはCD-ROMファイルシステムなので、サーバが終了するまでCD-ROMをアンマウントできなくなってしまいます。このような問題を防ぐため、サーバはひとまずルートディレクトリにchdirするのが作法なのです。
もっとも、必ずルートディレクトリにchdirしなければいけないわけではありません。サーバが動作するために適切なディレクトリが特にあるなら、そのディレクトリにchdirしても構いません。
次に、3.の標準入出力を「/dev/null」へリダイレクトする理由は、うっかり標準入出力を使ってしまったときにエラーにならないようにするためです。標準入出力は端末につながっていますが、デーモンから端末に対して入出力を行うとエラーになってしまうのです。プログラムが完璧に書けていればそんなことは起こらないはずですが、完璧なプログラムは滅多にありませんから、念のために実行しておきましょう。
最後に4.のシグナルハンドラ設定は一般的な慣習のようなものです。INTシグナル(割り込み)はサーバでは大抵無視します。TERMシグナルは一般にサーバを停止するときに使われます。HUPシグナルは設定ファイルなどを読み直すときに使われます。
それから、daemonメソッドではexit!メソッドを使っているところも特徴的です。exit!メソッドは_exit()システムコールに対応するRubyのメソッドです。exit!メソッドには、通常のexitメソッドと違って入出力バッファをフラッシュしない、などの特徴があります。このメソッドで通常通りexitメソッドを使ってしまうと、ここまでにプログラムが出力した内容が二重になってしまうなどの問題が発生します。
このように、デーモンになる手順はなかなか面倒です。面倒ではありますが、手順は決まっているので、上記のコードをコピー&ペーストすれば済むことでもあります。私は上記のコードに関しては特に著作権を主張しないので、皆さんがデーモンになるコードを書くときは自由にコピーして使ってください。
TCP接続を待つ
次に、スタンドアロンモードの第2の難関、TCP接続を受け付けるためのコードを見ていきます。TCP接続を受け付けるコードを実装しているのはPOPd#listenメソッドです。POPd#listenメソッドのコードを以下に示します。
def listen(port) server = TCPServer.new(port) while true Thread.fork(server.accept) {|sock| @logger.notice "connection from #{sock.peer_ipaddr}" service sock, sock } end end
TCP接続を受け付けるためには、以下の2つの手順が必要です。
TCPServer.newメソッドを呼んで、TCPServerオブジェクトを作成するTCPServer#acceptメソッドを呼んで、実際にTCP接続を待つ
まず、TCPServer.newメソッドでTCPServerオブジェクトを作ります。第1引数は待ち受けるポート番号です。POP3では110番がデフォルトのポートすなわちウェルノウンポート(well-known port)なので、一般的には「110」を渡せばよいでしょう。
ただし、1023番以下のポートはスーパーユーザー(root)権限がないと接続を待てないので、一般ユーザー権限でPOPdを動かすときは「--port」オプションを使ってもっと大きなポート番号を指定してください。
ちなみに、TCPServerオブジェクトの実体は、TCP接続待ち受け用のソケットです。このソケットをサーバソケット(server socket)と言います。
さて次に、TCPServer#acceptメソッドを呼びます。TCPServer#acceptメソッドは、クライアントから接続があるまで待ち、接続が完了したら、クライアントと接続済みになったTCPSocketオブジェクトを返します。既に説明したように、TCPSocketオブジェクトに対しては普通のFileオブジェクトと同じようにgetsメソッドやprintメソッドを使って入出力が可能です。
複数のクライアントの並行処理
TCP接続を待ち受けるときの基本は以上の通りですが、上記のPOPd#listenメソッドでは、もう一工夫を加えています。
スタンドアロンモードの場合、クライアントからのTCP接続を次々に受け付けなければいけません。そこで次のようなコードを書いたとしましょう。
server = TCPServer.new(port) while true sock = server.accept service sock, sock end
ところが、これでは問題があります。クライアントからの接続はいつ来るか分かりませんから、もしかすると、既にクライアントと接続してserviceメソッドを実行しているあいだに、別のクライアントからも接続要求が来てしまうかもしれません。もし、現在処理中のクライアントの処理が非常に長時間かかってしまうと、そのあいだ次のクライアントはずっと待たされてしまいます。
そこで、複数のクライアントを並行処理できるように、Rubyのスレッドを使うことを考えましょう。次のようにコードを改善してみました。
server = TCPServer.new(port) while true sock = server.accept Thread.fork { service sock, sock } end
これで問題はないでしょうか? ……残念ながら、これは間違いです。Thread.forkを呼んだ時点でスレッドが1つ増えて並行処理が始まりますから、生成されたスレッドがブロックを実行してsockを参照する前に、whileループの次の回が実行されて、ローカル変数sockが上書きされてしまう可能性があります。
ですからこのループは、さきほど見せたPOPd#listenメソッドのように、Thread.forkの引数を使って書かなければいけないのです。正しいコードは以下のとおりです。
server = TCPServer.new(port) while true Thread.fork(server.accept) {|sock| service sock, sock } end
こう書くことで、server.acceptの返り値(接続済のTCPSocketオブジェクト)がブロック引数sockに渡されます。そしてブロック引数sockは、スレッドローカルなので、別のスレッドに変数を上書きされてしまうことはなくなります。
スタンドアロンモードとinetdモードの共通化
続いてPOPd#serviceメソッドに進みましょう。POPd#serviceメソッドのコードを以下に示します。
def service(input, output) @input = input @output = output session end
さきほどのPOPd#listenメソッドの最後は次のようになっていました。
service sock, sock
従って、スタンドアロンモードならば@input(入力元)も@output(出力先)もソケットということになります。
また、inetdモードなら次のようにserviceメソッドを呼び出します。
service STDIN, STDOUT
これでinetdモードのときはめでたくSTDIN(標準入力)が入力、STDOUT(標準出力)が出力となります。
ここまでのコードでスタンドアロンモードとinetdモードの違いが共通化できたので、以降はどちらのモードでもコードは完全に同じになります。
