AdonisJS V6 101
Typescript Framework ขั้นเทพที่ดีต่อใจผู้ใช้งาน
Intro
กราบสวัสดีท่านผู้อ่านทุกท่านครับ วันนี้เรากลับมาพบกันอีกครั้งกับบทความด้าน Tech ว่าด้วยเรื่องของ Framework ตัวหนึ่งในภาษา Typescript ที่เพิ่งถูกอัพเดท Version ไปเมื่อช่วงต้นเดือนก.พ. 67 ที่ผ่านมานี้
ดังนั้นในฐานะผู้ที่ใช้งานมันมาตั้งแต่ 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
เสร็จก็เข้าไปเปิดขึ้นมาดูแล้วแก้ .env ได้เลยเป็นอันเสร็จการ Init project อย่างรวดเร็ว
cd adonis-101/
code .
แต่! ยังไม่หมด เพราะ 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
ในเมื่อ 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
สร้าง 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
ไม่ต้องหยุมหัว 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
ในที่สุดก็ถึงเวลาดูผลงานของเรากันได้อย่างรวดเร็วทันใจ ได้เวลา Run server
node ace serve --watch
เป็นไงครับทั้ง Relation และ Pagination พร้อม ที่เหลือก็ไปลองเล่นกันเองตามสะดวกเลยจ้า ขนมกรุบแล้วทีนี้
เนื่องจาก AdonisJS ทำ Document ไว้ดี, อ่านง่าย,กระชับ และจะดีขึ้นกว่านี้อีกด้วย(เพราะเพิ่ง Update version) เรียกได้ว่าติดอะไรหาได้ที่ Document เอาเลย
สรุป
ใครอ่านมาถึงตรงนี้แล้วทำตามไปด้วยก็ยินดีด้วย เพราะท่านสามารถนำมันไปใช้งานได้แล้ว ทีนี้เชื่อผมยังว่าของมันดี ของมันเด็ด 😂
ได้ลองใช้แล้วจะติดใจไม่อยากไปไหนแน่นอนครับ (นี่มันกาวหรือ Framework ว้าาา)
Bonus Cat
ตามธรรมเนียมถึงแม้ว่าจะเขียนเรื่อง Tech ก็ตาม สิ่งที่ขาดไม่ได้ของมันต้องมีจริงๆในบทความของผมก็คือรูปแมว ขอให้ทุกท่านอย่าหมดกำลังใจ ทำในสิ่งที่รักต่อไปไม่ว่าท่านจะทำอะไรก็ตามนะครับ ไม่ว่าท่านจะอ่านถึงนี่หรือเลื่อนมาดูรูปแมวเลยก็อย่าลืมกด Clap เป็นกำลังใจให้ผมด้วยเด้อ กดหลายทีก็ได้ไม่มีใครว่า 🤣