Deno در یک مقاله! +‌ ساخت API با Oak

?What is Deno

Deno در یک مقاله! +‌ ساخت API با Oak

Deno چیست؟

اگر از توسعه دهندگان سرور باشید احتمالا با نام deno آشنا هستید چرا که در سال گذشته سر و صدای زیادی به پا کرد. اگر در فضای وب به دنبال تعریف و توضیحات Deno باشید احتمالا با جمله ای شبیه به این جمله روبرو می شوید: Deno یک TypeScript/Javascript Runtime است که بر اساس موتور V8 جاوا اسکریپت و زبان Rust ساخته شده است. این جمله یعنی چه؟ برای درک این جمله باید دو مفهوم Runtime و موتور V8 را بدانید:

  • Runtime: در حوزه برنامه نویسی به دو چیز اشاره می کند: runtime به معنی آخرین مرحله زندگی یک برنامه و runtime به معنی محیط اجرا برای یک برنامه (runtime environment). در این مقاله منظور ما مورد دوم است.
  • موتور V8: زبان جاوا اسکریپت برای کامپایل شدن و اجرا شدن نیاز به موتور خاصی دارد. V8 موتوری است که گوگل برای زبان جاوا اسکریپت ساخته است.

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

البته شباهت Node.js و Deno در اینجا به پایان نمی رسد. سازنده Deno همان سازنده Node.js است؛ آقایی به نام Ryan Dahl که در سال ۲۰۱۸ سخنرانی معروفی در رابطه با اشتباهات خود در ساخت Node.js داشت و نقطه شروع deno نیز از همین جا بود. آقای Ryan Dahl تمام این اشتباهات را تصحیح کرد و آن را در قالب runtime جدیدی ارائه کرد که deno نام گرفت. با این حساب یادتان باشد که deno (یا node.js) زبان برنامه نویسی نیستند بلکه محیطی برای اجرا شدن جاوا اسکریپت می باشند که به آن ها runtime می گوییم.

آیا دوران node تمام شده است؟

قطعا خیر. Node.js یکی از بزرگترین زبان های حال حاضر در دنیای وب است و به هیچ عنوان حذف نخواهد شد مگر طی گذر زمان بسیار زیاد، اما معرفی deno نمی تواند node را از بین برده یا آن را تضعیف کند.

پیش نیاز مقاله

ما در این مقاله به سرعت موارد مهم در deno را بررسی می کنیم و سپس به سراغ ساخت یک API خواهیم رفت. با این حساب این مقاله برای افراد مبتدی در نظر گرفته نشده است و باید با مباحث ساخت API آشنا باشید.

ویژگی های خاص Deno

من در این بخش می خواهم به چند ویژگی بسیار مهم Deno اشاره کنم که تمام توسعه دهندگان باید آن ها را بدانند.

۱. پشتیبانی از تایپ اسکریپت: در Deno می توانید از جاوا اسکریپت یا تایپ اسکریپت استفاده کنید. از آنجایی که کامپایلر تایپ اسکریپت درون Deno وجود دارد، می تواند کدهای تایپ اسکریپت شما را مستقیما گرفته و کامپایل و اجرا کند.

۲. امنیت بالاتر: Node.js به صورت پیش فرض به عناصری مانند شبکه، فایل سیستم و درخواست های سیستم دسترسی دارد که امنیت را پایین می آورد اما در Deno اگر بخواهید به چنین چیز هایی دسترسی داشته باشید باید حتما از فلگ (flag) هایی خاص (مانند allow-write-- و غیره) استفاده کنید. در این مقاله با این فلگ ها آشنا خواهیم شد.

۳. پکیج های غیرمتمرکز: یکی از بزرگترین شکایت های کاربران node.js نحوه مدیریت وابستگی ها (dependencies) در پکیج های npm بود. به طور مثال اگر فریم ورک express را در node نصب می کردیم، npm ابتدا express و وابستگی های express را نصب می کرد و سپس وابستگی های آن وابستگی ها را نیز نصب می کرد! این مسئله باعث شلوغ شدن عجیب پوشه node_modules می شد. در Deno دیگر فایل package.json یا پکیج های npm را نداریم بلکه اگر بخواهید از یک پکیج استفاده کنید باید URL آن را از سایت deno.land/x مستقیما وارد اسکریپت خود کنید. این پکیج در زمان اجرا در هارد شما cache خواهد شد.

۴. کتابخانه استاندارد: کتابخانه استاندارد (standard library) مجموعه ای از پکیج هایی است که توسط تیم توسعه deno تایید شده اند و می توانید به راحتی از آن ها در deno استفاده کنید. پکیج های fs و datetime و http از جمله این پکیج ها هستند.

۵. جاوا اسکریپت مدرن: node در سال ۲۰۰۹ معرفی شد و به همین خاطر همیشه کمی عقب تر از آخرین نسخه های جاوا اسکریپت بوده است اما در سال های اخیر توانسته است خودش را به روز رسانی کند. از طرفی deno بسیار جدید است و با ساختار های جدید ساخته شده است بنابراین می توانید بدون هیچ مشکلی از آخرین امکانات جاوا اسکریپت در آن استفاده کنید. به طور مثال استفاده از ES Modules که به تازگی به node اضافه شده است اما از ابتدا در deno وجود دارد.

۶. await سطح بالا: در node اگر بخواهید از async/await استفاده کنید باید ابتدا یک تابع async داشته باشید تا بتوانید درون آن await را صدا بزنید اما deno به شما اجازه می دهد از await در هر بخشی از برنامه و در بالاترین scope نیز استفاده کنید.

۷. Testing: مبحث تست نویسی و اجرای آن به صورت پیش فرض در deno وجود دارد و برای انجام آن نیاز به پکیج های خاصی نخواهید داشت.

۸. API مرورگر: node به API مرورگرها دسترسی ندارد اما deno به صورت پیش فرض به آن دسترسی دارد. مثلا می توانید از fetch استفاده کرده یا به شیء window دسترسی داشته باشید.

۹. اجرای مستقیم باینری های WASM: شما می توانید در deno باینری های WASM یا webassembly binaries را مستقیما اجرا کنید.

۱۰. deno می تواند کل پروژه شما را به صورت یک تک فایل قابل اجرا استخراج کند.

۱۱. deno ابزار formatting کدها را به صورت پیش فرض در خود دارد و برای این کار از Prettier استفاده می کند.

آموزش نصب Deno

پکیج deno به صورت یک فایل اجرایی است و هیچ وابستگی ندارد به همین دلیل نصب آن بسیار ساده است. اگر از کاربران Mac یا Linux هستید باید دستور زیر را در ترمینال خود اجرا کنید:

curl -fsSL https://deno.land/x/install/install.sh | sh

نکته: اگر از کاربران لینوکس هستید و با اجرای کد بالا خطای عدم وجود curl را دریافت کردید ابتدا curl را نصب کنید. مثلا کاربران ubuntu می توانند از دستور sudo apt install curl استفاده کنند.

پس از پایان نصب کاربران لینوکس چنین خروجی را می گیرند:

######################################################################## 100.0%-=#=#   #   #                                                        ######################################################################## 100.0% -=#=#   #   #                                                       ######################################################################## 100.0%

Archive:  /home/amir/.deno/bin/deno.zip

  inflating: /home/amir/.deno/bin/deno 

Deno was installed successfully to /home/amir/.deno/bin/deno

Manually add the directory to your $HOME/.bash_profile (or similar)

  export DENO_INSTALL="/home/amir/.deno"

  export PATH="$DENO_INSTALL/bin:$PATH"

Run '/home/amir/.deno/bin/deno --help' to get started

برای اینکه بتوانید از طریق ترمینال به deno دسترسی داشته باشید باید به فایل bash_profile. یا profile. رفته و دو دستور export بالا را در انتهای آن قرار بدهید. این دو دستور بر اساس نام حساب شما در لینوکس متفاوت هستند. مثلا برای من دو دستور زیر برگردانده شده است:

export DENO_INSTALL="/home/amir/.deno"

export PATH="$DENO_INSTALL/bin:$PATH"

اگر از کاربران ویندوز هستید، PowerShell خود را باز کرده و دستور زیر را در آن اجرا کنید:

iwr https://deno.land/x/install/install.ps1 -useb | iex

اگر از کاربران Mac بوده و می خواهید حتما از Homebrew استفاده کنید باید دستور زیر را اجرا کنید:

brew install deno

همچنین کاربران ویندوزی که از Chocolatey استفاده می کنند، می توانند دستور زیر را اجرا کنند:

choco install deno

باز کردن REPL و محیط ترمینال

REPL به محیط اجرای کدهای جاوا اسکریپت توسط deno در ترمینال گفته می شود. پس از اینکه deno را نصب کردید ترمینال یا PowerShell خود را بسته و دوباره باز کنید. حالا دستور deno را در آن اجرا کنید. نتیجه باید چیزی شبیه به نتیجه زیر باشد:

Deno 1.10.2

exit using ctrl+d or close()

>

ما می توانیم در این محیط کدهای جاوا اسکریپتی نوشته و آن ها را اجرا کنیم. به طور مثال:

Deno 1.10.2

exit using ctrl+d or close()

> let data = 200;

