AdonisJS V6 101

Netipun "Nae" Jiwjaroen
Abbon Corporation
Published in
7 min readFeb 11, 2024

Typescript Framework ขั้นเทพที่ดีต่อใจผู้ใช้งาน

Intro

กราบสวัสดีท่านผู้อ่านทุกท่านครับ วันนี้เรากลับมาพบกันอีกครั้งกับบทความด้าน Tech ว่าด้วยเรื่องของ Framework ตัวหนึ่งในภาษา Typescript ที่เพิ่งถูกอัพเดท Version ไปเมื่อช่วงต้นเดือนก.พ. 67 ที่ผ่านมานี้

https://adonisjs.com/

ดังนั้นในฐานะผู้ที่ใช้งานมันมาตั้งแต่ Version แรกสุดอย่างผมก็ถึงเวลาต้องมา Run วงการอีกครั้ง โดยในบทความนี้สิ่งที่เราจะทำคือ

  • สร้าง API server
  • สร้าง Model, Schema
  • Seed data
  • ทำ CRUD endpoint
  • ลองยิง API กันดู

มันดียังไง ทำไมควรเปลี่ยนมาใช้มัน?

เปิด Intro มาแล้วก็กลัวผู้อ่านจะขี้เกียจอ่านต่อเพราะมันดูบ้านๆเบๆมาก ก็อาจจะเกิดคำถามในใจกันว่า “ทำไมตูจะต้องเปลี่ยนมาใช้มัน(วะ)” ผมก็เลยสรุปให้สั้นๆตามนี้ก่อนที่จะไปลุยแล้วจะได้เห็นด้วยตาว่า

  • เร็ว (ปั้น API ได้ไว)
  • แรง (พอๆกับ Fastify)
  • ครบ (ไม่ต้องไล่หา Lib ที่จะใช้ มันมีมาให้พร้อม)
  • ง่าย (ติดตั้งปุ๊บเริ่มงานได้ปั๊บแทบไม่ต้อง Config อะไร)
  • เรียบร้อย (มีโครงสร้างไฟล์ชัดเจนตั้งแต่เริ่ม)

ไม่เชื่อก็ตามอ่านดูสิครับ 😆

สร้าง API Server

ก่อนจะทำอะไรก็เช็ค Node version กันก่อนว่า ≥ 20.6 หรือยัง ถ้ายังก็ไปจัดการซะ

node -v
# v20.11.0

สร้าง Project กัน โดยผมต้องการใช้ Access tokens ในภายหลังและใช้ Database เป็น PostgreSQL ด้วยคำสั่งดังนี้

npm init adonisjs@latest -- -K=api --auth-guard=access_tokens --db=postgres
ผลลัพธ์ของการใช้คำสั่ง
prompt ที่เกิดหลังจากใช้คำสั่ง

เสร็จก็เข้าไปเปิดขึ้นมาดูแล้วแก้ .env ได้เลยเป็นอันเสร็จการ Init project อย่างรวดเร็ว

cd adonis-101/
code .
ต่อ database เรียบร้อย

แต่! ยังไม่หมด เพราะ Style ก็เป็นสิ่งสำคัญ ดังนั้นอย่าลืมจัดการ Prettier, ESLint, TSConfig ให้เรียบร้อยก่อนด้วย

npm i -D @adonisjs/tsconfig

npm i -D typescript ts-node @swc/core

npm i -D @adonisjs/prettier-config

npm i -D prettier

npm i -D @adonisjs/eslint-config

npm i -D eslint

อย่าลืมสร้างไฟล์ .prettierignore

# .prettierignore
build
node_modules

เท่านี้ก็เรียบร้อยครับ ไป Code กันได้เลย

สร้าง Model, Schema

ผมออกแบบ Diagram ของ Database ไว้ง่ายๆตามนี้ (ส่วน users ปล่อยไว้ก่อนเดี๋ยวค่อยทำ part ต่อไป)

