مقدمه‌ای بر Node.js‌ که ممکن است از دست داده باشید
ﺯﻣﺎﻥ ﻣﻄﺎﻟﻌﻪ: 13 دقیقه

مقدمه‌ای بر Node.js‌ که ممکن است از دست داده باشید

همه می‌دانند که Node.js‌ یک runtime متن باز و میان پلتفرمی برای JavaScript است. اکثر توسعه دهندگان Node.js می‌دانند که این runtime بر پایه V8، یک موتور JavaScript و libuv، یک کتابخانه C‌ چند پلتفرمی ساخته شده است که پشتیبانی I / O ناهمگام را بر پایه حلقه‌های رویداد فراهم می‌کند. اما تعداد کمی از توسعه دهندگان می‌توانند نحوه کار Node.js به صورت داخلی و نحوه تاثیر گذاشتن بر روی کد آن‌ها را به وضوح توضیح دهند. پس آن‌ها اغلب شروع به یادگیری Node با Express.js، Sequelize، Mongoose، Socket.io و برخی کتابخانه‌های شناخته شده دیگر می‌کنند، بدون این که وقت خود را بر روی یادگیری خود Node.js و APIهای استانداردش سرمایه گذاری کنند. به نظر من این یک انتخاب اشتباه است؛ زیرا درک رانش Node.js و دانستن مشخصات APIهای داخلی آن می‌تواند در جلوگیری از اشتباهات رایج کمک کند.

این پست مقدمه‌ای بر Node.js را به شیوه‌ای فشرده، اما همه جنبه به شما می‌دهد. ما یک بررسی اجمالی بر روی معماری Node.js خواهیم داشت. در نتیجه، سعی خواهیم کرد که برخی دستور العمل‌ها را برای ساخت یک وب‌اپلیکیشن سمت سرور با کارایی بالاتر و امنیت بیشتر با Node.js تعیین کنیم. این دستور العمل‌ها باید برای تازه کاران Node.js، و همچنین توسعه دهندگان با تجربه کاربردی باشند.

بلوک‌های ساخت اصلی

هر برنامه Node.js بر پایه این کامپوننت‌ها ساخته شده است:

  • V8 - موتور JavaScript متن باز، با کارایی بالاتر ساخته Google، که در C++ نوشته شده است. این موتور همچنین در مروگر Google Chrome و برخی موارد دیگر هم استفاده شده است. Node.js موتور V8 را از طریق اِی‌پی‌آی V8 C++ کنترل می‌کند.
  • libuv - یک کتابخانه پشتیبانی چند پلتفرمی با تمرکز بر روی I / O ناهمگام، نوشته شده در C. این کتابخانه در درجه اول برای استفاده شدن توسط Node.js توسعه داده شده بود، اما همچنین توسط Luvit، Julia، pyuv و برخی موارد دیگر هم استفاده می‌شود. Node.js از libuv برای چکیده‌سازی عملیات‌های I / O غیر مسدود کننده، به یک رابط یکپارچه در میان تمام پلتفرم‌های پشتیبانی شده استفاده می‌کند. این کتابخانه مکانیزم‌هایی را برای مدیریت سیستم فایل، DNS، شبکه، پردازش‌های فرزند، لوله‌کشی‌ها، مدیریت سیگنال، polling و streaming فراهم می‌کند. libuv همچنین شامل یک thread pool می‌باشد که با نام Worker Pool شناخته می‌شود. Worker Pool برای تخلیه کارهایی که نمی‌توانند به صورت ناهمگام در سطح سیستم عامل انجام شوند، به کار می‌رود.
  • کامپوننت‌های متن باز و سطح پایین دیگر، که اغلب در C / C++ نوشته شده‌اند:
    - c-ares - یک کتابخانه C برای درخواست‌های DNS ناهمگام، که برای برخی درخواست‌های DNS در Node.js استفاده می‌شود.
    - http-parser - یک کتابخانه‌ parse کننده درخواست / پاسخ HTTP سبک.
    - OpenSSL - یک کتابخانه رمزنگاری چند منظوره به خوبی شناخته شده. این کتابخانه در ماژول‌های tls و crypto استفاده شده است.
    - zlib - یک کتابخانه فشرده‌سازی داده بدون اتلاف. این کتابخانه در ماژول zlib هم استفاده شده است.
  • برنامه - کد برنامه شما، و ماژول‌های استاندارد Node.js که در JavaScript نوشته شده‌اند.
  • اتصال‌های C / C++ - wrapperهایی برای کتابخانه‌های C / C++ که با استفاده از N-API، یک اِی‌پی‌آی C یا برخی APIهای دیگر مربوط به اتصالات برای ایجاد افزونه‌های Node.js بومی ساخته شده‌اند.
  • برخی ابزار bundle شده که در زیرساخت‌های Node.js‌ استفاده شده‌اند:
    - npm - یک ابزار مدیریت پکیج (و اکوسیستم) به خوبی شناخته شده.
    - gyp - یک مولد پروژه بر پایه پایتون که از V8‌ کپی شده است. این مولد توسط node-gyp، یک ابزار خط دستوری میان پلتفرمی برای کمپایل کردن ماژول‌های افزونه بومی که در Node.js نوشته شده است، مورد استفاده قرار گرفته می‌باشد.
    - gtest - چارچوب آزمایش C++ مختص Google. این چارچوب برای آزمایش کد بومی استفاده می‌شود.

در اینجا یک نمودار را مشاهده می‌نمایید که کامپوننت‌های اصلی Node.js که در لیست به آن‌ها اشاره شد را نشان می‌دهد:

Runtime نود جِی‌اِس

در اینجا یک نمودار را مشاهده می‌نمایید که نحوه اجرای کد JavaScript شما توسط رانش Node.js را نشان می‌دهد:

این نمودار تمام جزئیاتی که در Node.js اتفاق می‌افتند را نشان نمی‌دهد، اما مهم‌ترین موارد را برجسته می‌کند. ما به طور خلاصه آن‌ها را مورد بحث قرار خواهیم داد.

وقتی که در ابتدا برنامه Node.js شما شروع می‌شود، فاز اولیه را به پایان رسانده، یا به عبارتی اسکریپت شروع که شامل ماژول‌ها و ثبت callbackها برای رویدادها می‌باشد را اجرا می‌کند. سپس برنامه به حلقه رویداد (که با نام‌های «main thread» و «event thread» هم شناخته می‌شود) وارد می‌شود، که از نظر مفهومی برای پاسخ دادن به درخواست‌های کلاینت ورودی با اجرای callbackهای JavaScript مناسب ساخته شده است. Callbackهای JavaScript به صورت همگام اجرا می‌شوند، و ممکن است از APIهای Node برای ثبت درخواست‌های ناهمگام استفاده کنند، تا پس از اتمام callback به پردازش ادامه دهند. از اینگونه APIهای Node می‌توان به تایمرهای مختلف (setTimeout()، setInterval() و...)، توابع fs و http و... اشاره کرد. تمام این APIها نیازمند یک callback هستند که وقتی که عملیات مورد نظر به اتمام می‌رسد، اجرا خواهد شد.

حلقه رویداد، یک حلقه تک thread و نیمه بی نهایت بر پایه libuv است. علت این که این حلقه «نیمه بی نهایت» نام دارد، این است که در نهایت در جایی که دیگر کاری برای انجام دادن وجود ندارد، دست از کار می‌کشد. از دید یک توسعه دهنده، این زمانی است که برنامه شما خارج (exit) می‌شود.

حلقه رویداد بسیار پیچیده است. این حلقه دستکاری‌هایی را با صف‌های رویداد تصور می‌کند و شامل چند فاز می‌باشد:

  • فاز تایمرها - این فاز callbackهای برنامه‌ریزی شده وسط setTimeout() و setInterval() را اجرا می‌کند.
  • فاز callbackهای در انتظار - callbackهای I / O تعطیل شده تا تکرار حلقه بعدی را اجرا می‌کند.
  • فازهای بیکاری و آماده‌سازی - فازهای داخلی.
  • فاز poll - شامل این موارد می‌شود: دریافت رویدادهای I / O جدید؛ اجرای callbackهای مربوط به I / O (تقریبا تمام آن‌ها، به استثنای close، تایمرها و setImmediate())؛ Node.js در زمان مناسب، در اینجا مسدود خواهد شد.
  • فاز بررسی - callbackهای setImmediate() در اینجا فراخوانی می‌شوند.
  • فاز بستن callbackها - در اینجا برخی callbackهای close اجرا می‌شوند؛ برای مثال socket.on(‘close’, …).

در طی فاز poll، حلقه رویداد با استفاده از چکیده‌سازی‌های libuv برای مکانیزم‌های I / O مختص سیستم عامل polling، درخواست‌های غیر مسدود کننده و ناهمگام را برآورده می‌کند. این مکانیزم‌های مختص سیستم عامل، برابر با epoll برای لینوکس، IOCP برای ویندوز، kqueue برای BSD و MacOS و event ports در Solaris هستند.

این که Node.js در واقع تک thread است، یک افسانه رایج میان مردم می‌باشد. اساسا این مسئله حقیقت دارد (یا با توجه به این که یک پشتیبانی آزمایشی برای workerهای وب، که با نام Worker Thread شناخته می‌شود وجود دارد، قبلا حقیقا داشت)؛ زیرا کد JavaScript شما همیشه بر روی یک thread داخل حلقه رویداد اجرا می‌شود. اما شما همچنین ممکن است متوجه Worker Pool شوید، که در واقع یک thread pool با اندازه ثابت بر روی نمودار می‌باشد. پس هر پردازش Node.js چند thread دارد که به موازات هم اجرا می‌شوند. علت آن این است که تمام عملیات‌های Node می‌توانند به روشی غیر مسدود کننده بر روی تمام سیستم عامل‌های پشتیبانی نشده اجرا شوند. یک علت دیگر هم برای داشتن Worker Pool این است که حلقه رویداد برای محاسبات حساس به CPU مناسب نیست.

پس Node.js (یا به خصوص libuv) تمام تلاش خود را می‌کند تا API ناهمگام و رانده شده توسط رویداد مشابه برای عملیات‌های مسدود کننده این چنینی را نگه دارد و این عملیات‌ها را بر روی یک thread pool جداگانه اجرا می‌کند. در اینجا مثال‌هایی از عملیات‌های مسدود کننده این چنینی، در ماژول‌های داخلی را مشاهده می‌نمایید:

  • I/O-bound:
    - برخی عملیات‌های DNS در ماژول dns: dns.lookup() و dns.lookupService().
    - اکثر عملیات‌های سیستم فایل که توسط ماژول fs فراهم شده‌اند؛ مانند fs.readFile().
  • CPU-bound:
    - برخی عملیات‌های رمزنگاری فراهم شده توسط ماژول crypto، مانند crypto.pbkdf2()، crypto.randomBytes() یا crypto.randomFill().
    - عملیات‌های فشرده‌سازی داد فراهم شده توسط ماژول zlib.

دقت کنید که کتابخانه‌های بومی جداگانه مانند bcrypt هم محاسبات را به داخل worker thread pool خالی می‌کنند.

حال که باید یک درک بهتر از معماری کلی Node.js‌ داشته باشید، بیایید دستور العمل‌های مربوط به نوشتن یک برنامه سمت سرور با کارایی بالاتر، و امن‌تر را مورد بحث قرار دهیم.

قانون اول - از ترکیب Sync و Async در توابع خودداری کنید

وقتی که یک تابع می‌نویسید، یا باید آن‌ها را کامل همگام کنیم، یا کاملا ناهمگام. شما باید از ترکیب این رویکردها در یک تابع تکی خودداری کنید.

نکته: یک تابع یک callback را به عنوان یک آرگومان می‌پذیرد، اما به این معنی نیست که ناهمگام است. به عنوان مثال می‌توانید به تابع Array.forEeach() فکر کنید. یک رویکرد این چنینی اغلب با نام «استایل منتقل کردن ادامه دار» (CPS = Continuation-passing style) شناخته می‌شود.

بیایید این کد را به عنوان یک مثال در نظر بگیریم:

const fs = require('fs')

function checkFile (filename, callback) {
  if (!filename || !filename.trim()) {
    // تله‌ها در اینجا هستند:
    return callback(new Error('Empty filename provided.'))
  }

  fs.open(filename, 'r', (err, fileContent) => {
    if (err) return callback(err)
   
    callback(null, true)
  })
}

این تابع بسیار ساده است، اما همچنان برای نیازهای ما کافیست. در اینجا مشکل ما شاخه return callback(…) است؛ زیرا callback مورد نظر در هنگام رو در رویی با یک آرگومان نامعتبر، به صورت همگام فراخوانی می‌شود. در سمت دیگر و در هنگام رو در رویی با یک ورودی معتبر، callback مورد نظر به روش async، داخل دستور fs.open() فراخوانی می‌شود.

برای نمایش مشکل احتمالی این کد، بیایید آن را با ورودی‌های مختلفی آزمایش کنیم:

checkFile('', () => {
  console.log('#1 Internal: invalid input')
})
console.log('#1 External: invalid input')

checkFile('main.js', () => {
  console.log('#2 Internal: existing file')
})
console.log('#2 External: existing file')

کد ما این نتیجه را در کنسول خروجی خواهد داد:

#1 Internal: invalid input
#1 External: invalid input
#2 External: existing file
#2 Internal: existing file

ممکن است که حال هم در اینجا متوجه مشکل شده باشید. ترتیب اجرای کد در این موارد متفاوت است. این ترتیب، تابع ما را غیر قطعی می‌کند، و از این رو باید از یک استایل این چنینی خودداری شود. تابع می‌تواند با جمع‌بندی return callback() با setImmediate() یا process.nextTick() به یک استایل کاملا async تبدیل شود.

if (!filename || !filename.trim()) {
  return setImmediate(
    () => callback(new Error('Empty filename provided.'))
  )
}

حال تابع ما بسیار قطعی‌تر خواهد بود.

قانون دوم - حلقه رویداد را مسدود نکنید

به لحاظ وب‌اپلیکیشن‌های سمت سرور، برای مثال سرویس‌های RESTful، تمام درخواست‌ها به صورت همزمان داخل thread تکی حلقه رویداد پردازش می‌شوند. پس برای مثال اگر پردازش یک درخواست HTTP در برنامه شما زمان قابل توجهی را بر روی اجرای یک تابع JavaScript که یک محاسبه سنگین را انجام می‌دهد صرف می‌کند، این پردازش حلقه رویداد را برای تمام درخواست‌های دیگر مسدود می‌کند. به عنوان یک مثال دیگر، اگر برنامه شما ۱۰ میلی ثانیه را بر روی پردازش کد JavaScript مربوط به هر درخواست HTTP صرف می‌کند، بازده یک نمونه تکی از برنامه شما حدود 1000 / 10 = 100 درخواست بر ثانیه خواهد بود.

