2023年3月10日 星期五

AdonisJs第七天validate

 參數驗證算是後端功能中非常重要的一環,可以過濾前端算計來的參數,也可以轉換成適當的類型。

AdonisJs中有內建validate,使用方式先依照規範把validator建立起來,寫出要接收的schema以及相對應的訊息,之後加以驗證即可,範例如下

export default class BasicValidator {
constructor(protected ctx: HttpContextContract) {}

public schema = schema.create({
name: schema.string(),
})

public messages = {
"name.required": "name is required",
"name.string": "name must be string",
}
}

// 使用方式
export default class BasicController {
async create({ request }: HttpContextContract) {
const { name } = await request.validate(BasicValidator)
return name
}
}

這邊值得一題,驗證完會回傳驗證結果,並轉成相對應的物件,例如string, number, DateTime, File......因此不會接收前端傳回來多餘的內容。如果設計得宜,是非常好的保障。

ect. 如果messages沒有相對應的定義訊息,系統就會使用預設訊息拋出

schema的規範相當多元,也可以上訂傳進來是物件,陣列,或是把時間格式轉換成DateTime,又或者限制輸入的內容金桔現在陣列之中的enum

public schema = schema.create({
date: schema.date({ format: 'yyyy-MM-dd'}), date格式必須為yyyy-MM-dd否則報錯
gender: schema.enum([1, 2]) // gender只能為1或2
object: schema.object().members({ a: schema.string(), b: schema.number() }),
array: schema.array().members(schema.number()),
})

validate也可以搭配rules使用,rules也預設有非常多的規則,甚至可以自定義驗證規則

public schema = schema.create({
id: schema.number([rules.exists({ table: "users", column: "id" })]), //id需存在於users table
})

更多細節都可以上官網查詢,這邊就不一一贅述。

接下來要介紹一個套件,如果你使用過nest js那你一定對他撰寫驗證的檔案驗證新穎,我同樣抱持著adonis會不會也這麼剛好有類似的東西開始搜索,(先說adonis提供的所有工具已足夠應付幾乎90%專案會遇上的問題,但小編我們身為攻城屍要足夠貪婪,能好就絕對不要濫,能省時間能更方便就絕對要去追求!!),終於讓我找到adonis-class-validateor,不過這套有點小bug,在File的轉換上會報錯,經小編修正後為@lu7766lu7766/adonis-class-validateor,安裝後下指令node ace invoke @lu7766lu7766/adonis-class-validateor設置好相關配置,即可開始使用。一樣先上範例

import { schema, validate } from "@ioc:Adonis/ClassValidator"
import { DateTime } from "luxon"