จะเห็นได้ว่าเรามีตาราง owners กับ pets ซึ่งเราจะต้อง

  • สร้าง Model
  • เขียน Schema
  • Run migration

จัดไปสิครับน้อนๆ ใช้คำสั่งสร้าง Model ตามนี้ได้เลยโดยจะมี Flag -m เพื่อสั่งให้สร้าง Migration schema ให้ด้วยนะ

node ace make:model owners -m
node ace make:model pets -m
ผลลัพธ์ของคำสั่ง

แล้วเราก็ไปเขียน Model และ Schema ตาม Design

// app/models/owner.ts
import { DateTime } from 'luxon'
import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import Pet from '#models/pet'

export default class Owner extends BaseModel {
@column({ isPrimary: true })
declare id: number

@column()
declare firstName: string

@column()
declare lastName: string

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime

@hasMany(() => Pet, {
localKey: 'id',
foreignKey: 'ownerId',
})
declare pets: HasMany<typeof Pet>
}
// app/models/pet.ts
import { DateTime } from 'luxon'
import { BaseModel, column, hasOne } from '@adonisjs/lucid/orm'
import type { HasOne } from '@adonisjs/lucid/types/relations'
import Owner from '#models/owner'

export default class Pet extends BaseModel {
@column({ isPrimary: true })
declare id: number

@column()
declare ownerId: number

@column()
declare name: string

@column()
declare type: string

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime

@hasOne(() => Owner, {
localKey: 'ownerId',
foreignKey: 'id',
})
declare owner: HasOne<typeof Owner>
}
// database/migrations/1707376400552_create_owners_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'owners'

async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('first_name')
table.string('last_name')

table.timestamp('created_at')
table.timestamp('updated_at')
})
}

async down() {
this.schema.dropTable(this.tableName)
}
}
// database/migrations/1707376407563_create_pets_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'pets'

async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('owner_id').unsigned().references('owners.id').onDelete('CASCADE')
table.string('name')
table.string('type')

table.timestamp('created_at')
table.timestamp('updated_at')
})
}

async down() {
this.schema.dropTable(this.tableName)
}
}

สั่ง Run migration ได้เลยจ้า

node ace migration:run
ได้ owners, pets และตารางอื่นๆที่ขอติดไว้ก่อน

ในเมื่อ Table พร้อม หลีดพร้อม สาม…สี่…. ไปยัด Data มาทดลองแบบคนดีมี Style เขาทำกันโลดหมู่เฮา 🥳

Seed data

เราต้องการจะสร้างไฟล์ Seed ของ Owner, Pet ขึ้นมา ก็ใช้ CLI ได้เลยไม่ยุ่งยาก

node ace make:seeder Owner
node ace make:seeder Pet

ผมอยากจะสร้างตัวอย่างสัก Table ละ 100 records ไอ้ครั้นจะไปเขียนเอง ผูก Relation เองก็ขี้เกียจเหลือเกิน เอาเป็นว่าเดี๋ยวเขียน Factory ไว้ก็แล้วกัน

ท่านอาจจะมีคำถามว่า “แล้ว Factory มันคืออิหยังล่ะสู?”
ให้นึกภาพว่ามันคือโรงงานที่จะปั้น Model ออกมาให้เราใช้ โดยที่เราจะได้เขียน Code ให้น้อยที่สุดนั่นเอง (ก็ผมเป็นคนขี้เกียจ)

ผมก็ใช้ CLI เหมือนเดิม

node ace make:factory Owner
node ace make:factory Pet
file ที่ถูก generate มาจาก CLI ของทั้ง seeders, factories

สร้าง Factory ของ Owner กับ Pet พร้อมทั้งเขียน Relation ไว้เพราะเราจะเอาไป Seed งานนี้หมูมากเนื่องจาก AdonisJS มี Faker ติดมาให้พร้อมแล้ว

// database/factories/owner_factory.ts
import factory from '@adonisjs/lucid/factories'
import Owner from '#models/owner'
import { PetFactory } from '#database/factories/pet_factory'

