全端網站設計範例:CRUD API

本篇為「全端網站架構」中的後端範例及細節。接續前一篇:全端網站設計範例:連結資料庫

有了資料庫之後,繼續實做基本的 User CRUD,所謂 CRUD 也就是 Create, Read, Update 和 Delete 的首字母縮寫。

Read

首先在有資料的情況,可以先把資料一筆一筆讀出來,最直接的方式就是在 Resolver 裡面對資料庫進行操作。

先把 GraphQL 的 Schema 定義好,在 general.graphql 中加上

1
2
3
4
5
6
7
8
9
type Query {
user(id: ID!): User!
}

type User {
id: ID!
username: String!
name: String!
}

便能在 Resolver resolvers/index.ts 裡面實做,給定傳入的 id,回傳一整個 User Object

1
2
3
4
5
export default {
Query: {
user: (_, { id }) => knex('Users').select('*').where('id', id),
},
};

用 GraphQL 定義完 Schema 的好處就是欄位可以直接全選,不用擔心你資料庫存的其它東西(如密碼)被送出去,因為你沒有定義。

Create, Update, Delete

新增、修改和刪除這裡都屬於寫入資料的範疇,放在 Mutation 中,可參考以下的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
},
},
};

在對資料的讀取中,讀單一的 instace 相對簡單一些,但一次讀多筆就複雜的多了。因為考慮的不單單是透過 id 取得唯一值,或是失敗這兩種可能而已,還要考慮

  • Pagination
  • Sorting
  • Filtering

不只是考慮單一作用的情況,還要考慮複合的狀態,像是同時排序又過濾還要分頁,光寫 SQL 就感覺有點頭大了!這也是為何用 Knex 的原因,Query Builder 可以幫助我們在組合 SQL 以任意順序加上語法。

我們先直接來看完整的 Search Function,一段一段分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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,其定義是這樣的

1
2
3
4
5
6
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,長的像這樣

1
2
Teachers (id, name)
Students (id, teacher_id, name)

如果我們現在要撈出所有老師,那麼用老師的 name 來過相對簡單,但是要撈出有教某學生的老師,就麻煩一些了,重點是過濾的方式百百種,與其試著寫一個 General 的版本,不如每個 Filter 都獨自寫 SQL 實做。

1
2
3
4
5
6
7
// 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

1
2
3
function name(value: string, query: Knex.QueryBuilder) {
query.where('Users.name', 'like', `%${value}%`);
}

Sorter

Sorter 和 Filters 類似,我們也採取個案處理的方式去做,雖然相對於 Filters 的高複雜度,Sorter 最常出現也就是針對數字大小、文字做排序,但偶爾會有其它難處理的例外。

例如上述老師和學生 Tables 的例子,延伸一下,如果要撈出老師但依照其學生平均分數來排序,就不得不另外寫 SQL 了。

這邊是基本的 addSorterImpls

1
2
3
function name(direction: Direction, query: Knex.QueryBuilder) {
query.orderBy('Users.name', direction);
}