在工作中已经涉及NodeJS项目开发,但是项目的整体框架已经是大佬搭建好的,目前可以基于现有框架正常开发,但是,如果有一个新的Node项目,我应该如何设计?如何搭建?通过本篇文章明确NodeJS能为我们提供什么以及如何搭建Node服务并逐步优化。如有理解有误的地方,还希望各位大佬们批评指正。
NodeJS可以做什么
NodeJS是什么:一个开源与跨平台的 JavaScript 运行时环境(来自官网定义)
前端工程化
NodeJS在前端工程化方面的作用是显而易见的,工程化最重要的就是Webpack工具,而Webpack核心是基于Node.js来运行的,但是这方面应用的最终目标是为了提升前端研发效率或者保证研发质量,并没有真正地应用到Node.js核心特点,而后端服务应用才是真正地应用 Node.js 异步事件驱动的特性。
后端服务
-
服务类型
-
从架构设计规范来说,可以分为:RESTful服务,RPC服务
-
根据服务提供的功能,可以分为:
-
网关:处理请求转发等逻辑,比如:Nginx
-
业务网关:处理业务相关的逻辑,比如一些通用的协议转化、通用的鉴权处理,以及其他统一的业务安全处理等
-
运营系统:处理日常的运营活动
-
业务系统,负责核心的业务功能的系统
-
中台服务:负责一些通用 App 类的服务,比如配置下发、消息系统及用户反馈系统等
-
各类基础层:这些就是比较单一的核心后台服务,例如用户模块,这就需要根据不同业务设计不同的核心底层服务
-
数据缓存和存储服务
-
哪些服务适合用Node实现
-
通用性好
-
低CPU计算
-
网络I/O要求高
-
并发要求高
-
服务端设计架构
-
传统的MVC模式
但是,在服务划分较细的情况下MVC模式显得有些不够清晰,如下图所示,M层不仅仅是数据库操作,导致开发的数据以及业务逻辑有时候在M层,有时候在C层。出现这类情况的核心原因是C与C之间无法进行复用,如果需要复用则需要放到M层,那么业务逻辑就会冗余在M,代码会显得非常繁杂。
-
View: 页面显示和交互
-
Model: 处理数据库相关操作
-
Controller: 处理业务逻辑
-
MSVC模式(注:Node.js应用开发实战专栏中学到的一种方式,类似Eggjs提供的分层结构,我们境内反洗钱系统也引入了service层)
MSVC模式与传统MVC模式相比多了一个可复用的Service层,所有数据相关的操作都集中于M层,而M层复用的业务逻辑则转到新的S层,C层则负责核心业务处理,可以调用M和S层。
搭建一个Node服务
接下来就通过一个例子来实践搭建一个Node服务,并应用MVC模式和MSVC模式对一个简单的RESTful服务逐步优化。
假设我们有这样一个需求:获取用户发帖的列表信息。该列表的内容包含两部分:一部分是从数据库获取的文章内容,这部分只包含用户ID,所以还需要通过用户ID批量拉取用户信息。根据这个需求,可以绘制如下时序图来理清楚接口功能及数据操作。
上图的详细过程是这样的:
-
通过
/v1/contents
拉取RESTfulServer的内容 -
RESTfulServer会首先去数据库中获取contents
-
拿到contents后解析出其中的userIds
-
通过
/v1/userinfos
API 调用apiServer的服务获取用户信息列表 -
apiServer同样需要和数据库交互,查询到所需要的用户信息
-
拿到用户信息后将其整合到contents中去
-
最后将contents返回给用户
apiServer
-
初始化:
npm init -y
-
安装依赖:
npm i mongodb querystring request request-promise
-
创建服务:
const server = http.createServer(async (req, res) => {
// 获取 get 参数
const myUrl = new URL(req.url, `http://${req.headers.host}`)
const pathname = myUrl.pathname
const pathname = url.parse(req.url).pathname
const paramStr = url.parse(req.url).query
const param = querystring.parse(paramStr)
// 过滤非拉取用户信息请求
if('/v1/userinfos' !== pathname) {
return setResInfo(res, false, 'path not found')
}
// 参数校验,没有包含参数时返回错误
if(!param || !param['user_ids']) {
return setResInfo(res, false, 'params error')
}
const userInfo = await queryData({'id' : { $in : user_ids.split(',')}})
return setResInfo(res, true, 'success', userInfo)
})
使用Node.js的url模块来获取请求路径和查询字符串,拿到查询字符串后还需要使用Node.js的querystring将字符串解析为参数的JSON对象。参数和请求路径解析成功后,再进行路径的判断和校验,如果不满足当前的要求,调用 setResInfo 报错返回相应的数据给到前端。具体实现如下:
function setResInfo(res, ret, message, dataInfo, httpStatus=200) {
let retInfo = {}
if(!ret) {
retInfo = {
'ret' : -1,
'message' : message ? message : 'error',
'data' : {}
}
} else {
retInfo = {
'ret' : 0,
'message' : message ? message : 'success',
'data' : dataInfo ? dataInfo : {}
}
}
res.writeHead(httpStatus, { 'Content-Type': 'text/plain' })
res.write(JSON.stringify(retInfo))
res.end()
return
}
路径和参数解析成功后,再根据当前请求参数从数据库中查询用户信息,这里的数据库使用的是MongoDB。
async function queryData(queryOption) {
const client = await baseMongo.getClient()
const collection = client.db('nodejs_cloumn').collection('user') // nodejs_cloumn 库中的 user 表
const queryArr = await collection.find(queryOption).toArray() // 转成数组形式
return queryArr
}
-
监听
server.listen(5000, () => {
console.log('server start http://127.0.0.1:5000')
})
-
启动服务:
node index.js
RESTful服务
-
初始化:
npm init -y
-
安装依赖模块:
npm i mongodb querystring request request-promise
-
创建服务:
const server = http.createServer(async (req, res) => {
// 获取get参数
const myUrl = new URL(req.url, `http://${req.headers.host}`)
const pathname = myUrl.pathname
if('/v1/contents' !== pathname) {
return setResInfo(res, false, 'path not found', null, 404) // 设置响应数据
}
let contents = await queryData({}, {limit: 10}) // 查询数据并获取
contents = await filterUserinfo(contents) // 在contents中增加用户信息
return setResInfo(res, true, 'success', contents) // 将整合后的contents返回给用户
})
与apiServer类似,首先解析路径与请求参数,然后通过queryData方法从数据库中获取10条数据,再将拿到的contents中的用户ID字段转换为用户信息。filterUserinfo逻辑如下:
async function filterUserinfo(contents) {
const userIds = []
contents.forEach(content => {
if (content['user_id']) {
userIds.push(content['user_id'])
}
})
if (userIds.length < 1) {
return addUserinfo(contents)
}
// 调用apiServer将用户的userIds转化为userinfos
const userinfos = await callApi('http://127.0.0.1:5000/v1/userinfos', {user_ids: userIds.join(',')})
if (!userinfos || userinfos.length < 1) {
return addUserinfo(contents)
}
const mapUserinfo = {}
userinfos.forEach(item => {
if (userIds.includes(item.id)) {
mapUserinfo[item.id] = item
}
})
return addUserinfo(contents, mapUserinfo)
}
function addUserinfo(contents, mapUserinfo={}) {
contents = contents.map(content => {
content['user_info'] = mapUserinfo[content['user_id']] ? mapUserinfo[content['user_id']] : {}
return content
})
return contents
}
-
监听:
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000')
})
-
启动服务:
node index.js
apiServer和RESTful Server启动成功后,在浏览器输入http://127.0.0.1:4000/v1/contents
拿到的数据结果如下:
{
"ret": 0,
"message": "success",
"data": [
{
"_id": "5fe7eb32d0a94b97431b6043",
"content": "test content",
"desc": "test desc",
"user_id": "1001",
"user_info": {
"_id": "5fe7ebf1d0a94b97431b6049",
"id": "1001",
"name": "test001",
"desc": "desc001"
}
},
{
"_id": "5fe7ebc4d0a94b97431b6048",
"content": "view content",
"desc": "view desc",
"user_id": "1002",
"user_info": {
"_id": "5fe7ec16d0a94b97431b604a",
"name": "test002",
"desc": "desc002",
"id": "1002"
}
}
]
}
至此,在没有使用架构模式的情况下实现了获取用户评论的需求,但是代码是混在一起的,不好维护。MVC被实践证明是非常好的架构模式,接下来就利用MVC模式对上述服务进行优化。
MVC模式
逻辑分析:如果想要使用MVC模式进行改造,首先要分析清楚哪些逻辑属于M层,哪些逻辑属于C层。
这样,就可以创建两个目录分别为model和controller,其中model下创建content.js用来处理content的model逻辑,controller下创建content.js用来处理content的controller逻辑。
在没有架构模式时,index.js中基本上处理了所有的业务,但是根据当前架构模式,此时index.js中只适合处理url路径解析、路由判断及转发,因此需要简化原来的逻辑。首先需要根据路由配置一份路由转发逻辑:
const routerMapping = {
'/v1/contents': {
'controller': 'content',
'method': 'list' // 一个异步方法
},
'/v1/test': {
'controller': 'content',
'method': 'test' // 一个同步方法
}
}
路由配置完成以后,就需要根据路由配置,将请求路径、转发到处理相应功能的模块或者类、函数中去:
if(!routerMapping[pathname]) {
return baseFun.setResInfo(res, false, 'path not found', null, 404)
}
// require 对应的 controller 类
const ControllerClass = require(`./controller/${routerMapping[pathname]['controller']}`)
try { // 尝试调用类中的方法
const controllerObj = new ControllerClass(res, req)
if(controllerObj[
routerMapping[pathname]['method']
][
Symbol.toStringTag
] === 'AsyncFunction') { // 判断是否为异步 promise 方法,如果是则使用 await
return await controllerObj[routerMapping[pathname]['method']]()
} else { // 普通方法则直接调用
return controllerObj[routerMapping[pathname]['method']]()
}
} catch (error) { // 异常时,需要返回 500 错误码给前端
console.log(error)
return baseFun.setResInfo(res, false, 'server error', null, 500)
}
这里实现的主要功能为:
-
判断路由是否在配置内,不存在则返回 404
-
加载对应的Controller模块
-
判断所调用的方法类型是异步还是同步,如果是异步使用await来获取执行结果,如果是同步则直接调用获取返回结果
接下来是Controller。首先,在项目根目录下创建一个 core 文件夹,并创建一个 controller.js作为基类然后把一些相同的功能放入这个基类,比如res和req的赋值,以及通用返回处理,还有url参数解析等,即:
const baseFun = require('../util/baseFun')
class Controller {
constructor(res, req) {
this.res = res
this.req = req
}
/**
*
* @description 设置响应数据
* @param object res http res
* @param boolean ret boolean
* @param string message string
* @param object dataInfo object
* @param int httpStatus
*/
resApi(ret, message, dataInfo, httpStatus=200) {
return baseFun.setResInfo(this.res, ret, message, dataInfo, httpStatus)
}
}
module.exports = Controller
然后再是content的controller:
const Controller = require('../core/controller')
class Content extends Controller {
constructor(res, req) {
super(res, req)
}
async list() {
// ...
}
test() {
return this.resApi(true, 'good')
}
}
module.exports = Content
和Controller类似,也需要一个基类来处理Model层相似的逻辑,然后其他Model来继承这个基类:
const baseMongo = require('../core/baseMongodb')()
class Model {
constructor() {
this.db = 'nodejs_cloumn'
this.baseMongo = baseMongo
}
async get(collectionName) {
const client = await this.baseMongo.getClient()
const collection = client.db(this.db).collection(collectionName)
return collection
}
}
module.exports = Model
基类首先设置了数据库名称,其次定义了一个get方法来获取表的操作句柄。接下来就是content的model逻辑:
const Model = require('../core/model')
class ContenModel extends Model {
constructor() {
super()
this.collectionName = 'content'
}
async getList() {
const queryOption = {}
const collection = await this.get(this.collectionName)
const queryArr = await collection.find(queryOption).toArray()
return queryArr
}
}
module.exports = ContenModel
这里的getList方法的原理和RESTfulServer中的查询类似,通过父类的get方法获取表 content的操作句柄,再调用MongoDB的find方法查询contents。然后,再去完善content.js的controller逻辑中list函数部分的逻辑:
async list() {
let contentList = await new ContentModel().getList()
contentList = await this._filterUserinfo(contentList) // 通过私有方法_filterUserinfo 处理用户信息部分
return this.resAPI(true, 'success', contentList)
}
至此,就将简单的RESTful服务优化为一个符合MVC模式的服务,代码可扩展且易于维护。
MSVC模式
在上面的代码中存在一个问题,就是_filterUserinfo
是放在controller来处理,这个方法又会涉及调用apiServer的逻辑,看起来也是数据处理部分,从原理上说这部分不适合放在 controller。其次在其他controller也需要_filterUserinfo
时就比较繁琐了,比如我们现在有另外一个controller叫作recommend.js,这里面也是拉取推荐的content,也需要这个 _filterUserinfo
方法, 这个时候这个方法的可复用性就比较低了,因为_filterUserinfo
在controller是私有方法,recommend controller调用不到,那么为了复用,只能将该方法封装到content-model中,并且将数据也集中在model层去。
虽然解决了上述问题,但是它又会存在一个新的问题就是分层不够清晰了,Model层既要负责数据处理,又要负责业务逻辑,Controller层的业务减少了,但是分层不明确,有些业务放在Model,有些在Controller层,对于后期代码的维护或者扩展都变得困难了。这个问题通过引入Service层来解决,即:将需要复用的方法放在Service层而不是设置私有方法。这样就可以复用 _filterUserinfo,并解决M与C层不明确的问题。
所以,遵循MVC模式的服务端代码再调整一下:
-
创建一个文件夹service来存放相应的Service层代码
-
创建一个 content.js 来表示
content-service
这个模块; -
将原来代码中的
_filterUserinfo
逻辑转到content-service
中去
整个代码逻辑组织就变成下图这种形式:
最终,controller的list方法就调整为:
async list() {
let contentList = await new ContentModel().getList()
contentList = await contentService.filterUserinfo(contentList) // 可复用
return this.resApi(true, 'success', contentList)
}
demo地址:
https://github.com/liumengge/frontEnder-Milly/tree/master/13.Node/Servers
至此,可复用、易维护的Node服务demo就完成了。
小结
本篇文章主要记录了如何搭建Node服务并逐步优化的过程,主要有以下几点收获:
-
NodeJS可以做什么
-
如何搭建一个Node服务
-
画简易版时序图梳理请求流程,梳理清楚接口和数据就梳理清楚了前端交互
-
分析流程各个阶段匹配在哪个层面的逻辑,对号入座
-
实践、迭代、优化
参考资料:
-
[NodeJS教程](http://nodejs.cn/learn/introduction-to-nodejs)
-
[理解RESTful架构](http://www.ruanyifeng.com/blog/2011/09/restful.html)
-
[RESTful API 设计参考文献](https://github.com/aisuhua/restful-api-design-references)
文章评论