export default class GetValidator {
@validate(schema.date({ format: "yyyy-MM-dd" }), {
date: "date content error",
required: "date required",
format: "date format error",
})
public date: DateTime

@validate(schema.enum(["a", "b"])) // 使用預設message
public centre_code: string

@validate(schema.string.optional() // 可不送
public code?: string
}

// 使用方式
export default class BasicController {
// ...service 定義方式省略
async getList({ request }: HttpContextContract) {
const { date, centre_code, code } = await request.classValidate(GetValidator)
return this.service.getList({ date, centre_code, code })
}
}

使用class-validator有兩大好處。
第一個好處,邏輯較為集中,傳統設計分為schema,message兩大部分,一但參數多起來,在定義message時很常要滾輪往上看schema是如何設計,相當不便,使用class-validator可以一目瞭然。
第二個好處,因為小編習慣把所接收到的參數全部丟進service進行處理,在參數上類別用傳統的方式就必須一個一個重新定義,否則所有參數就會是any,無法發揮ts跟vscode的長處

getList({ date, centre_code, code }: {date: DateTime; centre_code: string; code?: string}) {
const start_day = date.startOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
const end_day = date.endOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
return return [start_day, end_day, centre_code, code]
}

如果使用class-validator就可以直接將參數類別定義為validator

getList({ date, centre_code, code }: GetValidator) {
const start_day = date.startOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
const end_day = date.endOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
return [start_day, end_day, centre_code, code]
}

簡單說就是善用ts的優勢,讓之後的操作上可以說會方便許多。

以上就是關於validate想要跟各位分享的內容。


3/25更新一下

@validate(schema.string())
public name: string

name會是必填沒錯,但卻沒辦法送出空字串,只是空字串應該也算是合法字串吧,小編認為這應該算是個bug,目前已在github上題出,看之後會不會得到作者回覆

3/26更新一下

由於我寄去的信被作者打槍了,他們一再強調這並非是bug而是有意為之,要我提出為什麼非得這麼做的“討論”。我哪有那美國時間跟他寫英文信在討論,難道要我告訴他,前端編輯文字,當把文字刪除還要特別處理為null這件事不會很不直覺嗎?這段話用中文講都不太好理解更何況要用英文,所以不得已之下先在前端把所有空字串改為null,再把後端設為nullable結束第一階段。

既然有第一階段當然會有第二階段,無奈下我把adonis/validation的原始檔下載回本地研究了一下,發現兩件事

// src/Schema/index.ts
// 可以看到schema.string的實作
function string(options?: { escape?: boolean; trim?: boolean } | Rule[], rules?: Rule[]) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', false, false, options, rules || []) as ReturnType<StringType>
}
string.optional = function optionalString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', true, false, options, rules || []) as ReturnType<
StringType['optional']
>
}
string.nullable = function nullableString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', false, true, options, rules || []) as ReturnType<
StringType['nullable']
>
}
string.nullableAndOptional = function nullableAndOptionalString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', true, true, options, rules || []) as ReturnType<
StringType['nullableAndOptional']
>
}
// src/utils.ts 看到實作的方法
export function getLiteralType(
subtype: string,
optional: boolean,
nullable: boolean,
ruleOptions: any,
rules: Rule[]
): { getTree(): SchemaLiteral } {
const subTypeRule = rules.find((rule) => rule.name === subtype)
const optionsTree = {}

return {
getTree() {
return {
type: 'literal' as const,
nullable,
optional,
subtype: subtype,
rules: ([] as Rule[])
.concat(optional ? [] : nullable ? [schemaRules.nullable()] : [schemaRules.required()])
.concat(subTypeRule ? [] : [schemaRules[subtype](ruleOptions)])
.concat(rules)
.map((rule) => compileRule('literal', subtype, rule, optionsTree)),
}
},
}
}

// ----------------------------------------
// 意思是
schema.string() // 其實等同於
schema.string.optional([rules.required()])

schema.string.nullable() // 其實等同於
schema.string.optional([rules.nullable()])

schema.string.optional() // 意指不加入任何條件,沒後綴跟nullable後綴差別只在有沒有幫你預加上rule而已

// 另外再科普一下
schema.string.nullableAndOptional() // 其實等於optional一點用也沒有,不知道為什麼要這樣設計


那既然是由rule決定是否需要輸入,那我當然可以透過自定rule來定義出必填,但又可以是空白這件事,這我們必須先來看一下required到底做了什麼判斷

// src/Compiler/Validators/existence/required.ts
export const required: SyncValidation = {
compile: wrapCompile(RULE_NAME, [], () => {
return {
allowUndefineds: true,
}
}),
validate(value, _, { errorReporter, pointer, arrayExpressionPointer }) {
if (!exists(value)) {
errorReporter.report(pointer, RULE_NAME, DEFAULT_MESSAGE, arrayExpressionPointer)
}
},
}
// src/Validator/helpers.ts
export function exists(value: any) {
return !!value || value === false || value === 0
}
就是把false成員抓出來而已,但這樣並不能說明為什麼不送參數會報錯,因為不送就不會被驗證了吧,那原因應該在其他地方,想必就是compile裡面的allowUndefineds這個參數吧!!
既然知道方法,那剩下就是看自定rule怎麼寫,只是我看了一下官網,不只寫法跟原始碼的寫法不一樣,官網甚至只有教怎麼寫validate,也沒有地方可以設allowUndefineds。這時就要大大讚嘆typescript了,我看了一下rule的類別說明,竟然還可以寫第三個參數compileFn,這怎麼有點眼熟,裡面要回傳一個物件,裡面正好有我要的參數
{
name: string;
async: boolean;
allowUndefineds: boolean;
compiledOptions: Options;
}
那就開始動工吧

