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