[转] 使用Node.js实现简易MVC框架

使用Node.js搭建静态资源服务器一文中咱们完成了服务器对静态资源请求的处理,但并未涉及动态请求,目前还没法根据客户端发出的不一样请求而返回个性化的内容。单靠静态资源岂能撑得起这些复杂的网站应用,本文将介绍如何使用Node处理动态请求,以及如何搭建一个简易的 MVC 框架。由于前文已经详细介绍过静态资源请求如何响应,本文将略过全部静态部分。javascript

一个简单的示例

先从一个简单示例入手,明白在 Node 中如何向客户端返回动态内容。html

假设咱们有这样的需求:java

  1. 当用户访问 /actors时返回男演员列表页
  2. 当用户访问 /actresses时返回女演员列表

能够用如下的代码完成功能:node

const http = require('http'); const url = require('url'); http.createServer((req, res) => { const pathName = url.parse(req.url).pathname; if (['/actors', '/actresses'].includes(pathName)) { res.writeHead(200, { 'Content-Type': 'text/html' }); const actors = ['Leonardo DiCaprio', 'Brad Pitt', 'Johnny Depp']; const actresses = ['Jennifer Aniston', 'Scarlett Johansson', 'Kate Winslet']; let lists = []; if (pathName === '/actors') { lists = actors; } else { lists = actresses; } const content = lists.reduce((template, item, index) => { return template + `<p>No.${index+1} ${item}</p>`; }, `<h1>${pathName.slice(1)}</h1>`); res.end(content); } else { res.writeHead(404); res.end('<h1>Requested page not found.</h1>') } }).listen(9527);

上面代码的核心是路由匹配,当请求抵达时,检查是否有对应其路径的逻辑处理,当请求匹配不上任何路由时,返回 404。匹配成功时处理相应的逻辑。git

simple request

上面的代码显然并不通用,并且在仅有两种路由匹配候选项(且还未区分请求方法),以及还没有使用数据库以及模板文件的前提下,代码都已经有些纠结了。所以接下来咱们将搭建一个简易的MVC框架,使数据、模型、表现分离开来,各司其职。github

搭建简易MVC框架

MVC 分别指的是:数据库

  1. M: Model (数据)
  2. V: View (表现)
  3. C: Controller (逻辑)

在 Node 中,MVC 架构下处理请求的过程以下:npm

  1. 请求抵达服务端
  2. 服务端将请求交由路由处理
  3. 路由经过路径匹配,将请求导向对应的 controller
  4. controller 收到请求,向 model 索要数据
  5. model 给 controller 返回其所需数据
  6. controller 可能须要对收到的数据作一些再加工
  7. controller 将处理好的数据交给 view
  8. view 根据数据和模板生成响应内容
  9. 服务端将此内容返回客户端

以此为依据,咱们须要准备如下模块:json

  1. server: 监听和响应请求
  2. router: 将请求交由正确的controller处理
  3. controllers: 执行业务逻辑,从 model 中取出数据,传递给 view
  4. model: 提供数据
  5. view: 提供 html

代码结构

建立以下目录:数组

-- server.js -- lib -- router.js -- views -- controllers -- models

server

建立 server.js 文件:

const http = require('http'); const router = require('./lib/router')(); router.get('/actors', (req, res) => { res.end('Leonardo DiCaprio, Brad Pitt, Johnny Depp'); }); http.createServer(router).listen(9527, err => { if (err) { console.error(err); console.info('Failed to start server'); } else { console.info(`Server started`); } });

先无论这个文件里的细节,router是下面将要完成的模块,这里先引入,请求抵达后即交由它处理。

router 模块

router模块其实只需完成一件事,将请求导向正确的controller处理,理想中它能够这样使用:

const router = require('./lib/router')(); const actorsController = require('./controllers/actors'); router.use((req, res, next) => { console.info('New request arrived'); next() }); router.get('/actors', (req, res) => { actorsController.fetchList(); }); router.post('/actors/:name', (req, res) => { actorsController.createNewActor(); });

总的来讲,咱们但愿它同时支持路由中间件和非中间件,请求抵达后会由 router 交给匹配上的中间件们处理。中间件是一个可访问请求对象和响应对象的函数,在中间件内能够作的事情包括:

  1. 执行任何代码,好比添加日志和处理错误等
  2. 修改请求 (req) 和响应对象 (res),好比从 req.url 获取查询参数并赋值到 req.query
  3. 结束响应
  4. 调用下一个中间件 (next)

Note:

须要注意的是,若是在某个中间件内既没有终结响应,也没有调用 next 方法将控制权交给下一个中间件, 则请求就会挂起

__非路由中间件__经过如下方式添加,匹配全部请求:

router.use(fn);

