۳۰ - ۴فینگر-دیِ یه ذره مدرنتر
قدیمترها، دادهای که 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 به عنوانِ یه جور میکروبلاگ بوده باشه.