undefined

> data + 200;

400

>

REPL برای کسانی است که می خواهند کمی با کدهای جاوا اسکریپت بازی کنند اما برای پروژه های واقعی مفید نیست. برای خروج از REPL کلید های Ctrl + D را فشار داده یا دستور ()close را اجرا کنید.

در صورتی که می خواهید با گزینه های مختلف deno در ترمینال یا PowerShell آشنا شوید دستور deno --help را اجرا کنید. اگر deno با موفقیت نصب شده باشد باید چنین گزارشی را دریافت کنید:

deno 1.10.2

A secure JavaScript and TypeScript runtime




Docs: https://deno.land/manual

Modules: https://deno.land/std/ https://deno.land/x/

Bugs: https://github.com/denoland/deno/issues




To start the REPL:




  deno




To execute a script:




  deno run https://deno.land/std/examples/welcome.ts




To evaluate code in the shell:




  deno eval "console.log(30933 + 404)"




USAGE:

    deno [OPTIONS] [SUBCOMMAND]




OPTIONS:

    -h, --help                    

            Prints help information




    -L, --log-level <log-level>   

            Set log level [possible values: debug, info]




    -q, --quiet                   

            Suppress diagnostic output

            By default, subcommands print human-readable diagnostic messages to stderr.

            If the flag is set, restrict these messages to errors.

        --unstable                

            Enable unstable features and APIs




    -V, --version                 

            Prints version information







SUBCOMMANDS:

    bundle         Bundle module and dependencies into single file

    cache          Cache the dependencies

    compile        UNSTABLE: Compile the script into a self contained executable

    completions    Generate shell completions

    coverage       Print coverage reports

    doc            Show documentation for a module

    eval           Eval script

    fmt            Format source files

    help           Prints this message or the help of the given subcommand(s)

    info           Show info about cache or info related to source file

    install        Install script as an executable

    lint           UNSTABLE: Lint source files

    lsp            Start the language server

    repl           Read Eval Print Loop

    run            Run a JavaScript or TypeScript program

    test           Run tests

    types          Print runtime TypeScript declarations

    upgrade        Upgrade deno executable to given version




ENVIRONMENT VARIABLES:

    DENO_AUTH_TOKENS     A semi-colon separated list of bearer tokens and

                         hostnames to use when fetching remote modules from

                         private repositories

                         (e.g. "abcde12345@deno.land;54321edcba@github.com")

    DENO_CERT            Load certificate authority from PEM encoded file

    DENO_DIR             Set the cache directory

    DENO_INSTALL_ROOT    Set deno install's output directory

                         (defaults to $HOME/.deno/bin)

    DENO_WEBGPU_TRACE    Directory to use for wgpu traces

    HTTP_PROXY           Proxy address for HTTP requests

                         (module downloads, fetch)

    HTTPS_PROXY          Proxy address for HTTPS requests

                         (module downloads, fetch)

    NO_COLOR             Set to disable color

    NO_PROXY             Comma-separated list of hosts which do not use a proxy

                         (module downloads, fetch)

همانطور که می بینید deno یک runtime کامل است و گزینه های کاری آن بسیار بسیار زیاد است. در گزارش بالا چند دستور وجود دارد که بهتر است تمام افراد با آن آشنا باشند. اولین دستور deno repl بود که به صورت پیش فرض با deno نیز اجرا می شد. دستور بعدی deno run است که با استفاده از آن می توانید فایل های جاوا اسکریپت یا تایپ اسکریپت محلی یا اسکریپت های remote (در یک سرور جداگانه) را اجرا کنید. به طور مثال:

deno run https://deno.land/std/examples/welcome.ts

اسکریپت welcome.ts یک اسکریپت ساده برای خوش آمد گویی به deno است و من برای تست آن را انتخاب کرده ام. با اجرای این اسکریپت چنین نتیجه ای را دریافت می کنید:

Download https://deno.land/std/examples/welcome.ts

Warning Implicitly using latest version (0.97.0) for https://deno.land/std/examples/welcome.ts

Download https://deno.land/std@0.97.0/examples/welcome.ts

Check https://deno.land/std/examples/welcome.ts

Welcome to Deno!

یعنی ابتدا فرآیند دانلود فایل آغاز شده است اما هنوز عملیات دانلود اتفاق نیفتاده است، سپس نسخه مورد نظر آن انتخاب شده و آن نسخه دانلود شده است، در نهایت پس از بررسی این فایل آن را اجرا کرده ایم. نتیجه چاپ شدن عبارت Welcome to Deno است. به عبارتی ما یک اسکریپت (فایل تایپ اسکریپتی) ساده را از یک سرور دیگر دانلود کرده و اجرا کرده ایم! اگر می خواهید این فایل welcome.ts را ببینید باید به این لینک مراجعه کنید.

دستور بعدی deno upgrade است که نسخه deno را به نسخه بعدی ارتقاء می دهد. همچنین دستور deno cache تمام وابستگی های پروژه را cache می کند و دستور deno bundle تمام ماژول های شما و وابستگی هایشان را در قالب یک فایل تنها قرار می دهد. دستور نهایی نیز deno install است که به شما اجازه می دهد اسکریپت های مورد نیاز خود را به صورت یک executable (فایل اجرایی) نصب کنید.

Permission ها در deno

همانطور که در ابتدای مقاله توضیح دادم deno برخلاف node به فایل های سیستم یا شبکه و امثال آن دسترسی ندارد مگر آنکه خودتان این اجازه را بدهید. برای توضیح دادن این مسئله به یک اسکریپت به نام file_server نیاز داریم. این اسکریپت نیاز به دسترسی به هارد سیستم دارد چرا که با فایل ها کار می کند بنابراین اگر آن را به صورت عادی اجرا کنیم خطا می گیریم. به طور مثال:

deno run https://deno.land/std@0.97.0/http/file_server.ts

با اجرای این دستور ابتدا چند فایل برایتان دانلود می شود و در نهایت خطایی به شکل زیر دریافت می کنید:

// دانلود فایل های مختلف

Check https://deno.land/std@0.97.0/http/file_server.ts

