dogboot是一款用于nodejs的web框架,使用TypeScript编写,支持最新的js/ts语法。在设计理念上,我们致敬了著名的Java框架Spring Boot,装饰器、依赖注入等等,都是web开发中最流行的技术。
新建项目并且安装dogboot
mkdir dogboot-demo
cd dogboot-demo
npm init -y
npm i dogboot
npm i typescript -D
接下来,根据约定优于配置的理念,创建一些目录以及文件,最终,你的目录结构大概如下
├──src
| ├── controller
| | └── HomeController.ts
| └── app.ts
├──package-lock.json
├──package.json
└──tsconfig.json
package.json是npm的包管理清单文件。现在,请打开这个文件,大概里面的内容会是这个样子
{
"name": "dogboot-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dogboot": "^1.6.0"
},
"devDependencies": {
"typescript": "^3.8.3"
}
}
将scripts节替换为
"scripts": {
"tsc": "tsc",
"start": "node bin/app.js"
}
tsconfig.json是TypeScript项目的可选配置文件,对于dogboot我们建议填入以下内容,
{
"compilerOptions": {
"module": "commonjs",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"lib": ["esnext"],
"outDir": "bin"
},
"include": ["src"]
}
如果想要深入了解tsconfig.json,请移步TypeScript官网关于tsconfig的讲解
是时候写(复制/粘贴)点代码了
打开app.ts,输入以下内容
import { DogBootApplication, getContainer } from 'dogboot';
getContainer().loadClass(DogBootApplication).runAsync()
打开HomeController.ts,输入以下内容
import { Controller, GetMapping, BindQuery } from "dogboot";
@Controller('/home')
export class HomeController {
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
return `Hello ${name}`
}
}
现在,最小可运行项目已经准备好,是时候检查一下成果了,依次执行以下命令完成编译以及启动程序
npm run tsc
npm start
你会在控制台看到你的一行日志打印
Your application has started at 3000 in xxxms
然后,请在浏览器打开http://localhost:3000/home/index?name=World
你将看到
Hello World
这就是一个最小可运行的dogboot程序
dogboot采用组件化设计,程序由一个个组件组成,就像拼积木一样。这些组件都由dogboot提供的一个container管理,包括创建、实例化、注入。 组件又分为不同的类型,有Startup、Controller、ActionFilter、ExceptionFilter等特殊组件,以及最基本的组件Component。 应用层需要做的就是把你需要的组件用container装载,然后把container运行起来。 回顾一下app.ts的代码
getContainer().loadClass(DogBootApplication).runAsync()
getContainer是一个全局方法,用于获取dogboot提供的全局唯一的container,然后装载DogBootApplication类,然后是runAsync,将container运行起来。 runAsync方法中会先自动装载bin目录的组件,等所有组件都装载完成后,开始实例化所有Startup组件。 DogBootApplication是一个Startup组件,所以会在这一步被实例化。 为了定位到bin目录位置,dogboot需要知道程序的入口,以及固定的目录结构bin/app.js,通常不需要手动指定程序入口文件,dogboot会使用require.main.filename作为入口,然而你在测试的时候,dogboot程序可能是由测试程序启动的,这时候require.main.filename就不是app.js的路径了,这时候可以使用以下代码手动指定入口
process.env.dogEntry = __filename
一个dogboot程序是一个DogBootApplication的实例,但是不能通过new DogBootApplication()来创建。
要使用getContainer().loadClass(DogBootApplication)来装载,然后交由dogboot的全局唯一container来管理DogBootApplication的生命周期。
程序会自动判断当前的运行环境,如果是直接运行ts文件(使用ts-node运行),会扫描src目录,如果是运行编译后的js文件,会扫描bin目录。
⚠️所以实际上,我们指定了编译后的文件存放的目录为bin,不能修改。
使用@Controller装饰器标记一个类为控制器,并且传入一个可选的path参数,用于指定路由前缀。按照约定,控制器文件名应该以Controller结尾,但这不是必须的。
path参数也是可选的,如果不传,dogboot会指定这个类名的前面一部分并且转为小写作为路由前缀。比如:HomeController的默认路由前缀是/home。
使用@StartUp装饰器标记一个类为预启动组件,并且传入一个可选的order参数。dogboot程序在启动其他组件之前会先执行预启动组件的启动方法,使用order参数定制你希望的启动顺序。 一个常规的预启动组件使用方式如下 声明:
import { StartUp, Init } from "dogboot";
@StartUp()
export class StartUp1 {
index: number;
@Init
private init() {
this.index = 0;
}
doSth() {
return this.index++
}
}
使用:
import { Controller, GetMapping, BindQuery } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
@Controller('/home')
export class HomeController {
constructor(private readonly startUp1: StartUp1) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth();
return `Hello ${name} ${index}`
}
}
执行之前的启动命令
npm run tsc
npm start
再次在浏览器打开http://localhost:3000/home/index?name=World
你会看到
Hello World 0
刷新页面,你会看到
Hello World 1
我们的预启动组件生效了,它通过构造器被注入到HomeController,并且保持了一个index变量,每次执行doSth方法,index会加1
使用@Config标记一个类为配置文件映射器。使用配置文件映射器,而不是require('xxx.json'),前者得到的对象具有类型声明,更便于使用。
name参数表示使用的配置文件名,默认为config.json
field参数表示映射器映射的配置节,如果不传,表示整个配置文件,使用a.b.c映射a节下的b节下的c。
⚠️所以,不要在你的配置文件中使用任何类似于a.b表示一个节,这会使配置映射器出错。
一个常规的配置文件映射器使用方式如下
1、在项目根目录下新建一个config.json文件,输入
{
"mysql": {
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"password": "524163",
"db": "test1"
}
}
2、在src目录下新建文件MyConfig.ts
import { Config, Typed } from "dogboot";
@Config({ field: 'mysql' })
export class MyConfig {
@Typed()
host: string
@Typed()
port: number
@Typed()
user: string
@Typed()
password: string
@Typed()
db: string
}
3、使用
import { Controller, GetMapping, BindQuery } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
@Controller('/home')
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth();
return `Hello ${name} ${index} ${this.myConfig.host}`
}
}
重复之前的编译以及启动操作,然后在浏览器打开http://localhost:3000/home/index?name=World
你会看到
Hello World 0 127.0.0.1
我们的配置文件映射器生效了,MyConfig类映射了config.json,你在使用MyConfig的时候,所有的字段都是有类型声明的,并且已经转换为你希望的类型,这得益于我们使用了@Typed,dogboot会帮我们自动处理被@Typed标记的字段的类型。
如果这个config.json写法如下
{
"mysql": {
"host": "127.0.0.1",
"port": "3306",
"user": "root",
"password": "524163",
"db": "test1"
}
}
然后在HomeController里面测试typeof myConfig.port,会发现得到number而不是string
使用@Component标记一个类为可注入组件,同时也表示此组件的生命周期完全交给dogboot内置的依赖注入管理器管理。一个常规的Component使用方式如下
1、创建一个组件
import { Component, Init } from "dogboot";
@Component
export class HomeService {
@Init
doInit() { }
getSth() {
return 1
}
}
2、使用
import { Controller, GetMapping, BindQuery } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
import { HomeService } from "../service/HomeService";
@Controller('/home')
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig, private readonly homeService: HomeService) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth();
return `Hello ${name} ${index} ${this.myConfig.host} ${this.homeService.getSth()}`
}
}
几乎与StartUp一样,区别只是使用了@Component来标记类,Component表示一般组件,这些组件仅仅具有依赖注入的功能,dogboot还包含很多特殊的组件,请阅读本文档了解更多
在组件中,使用@Init标记一个方法,此方法用于初始化组件,支持异步方法。
用于属性注入,用法如下
@Controller()
export class HomeController{
@Autowired(UserService)
userService: UserService
GetMapping()
index(){
return this.userService.doSomething()
}
}
前面的例子我们都是使用构造器注入,如果你喜欢,你也可以使用属性注入。
大部分情况下,两者功能一致,唯一的不同是:Autowired可以实现循环依赖。虽然应该尽量避免出现循环依赖,但是我们也为那些特殊情况做了考虑。
假如UserService依赖ItemService,同时ItemService又依赖UserService,你可以使用一种“稍微难懂”的写法来实现循环依赖。用法如下
UserService.ts
@Component
export class UserService {
constructor() {
console.log('UserService', Math.random())
}
@Autowired(() => ItemService)
itemService: ItemService
index() {
console.log('UserService')
}
}
ItemService.ts
@Component
export class ItemService {
constructor() {
console.log('ItemService', Math.random())
}
@Autowired(() => UserService)
userService: UserService;
index() {
console.log('ItemService')
}
}
仔细看,使用了@Autowired(() => UserService)而不是@Autowired(UserService)
如果使用@Autowired(UserService),则会出现在ItemService中解析UserService时UserService为空,或者在UserService中解析ItemService时ItemService为空的情况,这取决于两者的加载顺序。
用于将Controller内的方法映射为Action,需要传入method以及path参数。这两个参数不是必须的,默认会映射为get方法,并且使用方法名作为路由,为了方便书写,我们提前准备好了几种常用的method对应的Mapping。分别是@GetMapping、@PostMapping、@PutMapping、@PatchMapping、@DeleteMapping、@HeadMapping。如果你要映射所有的method,可以使用AllMapping。
用于获取请求上下文信息使用方式如下
@Controller()
export class HomeController{
GetMapping()
index(@BindContext ctx:any){
console.log(ctx.query.name)
return 'ok'
}
}
这个上下文信息完全是koa提供的请求上下文,所以你可以从koa官网找到关于请求上下文的详细介绍,所以如果你需要一些类型检查以及智能提示,你可以安装koa的typing库
npm i @types/koa -D
然后你的HomeController可以写成
@Controller()
export class HomeController{
GetMapping()
index(@BindContext ctx:koa.Context){
console.log(ctx.query.name)
return 'ok'
}
}
类似的获取koa原生请求对象的装饰器还有:@BindRequest、@BindResponse。
使用@BindQuery获取url中查询参数,dogboot将会自动转换参数类型为期望的类型。在我们的从0开始中已经介绍过这个用法,就不重复介绍了。类似的装饰器还有:
@BindPath 用于获取path参数
@BindBody 用于获取请求体对象
⚠️注意:@BindContext、@BindRequest、@BindResponse、@BindQuery、@BindPath、@BindBody只能用于Controller
表示此字段需要转换为指定类型
如果需要转换的字段是一个数组,@Typed就无法胜任了,因为ts的Array是一个泛型,且程序运行时无法知道泛型确切类型,所以转换数组时还需要指定确切类型
对于请求参数,你可能需要做一些通用的验证,避免冗余代码,此装饰器用于指定此字段不可为空。
举个例子
import { Typed, NotNull } from "dogboot";
export class IndexIM {
@Typed()
pageSize: number
@Typed()
pageIndex: number
@Typed()
@NotNull()
status: number
}
类似的装饰器还有:@NotEmpty、@NotBlank、@Length、@MinLength、@MaxLength、@Range、@Min、@Max、@Decimal、@MinDecimal、@MaxDecimal、@Reg、你也可以使用更加灵活的@Func,支持传入自定义的验证方法。除了这些已经预先定义的验证装饰器,你还可以封装自己的验证装饰器,参考dogboot中@NotNull的源码
/**
* a != null
* @param errorMesage
*/
export function NotNull(errorMesage: string = null) {
errorMesage = errorMesage || '字段不能为空'
return Func(a => {
if (a != null) {
return [true]
}
return [false, errorMesage]
})
}
实现一个你自己的验证器
import { Func } from "dogboot";
export function MyValidator(errorMesage: string = null) {
errorMesage = errorMesage || '自定义验证不通过'
return Func(a => {
if (a == null) {
return [true]
}
if (a.includes('1')) {
return [true]
}
return [false, errorMesage]
})
}
使用
1、新建一个数据传输类,内容如下
import { Typed } from "dogboot";
import { MyValidator } from "../../common/validator/MyValidator";
export class UpdateNameIM {
@Typed()
@MyValidator()
name: string
}
2、修改我们的控制器为
import { Controller, GetMapping, BindQuery, PostMapping, BindBody } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
import { HomeService } from "../service/HomeService";
import { UpdateNameIM } from "../model/home/UpdateNameIM";
@Controller('/home')
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig, private readonly homeService: HomeService) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth();
return `Hello ${name} ${index} ${this.myConfig.host} ${this.homeService.getSth()}`
}
@PostMapping('/updateName')
async updateName(@BindBody im: UpdateNameIM) {
return im
}
}
我们加上了一个名字为updateName的action,并且映射为post方法,我们可以在postman(一款测试api的工具)测试这个接口
post参数为
{
"name":"2"
}
那么我们会看到我们程序的控制台有错误打印,类似于
Error: 自定义验证不通过
at C:\Users\zhang\Desktop\dogboot-demo\node_modules\dogboot\bin\lib\DogBoot.js:589:23
at Generator.next (<anonymous>)
at fulfilled (C:\Users\zhang\Desktop\dogboot-demo\node_modules\dogboot\bin\lib\DogBoot.js:13:58)
at process._tickCallback (internal/process/next_tick.js:68:7)
postman测试工具收到的回复是
Internal Server Error
当然,正常情况下不应该程序这样出错而不处理,我会在稍后介绍如何优雅的进行错误处理。
使用@UseExceptionFilter标记一个Controller或者Action使用指定的异常过滤器。
在ExceptionFilter组件中添加@ExceptionHandler标记到方法上,用于处理指定的异常。
用法如下
1、创建一个ExceptionFilter组件,内容如下
import { ExceptionFilter, ExceptionHandler } from "dogboot";
@ExceptionFilter
export class MyExceptionFilter {
@ExceptionHandler(Error)
async handleError(error: Error, ctx: any) {
error.stack && console.log(error.stack)
ctx.body = { success: false, message: error.message }
}
}
2、在HomeController中使用
import { Controller, GetMapping, BindQuery, PostMapping, BindBody, UseExceptionFilter } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
import { HomeService } from "../service/HomeService";
import { UpdateNameIM } from "../model/home/UpdateNameIM";
import { MyExceptionFilter } from "../filter/MyExceptionFilter";
@Controller('/home')
//放在这里对此Controller下所有Action生效
@UseExceptionFilter(MyExceptionFilter)
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig, private readonly homeService: HomeService) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth()
return `Hello ${name} ${index} ${this.myConfig.host} ${this.homeService.getSth()}`
}
@PostMapping('/updateName')
async updateName(@BindBody im: UpdateNameIM) {
return im
}
}
也可用于Action
import { Controller, GetMapping, BindQuery, PostMapping, BindBody, UseExceptionFilter } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
import { HomeService } from "../service/HomeService";
import { UpdateNameIM } from "../model/home/UpdateNameIM";
import { MyExceptionFilter } from "../filter/MyExceptionFilter";
@Controller('/home')
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig, private readonly homeService: HomeService) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth()
return `Hello ${name} ${index} ${this.myConfig.host} ${this.homeService.getSth()}`
}
//放在这里仅对此Action生效
@UseExceptionFilter(MyExceptionFilter)
@PostMapping('/updateName')
async updateName(@BindBody im: UpdateNameIM) {
return im
}
}
现在,使用postman测试之前的例子,postmen接收到的返回是
{
"success": false,
"message": "自定义验证不通过"
}
这样,就实现了一个异常过滤器
这是使用局部过滤器的例子,事实上,更多时候,只需要把过滤器放在filter目录即可被自动扫描到,并且全局有效,参考@GlobalExceptionFilter
上面介绍了@UseExceptionFilter,但是这种方式需要在每一个使用的地方手动添加,在业务代码比较多的时候会变得十分繁琐,实际业务中更加推荐使用@GlobalExceptionFilter。 只需要将你的过滤器打上标记@GlobalExceptionFilter,并且位于filter目录内,就可以被dogboot自动扫描到,并且全局有效
使用@UseActionFilter标记一个Controller或者Action使用指定的Action过滤器。
ActionFilter在权限处理时非常有用,设置了ActionFilter的Action在执行前后会执行ActionFilter内的@DoBefore、@DoAfter方法。
举个例子,我们要在每一个接口请求判断用户的身份信息,如果身份信息不存在或者不合法,就不允许继续执行Action。
1、创建一个ActionFilter,内容如下
import { ActionFilter, DoBefore, ActionFilterContext } from "dogboot";
@ActionFilter
export class MyActionFilter {
@DoBefore
doBefore(actionFilterContext: ActionFilterContext) {
let ticket = actionFilterContext.ctx.get('ticket')
//请求的header中的ticket不能为空,且需要包含admin
if (!ticket || !ticket.includes('admin')) {
actionFilterContext.ctx.throw(401)
}
}
}
2、使用
import { Controller, GetMapping, BindQuery, PostMapping, BindBody, UseExceptionFilter, UseActionFilter } from "dogboot";
import { StartUp1 } from "../startup/StartUp1";
import { MyConfig } from "../MyConfig";
import { HomeService } from "../service/HomeService";
import { UpdateNameIM } from "../model/home/UpdateNameIM";
import { MyExceptionFilter } from "../filter/MyExceptionFilter";
import { MyActionFilter } from "../filter/MyActionFilter";
@Controller('/home')
//放在这里对此Controller下所有Action生效
@UseExceptionFilter(MyExceptionFilter)
@UseActionFilter(MyActionFilter)
export class HomeController {
constructor(private readonly startUp1: StartUp1, private readonly myConfig: MyConfig, private readonly homeService: HomeService) { }
@GetMapping('/index')
async index(@BindQuery('name') name: string) {
let index = this.startUp1.doSth()
return `Hello ${name} ${index} ${this.myConfig.host} ${this.homeService.getSth()}`
}
@PostMapping('/updateName')
async updateName(@BindBody im: UpdateNameIM) {
return im
}
}
现在,使用postman测试之前的例子,postmen接收到的返回是
{
"success": false,
"message": "Unauthorized"
}
这样,就实现了一个ActionFilter,此例子仅用于介绍UseActionFilter用法,实际生产中不建议使用这样简单的处理。
与UseExceptionFilter一样,UseActionFilter也可用于Action,也可以使用@GlobalActionFilter,使过滤器全局生效。
⚠️注意,不要在过滤器中保存请求上下文信息,因为dogboot所有的组件都是单例的。
另外,在这些过滤器中,可能会有一些请求上下文相关数据的流转,需要从Action带到Filter或者从Filter带到Action,或者Filter到Filter,可以把这些数据保存到ctx.state上,这也是koa官方的推荐方式。
比如,在ActionFilter中设置
actionFilterContext.ctx.state.userName = 'dogzhang'
然后在HomeController中使用
index(@BindContext ctx:any){
let userName = ctx.state.userName
}
与@GlobalExceptionFilter,是ActionFilter的全局版本
到目前为止,我还没有介绍怎样让我们的程序监听不同的端口,毕竟你不可能总是使用3000端口,是的,这需要配置
dogboot会优先从环境变量获取端口参数,这在通过命令行启动程序时很有用
dogPort=3002 node ./bin/app.js
这样,dogboot会监听3002端口,而不会理会你在配置文件指定的端口
当然,既然是环境变量,所以也可以在程序内用代码指定
process.env.dogPort = '3002'
这样指定端口等效于从命令行指定
再来说说配置文件,dogboot会尝试从config.json的app节中获取配置,如果配置没有找到,会使用dogboot内置的预设配置。全部配置如下
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
port | number | 3000 | 优先从createApp(port: number)获取参数 |
prefix | string | undefined | 路由前缀,比如赋值为/api,那么所有的路由前面需要加上/api,/home/index + /api = /api/home/index |
staticRootPathName | string | public | 静态资源的根目录 |
enableCors | boolean | false | 是否开启跨域 |
corsOptions | CorsOptions | new CorsOptions() | 跨域选项,参考下面的CorsOptions |
CorsOptions,具体说明请参考koa2-cors
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
origin | string | undefined | |
exposeHeaders | string[] | undefined | |
maxAge | number | undefined | |
credentials | boolean | undefined | |
allowMethods | string[] | undefined | |
allowHeaders | string[] | undefined |
dogboot会智能的合并你在config.json提供的配置,只有你配置了的字段,dogboot才会使用你的,否则,使用dogboot对于该字段预设的值
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。