[Ruby][Haskell] SNTPクライアントを書く その１

同じことをするプログラムを２つの言語で書いてみるといろいろ発見があって面白い。

echoサーバ/クライアントやhttpクライアントなどテキストデータでやりとりするネットワークプログラムはネットに多く見つかる。

そこで今回はバイナリデータを扱うネットワークプログラムを書いてみようと思い、もっとも簡単な例として、日本標準時を取得するSNTPクライアントを書いてみることにした。バイナリデータを送受信するUDPクライアントである。

まず、SNTPプロトコルの仕様をRFCで読んで理解し、Rubyで書いて動作を確認する。

```ruby:QiitaSntpClient.rb
# coding: utf-8
# sntp sample program

require 'socket'

# send udp packet to ntp server and receive a response
us = UDPSocket.open()
host = "ntp.nict.jp" # 日本標準時 NTPサーバ
port = 123
us.connect(host,port)
request = "\x0b" +  "\x00" * 47
us.send(request, 0)
a = select([us],nil,nil,3)
if not a then  # Timeout
  us.close
  raise "Timeout" 
end
response = us.recv(256,0)
us.close

# transmit_timestamp_seconds
# 4文字のStringをビッグエンディアンで32ビット整数に
secs = response[40..43].unpack("N1")[0]    

# SNTPでは1900年からの秒数が返ってくるので、
# それを1970年からの秒数に変換するために70年分引く
seventy_years = Time.utc(1970,1,1).to_i - Time.utc(1900,1,1).to_i
epoc_time = secs - seventy_years   # 1970/01/01からの秒数に変換
printf("epoc_time [#{epoc_time}]\n")

# 1970年からの秒数でTimeオブジェクトを作る。時差は環境が知っている。
utc = Time.at(epoc_time)
printf("utc       [#{utc}]\n")
ntp_time = utc.strftime("%Y/%m/%d %H:%M:%S %z")
printf("format    [#{ntp_time}]\n")

# 比較のためPC時刻を取得する
pc_time = Time.now.strftime("%Y/%m/%d %H:%M:%S %z")
printf("pc_time   [#{pc_time}]\n")
```

動作確認 (Windows 7)

```text
C:\>ruby -v
ruby 2.0.0p481 (2014-05-08) [i386-mingw32]

C:\>ruby -w qiita_sntp_client.rb
epoc_time [1409664608]
utc       [2014-09-02 22:30:08 +0900]
format    [2014/09/02 22:30:08 +0900]
pc_time   [2014/09/02 22:30:07 +0900]
```

動作確認 (MacOS 10.9.4)

```text
$ ruby -v
ruby 2.0.0p451 (2014-02-24 revision 45167) [universal.x86_64-darwin13]

$ ruby -w qiita_sntp_client.rb 
epoc_time [1409664827]
utc       [2014-09-02 22:33:47 +0900]
format    [2014/09/02 22:33:47 +0900]
pc_time   [2014/09/02 22:33:47 +0900]
```
SNTPはSimple NTPで、48バイトのリクエストを送り、48バイトのレスポンスを受け取るだけのSimpleプロトコルである。これをHaskellに移植する。

```haskell:NonApplicativeSntpClient.hs

{-# OPTIONS -Wall -Werror #-}
module Main where
-- | NTPパケットの送受信に ByteString.Char8 を使用する
import Network.Socket hiding (sendTo,recvFrom)
import Network.Socket.ByteString
import qualified Data.ByteString.Char8 as B
import Data.Char (chr, ord)
-- | 時刻
import Data.Time.Clock
import Data.Time.Format
import Data.Time.Calendar
import Data.Time.LocalTime
import System.Locale

-- | convert big-endian ByteString to Int
big :: B.ByteString -> Int
big = B.foldl (\a c -> a*256 + ord c) 0

-- | NTP リクエスト
request :: B.ByteString
request = B.pack $ map chr (0x0b:replicate 47 0)

-- | NTP レスポンスからtransmit_timestamp_secondsを得る
getSeconds :: B.ByteString -> Int
getSeconds = big . B.take 4 . B.drop 40

-- | 70年分の秒数
seventyYears :: Int
seventyYears = (*) (24*60*60::Int) (fromInteger days)
                where days = diffDays (fromGregorian 1970 1 1) 
                                      (fromGregorian 1900 1 1)

-- | サーバ名型
type ServerName = String

-- | NTPサーバ
server1 :: ServerName
server1 = "ntp.nict.jp" -- | 日本標準時 NTPサーバ
-- server1 = "10.8.24.1"   -- intranet router

-- | NTPポート
port1 :: PortNumber
port1 = 123

-- | 名前解決
getHostAddr :: ServerName -> IO HostAddress
getHostAddr server = do
        (serveraddr:_) <- getAddrInfo Nothing (Just server) (Just "ntp")
        let host = takeWhile (/=':') . show $ addrAddress serveraddr
        inet_addr host

-- | SNTPクライアント (UDPクライアント)
-- | withSocketsDo :: IO a -> IO a
sntpClient :: ServerName -> PortNumber -> IO ()
sntpClient server port = withSocketsDo $ do
        hostAddr <- getHostAddr server
        soc <- socket AF_INET Datagram defaultProtocol
        _ <- sendTo soc request (SockAddrInet port hostAddr)
        (response,　_) <- recvFrom soc 256
        close soc
        -- | SNTPでは1900年からの秒数が返ってくるので、
        -- | それを1970年からの秒数に変換するために70年分引く
        let epocTime = getSeconds response - seventyYears
        showTime epocTime

-- | 時刻表示フォーマット
format :: UTCTime -> String
format = formatTime defaultTimeLocale "%Y/%m/%d %H:%M:%S"

-- | 時刻表示
showTime :: Int -> IO ()
showTime epocTime = do
        putStrLn $ "epocTime   [" ++ show epocTime ++ "]"
        -- | UTCTimeを作る
        -- | readTime :: ParseTime t => TimeLocale -> String -> String -> t
        let utcTime = readTime defaultTimeLocale "%s" (show epocTime) :: UTCTime
        putStrLn $ "utc        [" ++ show utcTime ++ "]"
        -- | Asia/Tokyoの時差9時間を足す
        -- | addUTCTime :: NominalDiffTime -> UTCTime -> UTCTime
        let zoned = addUTCTime (9*60*60) utcTime
        putStrLn $ "format     [" ++ format zoned ++ "]"

-- | PCの時刻取得
-- | formatTime :: FormatTime t => TimeLocale -> String -> t -> String
zonedTime :: IO String
zonedTime = do z <- getZonedTime
               return $ formatTime defaultTimeLocale "%Y/%m/%d %H:%M:%S %z" z

-- | PCの時刻取得
currentTime :: IO String
currentTime = do z <- getCurrentTime
                 return $ formatTime defaultTimeLocale "%Y/%m/%d %H:%M:%S %z" z

-- | メイン
main  :: IO ()
main  = do sntpClient server1 port1
           z <- zonedTime
           putStrLn $ "pc_zoned   [" ++ z ++ "]"
           c <- currentTime
           putStrLn $ "pc_current [" ++ c ++ "]"

```

動作確認 (Windows 7)

```text

C:\>runghc --version
runghc 7.8.3

C:\>runghc NonApplicativeSntpClient.hs
epocTime   [1409665324]
utc        [2014-09-02 13:42:04 UTC]
format     [2014/09/02 22:42:04]
pc_zoned   [2014/09/02 22:42:03 +0900]
pc_current [2014/09/02 13:42:03 +0000]

この時送信したネットワークパケット
00000000: 0B 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00000010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00000020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................

この時受信したネットワークパケット
00000000: 0C 01 00 EC 00 00 00 00 00 00 00 00 4E 49 43 54   ............NICT
00000010: D7 B0 47 AC 00 00 00 00 00 00 00 00 00 00 00 00   ..G.............
00000020: D7 B0 47 AC 1A 29 FE C5 D7 B0 47 AC 1A 2A 0C 42   ..G..)....G..*.B

```

動作確認 (MacOS 10.9.4)

```text

$ hlint NonApplicativeSntpClient.hs 
No suggestions

$ runghc --version
runghc 7.8.3

$ runghc NonApplicativeSntpClient.hs
epocTime   [1409665697]
utc        [2014-09-02 13:48:17 UTC]
format     [2014/09/02 22:48:17]
pc_zoned   [2014/09/02 22:48:17 +0900]
pc_current [2014/09/02 13:48:17 +0000]

```
---
わかったこと

1. 初めてByteStringを使ったプログラムを書いた。Network.Socket にある sendTo,recvFrom はStringを送受信するものだが、Network.Socket.ByteString にある sendTo,recvFrom を使えばByteStringを送受信できる。

できていないところ

1. サーバの名前解決ができなくて例外が投げられたときの処理や、一定時間以内にレスポンスが返ってこなかったときの処理や、そもそも例外じゃなくてMaybeモナドとか使って美しくHaskellらしく書くこと。

わからないところ

1. Rubyでは、メインプログラム部分を「if \_\_FILE\_\_ == $0 ～ end」で囲むとコマンドとしてもライブラリとしても使えるrbファイルを作ることができる。これと同等のことをHaskellでやりたいが、他のプログラムからimportされても、単独でもrunghcで実行できるhsファイルを書く方法がわからなかった。
2. Haskellで、UTCTimeを時差付きで、+0900付きで表示させる方法がわからなかった。プログラムでは9時間分足してごまかしたが、UTCをシステムが知っている時差で表示する方法がわからない。
3. Qiitaの投稿画面で、この文章を書いている下に、「画像を選択またはドラッグ＆ドロップ-100MB/100MB」と表示されている。状態マシン図の記事で画像を貼り付けすぎたため100%になっちゃったらしいが、私はもう画像を貼り付けた記事が書けないのだろうか。

前もってありがとうございます。