error: Uncaught PermissionDenied: Requires read access to <CWD>, run again with the --allow-read flag

      path = Deno.cwd();

                  ^

    at deno:core/core.js:86:46

    at unwrapOpResult (deno:core/core.js:106:13)

    at Object.opSync (deno:core/core.js:120:12)

    at Object.cwd (deno:runtime/js/30_fs.js:57:17)

    at Module.resolve (https://deno.land/std@0.97.0/path/posix.ts:36:19)

    at https://deno.land/std@0.97.0/http/file_server.ts:53:22

همانطور که می بینید خطای بالا به ما می گوید که تابع cwd نیاز به دسترسی به هارد دارد. چرا؟ به دلیل اینکه cwd در جاوا اسکریپت مسیر حال حاضر فایل را به ما نشان می دهد. با این حساب باید به این اسکریپت اجازه بدهیم به فایل های سیستم دسترسی داشته باشد:

deno run --allow-read https://deno.land/std@0.97.0/http/file_server.ts

فلگ allow-read-- به deno اجازه می دهد به هارد درایو دسترسی داشته باشد اما اگر دستور بالا را اجرا کنید به خطای زیر برخورد می کنید:

rror: Uncaught (in promise) PermissionDenied: Requires net access to "0.0.0.0:4507", run again with the --allow-net flag

  const listener = Deno.listen(addr);

                        ^

    at deno:core/core.js:86:46

    at unwrapOpResult (deno:core/core.js:106:13)

    at Object.opSync (deno:core/core.js:120:12)

    at opListen (deno:runtime/js/30_net.js:18:17)

    at Object.listen (deno:runtime/js/30_net.js:184:17)

    at serve (https://deno.land/std@0.97.0/http/server.ts:303:25)

    at listenAndServe (https://deno.land/std@0.97.0/http/server.ts:323:18)

    at main (https://deno.land/std@0.97.0/http/file_server.ts:461:5)

    at https://deno.land/std@0.97.0/http/file_server.ts:467:3

یعنی تابعی به نام listen وجود دارد که برای کار نیاز به دسترسی به شبکه (network) را دارد با این حساب باید یک فلگ دیگر را نیز پاس بدهیم:

deno run --allow-read --allow-net https://deno.land/std@0.97.0/http/file_server.ts

این بار اسکریپت با موفقیت انجام می شود و چنین نتیجه ای را می گیرید:

HTTP server listening on http://0.0.0.0:4507/

کار این اسکریپت ایجاد یک سرور محلی برای دسترسی به فایل هایتان است بنابراین اگر به آدرس http://0.0.0.0:4507 بروید تمام فایل های خود را در مرورگر مشاهده می کنید. با فشردن کلید های Ctrl + C اجرای این اسکریپت را متوقف کنید.

نصب اسکریپت ها با deno

ما اسکریپت بالا را فقط اجرا کردیم اما آن را روی سیستم خود نصب نکرده ایم. اگر بخواهیم این اسکریپت را نصب کنیم باید به جای run از install استفاده کنیم:

deno install --allow-read --allow-net https://deno.land/std@0.97.0/http/file_server.ts

با اجرای این دستور نتیجه زیر را دریافت می کنید:

✅ Successfully installed file_server

/home/amir/.deno/bin/file_server

یعنی این اسکریپت در آدرس /home/amir/.deno/bin/file_server نصب شده است و من می توانم به راحتی آن را از هر جای سیستم صدا بزنم. مثلا می توانم به یک درایو دیگر از هارد خودم بروم و در آنجا دستور زیر را اجرا کنم:

file_server

به عبارت ساده تر file_server تبدیل به یک دستور در ترمینال شده است بنابراین من ترمینال خودم را در پوشه مورد نظرم باز کرده ام و دستور file_server را در آن اجرا کرده ام. با انجام این کار محتوای آن پوشه از طریق آدرس http://0.0.0.0:4507 دسترسی خواهم داشت.

چرخه زندگی در Deno

اسکریپت های deno رویداد های load (بارگذاری) و unload (خروج از بارگذاری) را در چرخه زندگی خودشان دارند. listener هایی که برای رویداد load هستند از جنس ناهمگام (async) بوده و باید await شوند اما listener های رویداد unload همیشه همگام هستند. در ضمن هر دو رویداد روی شیء window در deno قرار دارند. به طور مثال:

const handler = (e: Event): void => {

  console.log(`got ${e.type} event in event handler (main)`);

};




window.addEventListener("unload", handler);

از این رویداد ها زمانی استفاده می شود که بخواهیم در زمان بارگذاری یک اسکریپت کار خاصی را انجام بدهیم.

اجرای فایل های محلی

همانطور که می دانید در اکثر اوقات ما می خواهیم فایل های محلی را اجرا کنیم نه اینکه با اسکریپت های remote کار کنیم بنابراین بیایید یک پوشه به نام code ایجاد کرده و در آن فایلی به نام hello.ts ایجاد کنیم. شما می توانید از پسوند js (فایل های جاوا اسکریپت) نیز استفاده کنید اما من همیشه از تایپ اسکریپت استفاده می کنم. در قدم بعدی فایل hello.ts را باز کرده و محتوای ساده ای را در آن بنویسید:

const greeting = "Hello from roxo.ir/blog";




console.log(greeting);

اگر بخواهیم این اسکریپت ساده را اجرا کنیم باید دستور deno run hello.ts را اجرا کنیم. با انجام این کار نتیجه زیر را خواهید گرفت که طبیعی است:

Hello from roxo.ir/blog

deno فایل تایپ اسکریپتی شما را ابتدا به جاوا اسکریپت کامپایل می کند و سپس آن را اجرا خواهد کرد. تمام این اتفاقات در پس زمینه رخ می دهد بنابراین شما آن را نمی بینید.

استفاده از پکیج های deno در فایل های محلی

همانطور که در ابتدای مقاله توضیح دادم برای وارد کردن پکیج های deno دیگر نه package.json ای وجود دارد و نه پوشه node_modules. نحوه وارد کردن اسکریپت ها نیز مستقیما از URL خواهد بود. برای تمرین این موضوع ابتدا فایل جدیدی به نام dateTime.ts بسازید. از کتابخانه استاندارد deno به سراغ اسکریپتی به نام datetime می رویم. کار این اسکریپت این است که به شما در مدیریت زمان و تاریخ کمک می کند.

من می خواهم این اسکریپت را وارد فایل datetime.ts کنم. اگر در node بودیم احتمالا دستوری شبیه به npm install datetime به ما داده می شد اما چنین چیزی در deno وجود ندارد. در deno برای استفاده از این اسکریپت بدین شکل عمل می کنیم:

import {

  dayOfYear,

  weekOfYear,

} from "https://deno.land/std@0.97.0/datetime/mod.ts";

همانطور که می بینید من مستقیما یک URL را به عنوان آدرس پکیج داده ام. اگر از ویرایشگر هایی مانند visual studio code استفاده می کنید باید بدانید که زیر آدرس پکیج خط کشیده می شود چرا که چنین چیزی در تایپ اسکریپت وجود ندارد اما این مسئله مشکلی برای ما ایجاد نمی کند (در ادامه توضیح بیشتری می دهم). من دو متد dayOfYear و currentDayOfYear را از این اسکریپت می خواهم بنابراین آن ها را وارد کرده ام.

نکته: پیشنهاد می کنم حتما افزونه deno برای visual studio code را نصب کنید.

در مرحله بعدی از این دو متد استفاده می کنم:

import {

  dayOfYear,

  weekOfYear,

} from "https://deno.land/std@0.97.0/datetime/mod.ts";




console.log(dayOfYear(new Date("2021-05-23")));

console.log(weekOfYear(new Date("2021-05-23")));

تابع dayOfYear شماره روز فعلی و weekOfYear شماره هفته فعلی از سال را به ما می دهد. در حال حاضر زیر import شما خط قرمز کشیده شده است که چنین اسکریپتی وجود ندارد. شما باید یک بار این اسکریپت را اجرا کنید تا پکیج مورد نظر برایتان cache شود. زمانی که این کار انجام شد دیگر خط قرمزی را مشاهده نخواهید کرد بنابراین بیایید دستور deno run dateTime.tsc را اجرا کنیم. نتیجه باید به شکل زیر باشد:

// دانلود اسکریپت و کش کردن آن

143

20

یعنی در زمانی که من این کد را اجرا کرده ام روز ۱۴۳ ام از سال و هفته ۲۰ ام از سال بوده است. طبیعتا این اعداد برای شما متفاوت خواهد بود.

نکته: این کار باید خط قرمز را از بین ببرد اما در صورتی که هنوز هم مشکل داشتید، در visual studio code کلید های Ctrl + Shift + P را فشار داده و عبارت deno را در کادر باز شده تایپ کنید. یکی از گزینه های ظاهر شده Cache Dependencies است. با انتخاب این گزینه باید مشکلتان حل شود.

قسمت های مختلف Runtime

Deno از نظر runtime چندین قسمت دارد که دو قسمت آن به عنوان قسمت های اصلی شناخته می شوند:

  • Web Platform APIs: همان API های استانداردهای وب هستند که توسط مرورگرها پیاده سازی شده اند (مانند دستور fetch) و deno نیز آن ها را دقیقا مشابه با مرورگرها ایجاد کرده است تا دو دستگی ایجاد نشود.
  • Deno: تمام API هایی که مربوط به Web API و مرورگرها نیستند در این بخش قرار می گیرند. در این بخش یک namespace سراسری به نام Deno وجود دارد و قابلیت های مختلفی مانند خواندن فایل، باز کردن سوکت های TCP و غیره را در اختیار شما قرار می دهد.

Web Platform APIs خودش دارای بخش های زیر است:

  • fetch API: تقریبا تمام کسانی که با جاوا اسکریپت مرورگر کار کرده اند این متد را می شناسند. اگر به هر دلیل با آن آشنا نیستید می توانید به مثال های معرفی شده توسط صفحه توسعه دهندگان موزیلا مراجعه کنید.
  • EventTarget: این بخش مربوط به کار با رویداد ها است که دقیقا مانند مرورگر است. اطلاعات بیشتر در صفحه توسعه دهندگان موزیلا قرار دارد با این تفاوت که در Deno رویداد ها به سمت بالا منتشر نمی شوند (bubble up نداریم) چرا که در deno خبری از DOM نیست.
  • Web Worker: این API به شما اجازه می دهد با استفاده از یک اسکریپت در پس زمینه کاری را انجام بدهید و نتیجه را به اسکریپت اصلی برگردانید.
  • Blob که به شما اجازه می دهد یک شیء فایل مانند و غیر قابل تغییر را داشته باشید که توانایی خوانده شدن به صورت باینری یا متنی را دارد.
  • Console: این بخش به شما دسترسی console می دهد (دستوراتی مانند console.log).
  • FormData: این بخش به شما اجازه می دهد جفت های key/value بسازید که به شکل فیلدهای یک فرم و مقدار آن باشد.
  • Performance: این بخش مربوط به مباحث اندازه گیری سرعت است.
  • Interval: تمام مباحث مربوط به بازه ها مانند setTimeout و setInterval و clearInterval در این بخش قرار می گیرند.
  • Streams_API: این بخش به شما اجازه می دهد که به داده های استریم شده دسترسی داشته و آن ها را بر اساس سلیقه خود ویرایش کنید.
  • URL: این بخش به ما اجازه می دهد که URL های مختلف را ساخته یا تحلیل کرده یا ویرایش کنیم و متد های مختلفی برای کار با URL ها در اختیارمان قرار می دهد.
  • URLSearchParams: این بخش متد های کمکی را در اختیار ما می گذارد تا بتوانیم با بخش query string در URL ها کار کنیم.
  • WebSocket: این بخش به ما کمک می کند تا وب سوکت هایی را تعریف کرده و به سرور مورد نظرمان متصل شویم.

قسمت های دیگر Runtime نیز مهم هستند. به طور مثال Location API که برای کار با URL فعلی کاربر کاربرد دارد و بخش های دیگر مانند Web Storage API (شامل localStorage و sessionStorage) به آن نیاز دارند.

ساخت API ساده با Deno

حالا که با کلیت deno آشنا شده ایم نوبت به ساخت یک API با آن می رسد. طبیعتا هیچکس API را با زبان خالی نمی سازد. یعنی چه؟ یعنی هیچکس با جاوا اسکریپت خالی و node یا deno یا با PHP خالی شروع به نوشتن یک API واقعی نمی کند بنابراین به یک فریم ورک نیاز داریم.

در حال حاضر deno بسیار جوان است بنابراین تنوع پکیج های مختلف برای node را نداریم اما هنوز هم انتخاب های مختلفی موجود است. از بین فریم ورک های مختلفی که برای deno ساخته شده است، می توان به جرات گفت که oak از همه مشهور تر است بنابراین ما نیز از این فریم ورک استفاده خواهیم کرد.

برای شروع یک پوشه به نام deno API ایجاد کنید و درون آن یک فایل به نام server.ts بسازید.  در این فایل ابتدا باید oak را وارد کنیم:

import { Application, Router } from "https://deno.land/x/oak@v7.5.0/mod.ts";

مثل همیشه اگر از قبل این پکیج را نصب نکرده باشید در اینجا زیر بخش from خط قرمز کشیده می شود چرا که چنین پکیجی هنوز در سیستم ما کش نشده است. قبلا هم گفته بودم که راه حل نادیده گرفتن آن است بنابراین ما کار را ادامه می دهیم:

import { Application, Router } from "https://deno.land/x/oak@v7.5.0/mod.ts";




const app = new Application();




console.log("server running on port 5000");

await app.listen({ port: 5000 });

همانطور که می بینید شباهت بسیار زیادی بین oak و express برای node وجود دارد. ما با new Application یک برنامه oak جدید ساخته ایم و سپس با صدا زدن app.listen و پاس دادن یک پورت به آن سرور را راه اندازی کرده ایم. در نظر داشته باشید که حتما باید آن را await کنید. قبلا هم در این مقاله توضیح دادم که در deno الزامی به استفاده از توابع async وجود ندارد و می توانیم از await سطح بالا (مانند مثال بالا) استفاده کنیم. حالا برای اجرای این سرور ساده باید چه کار کرد؟ طبیعتا هر سروری نیاز به دسترسی به شبکه دارد بنابراین از allow-net-- استفاده می کنیم:

deno run --allow-net server.ts

با اجرای دستور بالا دانلود فایل های oak و وابستگی های آن شروع می شود اما پس از تمام آن به خطای زیر برخورد می کنید:

error: Uncaught TypeError: There is no middleware to process requests.

      throw new TypeError("There is no middleware to process requests.");

            ^

    at Application.listen (https://deno.land/x/oak@v7.5.0/application.ts:450:13)

اگر از express استفاده کرده باشید احتمالا تصور می کردید که کد بالا بدون مشکل کار کند اما اینطور نیست. در oak اگر هیچ middleware ای برای پردازش درخواست ها نداشته باشید، سرور اجرا نمی شود بنابراین بیایید از router برای تعریف مسیرهای مختلف استفاده کنیم. با اینکه سرور ما اجرا نشده است اما به دلیل دانلود و کش شدن پکیج oak دیگر خطای عدم وجود این پکیج را نمی گیرید (ممکن است چند لحظه طول بکشد تا visual studio code بتواند کش شدن پکیج را تشخیص بدهد).

با این حساب من از router برای تعریف یک مسیر ساده استفاده می کنم:

import { Application, Router } from "https://deno.land/x/oak@v7.5.0/mod.ts";




const app = new Application();

const router = new Router();




router.get("/api/v1/products", ctx => {

  ctx.response.body = "Hello from roxo.ir";

});




app.use(router.routes());

app.use(router.allowedMethods());




console.log("server running on port 5000");

await app.listen({ port: 5000 });

پس از اینکه یک نمونه از Router را ساختیم از router.get استفاده کرده ایم تا یک مسیر get را تعریف کنیم. این مسیر api/v1/products است و در جواب رشته Hello from roxo.ir را ارسال می کند. پس از تعریف مسیرها (route ها) باید آن ها را به یک middleware بدهیم تا در سرور ثبت شوند. این کار توسط متد router.routes انجام می شود بنابراین آن را به app.use داده ایم. همچنین در مرحله بعدی متد  allowedMethods را درون یک middleware دیگر صدا زده ایم که به ما اجازه می دهد از تمام متد های HTTP (مانند GET و POST و PATCH و DELETE) استفاده کنیم. برای اجرای این اسکریپت مثل قبل عمل می کنیم:

deno run --allow-net server.ts

با اجرای این دستور سرور در حال اجرا می باشد و عبارت server running on port 5000 در ترمینال چاپ می شود.

برای تست این سرور می توانید از روش های مختلفی استفاده کنید. به طور مثال می توانید از برنامه Postman یا Insomnia استفاده کنید که هر دو برای تست REST API ها هستند و انواع دیگر API را نیز پشتیبانی می کنند اما من این برنامه ها را نمی پسندم چرا که بسیار سنگین هستند (مخصوصا Postman) و باز شدنشان زمان زیادی می برد. خوشبختانه افزونه Thunder Client برای visual studio code برای حل این مشکل ساخته شده است و به شما اجازه می دهد درون visual studio code درخواست های مختلفی را به API خود ارسال کنید! این برنامه بسیار سریع است و به عنوان یک سربرگ جدید در visual studio code باز می شود و از نظر من بهترین گزینه برای توسعه API است.

تست اولیه توسط thunder client
تست اولیه توسط thunder client

همانطور که در تصویر بالا در سمت چپ مشخص است من یک دستور GET را به آدرس localhost:3900/api/v1/products ارسال کرده ام. نتیجه نیز در سمت راست نمایش داده شده است (رشته Hello from roxo.ir) و Status نیز روی 200 OK است بنابراین همه چیز به درستی کار می کند.

نصب پکیج denon

در حال حاضر یک مسئله کوچک وجود دارد که در توسعه آزار دهنده می شود. زمانی که سورس کد خود را تغییر می دهید حتما باید سرور را از ترمینال بسته و دوباره باز کنید. اگر سرور بدین شکل ریستارت نشود، تغییرات ایجاد شده اعمال نخواهند شد. برای حل این مشکل دو راه حل دارید:

  • استفاده از حالت watch
  • استفاده از پکیج denon

watch mode حالتی در deno است که فایل های شما را زیر نظر می گیرد و در صورتی که تغییراتی در فایل های شما اجرا شود برنامه را ریستارت می کند. برای استفاده از این حالت کافی است فلگ watch-- را به deno run پاس بدهید:

deno run --allow-net --watch server.ts

پکیج denon نیز راه حل دوم شما است که دقیقا مانند watch mode در deno است. برای نصب این پکیج ابتدا ترمینال خود را باز کرده و سپس دستور زیر را در آن اجرا کنید:

deno install -qAf --unstable https://deno.land/x/denon/denon.ts

از این به بعد می توانید به جای deno run از denon run استفاده کنید. این پکیج فایل های شما را نیز نظر می گیرد و با ایجاد هر تغییر در آن ها به صورت خودکار اسکریپت را ریستارت می کند:

denon run --allow-net server.ts

شما می توانید از هر کدام از این دو روش استفاده کنید اما در نظر داشته باشید که watch mode یک عیب کوچک دارد: اگر فایل های موجود را ویرایش کنید برنامه ریستارت می شود اما اگر فایل جدیدی به پروژه اضافه کنید watch mode آن را تشخیص نمی دهد بنابراین شاید بهتر باشد از denon استفاده کنید (من شخصا از denon استفاده می کنم).

تعریف route ها

من نمی خواهم تمام مسیرهای API در همان فایل اصلی باشند چرا که مدیریت آن بسیار سخت شده و همه چیز شلوغ خواهد شد. برای حل این مشکل یک فایل دیگر در همین پوشه (deno API) به نام routes.ts ایجاد می کنیم. محتوای این فایل باید بدین شکل باشد:

import { Router } from "https://deno.land/x/oak@v7.5.0/mod.ts";




const router = new Router();




router.get("/api/v1/products", ctx => {

  ctx.response.body = "Hello from roxo.ir";

});




export default router;

همانطور که می بینید من ابتدا router را وارد کرده ام و از آن یک نمونه ساخته ام. در مرحله بعدی همان route همیشگی را داریم و نهایتا router را export کرده ایم. حالا به فایل server.ts برمی گردیم و محتویات آن را بدین شکل ویرایش می کنیم:

import { Application } from "https://deno.land/x/oak@v7.5.0/mod.ts";

import router from "./routes.ts";




const app = new Application();




app.use(router.routes());

app.use(router.allowedMethods());




console.log("server running on port 5000");

await app.listen({ port: 5000 });

یعنی فایل router.ts را وارد کرده ایم و آن را به middleware های خودش پاس داده ایم. یک بار دیگر با Client Thunder (یا هر برنامه ای که دوست دارید) API را تست کنید تا مطمئن شوید هنوز همه چیز کار می کند.

ساخت controller

در پوشه اصلی برنامه (deno API) یک پوشه دیگر به نام controllers ایجاد کرده و درون آن فایلی به نام products.ts بسازید. من نمی خواهم در این پروژه از پایگاه داده استفاده کنم تا تمرکز ما حتما روی deno باشد بنابراین محصولاتمان را در قالب یک آرایه  در همین فایل products.ts تعریف می کنیم:

const products = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

  {

    id: "2",

    name: "product two",

    description: "This is the description for product 2",

    price: 39.99,

  },

  {

    id: "3",

    name: "product three",

    description: "This is the description for product 3",

    price: 59.99,

  },

  {

    id: "4",

    name: "product four",

    description: "This is the description for product 4",

    price: 10.99,

  },

];

همانطور که می بینید در کل چهار محصول داریم و هر محصول چهار فیلد id و name (نام) و description (توضیحات) و price (قیمت) را دارد. از آنجایی که در حال نوشتن تایپ اسکریپت هستیم بهتر است یک اینترفیس برای این محصولات تعریف کنیم. برای این کار در پوشه deno API (پوشه اصلی برنامه) یک فایل به نام types.ts ایجاد می کنیم و درون آن یک اینترفیس ساده را می نویسیم:

export interface ProductsInterface {

  id: string;

  name: string;

  description: string;

  price: number;

}

همانطور که می بینید من آن را export کرده ام. در مرحله بعدی به فایل products.ts برمی گردیم و این اینترفیس را به آرایه محصولاتمان می دهیم:

import { ProductsInterface } from "../types.ts";




const products: ProductsInterface[] = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

  {

    id: "2",

    name: "product two",

    description: "This is the description for product 2",

    price: 39.99,

  },

  {

    id: "3",

    name: "product three",

    description: "This is the description for product 3",

    price: 59.99,

  },

  {

    id: "4",

    name: "product four",

    description: "This is the description for product 4",

    price: 10.99,

  },

];

در مرحله بعدی در پایین این آرایه یک متد جدید را تعریف می کنیم که مسئول دریافت تمام محصولات ما باشد. به این متد ها route handler یا controller گفته می شود و حتما با آن ها آشنایی دارید:

import { ProductsInterface } from "../types.ts";

import type { Context } from "https://deno.land/x/oak@v7.5.0/mod.ts";




const products: ProductsInterface[] = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

  {

    id: "2",

    name: "product two",

    description: "This is the description for product 2",

    price: 39.99,

  },

  {

    id: "3",

    name: "product three",

    description: "This is the description for product 3",

    price: 59.99,

  },

  {

    id: "4",

    name: "product four",

    description: "This is the description for product 4",

    price: 10.99,

  },

];




export const getProducts = (ctx: Context) => {

  ctx.response.body = {

    success: true,

    data: products,

  };

};

همانطور که می بینید من بدنه پاسخ سرور را به شکل یک شیء تعریف کرده ام که دو خصوصیت دارد: success به معنای موفقیت آمیز بودن درخواست است و من true را برایش قرار داده ام و data که همان payload یا داده های مورد انتظار کاربر است و من محصولات خودم را برایش قرار داده ام. توجه داشته باشید که بدنه پاسخ مثل هر API دیگری کاملا به سلیقه شما بستگی دارد و می توانید پاسخ بالا را به هر شکلی که می خواهید ویرایش کنید.

من این متد را export کرده ام و حالا به فایل routes.ts برمی گردم و از آن در مسیر فعلی خودمان استفاده می کنم:

import { Router } from "https://deno.land/x/oak@v7.5.0/mod.ts";

import { getProducts } from "./controllers/products.ts";




const router = new Router();




router.get("/api/v1/products", getProducts);




export default router;

تنها کافی است که controller خود (getProducts) را به مسیر مورد نظر پاس بدهیم. حالا به thunder client برمی گردم و یک درخواست GET را به آدرس localhost:5000/api/v1/products ارسال می کنم. نتیجه باید به شکل زیر باشد:

{

  "success": true,

  "data": [

    {

      "id": "1",

      "name": "product one",

      "description": "This is the description for product 1",

      "price": 29.99

    },

    {

      "id": "2",

      "name": "product two",

      "description": "This is the description for product 2",

      "price": 39.99

    },

    {

      "id": "3",

      "name": "product three",

      "description": "This is the description for product 3",

      "price": 59.99

    },

    {

      "id": "4",

      "name": "product four",

      "description": "This is the description for product 4",

      "price": 10.99

    }

  ]

}

بنابراین همه چیز بدون مشکل کار می کند و اولین route از API خودمان را به سادگی بررسی کرده ایم.

ظاهر عجیب import ها

در حال حاضر اگر به فایل های routes.ts و products.ts و server.ts توجه کنید متوجه خواهید شد که ما چندین بار از URL ها import کرده ایم تا به oak دسترسی داشته باشیم. این مسئله علاوه بر طولانی کردن import ها و ناخوانا شدن آن ها از نظر مدیریت کد و به روز رسانی آن در آینده سخت خواهد بود چرا که احتمال وجود خطا در آن بالاتر می رود. راه حل چیست؟

documentation رسمی deno در آخرین نسخه خود درباره این موضوع توضیح می دهد. برای حل این مشکل به پوشه اصلی پروژه (deno API) مراجعه کرده و یک فایل جدید به نام deps.ts را در آن می سازیم که مخفف dependencies.ts است. فایل deps.ts را باز کنید و oak را یک بار import نمایید. از این به بعد می توانیم از فایل deps.ts به بقیه ماژول های خود export کنیم! بیایید این کار را در پروژه خود انجام بدهیم.

ابتدا فایل deps.ts را ساخته و محتوای زیر را در آن قرار می دهیم:

export {

  Application,

  Router,

  Context,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";

یعنی سه بخش از oak که مورد نیاز ما بوده اند را از URL گرفته ایم و سپس export کرده ایم. حالا به server.ts برمی گردیم و بدین شکل عمل می کنیم:

import { Application } from "./deps.ts";

import router from "./routes.ts";




const app = new Application();




app.use(router.routes());

app.use(router.allowedMethods());




console.log("server running on port 5000");

await app.listen({ port: 5000 });

همانطور که می بینید دیگر خبری از URL نیست و مستقیما از فایل deps.ts وارد کرده ایم. این کار را برای router.ts نیز انجام می دهیم:

import { Router } from "./deps.ts";

import { getProducts } from "./controllers/products.ts";




const router = new Router();




router.get("/api/v1/products", getProducts);




export default router;

سپس به فایل types.ts رفته و تایپ context را نیز به آن اضافه می کنیم چرا که می خواهم همه تایپ های ما در یک فایل قرار داشته باشند:

import type { Context } from "./deps.ts";




export interface ProductsInterface {

  id: string;

  name: string;

  description: string;

  price: number;

}




export type CTX = Context;

در نهایت به کنترلر products.ts رفته و تایپ آن را نیز تصحیح می کنیم:

import { ProductsInterface, CTX } from "../types.ts";




const products: ProductsInterface[] = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

// بقیه کدها

export const getProducts = (ctx: CTX) => {

  ctx.response.body = {

    success: true,

    data: products,

  };

};

از این به بعد مشکلات عجیب import از URL را نخواهیم داشت.

تعریف باقی مسیرها

ما در حال حاضر یک مسیر و یک کنترلر را برای آن تعریف کرده ایم اما هیچ API تنها یک مسیر ندارد بنابراین در ابتدا باید بدانیم که API ما قرار است چه کاری انجام بدهد. من می خواهم مسیرهای زیر را تعریف کنم:

  • درخواست GET برای گرفتن تمام محصولات
  • درخواست GET برای گرفتن یک محصول
  • درخواست POST برای ثبت یک محصول
  • درخواست PUT برای ویرایش یک محصول
  • درخواست DELETE برای حذف یک محصول

در حال حاضر مسیر اول را تعریف کرده ایم و کنترلر آن در فایل products.ts وجود دارد. من در همین بخش قالب بقیه کنترلرها را نیز تعریف می کنم:

export const getProducts = (ctx: CTX) => {

  ctx.response.body = {

    success: true,

    data: products,

  };

};




export const getProduct = (ctx: CTX) => {};




export const addProduct = (ctx: CTX) => {};




export const updateProduct = (ctx: CTX) => {};




export const deleteProduct = (ctx: CTX) => {};

من این کنترلرها را از عمد خالی گذاشته ام. سعی کنید خودتان از روی این کنترلرها مسیرهایشان را تعریف کرده و سپس بدنه آن ها را کامل کنید.

امیدوارم خودتان به جواب رسیده باشید. بیایید ابتدا مسیرها را تعریف کنیم. برای اینکار به فایل routes.ts می رویم و پس از تعریف تک تک مسیرها، کنترلرهای متناظرشان را به آن ها پاس می دهیم:

import { Router } from "./deps.ts";

import {

  getProducts,

  addProduct,

  deleteProduct,

  getProduct,

  updateProduct,

} from "./controllers/products.ts";




const router = new Router();




router.get("/api/v1/products", getProducts);

router.get("/api/v1/products/:id", getProduct);

router.post("/api/v1/products", addProduct);

router.put("/api/v1/products/:id", updateProduct);

router.delete("/api/v1/products/:id", deleteProduct);




export default router;

همانطور که می بینید من تمام کنترلرها را وارد این فایل کرده ام و سپس آن ها را به مسیرهای تعریف شده داده ام. اگر با فریم ورک هایی مانند Express یا Koa آشنا باشید می دانید که برای دریافت یک پارامتر از URL از ساختاری مانند id: استفاده می کنیم و در واقع علامت دو نقطه به معنی پویا بودن این بخش از URL است. از آنجایی که مسیرهای تعریف شده بسیار ساده هستند فکر نمی کنم نیازی به توضیح اضافی باشد و باید مستقیما به فایل controllers/products.ts برگردیم تا منطق کاری کنترلرها را بنویسیم.

ما اولین کنترلر خود (getProducts) را از قبل نوشته ایم بنابراین در فایل products.ts به دومین کنترلر خود (getProduct) می رسیم که یک محصول خاص را به ما می دهد. سعی کنید ابتدا خودتان این کنترلر را کامل کنید و سپس به پاسخ من توجه کنید:

export const getProduct = (ctx: CTX) => {

  const params = ctx.params;

};

نحوه دریافت پارامترهای URL در oak از طریق دسترسی به خصوصیت params از شیء ctx است بنابراین من سعی کرده ام id: را بدین شکل استخراج کرده و درون متغیری به نام params قرار بدهم. مشکل اینجاست که کد بالا به ما خطا خواهد داد و می گوید params روی شیء CTX وجود ندارد! آیا می دانید مشکل کجاست؟

تعیین تایپ صحیح در Oak

مشکل این کد در تایپ CTX است. این تایپ نسخه خالص و ساده CTX است اما در oak یک تایپ ctx دیگر به نام RouterContext را نیز داریم که مخصوص ctx هایی است که با router سر و کار دارند بنابراین دارای خصوصیات بیشتری مانند params می باشد. با این حساب تمام CTX های ما باید از نوع RouterContext باشند! برای این کار ابتدا به فایل deps.ts می رویم تا routerContext را از پکیج oak وارد کنیم:

export {

  Application,

  Router,

  Context,

  RouterContext

} from "https://deno.land/x/oak@v7.5.0/mod.ts";

اما این کد باز هم به ما خطای زیر را می دهد:

Re-exporting a type when the '--isolatedModules' flag is provided requires using 'export type'.deno-ts(1205)

یعنی چه؟ deno به صورت پیش فرض ماژول های ایزوله شده (isolatedModules) را در تنظیمات تایپ اسکریپت روی true می گذارد. اگر با تایپ اسکریپت ساده کار کرده باشید می دانید که این گزینه یکی از گزینه های موجود در فایل پیکربندی تایپ اسکریپت (tsconfig) است. این گزینه می گوید که هر فایل باید به عنوان یک ماژول مستقل عمل کند بنابراین قوانین خاصی برای فایل های ما وضع می شود.

در کد بالا ما می خواهیم یک تایپ به نام RouterContext را از پکیج oak گرفته و دوباره export کنیم. این کار در حالت isolatedModules مجاز نیست. اگر بخواهیم چنین کاری را انجام بدهیم باید حتما type را export کنیم (مثلا export type X). چرا Context مجاز بود؟ به دلیل اینکه Context در اصل یک کلاس است و تایپ نیست (می توانید از سورس کد چک کنید) اما RouterContext یک تایپ است. من برای حل این مشکل از این راه حل استفاده می کنم:

export {

  Application,

  Router,

  Context,

} from "https://deno.land/x/oak@v7.5.0/mod.ts";




export type { RouterContext } from "https://deno.land/x/oak@v7.5.0/mod.ts";

در مرحله بعدی به فایل types.ts رفته و یک تایپ جدید را برای RouterContext تعریف می کنیم:

import type { Context, RouterContext } from "./deps.ts";




export interface ProductsInterface {

  id: string;

  name: string;

  description: string;

  price: number;

}




export type CTX = Context;

export type RouterCTX = RouterContext;

در نظر داشته باشید که من این کار را برای خلاصه تر بودن و راحتی کارم انجام داده ام. شما می توانید تایپ را مستقیما از deps.ts وارد کنید و نیازی به تعریف تایپ جدید در types.ts نیست. در مرحله بعدی به controllers/products.ts برمی گردیم و تایپ کنترلرهایمان را تصحیح می کنیم:

import { ProductsInterface, RouterCTX } from "../types.ts";




const products: ProductsInterface[] = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

  {

    id: "2",

    name: "product two",

    description: "This is the description for product 2",

    price: 39.99,

  },

  {

    id: "3",

    name: "product three",

    description: "This is the description for product 3",

    price: 59.99,

  },

  {

    id: "4",

    name: "product four",

    description: "This is the description for product 4",

    price: 10.99,

  },

];




export const getProducts = (ctx: RouterCTX) => {

  ctx.response.body = {

    success: true,

    data: products,

  };

};




export const getProduct = (ctx: RouterCTX) => {

  const params = ctx.params;

};




export const addProduct = (ctx: RouterCTX) => {};




export const updateProduct = (ctx: RouterCTX) => {};




export const deleteProduct = (ctx: RouterCTX) => {};

کنترلر دوم: دریافت یک محصول خاص

ما در بخش قبلی تایپ کنترلرها را تصحیح کریم و حالا که تایپ تصحیح شد به سراغ تکمیل کردن کنترلر getProduct می رویم:

export const getProduct = (ctx: RouterCTX) => {

  const params = ctx.params;

  const product: ProductsInterface | undefined = products.find(

    product => product.id === params.id

  );




  if (product) {

    ctx.response.status = 200;

    ctx.response.body = {

      success: true,

      data: product,

    };

  } else {

    ctx.response.status = 404;

    ctx.response.body = {

      success: false,

      msg: "No Product was found",

    };

  }

};

من با استفاده از متد find در بین آرایه خودمان گشته ام تا id پاس داده شده توسط کاربر (params.id) را در بین id محصولاتمان پیدا کنم. با این حساب product یا یکی از محصولات موجود در آرایه ما خواهد بود یا undefined خواهد بود. تایپی که به آن داده ام به همین دلیل است. در ادامه با یک شرط if وجود product را بررسی می کنیم و اگر وجود داشت آن را برمی گردانیم در غیر این صورت یک خطای ۴۰۴ برگردانده می شود و در بدنه پاسخ خصوصیت msg را داریم که می گوید هیچ محصولی یافت نشد.

برای تست این مسیر برنامه تست API خود (thunder client یا postman و غیره) را باز کرده و یک درخواست GET را به مسیر products ارسال کنید. به طور مثال مسیر localhost:5000/api/v1/products/2 باید محصول دوم را برایتان برگرداند:

دریافت یکی از محصولات
دریافت یکی از محصولات

از طرف دیگر اگر درخواست خود را به آدرسی مانند localhost:5000/api/v1/products/22 ارسال کنیم باید یک خطای ۴۰۴ بگیریم چرا که هیچ محصولی با آیدی ۲۲ وجود ندارد:

دریافت اشتباه یک محصول
دریافت اشتباه یک محصول

در ضمن باید در نظر داشته باشید که می توانید از خصوصیت های جدید جاوا اسکریپت برای ساده تر شدن کدهایتان استفاده کنید:

export const getProduct = ({ response, params }: RouterCTX) => {

  const product: ProductsInterface | undefined = products.find(

    product => product.id === params.id

  );




  if (product) {

    response.status = 200;

    response.body = {

      success: true,

      data: product,

    };

  } else {

    response.status = 404;

    response.body = {

      success: false,

      msg: "No Product was found",

    };

  }

};

من در اینجا از قابلیت object destructuring در جاوا اسکریپت استفاده کرده ام تا به جای دریافت کل شیء ctx، خصوصیات مورد نیاز خودم از شیء ctx را استخراج کنم. با این کار کدهایمان کمی خواناتر و خلاصه تر می شود بنابراین در ادامه این مقاله نیز از همین روش استفاده خواهم کرد.

کنترلر سوم: اضافه کردن یک محصول جدید

ما در این بخش می خواهیم کنترلر سوم را تعریف کنیم. این کنترلر مسئول اضافه کردن یک محصول جدید به محصولات ما است. ctx.params به ما اجازه می داد به پارامترهای موجود در URL دسترسی داشته باشیم اما اگر بخواهیم به داده های ارسال شده توسط کلاینت (کاربر) دسترسی داشته باشیم چطور؟ آنگاه باید به ctx.request دسترسی داشته باشیم. در نسخه های قبلی oak باید متد body را await می کردید چرا که async بود:

export const addProduct = async ({ request, response }: RouterCTX) => {

  const body = await request.body();

};

اما در نسخه های جدید دیگر اینطور نیست بنابراین نیاز به await نداریم اما هنوز هم کنترلر را به صورت async تعریف می کنم. چرا؟ به دلیل اینکه متد value که مقدار بدنه را به ما می دهد async است و باید آن را await کنیم. از طرفی اگر بخواهیم درون یک تابع از await استفاده کنیم آن تابع باید ناهمگام (async) باشد. حالا می توانیم به شکل زیر این کنترلر را تکمیل کنیم:

export const addProduct = async ({ request, response }: RouterCTX) => {

  const body = request.body({ type: "json" });

  const product = await body.value;




  if (!request.hasBody) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request does not have a body field",

    };

  } else if (body.type !== "json") {

    response.status = 400;

    response.body = {

      success: false,

      msg: "Your request is not in JSON format",

    };

  } else if (product.name && product.description && product.price) {

    product.id = products.length + 1;

    products.push(product);

    response.status = 201;

    response.body = {

      success: true,

      data: product,

    };

  }

};

