上节我们使用了npm i mongoose
命令安装了mongoose,那么这里的npm是什么?
NPM是一个模块管理工具,我们可以用它安装其他人编写好的模块。例如Mongoose就是别人编写好的模块,我们无需关心模块内部的内容,只要放心调用它的API就好了。
NPM的常用命令是:
- 安装:
$ npm install <targetModule>
- 更新:
$ npm update <targetModule>
- 删除:
$ npm uninstall <targetModule>
- 搜索:
$ npm search <targetModule>
- 列出项目所用模块:
$ npm list <targetModule>
更多关于NPM的使用,可以参考阮一峰老师的教程
与NPM有关的,在你的项目目录下,往往需要一个package.json
文件,这个文件定义了项目的配置信息和所用的模块。
生成package.json
文件:
$ npm init
当目录下有package.json
文件时,命令npm install
会根据文件中的依赖安装依赖包。
NPM安装的是别人安装好的模块,那我们能否开发自己的模块,或者说先定一个小目标:
把自己的应用模块化。
回头打开我们的server.js文件,我们会发现如果我们想进行修改,比如新增一个用户密码系统,让每个Todo都有所属的用户,那我们的代码势必要增加至少两倍。如果我们要为HTML添加一个新的CSS或JS文件,也势必要修改原来的代码。
好的代码应该是对扩展开放,对修改关闭的,所以我们现在要重构代码,模块化我们的服务端应用。
于是一个问题来了: 模块之间应该如何互相引用?
Node.js遵循CommonJS模块规范,在这一规范中,有两个方法:
require
负责引入模块exports
负责输出模块
举个例子,我们创建两个文件,一个叫math.js
,一个叫test.js
//math.js
function addTwo(x) {
return x + 2
}
function decTwo(x) {
return x - 2
}
exports.addTwo = addTwo
exports.decTwo = decTwo
//test.js
const math = require('./math')
console.log(math.addTwo(1)) // 3
console.log(math.decTwo(2)) // 0
test.js文件成功导入了math模块,并调用了math模块暴露的接口。
CommonJS模块规范也不是没有缺点,那就是假如我的test.js只想引入addTwo方法而不想引入decTwo,是无法实现的。 ES6实现了自己的模块规范,解决了这个问题。
//math.js
function addTwo(x) {
return x + 2
}
function decTwo(x) {
return x - 2
}
export {addTwo, decTwo}
//test.js
import {addTwo} from './math'
console.log(addTow(1)) //3
程序出现异常时,就会报错甚至崩溃,为了避免程序崩溃,也为了定位异常,我们需要对程序做完善的异常处理。
例如,在访问不存在的资源时,如果我们不做任何措施,后端程序就会报错,但是如果我们每次处理资源访问时都先检查它是否存在,不存在的话像客户端返回404状态码的http回应,那么程序不仅不会崩溃,我们还清晰的定位了问题。
常见的异常处理手段:
- 直接处理。有时候处理方法很明显,例如上文提到的文件不存在。
- 提示客户端。有时候是客户端错误,例如输入了错误格式的文件。
- 重试操作。有时存在网络问题,重试操作即可解决问题。
- 记录错误。有时错误的影响不会太大,可以考虑把错误记下来,然后继续运行。
- 崩溃。有时发生了严重错误,例如危及用户信息安全的错误,这时就强行停止服务,以防危害扩散。
单元测试(Unit Testing)又称为模块测试,是针对模块来进行正确性检验的测试工作。
单元测试保证了模块正常工作,写好测试用例后如果模块重构,单元测试能保证模块功能不变。
单元测试也是一种文档记录,让后续的开发者或维护者直观的理解程序API。
手写单元测试总是繁琐的,因此我们可以选择使用测试框架和断言库来帮助我们高效的编写单元测试。
Mocha是应用最广泛的测试框架之一。我们用它搭配断言库should来使用。下面通过一个小例子来演示单元测试:
首先安装should和mocha
$ npm i should
$ npm i mocha
我们新建一个math.js文件,写入代码:
//math.js
function addTwo(x) {
return x + 2
}
function decTwo(x) {
return x - 2
}
exports.addTwo = addTwo
exports.decTwo = decTwo
然后新建一个math.test.js,编写测试用例:
const math = require('./math')
const should = require('should')
describe('addTwo函数的测试', () => {
it('addTwo(2)应该等于4', () => {
const a = math.addTwo(2)
//这是一行断言
a.should.be.exactly(4).and.be.a.Number
})
})
describe('decTwo函数的测试', () => {
it('decTwo(2)应该等于0', () => {
const a = math.decTwo(2)
a.should.be.exactly(0).and.be.a.Number
})
})
然后运行:
mocha math.test.js
运行结果:
addTwo函数的测试
✓ addTwo(2)应该等于4
decTwo函数的测试
✓ decTwo(2)应该等于0
2 passing (12ms)
我们对现有后端逻辑进行分割,可以得出以下模块:
- 数据管理模块——负责与数据库进行交互
- 静态文件处理模块——负责处理不同的静态文件
- 路由注册模块——负责拆分针对不同路由的访问和对它们的回应
下面我们就一一实现这些模块
首先我们思考和数据库交互数据需要暴漏出怎样的接口。一般来说都应该实现对数据的增删查改的接口的暴露。另外还应该暴漏出数据库的配置接口,以便在不同的环境下修改方便。
首先我们思考如何定义数据库连接接口,经过对mongoose代码的观察,我们发现实例化数据库连接的代码为:
const db = mongoose.connect('mongodb://dbuser:password@dbhost:dbport/dbname'
因此,我们只需要定义传入的参数,就能暴露相应的接口了。
//model.js
//自定义数据库配置
function connect(cfg) {
let db
if(cfg.user) {
mongoose.Promise = global.Promise
db = mongoose.connect(`mongodb\:\/\/${cfg.user}:${cfg.pwd}@${cfg.host}:${cfg.port}/${cfg.db}`)
} else {
db = mongoose.connect(`mongodb\:\/\/${cfg.host}:${cfg.port}/${cfg.db}`)
}
db.connection.on('error', function(err) {
console.log(`link error: ${err}`)
})
return db
}
我们认为我们的应用所支持的应用模型最多提供6个接口: 增加单个内容,更新单个内容,查找单个内容,删除单个内容,查找多个内容,删除多个内容。
因此我们可以把模型作为类,当有新的模型时,可以选择实例化它或者继承模型类来修改增加或删除方法。
class ModelItem {
/**
* 构造数据模型的schema和model
* name是数据的名字
* struct是数据的结构
* db是实例化的数据库连接
*
* @param {String} name
* @param {Object} struct
* @param {Object} db
* @return {Void}
*/
constructor(name, struct, db) {
this.schema = new mongoose.Schema(struct)
this.Model = db.model(name, this.schema)
}
add(...) {
//TODO
}
update(...) {
//TODO
}
find(...) {
//TODO
}
findOne(...) {
//TODO
}
remove(...) {
//TODO
}
removeOne(...) {
//TODO
}
}
我们还需要一个记录数据模型的列表,它的作用有:
- 记录一个实例化的数据库连接
- 定义使用这个连接的数据模型
- 记录数据库配置信息
- 实例化数据模型以供外部调用
//model.js
class ModelList {
/**
* 初始化数据列表
* 加载默认配置
* 添加数据模型类型
*
* @param {Void}
* @return {Void}
*/
constructor() {
this.manager = {}
this._collections = new Set()
this._cfg = {
user: '',
pwd: '',
host: 'localhost',
port: '27017',
db: 'test',
}
//这里规定了数据模型的类型
//可以通过添加类型来达到使用不同模型的目的
this._modelType = {
//默认数据模型就是我们刚刚声明的模型
normal: (name, struct, db) => (
new ModelItem(name, struct, db)
)
//可以在这里添加其它类型数据模型
}
}
/**
* 数据库配置
* 未提及部分会遵循默认配置
* cfg是配置信息,有以下选项:
* 用户名 user
* 密码 pwd
* 主机 host
* 端口 port
* 所用数据库 db
*
* @param {Object} cfg
* @return {Void}
*/
setConfig(cfg) {
Object.assign(this._cfg, cfg)
}
/**
* 添加一个数据库集合
* name是集合的名称
* struct是集合的结构
* type是数据模型的类型
*
* @param {String} name
* @param {Object} Struct
* @param {String} type
* @return {Void}
*/
addModel(name, struct, type = 'normal') {
this._collections.add({
name: name,
struct: struct,
type: type
})
}
/**
* 从列表加载数据结构
* 列表的结构为
* [
* {
* name: 'name',
* struct: {
* name: 'name'
* },
* type: 'type' <optional>
* }
* ]
*
* @param {Array} list
* @return {Void}
*/
loadModelFromList(list) {
for(let i of list) {
this.addModel(i.name, i.struct)
}
}
/**
* 数据库连接函数
* 返回一个mongoose连接的实例
*
* @param {Void}
* @return {Object} db
*/
connect() {
let db
if(this._cfg.user) {
mongoose.Promise = global.Promise
db = mongoose.connect(`mongodb\:\/\/${this._cfg.user}:${this._cfg.pwd}@${this._cfg.host}:${this._cfg.port}/${this._cfg.db}`)
} else {
db = mongoose.connect(`mongodb\:\/\/${this._cfg.host}:${this._cfg.port}/${this._cfg.db}`)
}
db.connection.on('error', function(err) {
console.log(`link error: ${err}`)
})
return db
}
/**
* 初始化数据库
* 添加指定的集合,暴露接口
*
* @param {Void}
* @return {Object} manager
*/
init() {
const db = this.connect()
for (let m of this._collections) {
this.manager[m.name] = this._modelType[m.type](m.name, m.struct, db)
}
return this.manager
}
}
module.exports = ModelList
有了这个模块,我们就可以通过下面这种方式优雅的使用数据:
//引入model模块
const Model = require('./model')
const model = new Model()
//自定义配置
model.setConfig({
user: 'example',
pwd: 'example',
db: 'example'
})
//添加模型
model.addModel('todo', {
todo: {type: String},
finish: {type: Boolean, default: false}
})
//初始化
const manager = model.init()
const todoManager = manager.todo
//添加数据
todoManager.add()
//查找数据
todoManager.find()
完整的代码请移步chapter5/model.js进行阅读。
完成模块的构建之后我们需要编写单元测试,本模块的单元测试请阅读chapter5/model.test.js。
先创建一个对象,参考MIME,规定可能用到的文件的Content-Type
const mime = {
"html": "text/html",
"css": "text/css",
"js": "text/javascript",
"json": "application/json",
"gif": "image/gif",
"ico": "image/x-icon",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png"
}
然后处理的核心逻辑:
/**
* res是serverResponse
* pathname是静态文件位置
* ext是文件后缀
*
* @param {Object} res
* @param {String} pathname
* @param {String} ext
* @return {Void}
*/
function readStatic(res, pathname, ext) {
fs.exists(pathname, (exists) => {
if(!exists) {
res.writeHead(404, {'Content-Type': 'text/plain'})
res.write('The request url' + pathname + 'was not found on this server')
res.end()
} else {
fs.readFile(pathname, (err, file) => {
if(err) {
res.writeHead(500, {'Content-Type': 'text/plain'})
res.end(err)
} else {
const contentType = mime[ext] || 'text/plain'
res.writeHead(200, {'Content-Type': contentType})
res.write(file)
res.end()
}
})
}
})
}
在编写路由注册模块之前,我们需要先想想路由应该怎样注册足够优雅。
- 我们能够直观的定义不同请求方法的响应逻辑,而不是等请求拿到后再判断请求的方法是什么。
- 我们能够模式匹配,例如
/todo/:id/
(:id可以换成任意字符串)可以匹配/todo/1
和/todo/2
,而不是只能匹配单个路由。 - 我们希望路由被响应之前经过一个中间件,它能自动帮我们过滤不符合要求的路由。
核心思路是:
- 维护一个数组,在编写应用时,把需要处理的路由放在数组里。
- 请求到达时,遍历数组,有相应的处理方案就进行处理,没有则回复404信息
核心函数:
function core() {
let app = (req, res) => {
//用于被请求访问的app函数
}
//规定路由池
app.routes = []
//定义各种请求方法所对应的函数
const methods = ['get', 'post', 'put', 'options', 'delete', 'all']
methods.forEach((method) => {
app[method] = (path, fn) => {
app.routes.push({
method: method,
path: path,
fn: fn
})
}
})
//创建服务器
app.listen = (...config) => {
http.createServer(app).listen(...config)
}
return app
}
app函数中对路由的处理:
let app = (req, res) => {
//获取请求类型
const method = req.method.toLowerCase()
//解析请求url
const urlObj = url.parse(req.url, true)
//获取请求的路径
const pathname = urlObj.pathname
//获取请求的后缀名
const ext = path.extname(pathname).slice(1)
//判断是否有后缀
if(ext) {
//处理静态文件的逻辑
} else {
//递归调用来寻找路由是否被注册
let i = 0
(function next() {
if( i >= app.routes.length ) {
res.end(`Cannot ${method} ${pathname}`)
} else {
const route = app.routes[i++]
if((route.method === method
|| route.method === 'all')
&& (route.path === pathname
|| route.path === '*')) {
route.fn(req, res)
} else {
next()
}
})()
}
}
}
经过这层处理,我们的路由可以写为:
app.get('/path/name', (req, res) => {
//TODO:路由处理
})
著名Node.js框架Express就采用这种写法。
添加中间件,在处理请求前过滤不符合要求的请求
//前文中的next函数
!function next(){
...
const route = app.routes[i++]
//如果是中间件的话
if (route.method === 'middleware') {
if(route.path === '/'
|| route.path === pathname
|| pathname.startsWith(route.path + '/')) {
route.fn(req, res, next)
} else {
next()
}
} else {
//原来的遍历
if((route.method === method
|| route.method === 'all')
&& (route.path === pathname
|| route.path === '*')) {
route.fn(req, res)
} else {
next()
}
}
}()
//在core函数中,app函数外部添加
app.use = (path, fn) => {
app.routes.push({
method: 'middleware',
path: path,
fn: fn
})
}
这样,我们就能通过app.use
为路由添加中间件了,例如:
app.use('/path/name', (req, res, next) => {
if(//通过检测){
next()
}
})
为了让路由能达到多重匹配的目的,我们需要添加一些对路径的判断
我们希望访问的形式是/path/:id/:date/
时,我们在访问时能够调用:id
和:date
的值,为此我们要进一步修改next函数
!function next(){
...
//原来的遍历
if((route.method === method
|| route.method === 'all')
&& (route.path === pathname
|| route.path === '*')) {
route.fn(req, res)
} else {
//这里进行判断
//创造匹配:somename这种格式的正则表达式
const r = '^' + route.path.replace(/:[A-Za-z0-9][^\/]+/g, '[A-Za-z0-9][^\/]+') + '$'
const reg = new RegExp(r)
//如果路由中存在冒号
if(route.path.includes(':')
&& (route.method === method
|| route.method === 'all')
&& reg.test(pathname)) {
let index = 0
//分割注册的路由
const pathArray = route.path.split('/')
//分割当前路由
let target = pathname.split('/')
let params = new Map()
//比对得出当前路由和注册的路由的关系
pathArray.forEach((path) => {
if(/\:/.test(path)) {
params[path] = target[index]
}
index++
})
req.params = params
route.fn(req, res)
} else {
next()
}
}
}
}()
这样我们就得到了一个可以模式匹配的路由,举个例子:
app.get('/todo/:id', (req, res) => {
//获取:id的值
const id = req.params[':id']
})
完整的代码请移步chapter5/core.js进行阅读。
完成模块的构建之后我们需要编写单元测试,本模块的单元测试请阅读chapter5/core.test.js。
至此,我们就完成了后端的三个模块。