import { validator } from "@ioc:Adonis/Core/Validator"

validator.rule(
"stringAllowEmpty",
(value, _, options) => {
if (typeof value !== "string") {
options.errorReporter.report(options.pointer, "stringAllowEmpty", "stringAllowEmpty validation failed", options.arrayExpressionPointer)
}
},
() => ({
allowUndefineds: true,
})
)

declare module "@ioc:Adonis/Core/Validator" {
interface Rules {
stringAllowEmpty(): Rule
}
}

// usage
schema.string.optional([rules.stringAllowEmpty()])
以上打完收工!!
事實證明,偶爾看點原始碼還是有必要的。

3/26更新一下

小編還是覺得要多寫一個rule很麻煩,看能否把原本schema.string跟schema.number直接改寫,一開始也規劃直接寫在@lu7766lu7766/adonis-class-validateor裡面,後來絕地還是單除拆出來變成一個單獨得套件好了,所以我又增加了一個repo @lu7766lu7766/adonis-schema-override,非常直白告訴你我要覆寫了。
// src/providers/SchemaOverrideProvider.ts

import { ApplicationContract } from "@ioc:Adonis/Core/Application"
import { Rule, SchemaLiteral } from "@ioc:Adonis/Core/Validator"

// 因為這個rule沒有要給別人使用,所以類別宣告直接寫在檔案裡,沒有放在adonis-typings
declare module "@ioc:Adonis/Core/Validator" {
interface Rules {
defined(): Rule
}
}

export default class ClassValidatorProvider {
constructor(protected app: ApplicationContract) {}

public async boot() {
this.addRules()
this.overrideStringSchema()
}

private addRules() {
const { validator } = this.app.container.use("Adonis/Core/Validator")
    // 因為string, number本身就會檢測內容類別,所以我只需判斷是否有非文字的類別即可
validator.rule(
"defined",
(value, _, options) => {
if (value === undefined || value === null) {
options.errorReporter.report(options.pointer, "required", "required validation failed", options.arrayExpressionPointer)
}
},
() => ({
allowUndefineds: true,
})
)
}

private overrideStringSchema() {
const { schema, rules } = this.app.container.use("Adonis/Core/Validator")
// 因為schema.string類型比較特殊,既是方法又有屬性,所以暫時只想到一次全部覆寫這種寫法,歡迎有更好建議
function myString(...args) {
let option = {}
let params: Rule[] = [rules.defined()]
if (args.length === 1) {
params = params.concat(args[0])
} else if (args.length === 2) {
option = args[0]
params = params.concat(args[1])
}
return schema.string.optional(option, params) as {
t: string
getTree: () => SchemaLiteral
}
}
myString.nullable = schema.string.nullable
myString.optional = schema.string.optional
myString.nullableAndOptional = schema.string.nullableAndOptional
schema.string = myString
}
// number的方法就參照上面再寫一次就好
}


以上就真的大功告成收工了

小記:因為schema.string() == schema.strion.optional([rules.required()]),因為rule.required()是大家共用的,所以不能改,不然會天下大亂,我想作者大概也誤會我的意思,我只是想個別做調整就像上面這樣,總之就是小編又一次拯救了世界

2023年3月7日 星期二

AdonisJs第六天,全域取得context

 在AdonisJs設計中,原本只有Cntroller Method可以取得context(簡寫ctx),但在某些特殊情境下需要取得ctx,且無法透過controller傳遞參數怎麼辦呢?