من ابتدا با متد hasBody بررسی کرده ام که درخواست ارسال شده (request) بدنه داشته باشد. اگر نداشت یک خطای ۴۰۰ را ارسال می کنیم و می گوییم که فیلد body در درخواست وجود ندارد. در مرحله بعدی من می خواهم فقط داده ها را به صورت JSON دریافت کنم بنابراین بررسی کرده ام که body.type یا نوع بدنه حتما JSON باشد و اگر نبود یک خطای ۴۰۰ را ارسال می کنیم و دلیلش را به کاربر می گوییم. در مرحله آخر اگر فیلدهای name و description و price روی بدنه وجود داشت باید آن را به محصولاتمان اضافه کنیم. از آنجایی که با یک پایگاه داده واقعی کار نمی کنیم من id را به صورت دستی ایجاد کرده ام و آن را برابر با تعداد اعضای آرایه products به علاوه ۱ گذاشته ام و با push آن را به آرایه اضافه کرده ام. به عنوان پاسخ نیز محصول اضافه شده را به کاربر برگردانده ایم.

برای تست این کد باید به thunder client برگشته و یک درخواست را به این مسیر ارسال کنیم. در نظر داشته باشید که درخواست ارسالی باید از نوع POST بوده و یک بدنه نیز داشته باشد. برای انجام این کار باید از سربرگ body گزینه JSON را انتخاب می کنم و یک محصول ساده را به عنوان محصول جدید ارسال می کنم:

