ساخت وب سرور با تایپ اسکریپت و Node.js — از صفر تا صد
با این که بسیاری از توسعهدهندگان وب اپلیکیشنهای خود را به صورت روزانه مینویسند، اما این کارکرد داخلی عملی HTTP ،TCP و وبسرورها است که زمینهای برای استفاده از دستور fetch در اپلیکیشنها فراهم میسازد. در این مقاله این جعبه سیاه را باز میکنیم و با شیوه ساخت وب سرور با تایپ اسکریپت و Node.js با استفاده از سوکتها در هر سیستم عامل آشنا خواهیم شد.
سوکتها و TCP
«سوکتها» (Sockets) به پردازشهای روی رایانه امکان میدهند که از طریق سیستم فایل با همدیگر ارتباط برقرار سازند. انواع خاصی از فایل وجود دارند که پردازشها با استفاده از API معمول سیستم فایل در آنها مینویسند و میخوانند. TCP یک استاندارد دیگر برای استفاده از سوکتها روی یک شبکه است که امکان برقراری ارتباط بین چند دستگاه را فراهم میسازد. این استاندارد همان چیزی است که HTTP را ساخته است و اینترنت نیز به همراه آن پدید آمده است.
سوکتها با یک سیستم کلاینت-سرور کار میکنند. نخستین چیزی که در زمان اجرای یک سرور رخ میدهد، ایجاد یک «سوکت شنیداری» (listening socket) است. این سوکت با استفاده از یک IP و یک پورت پیکربندی میشود. سوکت شنیداری نوع خاصی از سوکت است، یعنی برای برقراری ارتباط ببین سیستم عامل و سرور و نه بین سرور و کلاینتها استفاده میشود. پس از ایجاد سوکت شنیداری، سرور یک دستور accept به این سوکت ارسال میکند. سپس سیستم عامل در تلاش بعدی خود میخواهد که IP و پورت ما را به سرور اتصال دهد.
زمانی که این اتفاق بیفتد، یک سوکت جدید برای اتصال ورودی ایجاد خواهد شد. این سوکت یک سوکت کلاینت است و برای انتقال دادههای واقعی بین سرور و کلاینت استفاده میشود. پس از این که سرور کار اتصال بین سرور و کلاینت را به پایان برد، دوباره دستور accept را ارسال میکند و اتصال بعدی به سرور بازگشت مییابد.
نکته: در ادامه این مقاله به بررسی سرورهای چندنخی که همزمان چندین اتصال برقرار میسازند را نیز مورد بررسی قرار میدهیم.
این مرحله جایی است که مفهوم backlog مطرح میشود. منظور از backlog صفی است که سیستم عامل برای ما مدیریت میکند و همه کلاینتهایی که همچنان باید تداوم یابند در آن قرار دارد. زمانی که یک سوکت شنیداری ایجاد میشود، تعداد کلاینتهایی را که پیش از سرباز زدن سیستم برای اتصال به کلاینتهای بیشتر میتوانند روی این صف باشند تعریف میکنیم. این عدد باید تعادلی بین عدم مصرف بیرویه منابع روی صفبندی کلاینتها و عدم رد کردن کلاینتها در زمان اوج بار ایجاد کند.
سوکتها، Node و net
در Node میتوان از سوکتها با استفاده از پکیج net در کتابخانه استاندارد Node استفاده کرد. این پکیج به ما امکان میدهد که با سوکتها اتصال یابیم، سوکتها را بنویسیم، از سوکتها بخوانیم و یک سرور را اجرا کنیم. کار خود را با ایجاد سوکت سرور و گوش دادن به IP و پورت آغاز میکنیم. دستور listen شروع به ارسال دستورهای accept میکند و همه موارد بازگشتی را میخواند.
1import * as net from 'net'
2
3const PORT = 3000
4const IP = '127.0.0.1'
5const BACKLOG = 100
6
7net.createServer()
8 .listen(PORT, IP, BACKLOG)
گام بعدی اجرای کاری است که کلاینت را به سوکت ما وصل کند. کتابخانه net به شدت به الگوی رویداد-شنیدن تکیه دارد که همه توسعهدهندگان جاوا اسکریپت آن را میشناسند و دوست دارند. بنابراین گوش دادن به اتصالهای ورودی از طرق گوش دادن به رویداد connection انجام مییابد:
1net.createServer()
2 .listen(PORT, IP, BACKLOG)
3 .on('connection', socket =>
4 console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`
5 )
هر بار که یک کلاینت به callback اتصال مییابد با سوکت کلاینت آن اتصال به عنوان یک پارامتر فراخوانی میشود با استفاده از این سوکت میتوانیم دادههایی که کلاینت به ما ارسال کرده را بخوانیم و پاسخی به آنها بدهیم. به این منظور از رویداد data روی سوکت اتصال استفاده میکنیم و یک callback به آن میدهیم که یک Buffer با دادههایی که کلاینت ارسال کرده میپذیرد. این بافر میتواند ورودی را مانند یک رشته متنی بخواند و میتوانیم دستور write را روی سوکت فراخوانی کنیم تا دادههایی را بازگشت دهد.
یک وظیفه مهم دیگر، مورد بستن (close) اتصال در زمان پایان یافتن کار است. در غیر این صورت اگر کلاینتها، اتصالها را نبندند، در نهایت با سرریز اتصالهای باز مواجه خواهیم شد. این منطق هنوز ارتباطی با بدنههای چندبخشی و هدر Keep-Alive ندارد. این موارد با توجه به مقصود ما از نوشتن یک وبسرور ساده و قابل درک، ضروری نیستند و میتوان در آینده آنها را اضافه کرد:
1net.createServer()
2 .listen(PORT, IP, BACKLOG)
3 .on('connection', socket => socket
4 .on('data', buffer => {
5 const request = buffer.toString()
6 socket.write('hello world')
7 socket.end()
8 })
این همه مواردی است که برای کار با اتصال TCP در Node نیاز داریم. اگر آدرس را با curl فراخوانی کنید، میبینید که پاسخ ظاهر میشود. باز کردن localhost:3000 در مرورگر هنوز عملی نیست، و به همین جهت ابتدا باید استاندارد HTTP را پیادهسازی کنیم.
1curl 127.0.0.1:3000
2hello world%
HTTP
HTTP یک استاندارد برای ارتباط از طریق سوکتهای TCP است. این استاندارد شیوه قالببندی پیامها و چگونگی مدیریت اتصالها از سوی سرور را تعیین میکند. یک پیام HTTP از کلاینت به سرور (درخواست) به صورت زیر است:
1GET / HTTP/1.1
2Host: localhost:3000
در اینجا مفاهیم آشنایی را میبینیم که در زمان ایجاد فراخوانیهای API برای مثال fetch شاهد هستیم. پیام با فعال (متد) GET آغاز میشود. سپس URI را میبینیم که در این مورد آدرس صفحه اصلی (/) است. اگر میخواستیم یک فریمورک در پشت سرور خود بنویسیم، از این مقدار برای مسیریابی و یافتن کنترلر صحیح و اکشن مناسب استفاده میکردیم. آخرین چیزی که در خط نخست میبینیم نسخه HTTP است. این مقدار برای تعیین سازگاری با کلاینتهای قدیمی استفاده میشود. این مورد را در این راهنما نادیده میگیریم تا همه چیز ساده بماند.
در خط دوم هدرها را میبینیم. «هدرها» (Headers) جفتهای کلید-مقدار هستند که با درخواست ارسال میشوند. همه آنها روی خط خود نوشته میشوند و کلید با یک دونقطه (:) از مقدار جدا میشود. پس از آخرین هدر یک خط خالی قرار دارد. زیر این خط بدنه درخواست آغاز میشود. در این مثال این بخش خالی است زیرا به دنبال یک درخواست GET هستیم، اما میتوانست یک فرم یا یک JSON نیز وجود داشته باشد. همه اطلاعاتی که در این جا فهرست کردیم، میتوانند به وسیله اینترفیس زیر توضیح داده شوند:
1export interface Request {
2 protocol: string
3 method: string
4 url: string
5 headers: Map<string, string>
6 body: string
7}
تحلیل رشته متنی درخواست به صورت یک شیء اینترفیس مانند مثال فوق با استفاده از برخی کارهای خاص روی رشته که در ادامه توضیح میدهیم امکانپذیر است. ما قصد ندایم با روشهای بهینه تحلیل رشتهها آشنا شویم، زیرا خارج از حیطه این مقاله است. ما با این تابع میتوانیم شروع به فراخوانی کنترلرها از طریق سیستمی بکنیم که شباهت زیادی به نوشتن «نقاط انتهایی» (endpoints) در هر فریمورک بکاند دارد.
1const parseRequest = (s: string): Request => {
2 const [firstLine, rest] = divideStringOn(s, '\r\n')
3 const [method, url, protocol] = firstLine.split(' ', 3)
4 const [headers, body] = divideStringOn(rest, '\r\n\r\n')
5 const parsedHeaders = headers.split('\r\n').reduce((map, header) => {
6 const [key, value] = divideStringOn(header, ': ')
7 return map.set(key, value)
8 }, new Map())
9 return { protocol, method, url, headers: parsedHeaders, body }
10}
11
12const divideStringOn = (s: string, search: string) => {
13 const index = s.indexOf(search)
14 const first = s.slice(0, index)
15 const rest = s.slice(index + search.length)
16 return [first, rest]
17}
یک پاسخ HTTP از همان مفاهیم درخواست HTTP پیروی میکند. تفاوت اصلی در خط نخست است که به جای منابعی که درخواست میشود در آنجا نتیجه درخواست را میبینیم. پس از آن، بخش هدرها و بدنه مشابه هستند:
1HTTP/1.1 200 OK
2Content-Type: application/text
3
4<html>
5 <body>
6 <h1>Greetings!</h1>
7 </body>
8</html>
از روی این اطلاعات میتوانیم اینترفیس Response را ایجاد کنیم و تابعی بنویسیم که آن را پیش از بازگرداندن روی سوکت به یک رشته تبدیل کند:
1export interface Response {
2 status: string
3 statusCode: number
4 protocol: string
5 headers: Map<string, string>
6 body: string
7}
8
9const compileResponse = (r: Response): string => `${r.protocol} ${r.statusCode} ${r.status}
10${Array.from(r.headers).map(kv => `${kv[0]}: ${kv[1]}`).join('\r\n')}
11${r.body}`
اگر از این بخش در کد سرور استفاده کنیم در نهایت کد زیر به دست میآید که به ما امکان میدهد تا یک وبسایت را در مرورگر خود ببینیم که با استفاده از وبسرور خودمان عرضه شده است. البته برخی موارد دیگر مانند بحث «کوکیها» (cookies) در HTTP نیاز به توجه دارند. این موارد از هدر Cookie بازیابی میشوند که امکان انجام این کار در پیادهسازی ما نیز وجود دارد. تنظیم کوکی از طریق هدر Set-Cookie انجام مییابد که میتواند چندین بار در پاسخ انجام یابد. کد زیر را باید به اینترفیس Response اضافه کنیم، زیرا ATM به ما امکان داشتن چندین هدر به صورت همزمان را نمیدهد:
1socket.write(compileResponse({
2 protocol: 'HTTP/1.1',
3 headers: new Map(),
4 status: 'OK',
5 statusCode: 200,
6 body: `<html><body><h1>Greetings</h1></body></html>`
7}))
سرورهای چندنخی
در آغاز این راهنما اشاره کردیم که backlog به سوکتهای لیست شده ما اتصال مییابد. در این مرحله سرور ما تنها یک پردازش را اجرا میکند که معنی آن این است که وقتی یک درخواست را پردازش میکنیم، بقیه اتصالهای ورودی در backlog جمع میشوند. این وضعیت را میتوانیم با بهروزرسانی کد و محاسبه دنباله فیبوناچی عدد 10 پیش از بازگرداندن پاسخ مشاهده کنیم.
اگر چندین زبانه را در مرورگر باز کنید و به خروجی کنسول نگاه کنید میبینید که تنها زبانه اول عملاً اتصال مییابد و بقیه زبانهها در backlog میمانند تا این که سرور کار محاسبه نتیجه زبانه اول را به پایان برساند.
1net.createServer()
2 .listen(PORT, IP, BACKLOG)
3 .on('connection', socket => {
4 console.log('new connection')
5 socket
6 .on('data', buffer => {
7 console.log('data')
8 socket.write(fibonacci(100))
9 console.log('done with connection')
10 socket.end()
11 })
12 })
13
14const fibonacci = (n: number) => (n < 2) ? n
15 : fibonacci(n - 2) + fibonacci(n - 1)
در صورتی که یک اپلیکیشن داشته باشیم و برخی از نقاط انتهایی آن کاملاً کُند باشند، این وضعیت میتواند مشکلی جدی ایجاد کند. بدین ترتیب کل سرور مسدود میشود و عملاً تا زمانی که اکسپورت تکمیل نشده است سرور با قطعی مواجه خواهد بود. روشهای زیادی برای حل این مشکل وجود دارند، اما همه آنها از یک نوع مدل «همروندی» (Concurrency) استفاده میکنند که به سرور امکان میدهد تا در زمانی که نقطه انتهایی با اکسپورت سنگین مشغول کار است، همه اتصالهای ورودی را بپذیرد. بسیاری از سرورهای در رده پروداکشن، سوکتهای کلاینت را در یک صف قرار میدهند و اجازه میدهند نخهای دیگر آنها را پردازش کنند.
با توجه به مقاصد آموزشی این راهنما تلاشمان این است که محتوای مقاله تا حد امکان ساده و کوتاه باشد و به این جهت از یک «استخر کار» (workerpool) برای اجرای محاسبات سنگین استفاده میکنیم و همه موارد مرتبط با اتصال را در پردازش اصلی نگه میداریم. این استخر کار به ما امکان میدهد که یک مجموعه از ورکرهای پسزمینه در Node داشته باشیم که وظایفی را روی پردازش پسزمینه دریافت میکنند. این استخر میتواند یک ورکر برای انجام کار یافته و کار را به آن انتساب دهد. تابع exec این استخر یک promise بازگشت میدهد که وقتی کار پایان یابد، با نتیجه resolve میشود. به این ترتیب اتصالهای ورودی جدید مسدود نمیشوند و میتوانند همزمان با محاسبه توالی فیبوناچی عدد 100 مورد پردازش قرار گیرند:
1import * as wp from 'workerpool'
2const workerpool = wp.pool()
3
4net.createServer()
5 .listen(PORT, IP, BACKLOG)
6 .on('connection', socket => {
7 console.log('new connection')
8 socket
9 .on('data', buffer => {
10 console.log('data')
11 workerpool.exec(() => fibonacci(100), [])
12 .then(res => {
13 socket.write(res)
14 console.log('done with connection')
15 socket.end()
16 })
17 })
18 })
اکنون اگر سایت را در چندین زبانه باز کنید، میبینید که همه اتصالها در ترمینال پذیرفته میشوند. در این حالت همه محاسبات روی ورکرهای خودشان اجرا میشوند که معنی آن این است که دیگر با مسدود شدن درخواستها در نتیجه وظایف سنگین مواجه نمیشویم. در این کد ما نتیجه Fibonacci را مستقیماً ارسال کردیم که به طور معمول باید چنین رفتار کرد، اما کل فریمورک بکاند روی یک ورکر قرار دارد و باید اجازه دهیم همه کارهای صفبندی و پردازشی خود را بدون مسدود ساختن سرور در برابر اجرای کارها انجام دهد.
سخن پایانی
در این مقاله به بررسی برخی مفاهیم سطح پایین سیستم عامل که برای ساخت سرورهای چندنخی جهت عرضه یک وبسایت کامل روی مرورگر مورد نیاز هستند پرداختیم. البته موارد زیاد دیگری هستند که همچنان باید مورد بررسی قرار گیرند. توضیح مدل همروندی سرور خود میتواند یک مقاله کامل را شامل شود. همین موضوع در مورد پیادهسازی یک فریمورک در پشت سرور برای اجرای مواردی از قبیل مسیریابی، ریدایرکت، کار با فایل و غیره نیز مصداق دارد. با این حال هدف این مقاله نمایش شیوه اتصال مرورگر به نقاط انتهایی بوده است و امیدواریم موارد مفیدی در این خصوص عرضه کرده باشد.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- گنجینه آموزش های طراحی وب
- مجموعه آموزشهای JavaScript (جاوا اسکریپت)
- توزیع وب اپلیکیشن ها در محیط Production — راهنمای جامع
- تنظیم سرورهای محیط توزیع نهایی (Production) برای وب اپلیکیشن — راهنمای مقدماتی
==