小編在官網上尋找答案只找到可以使用以下方法,但在實際運行卻什麼也取不到,程式碼如下:

import HttpContext from '@ioc:Adonis/Core/HttpContext'
class SomeService {
public async someOperation() {
const ctx = HttpContext.get()
}
}

應該是缺少一點東西,小編把get改成getOrFail,讓他把錯誤印出來,錯誤訊息顯示

E_INVALID_ALS_ACCESS: HTTP context is not available. Set "useAsyncLocalStorage" to true inside "config/app.ts" file

所以應該是少了設定,只是這設定要加在哪裡,官方文件上只有文字描述要加設定,卻沒有告訴你怎麼做,加在哪。好在config/app.ts裡面設定也不多,一個一個嘗試後是加在

export const http: ServerConfig = {
    ...... // 中間忽略
    useAsyncLocalStorage: true,
}

以下看個使用情境

import { BaseModel, column, beforeSave } from "@ioc:Adonis/Lucid/Orm"
import Hash from "@ioc:Adonis/Core/Hash"
import HttpContext from "@ioc:Adonis/Core/HttpContext"
export default class User extends BaseModel {
  @column({ serializeAs: null })
public password: string

@beforeSave()
public static async beforeSave(user: User) {
    // 當路由名稱為approve時,實際命名方法如下
    // Route.post('xxx', 'XxxController.method').as('approve')
if (HttpContext.get()?.route?.name == "approve") {
return
}
if (user.$dirty.password) {
user.password = await Hash.make(user.password)
}
}
}

基本上在user password改變的前提下都需要經過加密,但就是有某些特殊場景不用,但又懶得把邏輯拆出來到service見一次做一次,只好用這個訪法去完成。

另外還有一個解法,是當判斷

user.password.startsWith("$argon2")

但這有一些問題,例如加密的方法如果改用不是argon lib加密,開頭就會變,或是他就是需要加密在加密。這些往往會變成bug,不過也是要看需求拉。以上就是這次簡短分享,也算做個筆記。

2023年1月17日 星期二

AdonisJs第五天Middleware

前言

middleware是軟體設計的一種模式,可以用於特定事件的“前置”處理或“後置”處理(參見圖片),在AdonisJs裡面又可以把middleware設定為全域或指定兩種,算是我覺得非常簡單又好用的工具。



全域註冊

在./start/kernel.ts檔案裡面可以見到middleware的註冊,

Server.middleware.register([
() => import('@ioc:Adonis/Core/BodyParser'),
])

在這段程式碼中註冊了全域的middleware:BodyParser,BodyParser的作用為解析controller返回的內容,可以把view或是json或是model直接轉譯為前端應該接收的類型,以昨天的程式碼為例

export default class HellosController {
say() {
return {
say: "hello"
}
}
}

這時如果打api前端就可以拿到{ say: "hello" }的物件,不需要再使用ctx.response.json({ say: "hello" })來回傳,有效減少程式碼的長度及重複度。


指定註冊

正常新增專案的情況下,這時是不會有任何註冊的middleware,沒關係我們一步一步來,先在./start/kernel.ts註冊如下

Server.middleware.registerNamed({
api: () => import('App/Middleware/ApiFormat'),
})

接著手動新增檔案./app/Middleware/ApiFormat.ts,主要我希望只要有註冊該middleware,回傳的內容我希望是我指定的格式,程式碼如下

import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class ApiFormatMiddleware {
public async handle({ response }: HttpContextContract, next: () => Promise<void>) {
    
const start: number = Date.now()
    // 進入主程式前
await next()
// 進入主程式後
const resBody = {
code: [0],
data: response.lazyBody?.[0], // 取得主程式返回的內容
}

resBody['time'] = Date.now() - start + ' ms'
response.send(resBody)
}
}

然後再把route稍微修改一下

import Route from "@ioc:Adonis/Core/Route";

Route.get("/", "HellosController.say").middleware(["api"]);