اضافه کردن یک محصول از طریق API
اضافه کردن یک محصول از طریق API

همانطور که می بینید id برگردانده شده عدد ۵ است بنابراین آیدی مناسب اضافه شده است. حالا بیایید یک دستور GET را به این آدرس ارسال کنیم تا محصولات ارسال شده خودمان را مشاهده کنیم. نتیجه باید به شکل زیر باشد:

{

  "success": true,

  "data": [

    {

      "id": "1",

      "name": "product one",

      "description": "This is the description for product 1",

      "price": 29.99

    },

    {

      "id": "2",

      "name": "product two",

      "description": "This is the description for product 2",

      "price": 39.99

    },

    {

      "id": "3",

      "name": "product three",

      "description": "This is the description for product 3",

      "price": 59.99

    },

    {

      "id": "4",

      "name": "product four",

      "description": "This is the description for product 4",

      "price": 10.99

    },

    {

      "name": "product five",

      "description": "This is the description for product 5",

      "price": 5,

      "id": 5

    }

  ]

}

مدیریت خطا

در حال حاضر کد ما (متد addProduct) یک مشکل بزرگ را دارد. به این قسمت از کد توجه کنید:

const body = request.body({ type: "json" });

من به شما گفتم که متد body بدنه درخواست را استخراج کرده و به ما می دهد اما اگر بدنه ای نداشته باشیم چطور؟ اگر درخواست ارسال شده توسط کلاینت اصلا بدنه ای نداشته باشد یک خطای 500 یا همان internal server error را دریافت می کنیم. شما می توانید این مسئله را با thunder client امتحان کنید؛ مطمئن باشید که بدنه درخواست خالی است و سپس یک درخواست POST به به مسیر اضافه کردن محصول (localhost:5000/api/v1/products) ارسال کنید.