export const OwnerFactory = factory
.define(Owner, async ({ faker }) => {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
}
})
.relation('pets', () => PetFactory)
.build()
// database/factories/pet_factory.ts
import factory from '@adonisjs/lucid/factories'
import Pet from '#models/pet'

export const PetFactory = factory
.define(Pet, async ({ faker }) => {
return {
name: faker.animal.cat(),
type: 'cat',
}
})
.build()

ผมต้องการสร้าง Owner 100 คน ให้มี Pets เป็นแมวคนละสองตัวก็เขียนแค่นี้พอ

// database/seeders/owner_seeder.ts
import { OwnerFactory } from '#database/factories/owner_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
async run() {
await OwnerFactory.with('pets', 2).createMany(100)
}
}

Run seed กันได้เลย โดยใช้ Flag -i เพื่อให้เราสามารถเลือกไฟล์ที่เราจะ Seed ได้โดยง่าย

node ace db:seed -i
❯ Select files to run · database/seeders/owner_seeder
❯ completed database/seeders/owner_seeder
pets ที่ถูก generate พร้อมผูก relation ให้เรียบร้อย
owners ที่ถูก generate

ไม่ต้องหยุมหัว QA กันแล้วนะจ๊ะเวลาโดนขอให้ Seed อะไร เพราะเราเขียน Factory ไว้ให้พร้อมแล้ว จะเอาไป Seed อะไรก็ทำได้โดยง่ายนั่นเอง…เอง….เอง (Echo แบบ TV Champion)

ทำ CRUD endpoint

โดยปกติแล้วเราก็จะทำ Create, Update, Delete, List+Pagination, Get By ID กันอยู่เป็นประจำแทบทุกงานอยู่แล้ว ซึ่งการทำด้วย AdonisJS นั้นโคตรสบาย มาลงมือกันเลยดีกว่า

เริ่มจากสร้าง Controller ใช้ CLI ได้เหมือนเดิม

node ace make:controller owners
node ace make:controller pets
// app/controllers/owners_controller.ts
import Owner from '#models/owner'
import type { HttpContext } from '@adonisjs/core/http'

