Typescript express 打造Twitter專案 Part1


前言

過去筆者使用原生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是一個幫助軟體開發能達到關注點分離的軟體設計模式。而關注點分離簡單來說又是將程式邏輯依據用途、目的進行分類與管理,通常會參考一些前人踩過雷的成功的範例(例如設計模式)。
期望達到以下的目的:

  1. 程式重複使用(reuse)
  2. 解耦合(de-coupling)
  3. 易維護性(maintainability)
  4. 平行開發(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

== 前置作業 ==

  1. 安裝好 npm
  2. 安裝好 編輯器(例如 Vscode)

專案設定

  1. 打開Terminal安裝typescript在主機的環境中$npm install -g typescript
  2. 建立專案資料夾,並進到資料夾底下用npm init -y產生 package.json
  3. tsc --init 初始化產生 tsconfig.json(待會再來設定裏頭的內容)
  4. 接著安裝需要的套件 (套件用途在後續幾個小節中說明)
    • 在 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
  5. 在專案資料夾中新增一個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"
       ]
    }
    
  6. 設定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隨手翻 - 參考文件

參考

Repository 設計模式實作
淺談要不要用Repository
export funciton的方式

#TypeScript #TypeORM #MySQL #Enitity Desigin #Repository Pattern






Related Posts

target="_blank" 風險問題處理,加入 rel="noreferrer noopener"

target="_blank" 風險問題處理,加入 rel="noreferrer noopener"

Day00-Lavarel新手接觸

Day00-Lavarel新手接觸

使用 visx 製作資料圖表-台灣六都即時空氣品質指標

使用 visx 製作資料圖表-台灣六都即時空氣品質指標



Sponsored