دریافت خطای ۵۰۰ نه تنها برای API ما جالب نیست بلکه باعث گیج شدن کلاینت نیز می شود چرا که اصلا برایش توضیح نداده ایم چه چیزی دلیل خطا بوده است. از طرف دیگر ممکن است خطای ایجاد شده باعث crash شدن (متوقف شدن) سرور ما شود که بسیار بد است. برای حل این مشکل چه باید کرد؟ این مسئله ما را وارد موضوع مدیریت خطا می کند که دو جنبه دارد. جنبه اول، مدیریت خطا در سطح پایین است. یعنی چه؟ یعنی کل دستوراتمان را در یک بلوک try & catch قرار بدهیم:

export const addProduct = async ({ request, response }: RouterCTX) => {

  try {

    const body = request.body({ type: "json" });

    const product = await body.value;




    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

    } else if (body.type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

    } else if (product.name && product.description && product.price) {

      product.id = products.length + 1;

      products.push(product);

      response.status = 201;

      response.body = {

        success: true,

        data: product,

      };

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "There was something wrong with the sent request. Make sure the request has a body field",

      error,

    };

  }

};

در قسمت catch خطای ۴۰۰ (درخواست بد) را ارسال کرده ایم و گفته ایم ساختار درخواست ارسال شده مشکل دارد، لطفا مطمئن شوید که درخواست شما یک body داشته باشد. این یک روش ساده مدیریت خطا می باشد. بدین صورت به جای خطای ۵۰۰، این خطا ارسال می شود و همه چیز مدیریت شده است.