好比上面的例子:

router.use((req, res, next) => { console.info('New request arrived'); next() });

__路由中间件__经过如下方式添加,以 请求方法和路径精确匹配:

router.HTTP_METHOD(path, fn)

梳理好了以后先写出框架:

/lib/router.js

const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']; module.exports = () => { const routes = []; const router = (req, res) => { }; router.use = (fn) => { routes.push({ method: null, path: null, handler: fn }); }; METHODS.forEach(item => { const method = item.toLowerCase(); router[method] = (path, fn) => { routes.push({ method, path, handler: fn }); }; }); };

以上主要是给 router 添加了 usegetpost 等方法,每当调用这些方法时,给 routes 添加一条 route 规则。

Note:

Javascript 中函数是一种特殊的对象,能被调用的同时,还能够拥有属性、方法。

接下来的重点在 router 函数,它须要作的是:

  1. req对象中取得 method、pathname
  2. 依据 method、pathname 将请求与routes数组内各个 route 按它们被添加的顺序依次匹配
  3. 若是与某个route匹配成功,执行 route.handler,执行完后与下一个 route 匹配或结束流程 (后面详述)
  4. 若是匹配不成功,继续与下一个 route 匹配,重复三、4步骤
const router = (req, res) => { const pathname = decodeURI(url.parse(req.url).pathname); const method = req.method.toLowerCase(); let i = 0; const next = () => { route = routes[i++]; if (!route) return; const routeForAllRequest = !route.method && !route.path; if (routeForAllRequest || (route.method === method && pathname === route.path)) { route.handler(req, res, next); } else { next(); } } next(); };

对于非路由中间件,直接调用其 handler。对于路由中间件,只有请求方法和路径都匹配成功时,才调用其 handler。当没有匹配上的 route 时,直接与下一个route继续匹配。

须要注意的是,在某条 route 匹配成功的状况下,执行完其 handler 以后,还会不会再接着与下个 route 匹配,就要看开发者在其 handler 内有没有主动调用 next() 交出控制权了。

在__server.js__中添加一些route:

router.use((req, res, next) => { console.info('New request arrived'); next() }); router.get('/actors', (req, res) => { res.end('Leonardo DiCaprio, Brad Pitt, Johnny Depp'); }); router.get('/actresses', (req, res) => { res.end('Jennifer Aniston, Scarlett Johansson, Kate Winslet'); }); router.use((req, res, next) => { res.statusCode = 404; res.end(); });

每一个请求抵达时,首先打印出一条 log,接着匹配其余route。当匹配上 actors 或 actresses 的 get 请求时,直接发回演员名字,并不须要继续匹配其余 route。若是都没匹配上,返回 404。

在浏览器中依次访问 http://localhost:9527/erwehttp://localhost:9527/actorshttp://localhost:9527/actresses 测试一下:

404

network 中观察到的结果符合预期,同时后台命令行中也打印出了三条 New request arrived语句。

接下来继续改进 router 模块。

首先添加一个 router.all 方法,调用它即意味着为全部请求方法都添加了一条 route:

router.all = (path, fn) => { METHODS.forEach(item => { const method = item.toLowerCase(); router[method](path, fn); }) };

接着,添加错误处理。

/lib/router.js

