全端網站設計範例:CRUD API
本篇為「全端網站架構」中的後端範例及細節。接續前一篇:全端網站設計範例:連結資料庫。
有了資料庫之後,繼續實做基本的 User CRUD,所謂 CRUD 也就是 Create, Read, Update 和 Delete 的首字母縮寫。
Read
首先在有資料的情況,可以先把資料一筆一筆讀出來,最直接的方式就是在 Resolver 裡面對資料庫進行操作。
先把 GraphQL 的 Schema 定義好,在 general.graphql 中加上
type Query {
user(id: ID!): User!
}
type User {
id: ID!
username: String!
name: String!
}
便能在 Resolver resolvers/index.ts 裡面實做,給定傳入的 id,回傳一整個 User Object
export default {
Query: {
user: (_, { id }) => knex('Users').select('*').where('id', id),
},
};
用 GraphQL 定義完 Schema 的好處就是欄位可以直接全選,不用擔心你資料庫存的其它東西(如密碼)被送出去,因為你沒有定義。
Create, Update, Delete
新增、修改和刪除這裡都屬於寫入資料的範疇,放在 Mutation 中,可參考以下的程式碼
export default {
Mutation: {
addUser: async (_, { password, ...args }) => {
const id = (await knex('Users').insert({ password: hashPassword(password), ...args }))[0];
return (await knex.select('*').from('Users').where('id', id))[0];
},
editUser: async (_, { id, password, ...args }) => {
if (password) args = { ...args, password: hashPassword(password) };
if (Object.keys(args).length >= 1) await knex('Users').update(args).where('id', id);
},
deleteUser: async (_, { id }) => {
await knex('Users').delete().where('id', id);
},
},
};
Search
在對資料的讀取中,讀單一的 instace 相對簡單一些,但一次讀多筆就複雜的多了。因為考慮的不單單是透過 id 取得唯一值,或是失敗這兩種可能而已,還要考慮
- Pagination
- Sorting
- Filtering
不只是考慮單一作用的情況,還要考慮複合的狀態,像是同時排序又過濾還要分頁,光寫 SQL 就感覺有點頭大了!這也是為何用 Knex 的原因,Query Builder 可以幫助我們在組合 SQL 以任意順序加上語法。
我們先直接來看完整的 Search Function,一段一段分析
async search(searchOptions: SearchOptions, options?: { query?: Knex.QueryBuilder }) {
// Extract limit and page
const limit = searchOptions.pagination?.limit !== undefined ? searchOptions.pagination?.limit : DEFAULT_PAGE_SIZE;
const page = searchOptions.pagination?.page !== undefined ? searchOptions.pagination?.page : 0;
const { filters, sorter } = searchOptions;
// Declare query builder
const query = options?.query || knex.queryBuilder();
query.select('*').from(this.tableName);
// Add filters
if (filters && Object.keys(filters).length > 0) {
for (const [filter, value] of Object.entries(filters)) {
if (!this.addFilterImpls[filter]) throw new Error(`Filter ${filter} hasn't been implemented`);
this.addFilterImpls[filter](value, query);
}
}
// Add sorter
if (sorter) {
if (!this.addSorterImpls[sorter.column]) throw new Error(`Sorter ${sorter.column} hasn't been implemented`);
this.addSorterImpls[sorter.column](sorter.direction, query);
}
// Count total after filters are applied
const countQuery: { count: number }[] = await knex.select(knex.raw('count(*) as count')).from(query.as('sub'));
const total = countQuery[0].count;
const list: [] = await query.limit(limit).offset(limit * page);
return { list, total };
}
首先是傳入 Function 的變數,非必填的有 options 裡面的 query,這個變數的用途是在 Search 之前就已經有個 QueryBuilder 的 Instance 了,可以傳進來繼續用。
比較重要的是 searchOption,其定義是這樣的
type Pagintaion = { limit: number; page?: number };
type Filters = { [keys: string]: unknown };
type Direction = 'asc' | 'desc';
type Sorter = { column: string; direction: Direction };
type SearchOptions = { pagination?: Pagintaion; filters?: Filters; sorter?: Sorter };
裡面要傳入的就是分頁、過濾及排序的內容
- 分頁時要傳
limit限制每頁的筆數、page是第幾頁,預設第 0 頁 - 過濾時要傳一整個 Object,假設同時要過濾 username 及 name,這個 Object 就會有這兩個 Key,而裡面的 Value 就看是什麼型態的了,可以是字串,也可以是要過濾大小時的數字等等,都是可以擴充的
- 排序時要傳入
column看以誰做參照來排序;以及direction方向,看要升還是降。但不像過濾可以有多個,這邊的實做僅限一個排序而已,因為實務上兩個以上的排序其實用不太到
再來細講 Filters 及 Sorters
Filters
由於每個 Filter 要做的事情不同,很難去寫一個 General 的 Function 給每個 Filter 去做。用個例子來說明,現在有兩個 Table:Teachers Table 和 Students Table,長的像這樣
Teachers (id, name)
Students (id, teacher_id, name)
如果我們現在要撈出所有老師,那麼用老師的 name 來過相對簡單,但是要撈出有教某學生的老師,就麻煩一些了,重點是過濾的方式百百種,與其試著寫一個 General 的版本,不如每個 Filter 都獨自寫 SQL 實做。
// Add filters
if (filters && Object.keys(filters).length > 0) {
for (const [filter, value] of Object.entries(filters)) {
if (!this.addFilterImpls[filter]) throw new Error(`Filter ${filter} hasn't been implemented`);
this.addFilterImpls[filter](value, query);
}
}
所以 addFilterImpls 的這個物件存的就是每個 Filter 的實做 Function,如果要過濾用戶姓名,用的就會是這樣的 Function
function name(value: string, query: Knex.QueryBuilder) {
query.where('Users.name', 'like', `%${value}%`);
}
Sorter
Sorter 和 Filters 類似,我們也採取個案處理的方式去做,雖然相對於 Filters 的高複雜度,Sorter 最常出現也就是針對數字大小、文字做排序,但偶爾會有其它難處理的例外。
例如上述老師和學生 Tables 的例子,延伸一下,如果要撈出老師但依照其學生平均分數來排序,就不得不另外寫 SQL 了。
這邊是基本的 addSorterImpls
function name(direction: Direction, query: Knex.QueryBuilder) {
query.orderBy('Users.name', direction);
}