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






你可能感興趣的文章

Secure Apache Using Certbot with Let's Encrypt on Ubuntu 20.04

Secure Apache Using Certbot with Let's Encrypt on Ubuntu 20.04

Linkedin Java 檢定題庫 子類別複寫

Linkedin Java 檢定題庫 子類別複寫

AppWorks School Batch #16 Front-End Class 學習筆記&心得(駐點階段二:專題研討+協作練習專案)

AppWorks School Batch #16 Front-End Class 學習筆記&心得(駐點階段二:專題研討+協作練習專案)






留言討論