読者です 読者をやめる 読者になる 読者になる

右上➚

プログラミングに関するメモをのこしていきます

Haskellでechoサーバ

はいどうもー
引き続きHaskellの話題です. ちょっとHaskellTCPソケットを使ってみたくなったので, まず簡単なものから実装してみます.

TCPソケットのチュートリアルといえばechoサーバですね!クライアントからの入力をそのまま返すサーバです.
せっかくなのできちんと複数クライアントとの同時通信を可能にしましょう.

Network.Socket

HaskellTCPソケットを使うには, Network.Socketを使うようです. Network.Socket

ドキュメントには, 低レベルAPINetwork.Socketで, 高レベルAPINetworkと書いてあるのですが, Networkモジュールのドキュメントには, 互換性のために残してあるけどこれから使う人はNetwork.Socketを使ってくれみたいなことが書いてあります.
適当にググるNetworkモジュールを使ったサンプルが散見されますが, ここはドキュメントにしたがって, Network.Socketを使用することにします.

ソケットの用意

TCPのサーバ側は, ソケットの作成 -> ソケットをポート番号指定でbind -> 接続受付を開始(listen) -> 接続を受け付ける(accept) というステップを踏む必要があります.
というわけでまずは指定したポート番号にbindされたソケットを用意するアクションを定義します.

import Network.Socket

serveSocket :: PortNumber -> Socket
serveSocket port = do
    soc <- socket AF_INET Stream defaultProtocol
    addr <- inet_addr "0.0.0.0"
    bind soc (SockAddrInet port addr)
    return soc

これで引数に渡したポート番号にbindされたソケットが作成されます.

accept

複数クライアントとの同時通信を実現するためには, Control.Concurrentのちからを借ります.
今回は forkIO を使って, 各コネクションごとにスレッドを起動していくことにします. (非同期版も作れるのかな?つくれたらつくります)

というわけで次は acceptしてforkIOするという処理を繰り返し行うアクションを定義します.
forkIO した後に実行するアクション(echoLoop)についてはとりあえずundefinedとします.
Haskellundefined, とても便利ですね. 型で考えるっていうスタイルが実行しやすくなっているのは, ソースコード上にトップレベル関数の型指定を書きやすいHaskellの文法とundefinedのおかげって感じがします.

echoLoop :: Socket -> IO ()
echoLoop = undefined

-- import Control.Concurrent
-- import Control.Monad
-- が必要
acceptLoop :: Socket -> IO ()
acceptLoop soc = forever $ do
    (conn, _addr) <- accept soc
    forkIO $ echoLoop conn

forever :: Monad m => m a -> m bは引数にIOアクションを受け取り, それを無限に繰り返し実行し続けます. (無限にくりかえすので返り値の型変数bは不定)
foreverの引数には, acceptしてforkIOするアクションを渡しています.

echo

最後にソケットから読み込み, そのまま書き出すechoLoop部分を作ります.

-- import Control.Exception が必要
echoLoop :: Socket -> IO ()
echoLoop conn = do
    sequence_ $ repeat $ do
        (str, _, _) <- recvFrom soc 64
        send soc str

recvFrom :: Socket -> Int -> IO (String, Int, SockAddr)は, recvFrom conn n で, connから最大でn文字まで読み込みます. 返り値は, (読み込んだ文字列, 読み込んだ文字数, 読み込み元のアドレス??) を返します.
そして, send :: Socket -> String -> IO () でソケットに読み込んだ文字列をそのまま書き込みます.
このように, 読み込んでそのままm書き込むというアクションを repeatでつなげています. repeat :: a -> [a]は無限リストを作る関数です. repeat 0[0, 0, 0, 0, ...というリストが作成されます.
このままでは [IO ()]型なので, これをsequence_ :: Monad m => [m a] -> m ()を使って1つのIOアクションにまとめ上げます.
Haskellが遅延評価だから出来る芸当ですね. 無限に繰り返す感あふれるコードになっている気がします. (forever使ったほうがいいと思います)

例外処理

recvFromは相手側がコネクションを切断するとEnd of fileの例外を投げます. forkIOしているので, 1つのスレッドが例外で落ちてもサーバ全体は動き続けますが, ソケットのクローズも出来ませんし, 標準エラーになんかでてきてよろしくないので修正します.

-- import Control.Exceptionが必要
echoLoop :: Socket -> IO ()
echoLoop conn = do
   sequence_ $ repeat $ do
       (str, _, _) <- recvFrom conn 64
       send conn str
   `catch` (\(SomeException e) -> return ())
   `finally` close conn

catchfinallyを追加しています.
どちらもJavaとかのそれと同じように動きます.
SomeExceptionはすべての例外を補足することが出来ますが, ほんとはあんまり良くないですね. ここではEOFに達した(コネクションが切断された)という場合だけを補足したいので. (どの関数がどういう場合にどんな例外を投げるのかっていうドキュメントがわからなかったのでこのままにしておきました)
そして, 例外が発生してもしなくても, 最後にかならずソケットのクローズをするようfinallyを使います.

SomeExceptionですべての例外が捕捉出来るのって不思議じゃないですか?Haskellにはオブジェクト指向っぽい型の階層関係なんてないのに. Haskellの多相性 - あどけない話このへんが関係しているっぽいなという感じがしますが詳しいことはよくわかりませんでした...

全体

module Main where

import Network.Socket
import Control.Monad
import Control.Concurrent
import Control.Exception

main :: IO ()
main = do
    soc <- serveSocket 8080
    listen soc 5
    acceptLoop soc `finally` close soc

serveSocket :: PortNumber -> IO Socket
serveSocket port = do
    soc <- socket AF_INET Stream defaultProtocol
    addr <- inet_addr "0.0.0.0"
    bind soc (SockAddrInet port addr)
    return soc

acceptLoop :: Socket -> IO ()
acceptLoop soc = forever $ do
    (conn, addr) <- accept soc
    forkIO $ echoLoop conn

echoLoop :: Socket -> IO ()
echoLoop conn = do
    sequence_ $ repeat $ do
      (str, _, _) <- recvFrom conn 64
      send conn str
    `catch` (\(SomeException e) -> return ())
    `finally` close conn

main内で listenするのを忘れずに!また, acceptLoop中に例外が発生してもソケットをクローズするようにfinallyを使っています. (まぁプログラム終了するのでいらない気もします)

動作確認

telnetコマンドでテストします.

% telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
test
test
aaa
aaa
hooooogle
hooooogle

ちょっとわかりづらいですが, 入力した文字列が即座にそのまま帰ってきていることがわかります. バッファリングの関係で, 一行ずつになっていますが.

まとめ

Haskellでechoサーバ, 意外とすんなりかけましたね. 例外関係があまりよく理解できていない感じがしますが...
非同期版が気になります. 調べてみます.