SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

特集記事

Rubyで簡易POP3サーバを作る

RubyによるネットワークプログラミングとUNIXシステムプログラミングの基礎

  • X ポスト
  • このエントリーをはてなブックマークに追加

サーバの起動

 この節ではPOPサーバが起動するまでのコードを解説します。

オプションの解析

 最初は順当にプログラムの頭から見ていくことにしましょう。Rubyプログラムには、Cのmain関数やJavaのMainクラスのような決まった開始地点はありませんが、私はいつも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でコマンドラインオプションを解析するには、次のような手順を踏みます。

  1. OptionParser.newメソッドでOptionParserオブジェクトを作る
  2. OptionParser#onメソッドでコマンドラインオプションを登録する
  3. OptionParser#parse!メソッドで実際にARGVを解析する

 OptionParser#onメソッドにはいろいろと機能があるのですが、上記のコードに登場している基本的な使い方だけマスターしておけばとりあえずは十分でしょう。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メソッドの続きを以下に示します。

mainメソッド(POPdオブジェクトの作成)
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は以下のとおり、accountsloggerをインスタンス変数に代入するだけです。

POPdクラス
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メソッドの末尾、サーバを起動する部分を以下に示します。

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点です。

  1. デーモンになる方法
  2. TCP接続を受け付ける方法

 1点目のデーモンになるコードは、daemonメソッドで実装されています。2点目のTCP接続を受け付けるコードは、POPd#listenメソッドで実装されています。

 まずdaemonメソッドから見ていきましょう。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つあります。

  1. forkメソッドとProcess.setsidメソッドを呼び出す
  2. ルートディレクトリ(/)にchdirする
  3. 標準入出力を「/dev/null」にリダイレクトする
  4. 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メソッドのコードを以下に示します。

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つの手順が必要です。

  1. TCPServer.newメソッドを呼んで、TCPServerオブジェクトを作成する
  2. 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メソッドのコードを以下に示します。

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モードの違いが共通化できたので、以降はどちらのモードでもコードは完全に同じになります。

次のページ
ネットワーク入出力

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
特集記事連載記事一覧

もっと読む

この記事の著者

青木 峰郎(アオキ ミネロウ)

ふつうの文系プログラマ。Ruby界隈で活動中。主著は『ふつうのLinuxプログラミング』(ソフトバンククリエイティブ、2005)、『Rubyソースコード完全解説』(インプレス、2002)。

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/687 2006/11/10 00:00

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング