ถ้าจะเปลี่ยนจาก Node.js มา Deno เริ่มยังไง?
Tags: node, deno
Published: 2020-12-24 11:05:13

> deno v1.6.2

ใกล้จะสิ้นปีแล้ว ปีหน้ามีโปรเจคใหม่ๆ รออยู่เพียบ วันนี้เลยมาลองดูหน่อยว่า deno พร้อมที่จะเอามาแทน node.js ได้เลยรึยัง โดยจะเขียนโค้ดเปรียบเทียบระหว่าง Deno กับ Node.js ไว้ด้วย

##Content

1. deno คืออะไร
2. การติดตั้ง
3. ลองสร้างโปรเจคแรก
4. เจาะลึกการทำงานของ deno
5. สร้าง REST APIs
6. สร้าง Docker Image
7. สรุปใช้ หรือไม่ใช้

###1. Deno คืออะไร

[deno](https://deno.land/) นั้นเกิดจาก node.js นั้นถูกมาว่ายังไม่จุดอ่อนอยู่หลายๆ จุด จึงนำเอามาเขียนใหม่เพื่อกำจัดจุดอ่อนเหล่านั้น ตัวอย่างเช่น

| Deno                                            |                                           Node.js |
| ----------------------------------------------- | ------------------------------------------------: |
| รองรับทั้ง TypeScript และ JavaScript เลย        | รองรับแค่ JavaScript แต่สามารถใช้ TS compiler ได้ |
| พัฒนาบน Modern JS Features เช่น Promise         |                 Core Modules ยังมี JS แบบเก่าอยู่ |
| ใช้ ES Module (`import`)                        |                          ใช้ CommonJS (`require`) |
| import โดยใช้ URL (ไม่มี `package.json`)        |                       มี `npm` และ `package.json` |
| การรันต้องต้องระบุ Permissions เช่น `allow-net` |                         ไม่สนใจเรื่อง Permissions |

###2. การติดตั้ง

Shell (Mac, Linux):

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

PowerShell (Windows):

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

###3. ลองสร้างโปรเจคแรก

- สร้าง app.ts
- ลองเขียนโค้ด typescript

```ts
let message: string;

message = 'Hi there!';

console.log(message);
```

- ทดสอบรัน

```bash
$deno run app.ts
Hi there!
```

###4. เจาะลึกการทำงานของ deno

ถ้าดูจากหน้าเวบของ [deno](https://deno.land/) จะเห็นว่า Runtime API, Standard Library และ Third Party Modules แต่ละอันคืออะไรมาดูกัน

####Runtime API

[Runtime API](https://doc.deno.land/builtin/stable) คือ built-in utilities ที่ทาง Deno เตรียมไว้ให้ซึ่งสามารถเรียกใช้งานได้เลย เช่น `Deno.writeFile()` ไม่ต้อง import เข้ามาก่อนเหมือน Node.js

> สำหรับ VS Code
> ให้ลง Plugin ชื่อ [Deno](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno) เพื่อช่วยให้เขียนโค้ดสะดวกขึ้น

#####ตัวอย่าง โปรแกรมเขียน Text file

######Deno

```ts
// app.ts
let message: string;

message = 'Hi there!';

// เนื่องจาก Deno.writeFile รับค่าเป็น Uint8Array จึงต้องแปลงค่าก่อน
const encoder = new TextEncoder();
const data = encoder.encode(text);

// เป็น Promise
Deno.writeFile('message.txt', data).then(() => {
  console.log('Wrote to file!');
});
```

- ทดสอบรัน `$deno run app.ts` จะพบว่ามี Error เกี่ยวกับ Permission เนื่องจาก Deno จะมี Security มาตั้งแต่แรก
- แก้ไขโดยรัน `$deno run --allow-write app.ts` เพื่ออนุญาตให้เขียนไฟล์ได้

######Node.js

```js
// app.js
const fs = require('fs');

const message = 'Hi there!';

fs.writeFile('node-message.txt', message).then(() => {
  console.log('Wrote to file!');
});
```

- รัน `$node app.js` ได้เลย

####Standard Library

[Standard Library](https://deno.land/std) คือ lib ที่ทาง Core Team ของ Deno สร้างขึ้นมาให้เพื่อให้ใช้งานง่ายขึ้น โดยการใช้งานจะต้อง `import` เข้ามาก่อน

#####ตัวอย่าง ทดลองสร้าง HTTP Server

######Deno

```ts
// app.ts
import { serve } from 'https://deno.land/std@0.81.0/http/server.ts';

const server = serve({ port: 8000 });
console.log('HTTP server listening on http://localhost:8000/');

for await (const req of server) {
  req.respond({ body: 'Hello World\n' });
}
```

> การ import จะใช้วิธีดึงมาจาก URL ซึ่งเมื่อดาวน์โหลดมาแล้วจะถูก cache ไว้ที่เครื่องของเรา

- รันโค้ด `deno run --allow-net app.ts`

> การรันโค้ดจำเป็นที่จะต้องอนุญาตให้ใช้งาน network ก่อน

######Node.js

```js
// app.js
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('Hello World from Nodejs');
});

server.listen(3000, () => {
  console.log('HTTP server listening on http://localhost:3000/');
});
```

- รัน `$node app.js`

####Third Party Modules

[Third Party Modules](https://deno.land/x) คือ lib ที่ทาง Community Teams สร้างขึ้นมาให้เพื่อให้ใช้งานง่ายขึ้น โดยการใช้งานจะต้อง `import` เข้ามา

เนื่องจาก deno ไม่มี package management จึงไม่มี npm และ package.json การ `import` จะ `import` มาจาก url

#####ตัวอย่างใช้ [oak](https://deno.land/x/oak@v6.4.0) framework

```ts
// app.ts
import { Application } from 'https://deno.land/x/oak@v6.4.0/mod.ts';

const app = new Application();

app.use((ctx) => {
  ctx.response.body = 'Hello World!';
});

await app.listen({ port: 8000 });
```

- รัน `$ deno run --allow-net app.ts`

> deno จะทำการ cache remote file เอาไว้ที่ local
> ถ้าต้องการให้ deno ทำการ re-fetch remote file ให้ใส่ `--reload` เช่น `$ deno run --reload --allow-net app.ts`
> สามารถกำหนด version ได้ โดยใส่ @version เช่น `import { Application } from "https://deno.land/x/oak@v6.4.0/mod.ts";`

####Custom Modules

เนื่องจาก deno ใช้ ES Module ดังนั้นจะใช้วิธีการ `import` แทนการ `require`

#####ตัวอย่าง

######Deno

- ต้อง export แบบ ES Module

```ts
// greeting.ts
export const greeting = (name: String) => {
  return `Hi ${name}`;
};
```

- ใช้การ import

```ts
// app.ts
import { greeting } from './greeting.ts';

console.log(greeting('Ball'));
```

- รันโค้ด `deno run app.ts`

######Node.js

- ต้อง export แบบ CommonJS

```js
// greeting.js
exports.greeting = (name) => {
  return `Hi ${name}`;
};
```

- ใช้การ require

```js
// app.js
const { greeting } = require('./greeting');

console.log(greeting('Ball'));
```

- รัน `$node app.js`

###5. สร้าง REST APIs

ในส่วนจะมาลองสร้าง CRUD REST APIs ง่ายๆ โดยจะเปรียบเทียบไปทีละขั้นตอนระหว่าง Node.js ที่ใช้ [Express](https://expressjs.com/) กับ Deno ที่ใช้ [Oak](https://deno.land/x/oak@v6.4.0)

###5.1 สร้าง HTTP Server

เริ่มสร้าง HTTP Server ง่ายๆ

#####Node.js

- ต้องติดตั้ง `express` ก่อน `npm install express`
- สร้างไฟล์ `app.js`

```js
// app.js
const express = require('express');

const app = express();

app.use((req, res, next) => {
  res.send('Hello World from Node.js');
});

app.listen(3000);
```

#####Deno

- Deno สามารถใช้งาน Oak ได้โดยไม่ต้องติดตั้งก่อน
- สร้างไฟล์ `app.ts`

```ts
// app.ts
import { Application } from 'https://deno.land/x/oak@v6.4.1/mod.ts';

const app = new Application();

app.use((ctx) => {
  ctx.response.body = 'Hello World from Deno';
});

await app.listen({ port: 3000 });
```

###5.2 สร้าง Router

สร้าง route `/todos` ขึ้นมา เพื่อทำ CRUD แบบง่ายๆ

#####Node.js

- สร้างไฟล์ `routes/todos.js`

```js
// routes/todos.js
const express = require('express');

const router = express.Router();

let todos = [];

// C - Create
router.post('/todos', (req, res, next) => {
  res.send('create');
});

// R - Read
router.get('/todos', (req, res, next) => {
  res.json({ todos: todos });
});

// R - Read by Id
router.get('/todos/:id', (req, res, next) => {
  res.send('read by id');
});

// U - Update by Id
router.put('/todos/:id', (req, res, next) => {
  res.send('update');
});

// D - Delete by Id
router.delete('/todos/:id', (req, res, next) => {
  res.send('delete');
});

module.exports = router;
```

- แก้ไขไฟล์ app.js เพื่อเรียกใช้งาน route ที่สร้างมา

```js
// app.js
const express = require('express');

// เพิ่มบรรทัดนี้
const todoRoutes = require('./routes/todos');

const app = express();

// เพิ่มบรรทัดนี้
app.use(todoRoutes);

app.listen(3000);
```

#####Deno

- สร้างไฟล์ `routes/todos.ts`

```ts
// routes/todos.ts
import { Router } from "https://deno.land/x/oak@v6.4.1/mod.ts";

const router = new Router();

// เนื่องจากใช้ TypeScript จำเป็นต้องระบุ type ของ todo
interface Todo {
  id: string;
  text: string;
}

let todos: Todo[] = [];

router.get('/todos', (ctx) => {
  ctx.response.body = { todos: todos };
});

// C - Create
router.post('/todos', (ctx) => {
  ctx.response.body = 'create';
});

// R - Read
router.get('/todos', (ctx) => {
  ctx.response.body = { todos: todos };
});

// R - Read by Id
router.get('/todos/:id', (ctx) => {
  ctx.response.body = 'read by id';
});

// U - Update by Id
router.put('/todos/:id', ((ctx) => {
  ctx.response.body = 'update';
});

// D - Delete by Id
router.delete('/todos/:id', (ctx) => {
  ctx.response.body = 'delete';
});

export default router;
```

- แก้ไขไฟล์ app.ts เพื่อเรียกใช้งาน route ที่สร้างมา

```ts
// app.ts
import { Application } from 'https://deno.land/x/oak@v6.4.1/mod.ts';

// เพิ่มบรรทัดนี้
import todosRoutes from './routes/todos.ts';

const app = new Application();

// เพิ่มบรรทัดนี้
app.use(todosRoutes.routes());
app.use(todosRoutes.allowedMethods());

await app.listen({ port: 3000 });
```

###5.3 การอ่านค่าจาก Body

สำหรับการสร้างข้อมูลใหม่นั้นโดยปกติจะส่งข้อมูลมาในรูปแบบของ JSON ซึ่งจะถูกแนบมากับ body ของ method POST ดังนั้นเราจะอ่านค่าออกมาจาก body ก่อน แล้วนำไปใช้งานต่อ

#####Node.js

> เนื่องจาก [Express](https://expressjs.com/) ตัวมันเองไม่สามารถอ่านค่าจกา body ได้ จึงจำเป็นที่จะต้องใช้ middleware ที่ชื่อว่า `body-parser` มาช่วยแปลงค่าให้ก่อน โดยต้องติดตั้ง `npm install body-parser` จึงจะสามารถสามารถอ่านค่าจาก body ได้จาก `req.body`

- แก้ไขที่ไฟล์ app.js

```js
// app.js
const express = require('express');
// เพิ่มบรรทัดนี้
const bodyParser = require('body-parser');

const todoRoutes = require('./routes/todos');

const app = express();

// เพิ่มบรรทัดนี้
app.use(bodyParser.json()); // for parsing application/json

app.use(todoRoutes);

app.listen(3000);
```

- แก้ไฟล์ `routes/todos.js` โดยต้องแก้ที่ `router.post`

```js
// routes/todos.js
router.post('/todos', (req, res, next) => {
  const newTodo = {
    id: new Date().toISOString(),
    text: req.body.text,
  };

  todos.push(newTodo);

  res.status(201).json({
    message: 'Todo created!',
    todo: newTodo,
  });
});
```

#####Deno

- แก้ไฟล์ `routes/todos.ts` โดยต้องแก้ที่ `router.post`

```ts
// routes/todos.ts

router.post('/todos', async (ctx) => {
  // ตรวจสอบว่ามี body หรือไม่
  if (ctx.request.hasBody) {
    // สามารถใส่ option type เพื่อระบุประเภทของ body ที่ส่งมา
    const result = ctx.request.body({ type: 'json' });
    // ประเภท json -> result.value จะเป็น promise
    const body = await result.value;

    const newTodo: Todo = {
      id: new Date().getTime().toString(),
      text: body.text,
    };

    todos.push(newTodo);

    ctx.response.status = 201;
    ctx.response.body = { message: 'Created todo!', todo: newTodo };
  }
});
```

###5.4 การอ่านค่าจาก Path Parameters

Path Parameters คือ url endpoint ที่ใช้ดึงข้อมูลตามที่ระบุไปใน url เช่น `/todos/:id` ซึ่ง `:id` คือค่าที่เปลี่ยนแปลงได้ ตัวอย่าง ต้องการอ้างถึง id ที่ 1 จะเรียกไปที่ url endpoint `/todos/1` หรือ ถ้าต้องการอ้างถึง id ที่ 2 จะเรียกไปที่ url `/todos/2` เป็นต้น

ดังนั้นจะนำมาใช้ในการทำ R (Read), U (Update) และ D (Delete) กับข้อมูลเฉพาะ id ที่ต้องการ

#####Node.js

- Express สามารถอ่านค่า Path Parameters ได้จาก req.params โดยชื่อจะต้องกันกับที่ระบไว้ที่ url endpoint เช่น ค่าของ id จะอ่านได้จาก `req.params.id`

- แก้ไฟล์ `routes/todos.js` `/todos/:id`

```js
// routes/todos.js
router.get('/todos/:id', (req, res, next) => {
  const { id } = req.params;

  const todoIndex = todos.findIndex((todo) => {
    return todo.id === id;
  });

  res.status(200).json({ todo: todos[todoIndex] });
});

router.put('/todos/:id', (req, res, next) => {
  const { id } = req.params;

  const todoIndex = todos.findIndex((todo) => {
    return todo.id === id;
  });

  todos[todoIndex] = { id: todos[todoIndex].id, text: req.body.text };

  res.status(200).json({ message: 'Updated todo!' });
});

router.delete('/todos/:id', (req, res, next) => {
  const { id } = req.params;

  todos = todos.filter((todo) => todo.id !== id);

  res.status(200).json({ message: 'Todo deleted!' });
});
```

#####Deno

- Oak กำหนด url เหมือน Express แต่จะอ่านค่าออกมาจาก `ctx.params`
- แก้ไฟล์ `routes/todos.ts`

```ts
// routes/todos.ts
router.get('/todos/:id', (ctx) => {
  const { id } = ctx.params;

  const todoIndex = todos.findIndex((todo) => {
    return todo.id === id;
  });

  ctx.response.body = { todo: todos[todoIndex] };
});

router.put('/todos/:id', async (ctx) => {
  if (ctx.request.hasBody) {
    const result = ctx.request.body({ type: 'json' });
    const body = await result.value;

    const id = ctx.params.id;

    const todoIndex = todos.findIndex((todo) => {
      return todo.id === id;
    });

    todos[todoIndex] = { id: todos[todoIndex].id, text: body.text };
    ctx.response.body = { message: 'Updated todo' };
  }
});

router.delete('/todos/:id', (ctx) => {
  const { id } = ctx.params;

  todos = todos.filter((todo) => todo.id !== id);

  ctx.response.body = { message: 'Deleted todo' };
});
```

###5.5 รับค่าจาก Query string

ถ้าต้องการค้นหา todos จากคำที่ต้องการ จะส่งค่าไปคำค้นหาไปกับ Query string เช่น `/todos?q=deno` ตัวอย่างโค้ด

#####Node.js

- Express สามารถอ่านค่า Query string ได้จาก req.query โดยค่าของ q จะอ่านได้จาก `req.query.q`

- แก้ไฟล์ `routes/todos.js`

```js
// routes/todos.js

// แก้ให้รับค่า q มาค้นหาได้
router.get('/todos', (req, res, next) => {
  const { q } = req.query;

  if (q) {
    const results = todos.filter((todo) => {
      return todo.text.toLowerCase().includes(q.toLowerCase());
    });
    return res.json({ todos: results });
  }
  res.json({ todos: todos });
});
```

#####Deno

- Oak จะต้องใช้ฟังก์ชัน `helpers.getQuery()` มาช่วย
- แก้ไฟล์ `routes/todos.ts`

```ts
// routes/todos.ts

// เพิ่ม import
import { getQuery } from 'https://deno.land/x/oak@v6.4.1/helpers.ts';

// แก้ให้รับค่า q มาค้นหาได้
router.get('/todos', (ctx) => {
  const { q } = getQuery(ctx);

  if (q)
    const results = todos.filter((todo) => {
      return todo.text.toLowerCase().includes(q.toLowerCase());
    });
    ctx.response.body = { todos: results };
    return;
  }
  ctx.response.body = { todos: todos };
});
```

###5.6 สร้าง Middleware

เราสามารถสร้าง middleware ขึ้นมาเพื่อให้ทำงานบางอย่างการที่เข้าสู่ route ที่เรียกมาจริงๆ ได้

#####Node.js

- Express สร้าง middleware ได้จาก `app.use((req, res, next) => {next()})` โดยเมื่อเรียกใช้งาน `next()` จะเป็นการส่งไปทำที่ middleware ถัดไป

- แก้ไฟล์ `app.js`

```js
// app.js

app.use(bodyParser.json());

// เพิ่มบรรทัดนี้
app.use((req, res, next) => {
  console.log('Middleware A');
  next();
});

// เพิ่มบรรทัดนี้
app.use((req, res, next) => {
  console.log('Middleware B');
  next();
});

app.use(todoRoutes);
```

#####Deno

- Oak สร้าง middleware ได้จาก `app.use((ctx, next) => {next()})` โดยเมื่อเรียกใช้งาน `next()` จะเป็นการส่งไปทำที่ middleware ถัดไป

- แก้ไฟล์ `app.js`

```ts
// app.ts
const app = new Application();

// เพิ่มบรรทัดนี้
app.use(async (ctx, next) => {
  console.log('Middleware A');
  next();
});

// เพิ่มบรรทัดนี้
app.use(async (ctx, next) => {
  console.log('Middleware B');
  next();
});

app.use(todosRoutes.routes());
```

> จากโค้ดข้างบนจะแสดงคำว่า `Middleware A` แล้วแสดง `Middleware B` ก่อนที่จะเข้าไปทำงานที่ todo route ที่เรียกมา

- ซึ่งทั้ง Express และ Oak จะทำงานแบบ Stack คือ เมื่อมี request เข้ามาจะทำงานลงไปตามลำดับ และเมื่อตอบ respone กลับไปจะย้อนกลับเข้ามาใน middleware จากล่างขึ้นบน ตัวอย่างทำ logger แสดงระยะเวลาการทำงานของแต่ละ route

#####Node.js

- แก้ไฟล์ `app.js`

```js
// app.js

app.use(bodyParser.json());

// เพิ่มบรรทัดนี้
// Logger
app.use(async (req, res, next) => {
  const start = Date.now();
  await next();
  const rt = Date.now() - start;
  console.log(`${req.method} ${req.url} - ${rt} ms`);
});

app.use(todoRoutes);
```

#####Deno

- แก้ไฟล์ `app.ts`

```ts
// app.ts
const app = new Application();

// เพิ่มบรรทัดนี้
// Logger
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const rt = Date.now() - start;
  console.log(`${ctx.request.method} ${ctx.request.url} - ${rt} ms`);
});

app.use(todosRoutes.routes());
```

###5.7 Enable CORS

#####Node.js

- ต้องติดตั้ง `npm install cors` ก่อน

- แก้ไฟล์ `app.js`

```js
// app.js
const express = require('express');
const bodyParser = require('body-parser');
// เพิ่มบรรทัดนี้
const cors = require('cors');

const todoRoutes = require('./routes/todos');

const app = express();

// เพิ่มบรรทัดนี้
app.use(cors()); // Enable All CORS Requests

app.use(bodyParser.json());
```

#####Deno

- ต้อง import `oakCors` มาใช้งาน

- แก้ไฟล์ `app.ts`

```ts
// app.ts
import { Application } from 'https://deno.land/x/oak@v6.4.1/mod.ts';
// เพิ่มบรรทัดนี้
import { oakCors } from 'https://deno.land/x/cors@v1.2.1/mod.ts';

import todosRoutes from './routes/todos.ts';

const app = new Application();

// เพิ่มบรรทัดนี้
app.use(oakCors()); // Enable All CORS Requests

// Logger
```

###6. สร้าง Docker Image

ตัวอย่างการสร้าง Dockerfile ของทั้ง Nodejs และ Deno

> Deno version 1.6.1 สามารถ build เป็นไฟล์ binary ได้แล้ว

####Node.js

- สร้างไฟล์ Dockerfile

```dockerfile
FROM node:14-alpine

ENV NODE_ENV=production

WORKDIR /usr/app

COPY ./package*.json ./

RUN npm ci && \
    npm cache clean --force

COPY ./src ./src

CMD node ./src/app.js
```

- Build Docker Image จากคำสั่ง `docker image build -t api-todo-express .`

- Run จากคำสั่ง `docker container run -p 3000:3000 api-todo-express`

####Deno

- สร้างไฟล์ Dockerfile

```dockerfile
FROM hayd/deno:alpine-1.6.2

WORKDIR /usr/app

COPY ./src ./src

CMD deno run --allow-net src/app.ts
```

- Build Docker Image จากคำสั่ง `docker image build -t api-todo-deno .`

- Run จากคำสั่ง `docker container run -p 3000:3000 api-todo-deno`

###7. สรุปใช้ หรือไม่ใช้

ส่วนตัวมองว่า Deno นั้นยังใหม่อยู่ มี Bug อยู่เยอะ และที่สำคัญ Ecosystem ยังไม่เยอะเท่า Node.js ส่วน Node.js นั้นสร้างมานานแล้วไม่มี Major Bugs และ Ecosystem ก็แข็งแรงกว่า

สรุปว่า ปีหน้าก็ยังขึ้นโปรเจคใหม่ด้วย Node.js ต่อไป ส่วน Deno คงเอามาใช้ทำพวก side-project เอาครับ ^\_^