جنبه دوم، مدیریت خطا در سطح بالا است. ما یک error handler یا مدیریت کننده خطا را به عنوان یک middleware به oak پاس می دهیم تا اگر خطایی در قسمتی از برنامه پیش آمد و توسط بلوک های try & catch گرفته نشد، در سطح بالا مدیریت شود. برای این کار به فایل server.ts می رویم و می گوییم:

import { Application } from "./deps.ts";

import router from "./routes.ts";




const app = new Application();




app.use(async (ctx, next) => {

  try {

    await next();

  } catch (err) {

    ctx.response.status = 404;

    ctx.response.body = {

      msg: "Something went wrong...",

      error: err,

    };

  }

});




app.addEventListener("error", evt => {

  // با این کار می توانیم خطاهای پرتاب شده را لاگ کنیم

  console.log(evt.error);

});




app.use(router.allowedMethods());

app.use(router.routes());




console.log("server running on port 5000");

await app.listen({ port: 5000 });

همانطور که می بینید از app.use برای ثبت یک middleware استفاده کرده ایم. این تابع سعی می کند next را انجام بدهد. next باعث می شود یک درخواست از این middleware به middleware های بعدی منتقل شود اما اگر next انجام نشد یعنی قسمتی از برنامه دچار اشکال شده است. در این حالت وارد catch می شویم و به کاربر می گوییم که مشکلی نامعلوم رخ داده است. توجه داشته باشید که ترتیب middleware های شما (ترتیب app.use ها) بسیار مهم است. مثلا همیشه باید error handler خود را قبل از ثبت routes صدا بزنیم تا تمام خطاها را بگیرد، در غیر این صورت خطاهای رخ داده مربوط به route توسط آن مدیریت نخواهند شد.

همچنین من در کد بالا از addEventListener استفاده کرده ام و رویداد error را تشخیص داده ام. پکیج oak این رویداد را به deno اضافه کرده است بنابراین می توانیم به خطاها گوش داده و در صورت بروز خطا آن را در ترمینال log کنیم تا ببینیم خطا دقیقا چه چیزی بوده است. البته وجود این کد در این حالت بی فایده است چرا که هیچ خطایی را پرتاب (throw) نمی کنیم بنابراین اصلا رویداد error اجرا نمی شود اما می خواستم شما را با آن آشنا کنم.

در نهایت کد ما هنوز هم مشکل خاصی دارد! آیا می توانید این مشکل را حدس بزنید؟ ما یک درخواست بدون بدنه را به سمت سرور ارسال کردیم اما سرورمان خطای 500 داد. حالا که آن را در try & catch گذاشته ایم خطای ۴۰۰ می گیریم (کد درون بلوک catch). مشکل اینجاست که شرط if دارای request.hasBody هیچ کاری نمی کند و ما عملا هیچ وقت وارد آن نمی شویم. چرا؟ به دلیل اینکه قبل از بررسی hasBody با متد request.body سعی کرده ایم بدنه را دریافت کنیم! برای حل این مشکل می توان به شکل زیر عمل کرد:

export const addProduct = async ({ request, response }: RouterCTX) => {

  try {

    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    } else if (request.body().type !== "json") {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request is not in JSON format",

      };

      return;

    }

    const body = request.body({ type: "json" });

    const product = await body.value;

    if (product.name && product.description && product.price) {

      product.id = products.length + 1;

      products.push(product);

      response.status = 201;

      response.body = {

        success: true,

        data: product,

      };

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "There was something wrong with the sent request. Make sure the request has a body field",

      error,

    };

  }

};

همانطور که می بینید ابتدا بدنه داشتن درخواست را بررسی کرده ایم، سپس JSON بودن آن را بررسی کرده ایم و اگر هیچ کدام از این موارد صحیح نبودند شروع به استخراج بدنه و محتوای آن می کنیم. در نهایت اگر هر سه فیلد مورد نظر ما در بدنه حضور داشتند، آن را ثبت می کنیم. نکته بسیار مهم تر اینجاست که اگر بدین صورت پاسخی را به کاربر ارسال می کنید حتما return را انجام بدهید تا بقیه کدها اجرا نشوند در غیر این صورت پس از ارسال پاسخ به کاربر بقیه کدها نیز اجرا خواهند شد!