export default class OwnersController {
async list({ request, response }: HttpContext): Promise<void> {
try {
const page = request.input('page', 1) || 1
const limit = request.input('limit', 10) || 10
const owners = await Owner.query().preload('pets').paginate(page, limit)
const result = owners.baseUrl('/owners')
return response.json(result)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async show({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const owner = await Owner.query().preload('pets').where('id', id).firstOrFail()
return response.json(owner)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async create({ request, response }: HttpContext): Promise<void> {
try {
const owner = await Owner.create(request.only(['firstName', 'lastName']))
return response.status(201).json(owner)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async update({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const owner = await Owner.findOrFail(id)
owner.merge(request.only(['firstName', 'lastName']))
await owner.save()
return response.json(owner)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async destroy({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const owner = await Owner.findOrFail(id)
await owner.delete()
return response.json({ message: 'Owner ' + id + ' deleted' })
} catch (error) {
return response.status(500).json({ error: error.message })
}
}
}
// app/controllers/pets_controller.ts
import Pet from '#models/pet'
import type { HttpContext } from '@adonisjs/core/http'

export default class PetsController {
async list({ request, response }: HttpContext): Promise<void> {
try {
const page = request.input('page', 1) || 1
const limit = request.input('limit', 10) || 10
const pets = await Pet.query().preload('owner').paginate(page, limit)
const result = pets.baseUrl('/pets')
return response.json(result)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async show({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const pet = await Pet.query().preload('owner').where('id', id).firstOrFail()
return response.json(pet)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async create({ request, response }: HttpContext): Promise<void> {
try {
const pet = await Pet.create(request.only(['name', 'type', 'ownerId']))
return response.status(201).json(pet)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async update({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const pet = await Pet.findOrFail(id)
pet.merge(request.only(['name', 'type']))
await pet.save()
return response.json(pet)
} catch (error) {
return response.status(500).json({ error: error.message })
}
}

async destroy({ request, response }: HttpContext): Promise<void> {
try {
const id = request.param('id')
const pet = await Pet.findOrFail(id)
await pet.delete()
return response.json({ message: 'Pet ' + id + ' deleted' })
} catch (error) {
return response.status(500).json({ error: error.message })
}
}
}

แปบเดียวได้มาครบแล้วแถมอ่านง่ายด้วย ทีนี้ก็ไปเพิ่ม Route ได้เลย

// start/routes.ts
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/

import router from '@adonisjs/core/services/router'

router.get('/', async () => {
return {
hello: 'world',
}
})

router.get('/owners', '#controllers/owners_controller.list')
router.get('/owners/:id', '#controllers/owners_controller.show')
router.post('/owners', '#controllers/owners_controller.create')
router.patch('/owners/:id', '#controllers/owners_controller.update')
router.delete('/owners/:id', '#controllers/owners_controller.destroy')

router.get('/pets', '#controllers/pets_controller.list')
router.get('/pets/:id', '#controllers/pets_controller.show')
router.post('/pets', '#controllers/pets_controller.create')
router.patch('/pets/:id', '#controllers/pets_controller.update')
router.delete('/pets/:id', '#controllers/pets_controller.destroy')

เรียบร้อยสวยงาม ง่ายต่อการค้นหา และรู้ว่าอะไรทำอยู่ตรงไหนใช่ไหมล่ะ 😇

ลองยิง API กันดู

ลอง List route มันออกมาดูสักหน่อยก่อนจะยิงดู

node ace list:routes
ผลลัพธ์ของ list:route

ในที่สุดก็ถึงเวลาดูผลงานของเรากันได้อย่างรวดเร็วทันใจ ได้เวลา Run server

node ace serve --watch
ลองยิง Pets List สักตัว

เป็นไงครับทั้ง Relation และ Pagination พร้อม ที่เหลือก็ไปลองเล่นกันเองตามสะดวกเลยจ้า ขนมกรุบแล้วทีนี้

เนื่องจาก AdonisJS ทำ Document ไว้ดี, อ่านง่าย,กระชับ และจะดีขึ้นกว่านี้อีกด้วย(เพราะเพิ่ง Update version) เรียกได้ว่าติดอะไรหาได้ที่ Document เอาเลย

สรุป

ใครอ่านมาถึงตรงนี้แล้วทำตามไปด้วยก็ยินดีด้วย เพราะท่านสามารถนำมันไปใช้งานได้แล้ว ทีนี้เชื่อผมยังว่าของมันดี ของมันเด็ด 😂

ได้ลองใช้แล้วจะติดใจไม่อยากไปไหนแน่นอนครับ (นี่มันกาวหรือ Framework ว้าาา)

Bonus Cat

ตามธรรมเนียมถึงแม้ว่าจะเขียนเรื่อง Tech ก็ตาม สิ่งที่ขาดไม่ได้ของมันต้องมีจริงๆในบทความของผมก็คือรูปแมว ขอให้ทุกท่านอย่าหมดกำลังใจ ทำในสิ่งที่รักต่อไปไม่ว่าท่านจะทำอะไรก็ตามนะครับ ไม่ว่าท่านจะอ่านถึงนี่หรือเลื่อนมาดูรูปแมวเลยก็อย่าลืมกด Clap เป็นกำลังใจให้ผมด้วยเด้อ กดหลายทีก็ได้ไม่มีใครว่า 🤣

เด็กชายไมค์ ผู้เติบใหญ่ขึ้นทุกวัน

--

--

Netipun "Nae" Jiwjaroen
Abbon Corporation

My name is Netipun "Nae" Jiwjaroen AKA Kurenai. Custom keyboard enthusiasm, Streamer, Gamer, Musician, Cat Lover and also Programmer