middleware裡面可以直接塞入字串,或是用陣列註冊多個middleware

這樣就大功告成了。我們再試著打一下昨天的api結果會如下

{"code":[0],"data":{"say":"hello"},"time":"2 ms"}

可以完整輸出我希望輸出的格式還有程式執行的時間


小結

其實middleware也不是AdonisJs特有的功能,有興趣的小夥伴可以再去深究他實現的原理,並更有效的運用在自己生活中,他實踐起來並不困難,但卻能為程式碼帶來大大的便利性,算是我非常喜歡的一個功能。

2023年1月16日 星期一

AdonisJs第四天Controller

前言

一個不小心就休了十天,打這系列文章讓我真心佩服那些挑戰it鐵人賽的人,每天一篇文張看來真的不是開完笑的累。


建立控制器

可以使用指令`node ace make:controller [controller_name]`來新增controller,也可以手動新增檔案。

方法中第一個參數預設會帶入context,內容幾乎涵蓋網站開發的重要物件

  • request: 封裝了請求對象,包含了請求頭,請求參數,請求體等信息。

  • response: 封裝了回應對象,用於提供回應的方法,如返回 JSON 數據或重定向。

  • params: 封裝了路徑參數,可以訪問路徑中的參數。

  • view: 封裝了渲染視圖的方法,用於渲染模板並返回 HTML。

  • auth: 封裝了認證和授權的方法,用於確認用戶是否已經登錄。

  • antl: 封裝了國際化的方法,用於翻譯文本。

  • session: 封裝了 session 的方法,用於在請求之間存儲數據。

import type { HttpContextContract } from "@ioc:Adonis/Core/HttpContext";

export default class HellosController {
say(ctx: HttpContextContract) {
ctx.response.send("hello")
}
}

小提醒:特定物件需再安裝完套件之後才會有,例如auth, session, view, antl,這系列文章中因為主要focus在api的開發上,所以只會提到auth,對此有興趣的觀眾可以自行到官網上查詢。

小結

controller雖然不是個特別難的章節,但卻是在開發上非常重要的環節,有效使用controller好處是可以把route映射在controller的方法上,有效分離程式碼落實關注點分離,避免route做太多不關他的工作。

總而言之,AdonisJS 控制器是一個非常強大且易於使用的組件,能夠有效地組織應用程序的邏輯,並提供了豐富的功能供開發者使用。


2023年1月6日 星期五

AdonisJs第三天,Route

前言

相信大家接著看一定很疑惑為什麼第三天不是前一天講的Auth,沒錯,小編就是這麼不按照牌理出牌!沒有拉,那是考量到講Auth一定會講到Route跟Controller和Context,所以調整了一下順序,不浪費時間我們開始吧。


一切的起始

/.adonisrc.json檔案,很驚訝不是routes.ts吧,別緊張聽我娓娓道來,.adonisrc.json可以把這個檔案視為整個專案的配置檔,這裡面有配置起始必須載入的檔案,因此./start/routes就是在這個地方註冊的,不過/start/routes.ts是初始設定,不用調整,只是提一下讓你知道。至於./start/kernel是什麼東西,日後有機會我們再回來看。

"preloads": [
"./start/routes",
"./start/kernel"
],

接著來說明一下路由,簡單來說就設定是打api的uri,藉由不同的uri去呼叫不同的function處理不同的邏輯,大概就是這麼回事。

那我們來看一下routes.ts目前有什麼東西吧


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

大家還記得前天把專案run起來,進入根目錄看到的內容嗎?沒錯就是上面這段{hello: 'world'},因此這裡可以解釋為,由根目錄進入且走GET的會執行上述方法,該方法回傳一個物件。

當然大家也可以依照專案需求,不一定要把所有路由都寫在同一個routes.ts中,可以藉由引入的方式來分類不同的路由

// /start/routes.ts
import '/start/User/routes'
import '/start/Cart/routes'
import '/start/Product/routes'

Route有內建一些實用的方法,以下小編會依照常用的一一做介紹

Methods

那既然有GET那必然也有POST, PATCH, PUT, DELETE甚至是any或多個methods

Route.post('posts', async () => {})
Route.any('report', async () => {})
Route.route('/', ['POST', 'GET'], async () => {})

Prefix

prefix就是把網址加上前綴,讓同類別功能的api更有一至性

Middleware

這觀念過兩天會有更詳細的解釋跟範例,邊簡單跟大家可以簡單理解為進入方法前的前置作業及後置作業。

Namespace

一般我們不會真的把邏輯全部寫在routes.ts裡面,這樣檔案會過於龐大且混亂,通常我們會make一個新的controller,再藉由route的第二個參數,可以指定controller的method,例如

Route.get('/api/users', 'UsersController.getList')

這意思就是指,只要符合路由,就會呼叫UsersController底下的getList方法

然而controller預設的路徑是在/App/Controllers/http底下,如果因為專案規劃希望是不同的路徑,就需要在route增加namespace告訴系統檔案在什麼地方。

Group

Group的參數為一個匿名函數,可以理解為在函數中的所有route都附加該group的效果

Route.group(() => {
Route.get('/users', 'UsersController.index')
Route.get('/posts', 'PostsController.index')
}).prefix('/api')

這樣就會宣告兩個同時擁有/api前綴的接口:GET /api/users, GET /api/posts

Params

有時會會把primary key或是參數放在網址裡面,當然這參數也可以是選填,甚至可以限制輸入的條件(where),一旦符合才會進入route

// 需填入id才會符合路由
Route.get('/posts/:id', async ({ params }) => {
return `${params.id}`
})
// id為選填
Route.get('/posts/:id?', async ({ params }) => {
if (params.id) {
return `${params.id}`
}
return 'Viewing all posts'
})

// id須為數字組成才符合路由
Route
.get('/posts/:id', async ({ params }) => {
return `${params.id}`
})
.where('id', /^[0-9]+$/)

// 也可以全域限制id的類型,且不受group限制
Route.where('id', /^[0-9]+$/)


後記

AdonisJs5開始支援typescript,很多api其實不用像以前弱型別時需要特別去記它,只要 . 下去,大部分聰明的ide就會列出可以使用的api提供選擇可以說非常方便。其實我寫出來的內容並非完整的,如果需要完整的資訊,其實官網的文件寫得我覺得算很友善了,只要你不排斥看英文,這些網誌我只是希望分享我的開發經驗與心得,分享自己較常用到的功能、基礎知識及做個紀錄。

2023年1月4日 星期三

AdonisJs第二天,Migration

前言

其實我猶豫很久第二天要講Route還是Migration,思慮再三還是決定從資料庫開始講起,畢竟這也是後端的根本,不過既然提到資料庫,那至少要準備一個資料庫,可以使用免錢的MariaDB,準備好那我們就開始吧。

AdonisJs中的Migration底層是用knex去實現的,可以理解為程式碼去設計資料庫的架構,並用指令及迭代去完成資料表的創建及修改。那既然提到資料庫,就不能不提及代表資料的最小單位Model,而AdonisJs的Model是用lucid去實現的,所以在Migration開始教學之前,還需要把資料庫相關的套件裝起來。


實作階段

安裝很簡單,依序下指令就好

npm i @adonisjs/lucid

node ace configure @adonisjs/lucid

這時會詢問你要使用哪一種DB,此時我們選擇我們準備好的 MySql/MariaDB (請依自行狀況調整)。

當下完上述兩個指令,db相關的cli就會跳出來了,但此時記得先到環境變數檔裡去設定資料庫連線的ip及帳號密碼,不然到時實行可是會因為找不到資料庫而報錯的!

設定完接著我們就可以開始設計資料表了

node ace make:migration [table_name]

這時在database/migrations 底下就會產生一個附有時間戳的檔案。這時間戳很重要,是電腦判斷順序的依據,有時順序錯誤就會報出錯誤。因此建議大部分時候請用指令來產生migration檔案