کنترلر چهارم: ویرایش محصولات

مسیر ویرایش یا به روز رسانی محصولات به شکل api/v1/products/:id است بنابراین در کنترلر آن به سه عنصر اصلی نیاز داریم:

  • دریافت پارامترهای URL که در خصوصیت params هستند.
  • دسترسی به شیء درخواست (request) تا داده های ارسال شده توسط کلاینت (محصول جدید) را دریافت کنیم.
  • دسترسی به response تا پاسخ مناسب را به کلاینت ارسال کنیم.

این کنترلر بسیار شبیه به کنترلر دیگر ما برای نمایش یک محصول خاص است اما قبل از آنکه بخواهیم به سراغ کدنویسی برویم باید نکته مهمی را گوشزد کنیم. در حال حاضر ما از پایگاه داده استفاده نمی کنیم و تمام محصولاتمان درون یک آرایه به نام products است. این آرایه را با const تعریف کرده ایم که یعنی ثابت است و قصد ویرایش آن را نداریم. طبیعتا این موضوع صحیح نیست و حالا که به قسمت ویرایش محصولات رسیده ایم، می خواهیم آن را ویرایش کنیم بنابراین در همین ابتدا آن را به let تبدیل کنید:

let products: ProductsInterface[] = [

  {

    id: "1",

    name: "product one",

    description: "This is the description for product 1",

    price: 29.99,

  },

  {

    id: "2",

    name: "product two",

    description: "This is the description for product 2",

    price: 39.99,

  },

  {

    id: "3",

    name: "product three",

    description: "This is the description for product 3",

    price: 59.99,

  },

  {

    id: "4",

    name: "product four",

    description: "This is the description for product 4",

    price: 10.99,

  },

];

حالا که آن را از const به let تغییر داده ایم، می توانیم آن را به سادگی ویرایش کنیم.

منطق نوشتن کنترلر بسیار آسان است به همین دلیل من تمام کد را یکجا در اختیار شما می گذارم و سپس به بررسی بخش های مختلف آن خواهیم پرداخت:

export const updateProduct = async ({

  params,

  request,

  response,

}: RouterCTX) => {

  try {

    const product: ProductsInterface | undefined = products.find(

      product => product.id === params.id

    );




    if (!request.hasBody) {

      response.status = 400;

      response.body = {

        success: false,

        msg: "Your request does not have a body field",

      };

      return;

    }




    if (product) {

      const body = request.body({ type: "json" });

      const newProduct = await body.value;

      const updatedProduct = { ...product, ...newProduct };




      products = products.map(product =>

        product.id === params.id ? updatedProduct : product

      );




      response.status = 200;

      response.body = {

        success: true,

        data: updatedProduct,

      };

    } else {

      response.status = 404;

      response.body = {

        success: false,

        msg: "No Product was found",

      };

    }

  } catch (error) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "There was something wrong with the sent request.",

      error,

    };

  }

};

من در ابتدا سه پارامتر params و request و response را در کنترلر خود دریافت کرده ام. در مرحله بعدی بر اساس id ارسال شده در URL به دنبال محصول مورد نظر می گردم و نتیجه را در متغیر جدیدی به نام product ذخیره می کنم. مثل همیشه نیز یک شرط if را داریم که اگر درخواست، بدنه نداشته باشد ما درخواست را پردازش نمی کنیم.

در مرحله بعدی وجود محصول را بررسی کرده ایم تا اگر وجود داشت عملیات خاصی را انجام بدهیم. در قدم اول بدنه درخواست را دریافت کرده ایم و محصول یافت شده را با محصول جدید ادغام کرده ایم. این کار با استفاده از اپراتور spread (علامت سه نقطه) انجام می شود. ما این محصول جدید را در متغیری به نام updatedProduct ذخیره کرده ایم. مزیت استفاده از اپراتور spread این است که کاربر نیازی به ارسال تمام فیلدهای id و name و description و price ندارد بلکه می تواند فقط یک فیلد خاص (مثلا name) را برایمان ارسال کند و ما نیز فقط فیلد name را ویرایش کرده ایم.

در قدم دوم از متد جاوا اسکریپتی map استفاده می کنیم که یک callback را گرفته و آن را روی تک تک عناصر یک آرایه اجرا می کند و سپس آرایه جدیدی را برمی گرداند. من گفته ام اگر id محصول ما برابر با id پاس داده شده از params باشد باید updatedProduct را به عنوان محصول جدید قرار بدهیم. در نهایت نیز محصول جدید را به کاربر برگردانده ایم.

حالا برای تست یک درخواست PUT را به آدرس localhost:5000/api/v1/products/1 ارسال می کنیم. بدنه این درخواست می تواند به هر شکلی که دوست دارید باشد. در عین حال نتیجه باید موفقیت آمیز باشد:

به روز رسانی یک محصول از طریق API
به روز رسانی یک محصول از طریق API

همانطور که می بینید من فقط id و name را ارسال کرده ام بنابراین فقط این دو فیلد ویرایش شده اند. برای اطمینان از موفقیت آمیز بودن این عملیات، یک درخواست GET را برای دریافت تمام محصولات ارسال می کنیم. نتیجه باید بدین شکل باشد:

{

  "success": true,

  "data": [

    {

      "id": 200000,

      "name": "NEW NAME",

      "description": "This is the description for product 1",

      "price": 29.99

    },

    {

      "id": "2",

      "name": "product two",

      "description": "This is the description for product 2",

      "price": 39.99

    },

    {

      "id": "3",

      "name": "product three",

      "description": "This is the description for product 3",

      "price": 59.99

    },

    {

      "id": "4",

      "name": "product four",

      "description": "This is the description for product 4",

      "price": 10.99

    }

  ]

}

حالا مطمئن می شویم که همه چیز به درستی انجام شده است.

کنترلر پنجم: حذف محصولات

آخرین کنترلر ما کنترلر حذف محصولات است. اگر یادتان باشد مسیر حذف محصولات api/v1/products/:id بود بنابراین باید یک درخواست DELETE را به این مسیر ارسال کنیم:

export const deleteProduct = ({ params, response }: RouterCTX) => {

  try {

    const product: ProductsInterface | undefined = products.find(

      product => product.id === params.id

    );




    if (!product) {

      response.status = 404;

      response.body = {

        success: false,

        msg: `No product with id=${params.id} was found`,

      };

      return;

    }




    products = products.filter(product => product.id !== params.id);

    response.status = 200;

    response.body = {

      success: true,

      msg: `Product with id=${params.id} deleted successfully`,

    };

  } catch (error) {

    response.status = 400;

    response.body = {

      success: false,

      msg: "There was something wrong with the sent request.",

      error,

    };

  }

};

من در ابتدا بررسی کرده ام که آیا محصولی با id پاس داده شده داریم یا خیر؟ اگر چنین محصولی وجود نداشته باشد یک خطای ۴۰۴ را ارسال می کنیم و می گوییم چنین محصولی وجود ندارد. از طرف دیگر در صورتی که محصول را پیدا کرده باشیم از متد filter استفاده می کنیم. این متد یک callback را گرفته و آن را روی تک تک عناصر آرایه ایجاد می کند. هر عنصری که شرط مورد نظر را نداشته باشد فیلتر می شود (از آرایه حذف می شود) و سپس یک آرایه جدید با اعضای آپدیت شده برگردانده می شود. توجه داشته باشید که filter مانند map به آرایه اصلی دست نمی زند بلکه یک آرایه جدید برمی گرداند. من در شرط خود عنوان کرده ام که اگر id محصولی با id ارسال شده از کلاینت برابر نبود آن را نگه می داریم اما اگر برابر بود آن را فیلتر (حذف) می کنیم.

برای تست این کنترلر یک درخواست DELETE را به آدرس localhost:5000/api/v1/products/1 ارسال می کنیم تا محصول اول حذف شود:

حذف یک محصول از طریق API
حذف یک محصول از طریق API

همانطور که می بینید id محصول حذف شده را در پاسخ به کاربر نشان داده ایم. حالا اگر یک درخواست GET را ارسال کرده و تمام محصولات را دریافت کنید، متوجه می شوید که محصول اول حذف شده است:

{

  "success": true,

  "data": [

    {

      "id": "2",

      "name": "product two",

      "description": "This is the description for product 2",

      "price": 39.99

    },

    {

      "id": "3",

      "name": "product three",

      "description": "This is the description for product 3",

      "price": 59.99

    },

    {

      "id": "4",

      "name": "product four",

      "description": "This is the description for product 4",

      "price": 10.99

    }

  ]

}

بنابراین همه چیز موفقیت آمیز بوده است.

سخن آخر

به پایان این مقاله رسیده ایم. امیدوارم با مطالعه این مقاله،‌ deno و فریم ورک oak را یاد گرفته باشید. این پروژه یک پروژه بسیار ساده بود و قطعا همیشه جای پیشرفت و یادگیری بیشتر وجود دارد. من در آینده نزدیک با استفاده از همین پروژه مقاله ای می نویسم و در آن به شما نشان می دهم که چطور می توانید از JWT یا JSON Web Tokens برای احراز هویت (authentication) در deno استفاده کنید.

دانلود پروژه تکمیل شده

نویسنده شوید

دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.