const defaultErrorHander = (err, req, res) => { res.statusCode = 500; res.end(); }; module.exports = (errorHander) => { const routes = []; const router = (req, res) => { ... errorHander = errorHander || defaultErrorHander; const next = (err) => { if (err) return errorHander(err, req, res); ... } next(); };

server.js

...
const router = require('./lib/router')((err, req, res) => { console.error(err); res.statusCode = 500; res.end(err.stack); }); ...

默认状况下,遇到错误时会返回 500,但开发者使用 router 模块时能够传入本身的错误处理函数将其替代。

修改一下代码,测试是否能正确执行错误处理:

router.use((req, res, next) => { console.info('New request arrived'); next(new Error('an error')); });

这样任何请求都应该返回 500:

error stack

继续,修改 route.path 与 pathname 的匹配规则。如今咱们认为只有当两字符串相等时才让匹配经过,这没有考虑到 url 中包含路径参数的状况,好比:

localhost:9527/actors/Leonardo

router.get('/actors/:name', someRouteHandler);

这条route应该匹配成功才是。

新增一个函数用来将字符串类型的 route.path 转换成正则对象,并存入 route.pattern:

const getRoutePattern = pathname => { pathname = '^' + pathname.replace(/(\:\w+)/g, '\(\[a-zA-Z0-9-\]\+\\s\)') + '$'; return new RegExp(pathname); };

这样就能够匹配上带有路径参数的url了,并将这些路径参数存入 req.params 对象:

const matchedResults = pathname.match(route.pattern); if (route.method === method && matchedResults) { addParamsToRequest(req, route.path, matchedResults); route.handler(req, res, next); } else { next(); }
const addParamsToRequest = (req, routePath, matchedResults) => { req.params = {}; let urlParameterNames = routePath.match(/:(\w+)/g); if (urlParameterNames) { for (let i=0; i < urlParameterNames.length; i++) { req.params[urlParameterNames[i].slice(1)] = matchedResults[i + 1]; } } }

添加个 route 测试一下:

router.get('/actors/:year/:country', (req, res) => { res.end(`year: ${req.params.year} country: ${req.params.country}`); });

访问http://localhost:9527/actors/1990/China试试:

url parameters

router 模块就写到此,至于查询参数的格式化以及获取请求主体,比较琐碎就不试验了,须要能够直接使用 bordy-parser 等模块。

controller

如今咱们已经建立好了router模块,接下来将 route handler 内的业务逻辑都转移到 controller 中去。

修改__server.js__,引入 controller:

...
const actorsController = require('./controllers/actors'); ... router.get('/actors', (req, res) => { actorsController.getList(req, res); }); router.get('/actors/:name', (req, res) => { actorsController.getActorByName(req, res); }); router.get('/actors/:year/:country', (req, res) => { actorsController.getActorsByYearAndCountry(req, res); }); ...

新建__controllers/actors.js__:

const actorsTemplate = require('../views/actors-list'); const actorsModel = require('../models/actors'); exports.getList = (req, res) => { const data = actorsModel.getList(); const htmlStr = actorsTemplate.build(data); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(htmlStr); }; exports.getActorByName = (req, res) => { const data = actorsModel.getActorByName(req.params.name); const htmlStr = actorsTemplate.build(data); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(htmlStr); }; exports.getActorsByYearAndCountry = (req, res) => { const data = actorsModel.getActorsByYearAndCountry(req.params.year, req.params.country); const htmlStr = actorsTemplate.build(data); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(htmlStr); };

在 controller 中同时引入了 view 和 model, 其充当了这两者间的粘合剂。回顾下 controller 的任务:

  1. controller 收到请求,向 model 索要数据
  2. model 给 controller 返回其所需数据
  3. controller 可能须要对收到的数据作一些再加工
  4. controller 将处理好的数据交给 view

在此 controller 中,咱们将调用 model 模块的方法获取演员列表,接着将数据交给 view,交由 view 生成呈现出演员列表页的 html 字符串。最后将此字符串返回给客户端,在浏览器中呈现列表。

从 model 中获取数据

一般 model 是须要跟数据库交互来获取数据的,这里咱们就简化一下,将数据存放在一个 json 文件中。

/models/test-data.json

[
    {
        "name": "Leonardo DiCaprio", "birth year": 1974, "country": "US", "movies": ["Titanic", "The Revenant", "Inception"] }, { "name": "Brad Pitt", "birth year": 1963, "country": "US", "movies": ["Fight Club", "Inglourious Basterd", "Mr. & Mrs. Smith"] }, { "name": "Johnny Depp", "birth year": 1963, "country": "US", "movies": ["Edward Scissorhands", "Black Mass", "The Lone Ranger"] } ]

接着就能够在 model 中定义一些方法来访问这些数据。

models/actors.js

const actors = require('./test-data'); exports.getList = () => actors; exports.getActorByName = (name) => actors.filter(actor => { return actor.name == name; }); exports.getActorsByYearAndCountry = (year, country) => actors.filter(actor => { return actor["birth year"] == year && actor.country == country; });

view

当 controller 从 model 中取得想要的数据后,下一步就轮到 view 发光发热了。view 层一般都会用到模板引擎,如 dust 等。一样为了简化,这里采用简单替换模板中占位符的方式获取 html,渲染得很是有限,粗略理解过程便可。

建立 /views/actors-list.js:

const actorTemplate = ` <h1>{name}</h1> <p><em>Born: </em>{contry}, {year}</p> <ul>{movies}</ul> `; exports.build = list => { let content = ''; list.forEach(actor => { content += actorTemplate.replace('{name}', actor.name) .replace('{contry}', actor.country) .replace('{year}', actor["birth year"]) .replace('{movies}', actor.movies.reduce((moviesHTML, movieName) => { return moviesHTML + `<li>${movieName}</li>` }, '')); }); return content; };

在浏览器中测试一下:

test mvc

至此,就大功告成啦!

参考

  1. Nodejs实现一个简单的服务器
  2. Creating an MVC framework for our Node.js page - getting ready for scalability

源码

戳个人 GitHub repo: node-mvc-framework

博文也同步在 GitHub,欢迎讨论和指正:使用Node.js实现简易MVC框架