前言
過去筆者使用原生Javascript(JS)開發Nodejs應用,採用的是MVC的軟體架構,但開發上總會不小心過度包覆太多商業邏輯在Controller上,因此,希望導入Typescript與實驗新的軟體架構,對過去的Simple Twitter專案進行一系列的實驗開發。
- Part1 Web API與軟體架構基礎建立
- Part2 多個Entity的關聯與商業邏輯設計
- Part3 實作單元測試
(持續新增中)
目標
- 用Typescript打造Express Web Server
- 將商業邏輯與系統運作分離管理
開始
Typescript
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
typescript官網
這裡提到 TypeScript 是 JavaScript 的型別超集合,也就是說 Typescript 提供了 JS 各種型別定義的設計,算是種原生JS的擴充且最後還能把它編譯成原生JS
但究竟有哪些原因會讓開發者想要採用Typescript呢?
- 期望有清楚的型別定義
- Javascript 的動態語言(Dynamically Typed Languages)特性,容易衍生出開發期間不易發現的錯誤
- Typescript 幫你在開發階段就幫你抓出型別不清的bug
- 期望有支援OOP語言的feature(整合了ES6與ECMAScript之後的標準)
- typescript支援 interface、class、封裝與繼承的寫法
- 期望有包含ES6與之後的標準,對原生JS的開發者來說"語法糖"不能少
- typescript支援大部分的ES6與之後版本的語法,例如 let, const、箭頭函式(Arrow Functions)等
- 此外,typescript也能在config檔中選擇其他ES版本進行編譯
【相關資源推薦】
TypeScript 到底是什麼?
Javascript動態型別加弱型別
學會Typescript的四堂課 Day23-26
MVC
MVC是一個幫助軟體開發能達到關注點分離的軟體設計模式。而關注點分離簡單來說又是將程式邏輯依據用途、目的進行分類與管理,通常會參考一些前人踩過雷的成功的範例(例如設計模式)。
期望達到以下的目的:
- 程式重複使用(reuse)
- 解耦合(de-coupling)
- 易維護性(maintainability)
- 平行開發(parallel development)
筆者過去的開發都follow著MVC的模式,或說打從接觸後端開始,大多數的開發教學都圍繞著它,所以也沒什麼好挑!它的特色就是將程式邏輯化分成 Model、View、Controller。我記得我第一個接觸的是Ruby on Rails,Rails的專案架構就採用這樣的設計(MVC解說)。
然而,即便關注點分離是這個架構的初衷,但經驗尚淺的我還是很容易將一些商業與存取資料的邏輯包在Controller裏,不自覺往anti-pattern的方向開發去了,所以想試著採用其他design pattern,一方面看能否幫我把這些混雜的邏輯抽離乾淨,另一方面,嘗試從其他模式回頭看MVC。解偶與易維護性!真的需要經驗累積!!
Repository Pattern
為了避免肥大的Model與Controller產生,我覺得採納Repository模式或許是個可行的方法。
Repositories 將與存取資料有關的邏輯封裝在類別/元件裏頭,以提供更好的維護與解偶的架構來從 Model存取資料庫。
從Domain-Driven Design的概念來思考 Respository 要如何應用在這一系列的專案開發上。實作概念就是一個 Repository 搭配一個 Aggregate,而非一個 Repository 對應一個 Model。目的就是要讓領域模型(Domain Model)與資料模型(Data Model)的分離,以利日後能彈性擴展領域模型/提升資料庫存取效能。
下圖的概念就清楚表達每一個Repository有它對應的Aggregate
【相關資源推薦】
Design the infrastructure persistence layer
值得閱讀 Domain-Driven Design
DDD 戰術設計:Repository 資源庫
ORM簡介
ORM: 全名是Object-Relational Mapping,它讓操作資料庫就如同操作物件一樣,不需要撰寫SQL,使用ORM提供的method就可以達成與SQL一樣的資料庫操作;此外,ORM亦支援不同的關聯式資料庫,也就是說同一個method可以用在不同的資料庫上,達到的效果是一樣的!
- 減少撰寫重複的SQL法,例如對CRUD的操作
- 幫助降低資料庫的耦合(依賴性)
Typeorm淺談
筆者過去使用sequelize,儘管sequelize與typeorm同樣都支援主流資料庫,資料操作method也差不多,但發現typeorm本身在支援Entity、Repository的設計上符合這個新架構的實驗,此外、使用它提供的Query Builder讓我可以日後在查詢的設計上保持彈性,還有其他像是支援 MongoDB、Active Record 與 Data Mapper pattern。
【相關資源推薦】
採用orm的優/劣建議
Typeorm: Quick start
Connection APIs
Delete using Query Builder
== 前置作業 ==
- 安裝好 npm
- 安裝好 編輯器(例如 Vscode)
專案設定
- 打開Terminal安裝typescript在主機的環境中
$npm install -g typescript
- 建立專案資料夾,並進到資料夾底下用
npm init -y
產生 package.json - 用
tsc --init
初始化產生 tsconfig.json(待會再來設定裏頭的內容) - 接著安裝需要的套件 (套件用途在後續幾個小節中說明)
- 在 dependencies 安裝
npm install --save express body-parser dotenv
- 資料庫使用 MySQL 安裝
npm install --save mysql2
- 操作資料庫上需要用到ORM,這裡使用
npm install --save typeorm reflect-metadata
- 在 devDependencies 安裝
npm install --save-dev typescript ts-node nodemon @types/express @types/node
- 在 dependencies 安裝
- 在專案資料夾中新增一個src資料夾,並設定tsconfig.json,基本設定如下
{ "compilerOptions": { "sourceMap": true, "target": "es6", # 採用ECM標準 "module": "commonjs", "outDir": "./dist", # 編譯後產生的路徑 "baseUrl": "./src" # 取得ts檔的路徑 # 針對 typeorm 要開的設定,如果是TypeScript version 3.3 或以上 "emitDecoratorMetadata": true, "experimentalDecorators": true, # 如果之後在entity的property設定有問題的話就加以下這行 "strictPropertyInitialization": false }, "include": [ "src/**/*.ts" ], "exclude": [ "node_modules" ] }
- 設定package.json的執行腳本
通常只要寫"ts-node ./src/server.ts",Typescript就能執行編譯server.ts檔。但為了能拿到連線MySQL資料庫需要的username, password等敏感資料,筆者多加入了執行dotenv/config的設定(-r 表示 --require)。"scripts": { "ts-dev": "ts-node -r dotenv/config ./src/server.ts" }
專案架構
筆者希望打造一個Express Web API Server,實際目標就是能在瀏覽器輸入 http://localhost:3000/api/test 得到 you called user path test 的回傳;輸入 http://localhost:3000/api/user 回傳取得所有user的內容,此外,透過Postman(能操作Web API的圖形化軟體)輸入 http://localhost:3000/api/user 且指定POST讓server為我新增一個user,並回傳該user的內容。本文章針對查詢(Read)與新增(create)做說明,還有其他的功能細節就請參考repo的Part1分支。
架構與目錄用途:
SimpleTwitterTs/
├── src/
│ ├── config/ # 資料庫連線設定
│ ├── controllers/ # 處裡HTTP請求與回覆;指派給特定的business logic
│ ├── entities/ # 管理所有Schema
│ ├── repositories/ # 管理所有的 business logic
│ ├── routes/ # 處裡server的路由
│ ├── App.ts # 初始化路由、資料庫連線
│ ├── Server.ts # Web API 入口
├── .env
├── package.json
└── tsconfig.json
相關重要套件
- express: 讓javascript能在Node.js執行環境下使用Http模組建立 HTTP Server 與Routing等功能
- body-parser: 協助解析 HTTP Request body的內容
- mysql2: 讓javascript能在Node.js執行環境下操作MySQL引擎的模組,可以對資料庫進行CRUD等操作
- typeorm: 以操作物件的方式來操作MySQL,需要與mysql2一起安裝
入口 Enter point
建立一個Express Server,這裡我將express初始化、middleware套件載入、路由與MySQL資料庫連線設定寫在App.ts,啟動後讓他監聽 port 3000。
// src/App.ts
import * as express from "express";
import * as bodyParser from "body-parser";
class App {
public app: express.Application;
constructor() {
this.app = express();
this.config();
this.routerSetup();
this.mysqlSetup();
}
private config(): void {
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({ extended: false }));
}
private routerSetup() {
for (const route of router) {
this.app.use(route.getPrefix(), route.getRouter());
}
}
private mysqlSetup() {
createConnection(config).then(connection => {
console.log("Has connected to DB? ", connection.isConnected);
let userRepository = connection.getRepository(User);
}).catch(error => console.log("TypeORM connection error: ", error));
}
export default new App().app;
---
// src/Sserver.ts
import app from "./app";
const PORT = 3000;
app.listen(PORT, () => {
console.log('Express server listening on Port ', PORT);
})
Router
撰寫一個抽象路徑類別 MainRoute,讓它提供getPrefix()、getRouter()和setRoutes()的方法和屬性,讓其他路徑能繼承 MainRoute的方法與屬性。
// src/routes/route.abstract.ts
import { Router } from "express";
abstract class MainRoute {
private path = '/api';
protected router = Router();
protected abstract setRoutes(): void;
public getPrefix() {
return this.path;
}
public getRouter() {
return this.router;
}
}
export default MainRoute;
----
// src/routes/user.routes.ts
import { Application as ExpressApplication, Request, Response, Router } from 'express';
import MainRoute from './route.abstract';
import UserController from "../controllers/userController";
class UserRoutes extends MainRoute {
private userController: UserController = new UserController();
constructor() {
super();
this.setRoutes();
}
protected setRoutes() {
this.router.get('/test', (req: Request, res: Response) => {
res.status(200).send('you called user path test!')
});
this.router.route('/user')
.get(this.userController.getAll)
.post(this.userController.createOne);
this.router.route('/user/:id')
.get(this.userController.getOne)
.put(this.userController.updateOne)
.delete(this.userController.deleteOne);
}
}
export default UserRoutes;
router.ts負責集合所有路徑以供 App.ts 在程式啟動時載入。這個結構可避免將所有路徑撰寫在App.ts中變成肥大凌亂的路徑清單,也有利於日後持續新增與管理。
// src/routes/router.ts
import MainRoute from "./route.abstract";
import UserRoutes from "./user.routes";
const router: Array<MainRoute> = [
new UserRoutes(),
];
export default router;
Controller
路徑會依據url尋找對應的Controller的特定方法來處裡使用者的特定請求,比方說路徑得到一個 url 是http://localhost:3000/api/user ,它就去UserController執行getAll的方法。getAll有會去執行Repositery中的getUsers函式,之後取得函式的回傳結果,再將結果以json格式回傳給使用者。
import { Request, Response } from 'express';
import { getUsers, createUser, getUser, updateUser, deleteUser } from "../repositories/user.repo";
import { User } from '../entities/user.entity'
class UserController {
// 取得所有使用者
public getAll(req: Request, res: Response) {
getUsers().then((result) => {
console.log("Result id : " + result.id);
return res.status(200).json(result);
});
}
// 新增一個使用者
public createOne(req: Request, res: Response) {
const data: User = req.body;
createUser(data).then((result) => {
return res.status(200).json(result);
});
}
}
export default UserController
實體 Entity
引入typeorm模組的ConnectionOptions設定MySQL的資料庫連線(PostgreSQL同樣),加上synchronize: true(之後會用到),這個設定檔會被App.ts的mysqlSetup()呼叫。
// src/config/ormconfig.ts
import { ConnectionOptions } from 'typeorm';
const config: ConnectionOptions = {
type: 'mysql',
host: 'localhost',
port: 3306,
username: process.env.MYSQL_USERNAME, // 設定寫在.env
password: process.env.MYSQL_PASSWORD, // 設定寫在.env
database: process.env.MYSQL_DB, // 設定寫在.env
entities: [
__dirname + '/../entities/*.ts',
],
synchronize: true,
logging: false
};
export default config;
Entity的概念就好比把資料模型比作物件,而非一些屬性的集合體。關於更多Entity的觀念留到Part2之後再詳細補充。這裡建立一個 User Entity 要用寫類別的方式並搭配TypeORM提供的修飾符(decorator),這些修飾符像是Entity, Colum 和 PrimaryGeneratedColumn。沒有Entity加註不會被資料庫建立成資料表,沒有Column修飾符的屬性也不會加進資料表的欄位中。
// src/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity() // 預設資料表名稱 user;@Entity("Users") 指定資料表名稱
export class User {
@PrimaryGeneratedColumn() // 主鍵欄位,非一般欄位
id: number;
@Column()
email: string;
@Column()
password: string;
@Column()
name: string;
@Column("text") // 指定欄位屬性為TEXT
introduction: string;
@Column({ // 指定欄位預設值
default: "http://graph.facebook.com/{user-id}/picture?type=large"
})
avatar: string;
@Column({
default: "normal"
})
role: string;
}
Repository
TypeORM有兩種操作Entity的方式(EntityManager和Repository),但目前對於兩這間的用途與差異還不太清楚,有機會在日後補充。
此次示範Repository的操作。引入typeorm的getRepository功能。每當要對資料表執行CRUD的動作前都需要呼叫getRepository與Entity參數。最後,取得所有Users和新增User的操作邏輯包裝成方法輸出就完成了。
// src/repositories/user.repo.ts
import { User } from "../entities/user.entity";
import { getRepository } from "typeorm";
export function getUsers() {
return getRepository(User).find();
}
export async function createUser(data: User) {
let newUser = getRepository(User).create(data);
return await getRepository(User).save(newUser)
}
後續
更多Typescript開發練習
推薦wanago.io的一系列實作教學
其他軟體架構案例參考
Typescript隨手翻 - 參考文件