在AdonisJs裡面,大多數用指令產生的檔案,會依類型自動幫你複數化,假設是port => posts,唯獨model不會,產生檔案如下。


這時我們來看一下產生出來的內容

import BaseSchema from '@ioc:Adonis/Lucid/Schema'

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

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

/**
* Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
*/
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}

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



tableName這應該很好懂,就是到時會產生的資料表的名子

至於up跟down這裡需稍加說明migration的執行流程,跟幾個重要指令

node ace migration:run 以下簡稱run

node ace migration:rollback 以下簡稱rollback

想像建構一個資料庫像在蓋大樓,每跑一次run就代表上一個樓層(level),所以會跑up function,一次rollback就會下一個level,所以會跑down,又因為up跟down互為相反,所以內容必須完全相反才行,例如up在新增資料表,down就必須是刪除資料表,up是修改欄位名稱,down就必須改回原本名稱。

然後每一次run系統都會把migrations資料夾底下所有檔案讀取出來,扣除掉早於上次跑run的時間點(依時間戳判斷),執行剩餘的migration。

其餘的cli都是這兩個指令的延伸,為了符合更多不同的場景,大家有空可以玩一下這裡就不贅述。

以下舉個完整的例子

今天我make:migration post,然後跑一次run,此時建立post資料表,level為1,這時我在make:migration comment,再跑一次run,此時建立comment資料表,level為2,但後來我覺得comment設計得不太好,想要刪掉重來,此時下rollback就會回到level1,comment就會被drop掉。如下圖,順序由左至右



了解整個流程,我們就可以開始設計資料表了,依照表的功能進行設計,例如post代表po文,自然有po文的人(user_id),po的標題(title),po的內容(content),也會紀錄po文的時間(created_at)或修改的時間(updated_at),也給這個po文自己的unique id (id),其他的還沒想到我們可以先不想,反正之後還可以調整,那設計出來的migration大概會是這個樣子

import BaseSchema from "@ioc:Adonis/Lucid/Schema";

export default class extends BaseSchema {
protected tableName = "posts";

public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments("id");
table.integer("user_id");
table.string("title");
table.text("content");
table.timestamp("created_at", { useTz: true });
table.timestamp("updated_at", { useTz: true });
});
}

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



接著執行run,如果沒意外的話就恭喜你完成自己的第一張資料表。


後記

其實Migration跟Auth我也是很猶豫哪個要先做,但還是想說先把概念講一下,再開始實作可能觀眾會比較有概念,那接著Auth我們就明天見啦。(結果Route越排越後面....)

我直接在學習Migration時其實有想過,現在都什麼年代了,在功能越做越多,工程師越來越懶的情況下,為什麼不學NestJs或golang的gorm一樣,把model跟db schema同步就好,可以比較省事。

但實際自己在摸那兩套框架時測試,只要遇到欄位有時且無法轉換的情況下,其實還是會發生些不可預期的問題,要不是欄位無法轉成功,不然就是發生欄位被清空,例如原本是字串的欄位,突然被改成布林,這消失是必然的,當然這屬於比較極端的狀況,但如果發生沒有異動的狀況變成爛帳,會造成日後測試的困擾。

所以經過思考覺得還是資料庫這麼嚴謹的東西還是一步一腳印照著流程走最保險,這部分就別想偷懶了吧。


3/28更新一下

在上版到release甚至是prod之後,之後如果遇到要修改資料表就沒辦法再新建,而是要用alter的方式去修改,假設我要把title改成數字,並且新增一個欄位叫detail

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

public async up() {
this.schema.table(this.tableName, (table) => {
table.integer('title').alter()
table.string('detail')
})
}

public async down() {
this.schema.table(this.tableName, (table) => {
table.string('title').alter()
      table.dropColumns('detail')
})
}
}