از این رو، اولین قانون طلایی Node.js این است که «هیچ وقت حلقه رویداد را مسدود نکنیم». در اینجا یک لیست کوتاه از پیشنهادات را مشاهده می‌نمایید که به شما در پیروی از این قانون کمک خواهند کرد:

  • از هر محاسبه JavaScript سنگینی خودداری کنید. اگر هر کدی دارید که پیچیدگی زمانی بیش از حدی دارد، بهینه‌سازی آن یا حداقل تقسیم محاسبات به قطعه‌هایی که به صورت برگشتی و از طریق یک API تایمر مانند setTimeout() یا setImmediate() فراخوانی می‌شوند را در نظر داشته باشید. به این صورت شما در حال مسدود کردن حلقه رویداد نخواهید بود و هر callback دیگری خواهد توانست که پردازش شود.
  • از تمام فراخوانی‌های *Sync مانند fs.readFileSync() یا crypto.pbkdf2Sync() در برنامه‌های سرور خودداری کنید. تنها استثنا برای این قانون، می‌تواند فاز اولیه برنامه شما باشد.
  • کتابخانه‌های جداگانه را به صورت عاقلانه انتخاب کنید؛ زیرا آن‌ها ممکن است حلقه رویداد را مسدود کنند. مثلا با اجرای محاسبات حساس به CPU در JavaScript.

قانون سوم - Worker Pool را به صورت عاقلانه مسدود کنید

شاید تجعب‌آور باشد، اما Worker Pool هم ممکن است مسدود شده باشد. همانطور که می‌دانید، Worker Pool یک ابزار thread با اندازه مشخص و با مقدار پیشفرض چهار thread می‌باشد. اندازه مورد نظر می‌تواند با تنظیم متغیر محیط UV_THREADPOOL_SIZE افزایش یابد، اما در بسیاری از موارد مشکل شما را حل نخواهد کرد.

برای نمایش مشکل Worker Pool، بیایید این مثال را در نظر بگیریم: اِی‌‌پی‌‌آی RESTful شما یک اندپوینت احراز هویت دارد که مقدار hash را برای پسوورد داده شده محاسبه کرده، و آن را با مقدار دریافت شده از یک دیتابیس تطبیق می‌دهد. اگر شما همه چیز را به درستی انجام داده باشید، hash‌ کردن در Worker Pool انجام می‌شود. بیایید فرض کنیم که محاسبه برای به اتمام رسیدن، حدود ۱۰۰ میلی ثانیه زمان می‌برد. این یعنی با اندازه Worker Pool پیشفرض، شما تقریبا 4 * (1000 / 100) = 40 درخواست بر ثانیه را به لحاظ بازده اندپوینت hash کردن دریافت خواهید کرد. (نکته: ما موقعیتی با یک CPU دارای ۴ هسته به بالا را در نظر می‌‌گیریم) درحالیکه تمام threadها در Worker Pool مشغول هستند، تمام وظایف ورودی مانند محاسبات hash یا فراخوانی‌های fs صف‌بندی (queue) خواهند شد.

پس دومین قانون طلایی Node.js این است که «Worker Pool را به صورت عاقلانه مسدود کنیم.» در اینجا یک لیست کوتاه از پیشنهادات را مشاهده می‌نمایید که به شما در پیروی از این قانون کمک خواهند کرد:

  • از بروز دادن وظایف طولانی مدت در Worker Pool خودداری کنید. به عنوان مثال، APIهای بر پایه stream را نسبت به خواندن کل فایل با fs.readFile() ترجیح دهید.
  • در صورت امکان، بخش‌بندی وظایف حساس به CPU را در نظر داشته باشید.
  • باز هم تاکید می‌کنم که کتابخانه‌های جداگانه را به صورت عاقلانه انتخاب کنید.

قانون صفر - یک قانون برای حکمرانی همه

حال به عنوان خلاصه، ما می‌توانیم یک قانون کلیدی برای نوشتن یک برنامه سمت سرور Node.js با کارایی بالاتر را شکل دهیم. این قانون کلیدی عبارت است از این که: «اگر کار انجام شده برای هر درخواست در زمان داده شده کوتاه باشد، Node.js سریع است.» این قانون هم حلقه رویداد و هم Worker Pool را پوشش می‌دهد.

منبع

چه امتیازی برای این مقاله میدهید؟

خیلی بد
بد
متوسط
خوب
عالی
در انتظار ثبت رای

/@er79ka

دیدگاه و پرسش

برای ارسال دیدگاه لازم است وارد شده یا ثبت‌نام کنید ورود یا ثبت‌نام

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید