۳۰ - ۴فینگر-دیِ یه ذره مدرن‌تر

قدیم‌ترها، داده‌ای که ‏‎finger‎‏ درباره‌ی کاربرها برمیگردوند جزئی از سیستم عامل بود. هنوز اون اطلاعات به طورِ معمول داخلِ OS ذخیره میشن، اما به دلایلِ امنیتی، دیگه همینطوری از طریق درخواست‌های ‏‎finger‎‏ به اشتراک گذاشته نمیشن. ما هم منبع ِ داده ِ ‏‎finger‎‏ رو با استفاده از یه پایگاه داده ِ SQL،* به اسمِ SQLite به‌روزرسانی می‌کنیم. پایگاهِ داده راهِ آسون و مطمئنی برای سازماندهی و خوندن ِ داده‌ها هست، و SQLite یه پایگاهِ داده ِ سبک‌ه. داده‌ها رو توی یه فایل، در پوشه‌ی پروژه دخیره می‌کنیم، در نتیجه تعامل باهاش خیلی عجیب‌وغریب نمیشه.

*

م. هم «سیکوئِل» خونده میشه، هم «اِس-کیو‌-اِل».

اول چارچوب‌بندیِ منطقِ سرورِ TCP، بعد هم نحوه‌ی تعامل با پایگاه داده رو نشون‌تون میدیم. از اینجا به بعد، هر چی کد هست میره توی ‏‎Main.hs‎‏:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes       #-}
{-# LANGUAGE RecordWildCards   #-}

‏‎OverloadedSrings‎‏ رو که میشناسین. ‏‎QuasiQuotes‎‏ هم برای لیترال‌هاست و قبلاً دیدین. فقط ‏‎RecordWildCards‎‏ جدیده. کاری می‌کنه که دیگه لازم نباشه محتویاتِ یه رکورد رو دستی بیرون بکشیم؛ در عوض، دستگیره‌های رکورد تبدیل به انقیاد ِ به محتوا میشن، طوری که:

{-# LANGUAGE RecordWildCards #-}

module RWCDemo where

data Blah =
  Blah { myThing :: Int }

wew Blah{..} = print myThing

تابعِ ‏‎wew‎‏، اون ‏‎myThing‎‏ که داخلِ آرگومانِ ‏‎Blah‎‏ هست رو چاپ می‌کنه، بدونِ اینکه لازم باشه ‏‎myThing‎‏ رو به مقدارِ ‏‎Blah‎‏ اعمال کنیم، یا در تطبیق الگو محتویاتِ ‏‎Blah‎‏ رو افشا کنیم. فقط برای راحتی‌ه.

module Main where

import Control.Exception
import Control.Monad (forever)
import Data.List (intersperse)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding
       (decodeUtf8, encodeUtf8)

باید بتونیم یه مقدارِ ‏‎Text‎‏ رو از یه ‏‎ByteString‎‏ ِ UTF-8 کدگشایی کنیم و بعد دوباره به یه مقدارِ ‏‎Text‎‏ به عنوانِ یه ‏‎ByteString‎‏ ِ UTF-8 کدبندی کنیم.

import Data.Typeable
import Database.SQLite.Simple
       hiding (close)
import qualified Database.SQLite.Simple
       as SQLite
import Database.SQLite.Simple.Types
import Network.Socket hiding (close, recv)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Network.Socket.ByteString
       (recv, sendAll)
import Text.RawString.QQ

ساختنِ پایگاه داده

ما اینجا با استفاده از کتابخونه ِ ‏‎sqlite-simple‎‏ یه پایگاهِ داده، ذخیره شده در فایلی در همون پوشه‌ی پروژه درست می‌کنیم. این نقشِ مخزن ِ کاربرهایی رو بازی می‌کنه که فینگر دیمِن گزارش‌شون رو میده.

data User =
  User {
      userId   :: Integer
    , username :: Text
    , shell :: Text
    , homeDirectory :: Text
    , realName :: Text
    , phone :: Text
  } deriving (Eq, Show)

‏‎User‎‏ نوع‌داده ایه که رکوردهای کاربر رو توصیف می‌کنه. خیلی جذاب یا خوش‌ساختار نیست، اما کار رو راه میندازه. تنها چیزی که احتمالاً یه کم غیرِعادیه، اینه که برای تأمینِ کلیدِ اصلی به پایگاهِ داده، از یه فیلد ِ ‏‎userId‎‏ با تایپِ ‏‎Integer‎‏ استفاده کردیم. نقشِ این کلیدها تأمینِ راهی برای تشخیصِ یکتای داده‌ها در پایگاه داده هست.

چندتا نمونه ِ تایپکلاسی هم برای مارشال و آنمارشال کردنِ داده به/از پایگاهِ داده ِ SQLite لازم داریم:

instance FromRow User where
  fromRow = User <$> field
                 <*> field
                 <*> field
                 <*> field
                 <*> field
                 <*> field

instance ToRow User where
  toRow (User id_ username shell homeDir
              realName phone) =
    toRow (id_, username, shell, homeDir,
           realName, phone)

باید شما رو یادِ ‏‎FromJSON‎‏ و ‏‎ToJSON‎‏ بندازه.

createUsers :: Query
createUsers = [r|
CREATE TABLE IF NOT EXISTS users
  (id INTEGER PRIMARY KEY AUTOINCREMENT,
   username TEXT UNIQUE,
   shell TEXT, homeDirectory TEXT,
   realName TEXT, phone TEXT)
|]

تایپِ ‏‎Query‎‏ یه نیوتایپ برای مقدارِ ‏‎Text‎‏ ِه. ‏‎Query‎‏ یه نمونه ِ ‏‎IsString‎‏ هم داره تا لیترال‌های نوشتاری امکانِ تعیین شدن به مقادیرِ ‏‎Query‎‏ هم داشته باشن. ولی خب این کُدِ بالا در واقع یه استعلام (م. از پایگاه داده) نیست؛ یه دستور ِ SQL ِه که جدولِ پایگاه داده که شاملِ داده‌هامون میشه رو تعریف می‌کنه. اون خطی که کلیدِ اصلی رو تعریف کردیم، میگه اسم‌ش ‏‎id‎‏ باشه و می‌خوایم بعد از هر سطر ِ جدول، بصورت اتوماتیک یکی به اون فیلد اضافه بشه (م. auto-increment بشه). یعنی اگه ‏‎id‎‏ ِ آخرین سطری که واردِ جدول کردیم ۱ باشه، کلید اصلی ِ سطرِ بعدی، خودبه‌خود ۲ میشه. خط‌های بعدی اسمِ فیلدها و نحوه‌ی ارائه‌شون (‏‎TEXT‎‏) رو توصیف می‌کنن، فقط دقت کنین که گفتیم نام‌های کاربری (usernames) یکتا باشن تا نشه دوتا مقدارِ ‏‎User‎‏ ِ یکسان وجود داشته باشن.

insertUser :: Query
insertUser =
  "INSERT INTO users\
  \ VALUES (?, ?, ?, ?, ?, ?)"

allUsers :: Query
allUsers =
  "SELECT * from users"

getUserQuery :: Query
getUserQuery =
  "SELECT * from users where username = ?"

اینها بیشتر استعلام‌های کمکیِ پایگاه داده‌ای اند و به ترتیب برای وارد کردنِ یه کاربر ِ جدید، گرفتنِ همه‌ی کاربرها از جدولِ کاربرها، و گرفتنِ همه‌ی فیلدهای یک کاربر با یه نام‌کاربری ِ بخصوص تعریف شدن. کتابخونه ِ ‏‎sqlite-simple‎‏ با اون علامت‌های تعجب استعلام‌های پایگاهِ داده رو پارامتردار می‌کنه.

data DuplicateData =
  DuplicateData 
  deriving (Eq, Show, Typeable)

instance Exception DuplicateData

تایپِ بالا یه استثنا ِ یکبار مصرفه که هر وقت بیشتر از صفرتا یا یکی کاربر با یک نام‌کاربری ِ بخصوص دیدیم، میندازیم. در واقع اصلاً نباید چنین اتفاقی پیش بیاد، اما خدا رو چه دیدی.

data UserRow =
  (Null, Text, Text, Text, Text, Text)

‏‎UserRow‎‏ هم یه تایپِ مستعار از توپل‌ها‌ایه که موقعِ ساختِ یه کاربر ِ جدید وارد می‌کنیم.

getUser :: Connection
        -> Text
        -> IO (Maybe User)
getUser conn username = do
  results <-
    query conn getUserQuery (Only username)
  case results of
    [] -> return $ Nothing
    [user] -> return $ Just user
    _ -> throwIO DuplicateData

در استفاده از کتابخونه ِ ‏‎sqlite-simple‎‏، از داده‌ساز ِ ‏‎Only‎‏ برای پاس دادنِ آرگومان‌های تکی (بجای توپل‌هایی با ۲ عضو یا بیشتر) به پارامترهای استعلامِ‌مون استفاده می‌کنیم. چنین چیزی به این خاطر لازمه که ‏‎base‎‏ هیچ تایپِ تک-توپلی نداره، و ‏‎getUserQuery‎‏ فقط یک پارامتر می‌گیره.

نهایتاً هم یه تابع برای ساختنِ خودِ پایگاهِ داده به همراهِ یک سطر داده‌ی نمونه لازم داریم:

createDatabase :: IO ()
createDatabase = do
  conn <- open "finger.db"
  execute_ conn createUsers
  execute conn insertUser meRow
  rows <- query_ conn allUsers
  mapM_ print (rows :: [User])
  SQLite.close conn
  where meRow :: UserRow
        meRow =
          (Null, "callen", "/bin/zsh",
           "/home/callen", "Chris Allen",
           "555-123-4567")

Stack ممکنه بهتون فِف کنه، چون یه ماژول به اسمِ ‏‎Main‎‏ دارین که هیچ ‏‎main‎‏ ای توش تعریف نشده. اگه چنین مشکلی پیش اومد، می‌تونین این کُد رو اضافه کنین:

main :: IO ()
main = createDatabase

بعداً این ‏‎main‎‏ رو تغییر میدیم، ولی فعلاً اجازه میده ‏‎executable‎‏ ِ‌تون ساخته بشه.

اجرای این برای بارِ دوم خطا میده، تغییری هم روی پایگاهِ داده نمیذاره. اگه لازم داشتین پایگاهِ داده رو از نو شروع کنین، می‌تونین فایلِ ‏‎finger.db‎‏ رو پاک کنین.

قبل از ادامه

کُدِ زیر با فرضِ این نوشته شده که یه پایگاهِ داده ِ SQLite با اسمِ ‏‎finger.db‎‏ و اِسکیما یا الگویی که در ‏‎createUsers‎‏ تعریف کردیم، در همون پوشه‌ای که سرویسِ ‏‎fingerd‎‏ رو اجرا می‌کنین وجود داره.

برای اجرای ‏‎createDatabase‎‏ می‌تونین چنین کاری کنین:

$ stack ghci --main-is fingerd:exe:fingerd
{... شلوغی و پلوغی ...}
Prelude> createDatabase
User {userId = 1, ... شلوغی ... }

حالا که این کارها رو انجام دادیم، می‌تونیم به ساختِ فینگر دیمن ِمون ادامه بدیم.

بذارین انگشت‌هاتون کار کنن

هنوز توی ماژول ِ ‏‎Main‎‏ هستیم. حالا تابع‌هایی می‌نویسیم که به سرور امکانِ گوش کردن و پاسخ دادن به استعلام‌های کلاینت رو میدن.

returnUsers :: Connection
            -> Socket
            -> IO ()
returnUsers dbConn soc = do
  rows <- query_ dbConn allUsers
  let usernames = map username rows
      newlineSeparated =
        T.concat $
        intersperse "\n" usernames
  sendAll soc (encodeUtf8 newlineSeparated)

‏‎returnUser‎‏ با استفاده از یه ‏‎Connection‎‏ ِ پایگاهِ داده و یه ‏‎Socket‎‏ با کاربر صحبت می‌کنه. اون ارتباطِ پایگاهِ داده برای گرفتن لیستی از همه‌ی کاربرهای توی پایگاهِ داده، که بعدِش به یه مقدارِ ‏‎Text‎‏ که با کاراکترِ خطِ‌جدید از هم سوا شده تبدیل میشه، استفاده میشه. بعد هم اون ‏‎Text‎‏ به یه ‏‎ByteString‎‏ ِ UTF-8 کدبندی میشه و از طریقِ سوکت به کلاینت ارسال میشه.

formatUser :: User -> ByteString
formatUser (User _ username shell
            homeDir realName _) = BS.concat
  ["Login: ", e username, "\t\t\t\t",
   "Name: ", e realName, "\n",
   "Director: ", e homeDir, "\t\t\t",
   "Shell: ", e shell, "\n"]
  where e = encodeUtf8

این تابع برای فرمت کردنِ رکوردهای ‏‎User‎‏ به یه مقدارِ ‏‎ByteString‎‏ ِ UTF-8 به کار میره. این فرمت قراره پیاده‌سازی‌های محبوبِ ‏‎fingerd‎‏ رو تداعی کنه، ولی نمی‌خوایم اینجا دقیق باشیم.

returnUser :: Connection
           -> Socket
           -> Text
           -> IO ()
returnUser dbConn soc username = do
  maybeUser <-
    getUser dbConn (T.strip username) 
  case maybeUser of
    Nothing -> do
      putStrLn
        ("Couldn't find matching user\
          \ for username: "
        ++ (show username))
      return ()
    Just user ->
      sendAll soc (formatUser user)

این برای استعلام اطلاعاتِ یک کاربر ِه، که با استفاده از ‏‎formatUser‎‏ اطلاعاتِ با جزئیاتی از کاربر ِ مورد نظر رو به کلاینت میدیم. باید حالتی که هیچ کاربری با username ِ تأمین‌شده پیدا نشه رو هم به عهده بگیریم. الان همینطوری، حالتِ ‏‎Nothing‎‏ گزارشِ اینکه هیچ کاربری با اون username پیدا نشده رو توی ترمینالِ سرور چاپ می‌کنه، ولی اون اطلاعات رو به سمتِ کلاینت ارسال نمی‌کنه (هیچ اطلاعاتی رو ارسال نمی‌کنه). شاید بد نباشه تغییرش بدین تا کلاینت متوجه بشه چرا هیچ اطلاعاتی دریافت نکرد.

اگه یه کاربر پیدا بشه، رکورد ِ ‏‎User‎‏ که به ‏‎ByteString‎‏ فرمت شده رو به کلاینت می‌فرستیم. قبل از استعلام (م. با تابعِ ‏‎getUser‎‏ اون ‏‎Text‎‏ ِ نام‌کاربری رو عریان کردیم چون داده‌ی لیترالی که برای استعلام ِ یه نام‌کاربری ارسال شده شبیهِ ‏‎"yourname\r\n"‎‏ ِه، و برای اینکه با ‏‎yourname‎‏ منطبق بشه باید از کاراکترهای کنترلی‌ش عریان بشن، و این همون کاری‌ه که تابعِ ‏‎strip‎‏ از ماژول ِ ‏‎Data.Text‎‏ برامون انجام میده.

handleQuery :: Connection
            -> Socket 
            -> IO ()
handleQuery dbConn soc = do
  msg <- recv soc 1024
  case msg of
    "\r\n" -> returnUsers dbConn soc
    name ->
      returnUser dbConn soc
      (decodeUtf8 name)

‏‎handleQuery‎‏ تا ۱۰۲۴ بایت داده دریافت می‌کنه. براساس داده‌ای که کلاینت به سرور می‌فرسته، case بین اینکه یه لیست از همه‌ی کاربرها بفرسته یا اینکه فقط یک کاربر رو بفرسته تصمیم می‌گیره. خوشبختانه اینجا پروتکل نسبتاً ساده‌ست و نیازی به پارس‌کردن نیست (بطور معمول برای تعامل با پروتکل‌های پیچیده‌تر پارس‌کردن لازمه).

handleQueries :: Connection
              -> Socket 
              -> IO ()
handleQueries dbConn sock = forever $ do
  (soc, _) <- accept sock
  putStrLn "Got connection, handling query" 
  handleQuery dbConn soc
  sClose soc

مشابه سرورِ اِکو ِه، فقط یه آرگومانِ اضافه برای ارتباطِ پایگاه داده داره، و وقتی ارتباط‌ها قبول میشن یه پیغام لاگ می‌کنه.

حالا باید ‏‎main‎‏ رو تغییر بدیم تا کلِ برنامه‌مون رو سَرِهم کنه:

main :: IO ()
main = withSocketsDo $ do
  addrinfos <-
    getAddrInfo
    (Just (defaultHints
      {addrFlags = [AI_PASSIVE]}))
    Nothing (Just "79")
  let serveraddr = head addrinfos
  sock <- socket (addrFamily serveraddr)
          Stream defaultProtocol
  bindSocket sock (addrAddress serveraddr)
  listen sock 1
  -- در لحظه بازه connection فقط یک
  conn <- open "finger.db"
  handleQueries conn sock
  SQLite.close conn
  sClose sock

تنها تیکه‌ای که جدیده، باز کردنِ یه ارتباط به یه پایگاهِ داده ِ SQLite ِه که در پوشه‌ی پروژه قرار گرفته. ارتباطِ پایگاه داده به کُدِ مدیریتِ استعلام، که مثلِ سرورِ اِکو دائماً اجرا میشه، پاس میشه. اگه به هر طریقی بدون انداختنِ استثنا وایسه، مثل برنامه‌نویس‌های خوب، سوکتِ سرور رو می‌بندیم.

خوب، کارِمون تموم شد. با فرض اینکه با استفاده از ‏‎createDatabase‎‏ یه پایگاهِ داده ِ SQLite ِ معتبر و در دسترسِ برنامه درست کردین، دستوراتِ زیر باید کار کنن. این دو خط رو توی یه ترمینال وارد کنین:

$ stack build
$ sudo `stack exec which fingerd`

بعد دستور زیر باید توی یه ترمینالِ دیگه (جلسه ِ shell ِ مجزا) کار کنه:

$ finger callen@localhost
Login: callen             Name: Chris Allen
Directory: /home/callen   Shell: /bin/zsh

تمومه. تویِ تمرین‌ها راه‌هایی برای ارتقاء این دادیم، و امیدواریم از این سیاحت در سوکت‌های TCP و مبانیِ شبکه لذت برده باشین. مباحثِ امنیتی به کنار، در طولِ سال‌های گذشته از پروتکل ِ ‏‎finger‎‏ برای کارهای خیلی جالبی استفاده شده. شاید معروف‌ترین‌شون استفاده‌ی جان کارمَک از فایل‌های ‏‎.plan‎‏ برای ارسالِ آپدیت‌ها در فرایندِ توسعه‌ی Quake به عنوانِ یه جور میکروبلاگ بوده باشه.