甚至是遇到constants的資料,要再新增完表之後直接把固定資料寫入,官方有提供一個非常方便的功能可以在migration:run完之後執行,就是defer

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

public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('code')
table.timestamps()
})

this.defer(async () => {
await Task.createMany([
         { id: 1, code: 'Test' },
         { id: 2, code: 'Code' },
     ])
})
}

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

不過小編也遇過defer沒有跑,或是沒有跑完全的狀況(兩個檔案只跑了一個),目前原因不明,就還在觀察中。在此做個補充

2023年1月3日 星期二

AdonisJs第一天:Node.js版的Laravel

AdonisJs第一天,創建專案,夢想啟航

前情提要

約莫在Laravel6時期,小編還是php的小白,當時連laravel的中文書在市場上數量一隻手都數得完,剛踏入社會想著什麼都要自幹來磨練心法,後來終於意識到自己這樣慢慢磨練何時才是個頭,人生何其短,當有一定基礎後當然要站在巨人的肩膀上才看得遠,於是把市面上所有的Laravel中文書掃回家後啃了個遍。只是都還沒機會用到實際專案,便因為職涯規劃轉戰了前端工程,只有偶爾工作需要兼寫一下後端。此時我便開始思考,既然都要寫js那後端也選用Node.js好了,不僅自帶server,效能也較好(當時php7剛出)。寫了一陣子之後,發現Node.js雖然套件很多,但缺乏整合,多數時候還是要自己造輪子。於是突發奇想,會不會這麼剛好有大神開發了Node.js版的laravel框架呢?於是便開始AdonisJs這段旅程,至於為什麼身為一位前端工程師卻要來分享後端框架,那又是另外一個故事了。


勸退

Adonisjs的受歡迎程度在老實說不是名列前茅(2022年約第十名左右),甚至上網查連中文介紹都沒有(所意味著你出問題也只能看英文,但不至於你遇到的問題別人都沒遇到,至少我目前還不至於),但卻是我用起來覺得開發體驗最舒服的,特別是在我體驗過近幾年最火的框架,跟最多人用的框架後(不能說名字會被黑==),這也是我為什麼要花這麼多時間寫網誌來介紹這個框架(有看我前幾篇網誌的人應該知道,看心情更新,小編是很懶的人)。如果你跟小編一樣在意開發體驗,不在意是否社群是前幾名受歡迎,歡迎你一起加入這班車。


前置作業

請先安裝好Node.js, npm

Node.js version latest14


創建初始專案

npm init adonis-ts-app@latest [project-name]

依照你的需求可以選擇

api:不安裝view相關套件

slim:除了核心套件其他都不安裝

web:一般前後端開發

AdonisJs的前端可以使用Edge或pug語法來開發,就好像Laravel用blade語法來開發畫面一樣,不過通常我只拿來開發api,所以我們選擇api

剩下的專案名稱,是否要用eslint,就一個人開發習慣使用


cli(指令)

cli可以說是AdonisJs非常重要的一環,大部分的行為都可以,甚至是必須要用cli來執行,所以請好好的學習。

AdonisJs5之後把所有指令封裝載ace檔案裡面,指令如下,node ace,就好像Laravel用php artisan一樣,實際有的指令也會在套件安裝後增加,目前因為專案剛建立只有幾個比較基本的,執行結果如下


看到很多看不懂的指令沒關係,我們先了解幾個基本的

generate:key:產生新的app key,key會放在.env裡,初始會自動產生,參與資料的加解密

serve:開發模式

build:打包

這時我們下指令 node ace serve --watch 或 npm run dev就可以把專案在本機run起來了

這時開啟瀏覽器http://127.0.0.1:3333就可以看到初始的api

3333的port號如果被佔用或想要修改,可以藉由修改.env的PORT進行調整


小記

以上就建好一個初始專案並在本地run起來了,距離百萬年薪只差一小步了是不是很興奮啊!

剩下我們明天待續...吧


官網:https://docs.adonisjs.com/guides/introduction