接触nodejs挺久了,之前一直用nodejs的一些web框架做开发,如koa,express等,现在想自己写个简易的nodejs web框架,我使用es6和es2017的async/await实现个类似于Koa的web框架,文章中的代码将会存放到我的github上,欢迎下载学习。
github地址:https://github.com/sundial-dreams/nodeServer
前言
nodejs编写个服务器,只需要几行代码
//test.js
const http = require("http");
const server = http.createServer((req,res) => {
res.end("hello world");
});
server.listen(3000, () => {
console.log("LISTEN IN 3000")
});
浏览器输入localhost:3000可以看见结果
上面的例子,当客户端请求的时候,服务端响应的是一个字符串hello world
修改上面的例子,当用户请求的时候响应HTML网页
//test.js
const http = require("http");
const fs = require("fs");
const {resolve,join} = require("path");
const server = http.createServer((req,res) => {
res.writeHead(200,{"Content-type":"text/html"});
fs.createReadStream(resolve("./index.html")).pipe(res)
});
server.listen(3000, () => {
console.log("LISTEN IN 3000")
});
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">
<h1>
Hello world
</h1>
</div>
</body>
</html>
浏览器输入localhost:3000
Ok,页面有点难看,加点js和css美化一下
html文件
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="public/css/reset.css" type="text/css"/>
<link rel="stylesheet" href="a.css" type="text/css"/>
</head>
<body>
<div id="app">
<a href="/picture">
</ >
</a>
</div>
<script src="a.js"></script>
</body>
</html>
css文件
/*a.css*/
body {
background: #2d3143;
}
#app {
width: 100px;
height: 100px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -35%);
opacity: 0;
transition: .5s ease-in-out;
}
#app > a {
display: block;
width: 100%;
height: 100%;
background: blueviolet;
color: white;
font-weight: bold;
font-size: 30px;
border-radius: 50%;
transition: .5s ease-in-out;
text-align: center;
line-height: 100px;
}
#app > a:hover{
background: rebeccapurple;
transform:translateY(3px);
color: rgba(255,255,255,.6);
}
js文件
//a.js
!function () {
function selector(name, scope) {
scope = scope || document;
return scope.querySelector(name);
}
function selectorAll(name, scope) {
scope = scope || document;
return [].slice.call(scope.querySelectorAll(name))
}
function setStyle(element,object) {
element = element || {};
object = object || {};
for (var key in object){
if (object.hasOwnProperty(key)){
element.style[key] = object[key];
}
}
}
var app = selector("#app");
setTimeout(function () {
setStyle(app,{
transform:"translate(-50%, -50%)",
opacity:1
})
},100)
}();
浏览器上输入localhost:3000,理论上我们会看到这个页面
而实际上是这个页面
WTF! 发现样式全没了,而且js交互也没有
原因很简单,当浏览器上输入localhost:3000的时候,我们只响应了index.html文件,而在index.html文件中有 href="a.css" src="a.js"这两句,分别是请求a.css文件和a.js文件,而我们的服务端可没有响应这两个文件,修改一下服务端,当请求其他文件的时候,响应这个请求,并发送指定文件
const http = require("http");
const fs = require("fs");
const {resolve,join,extname} = require("path");
const {parse} = require("url");
//设置对应的mime类型
const mime = {
".css":"text/css",
".gif":"image/gif",
".html":"text/html",
".jpeg":"image/jpeg",
".jpg":"image/jpeg",
".js":"application/javascript",
};
const server = http.createServer((req,res) => {
let pathname = parse(req.url).pathname;
if(pathname === "/"){
//首页 localhost:3000
res.writeHead(200,{"Content-type":"text/html"});
fs.createReadStream(resolve("./index.html")).pipe(res);
}else if(mime[extname(pathname)]){//请求的是文件
let staticPath = join(__dirname,pathname);
if(fs.existsSync(staticPath)){//文件是否存在
res.writeHead(200,{"Content-type":mime[extname(pathname)]});
fs.createReadStream(staticPath).pipe(res);
}
}else{
res.writeHead(404)
}
});
server.listen(3000, () => {
console.log("LISTEN IN 3000")
});
浏览器输入localhost:3000
我们想要的效果出来了,例子看完了,接下来就是本文章的主要内容了,尝试编写一个类似于KOA的服务端框架。
nodejs web框架的简单实现
what is KOA?KOA是一款轻量级nodejs web开发框架,基于洋葱模型,使用es2017的async/await来处理回调,不用在编写过多的回调。
then, what is 洋葱模型
模型如下图所示
简单来说就是当请求来的时候得经过几个人的手然后才到响应
接下来看个koa的例子,先安装koa
yarn add koa --dev 或者npm install --save-dev koa
然后编写代码
//koa.js
const Koa = require("koa");
const app = new Koa();
//中间件1
app.use(async (ctx,next) => {
console.log("middleware1");
await next()
});
//中间件2
app.use(async (ctx,next) => {
console.log("middleware2");
await next()
});
app.use(async ctx => {
console.log("end");
ctx.body = "hello koa"
});
app.listen(3000,() => {
console.log("listen in 3000");
});
浏览器输入localhost:3000,可以看到控制台输出middleware1 middleware2 end,每一个请求都先经过中间1 -> 中间件2 -> 响应,koa部分的内容建议去koa官网看
有了洋葱模型的思想,然后尝试编写一个类似于koa的web框架
我们的目标:
const app = new App();
app.use(中间件)
.use([中间件1,中间件2,...,中间件n])
.use(中间件);
app.listen(port)
有了目标,接下来我们来实现这个App类
App.js
//App.js
const http = require("http");
const events = require("events");
//App类
class App extends events.EventEmitter {
constructor() {
super();
this.middleware = [];//存中间件的数组,每一个中间件都是async函数,参数为(ctx,next)两个,返回值是Promise类型
this.ctx = {};//ctx对象,挂装对象
this.mountObject = {};//待挂装对象
this.on("error", err => {//错误处理
console.log(err)
})
}
use(fn) {//use方法
Array.isArray(fn) ? this.middleware.push(...fn) : this.middleware.push(fn);
return this
}
mount(name, fn) {//往this.ctx挂载属性
this.mountObject[name] = fn;//保存待挂载对象
}
callback() {
const fn = compose(this.middleware);//这里是重点
return (req, res) => {//这个返回的函数是http.createServer()的参数
this.ctx = {req,res};
Object.assign(this.ctx,this.mountObject);//挂载对象
return fn(this.ctx)
}
}
listen(...args) {//监听方法
const server = http.createServer(this.callback());//创建Server
server.listen(...args)
}
}
//这是整个框架的核心,接入中间件数组
function compose(middleware) {
return function (ctx, next) {//返回函数next下一个为中间件函数,这里为undefined
let index = -1;
function dispatch(i) {//处理第i个中间件的函数
if (i <= index) return Promise.reject(new Error("error"));
index = i;
let fn = middleware[i];//第i个中间件
if (i === middleware.length) fn = next;//最后一个中间件,fn指向next,这里为undefined
if (!fn) return Promise.resolve();//fn===undefined时返回空Promise对象
try {
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))//next指向dispatch.bind(null, i + 1),执行下一个中间件函数
} catch (e) {
return Promise.reject(e);
}
}
return dispatch(0)
}
}
module.exports = App;
简单测试一下App类
const app = new App();
app.use(async (ctx,next) => {//使用中间件
console.log("middleware");//请求来的时候先走这一步
await next();
}).use(async ctx => {//然后才到这里
ctx.res.end("hello app")
}).listen(3000,() => {
console.log("listen 3000")
});
有了App类,接下来就可以编写开始中间件了
先来第一个中间件,router中间件,这个中间件是处理路由的,我们想要实现的功能是这样的:
const router = new Router();
const api = new Router();
const app = new App();
api.get("/getData",async ctx => {//处理get方法的路由
ctx.res.end("data")
});
api.post("/postdata",async ctx => {//处理post方法的路由
ctx.res.end("data")
});
router.get("/",async ctx => {
ctx.res.end("index page")
});
router.use("/api",api);//子路由
router.get("/page/:id",async ctx => {//能匹配的路由,这里叫做标记路由
ctx.res.end(ctx.url.id)
});
app.use(router.register())//使用路由中间件
接下来实现这个Router类
router.js
const {EventEmitter} = require("events");
const {parse} = require("url");
const {extname} = require("path");
const queryString = require("querystring");
/**
*
* @param url
* @param formatUrl
* @returns {boolean}
*/
function urlJudge(url, formatUrl) {//匹配路由 url:app/any 真实的从请求中获取的路由 formatUrl:app/:id这类待匹配的路由
//匹配方式 将url和formatUrl拆分为数组 然后挨个匹配碰到:id这种的跳过
let urlArray = url.split("/");
let formatUrlArray = formatUrl.split("/");
let sign = formatUrlArray.some(url => url.startsWith(":"));//是否为/app/:name/:id这种的路由
if (sign) {
let map = {};//将匹配的路由字段保存起来 比如 /app/:id/:name === /app/12/dpf map = {id:"12",name:"dpf"}
if (urlArray.length === formatUrlArray.length) {
for (let i = 0; i < urlArray.length; i++) {
if (urlArray[i] !== formatUrlArray[i] && !formatUrlArray[i].startsWith(":")) return false;//碰到不相等的直接返回false
}
for (let i = 0; i < urlArray.length; i++) {
if (formatUrlArray[i].startsWith(":")) {
if (urlArray[i].match(/\./) || !urlArray[i]) continue;//这里不希望匹配请求文件的路由和空路由 比如app/a.js 或app/
map[formatUrlArray[i].substring(1)] = urlArray[i]//保存健值
}
}
return map
} else {
return false
}
} else {
return url === formatUrl
}
}
/**
*
* @type {module.Router}
*/
module.exports = class Router extends EventEmitter {
constructor() {
super();
this.getRouter = new Map();//保存get路由,[path , callback]的形式
this.postRouter = new Map();//保存post路由
this.subRouter = new Map();//保存子路由
this.getRouterSign = new Map();//保存这一类的get路由 api/:method
this.postRouterSign = new Map();//保存这一类的post路由 api/:method
this.on("error", err => {
console.log(err)
})
}
get(path, callback) {
let sign = path.split("/").some(p => p.startsWith(":"));//是否为 app/:name这一类路由
sign ? this.getRouterSign.set(path, callback) : this.getRouter.set(path, callback);
return this;
}
post(path, callback) {
let sign = path.split("/").some(p => p.startsWith(":"));
sign ? this.postRouterSign.set(path, callback) : this.postRouter.set(path, callback);
return this;
}
use(path, subRouter) {//子路由
this.subRouter.set(path, subRouter);
return this
}
/**
*
* @param ctx
* @returns {Promise<void>}
*/
async routerHandle(ctx) {//路由匹配
let {pathname, query} = parse(ctx.req.url);//从请求中获取路由
let method = ctx.req.method;//获取请求方法
if (extname(pathname)) return;//如果有扩展名,跳过该方法
ctx.param = queryString.parse(query);//将路由里的参数保存到ctx.param里
ctx.url = {};//保存 这类路由的值 app/:id => ctx.url.id
/**
* \
* @param router get或post路由
* @param routerSign get或post带标记的路由 ==> api/:method
* @param subRouters 子路由
* @param method 方法类型
* @returns {Promise<void>}
*/
async function execute(router, routerSign, subRouters, method) {
let callback = router.get(pathname);//获取对应路由的回调,如果没有的话 返回null
for (let [signUrl, callback] of routerSign.entries()) {//搜索整个带标记的路由
let sign = urlJudge(pathname, signUrl);//是否有匹配上的
if (sign) {
Object.assign(ctx.url, sign);//给ctx.url挂载属性
await callback && callback(ctx)
}
}
await callback && callback(ctx);
for (let [parentPath, subRouter] of subRouters.entries()) {//匹配子路由
//子get路由和post路由
for (let [childPath, callback] of method === "GET" ? subRouter.getRouter.entries() : subRouter.postRouter.entries()) {
if (parentPath === "/" && childPath !== "/") parentPath = "";//防止出现 //app/index这类情况
if (childPath === "/") childPath = "";//同样防止出现 //app/index 的情况
if (parentPath + childPath === pathname) {//匹配上执行回调
await callback && callback(ctx);
}
}
//子路由的get和post带标记的路由
for (let [childPath, callback] of method === "GET" ? subRouter.getRouterSign.entries() : subRouter.postRouterSign.entries()) {
if (parentPath === "/" && childPath !== "/") parentPath = "";
if (childPath === '/') childPath = "";
let sign = urlJudge(pathname, parentPath + childPath);
if (sign) {
Object.assign(ctx.url, sign);
await callback && callback(ctx)
}
}
}
}
if (method === "GET") {//处理get方法
await execute(this.getRouter, this.getRouterSign, this.subRouter, method)
}
else if (method === "POST") {//处理post方法
await execute(this.postRouter, this.postRouterSign, this.subRouter, method)
}
}
register() {
return async (ctx, next) => {//返回个中间件
await this.routerHandle(ctx);//当请求到来,先执行路由匹配
await next()
}
}
};
简单使用这个路由中间件
const Router = require("./router");
const api = new Router();
const router = new Router();
api.get("/:method",async ctx => {
ctx.res.end(ctx.url.method)
});
router.get("/",async ctx => {
ctx.res.end("it is router")
});
router.use("/api",api);
const app = new App();
app.use(router.register()).listen(3000,() => {
console.log("listen 3000")
});
浏览器输入localhost:3000
浏览器输入localhost:3000/api/dpf
为了处理post方法提交的数据,我们需要中间件来接收post请求的数据,然后将数据保存到ctx.query里
post中间件
/**
*
* @param ctx
* @param next
* @returns {Promise<void>}
*/
async function postParse(ctx, next) {
let {req} = ctx;
if (req.method === "POST") {//请求方法为post
ctx.query = await new Promise(resolve => {
let data = "";
req.on("data", chunk => {//数据来临
data += chunk;
});
req.on("end", () => {//数据完成
resolve(queryString.parse(data))
});
});
}
await next();
}
使用的话只需在app.use(postParse).use(router.register())即可
中间件有了,然后可以设计基本的框架结构,框架目录如下
root
|__lib 这里主要是一些核心库
|__App.js
|__middleware 这里是中间件
|__router.js 路由中间件
|__postParse.js 处理post数据中间件
|__resource.js 处理资源文件的中间件
|__util 一些工具模块
|__mime.js mime类型映射表
|__router 存放路由
|__api.js api路由处理
|__index.js
|__pages 保存页面
|__index 首页页面文件 html,js,css文件,个人觉得这样设计找对应的css/js文件好找
|__index.html
|__index.css
|__index.js
|__public 静态文件 image 或 公共css/js之类的
|__image
|__js
|__css
|__server.js 程序入口
按照目录结构,我们还需要个中间件来处理资源文件,当请求的是资源文件时,响应资源文件,即resource中间件,用来分发html,js,css,image文件等
我们想要实现的功能是这样的:
const app = new App();
const router = new Router();
const resource = new Resource(["pages","public"]);//初始化静态目录,第一个为pages页面目录
app.mount("render",resource.render());
router.get("/",async ctx => {
await ctx.render("index") //pages/index/index.html
});
app.use(resource.register())
.use(router.register())
.listen(3000,() => {console.log("listen in 3000")})
按照功能来实现这个Rescource类:
//resource.js
const fs = require("fs");
const path = require("path");
const events = require("events");
const url = require("url");
const util = require("util");
const mime = require("../util/mime");
const asyncStat = util.promisify(fs.stat);
const asyncReadFile = util.promisify(fs.readFile);
/**
*
* 获取目录 输入/index.js ==> pages/index/index.js app/node/picture.css ==> pages/picture/picture
* 输入app/name/public/css/reset.css ==> public/css/reset.css
* @param pathname
* @param folds
* @returns {*}
*/
function getStaticPath(pathname, folds) {
let urlArray = null;
if (pathname.includes("/")) {
urlArray = pathname.split("/");
} else {
urlArray = [pathname]
}
for (let i = urlArray.length - 1; i >= 0; i--) {//倒序遍历 找存在的静态目录
if (folds.includes(urlArray[i])) {
return urlArray.slice(i).join("/")
}
}
//否则 考虑 index.js ==> pages/index/index.js是否存在
pathname = `${folds[0]}/${pathname.replace(new RegExp(path.extname(urlArray[urlArray.length - 1]) + "$"), "")}/${urlArray[urlArray.length - 1]}`;
if (fs.existsSync(path.resolve(`./${pathname}`))) {
return pathname
}
return false
}
/**
*
* @type {module.Resource}
*/
module.exports = class Resource extends events.EventEmitter {
constructor(folds = []) {
super();
this.folds = folds;//保存静态目录
this.on("error", err => {
console.log(err);
});
}
setFolds(folds = []) {
this.folds.push(...folds);
}
/**
* 根据输入路由,发送指定文件
* @param ctx
* @param pathname
* @returns {Promise<void>}
* @private
*/
async _send(ctx, pathname) {
if (this.folds.some(fold => pathname.startsWith(fold))) {//保证属于静态目录
const staticPath = path.resolve(`./${pathname}`);
if (fs.existsSync(staticPath)) {//文件是否存在
try {
let stats = await asyncStat(staticPath);
if (stats.isFile()) {//是否为文件
let type = mime[path.extname(pathname)];//根据扩展名,获取对应的mime类型
let data = await asyncReadFile(staticPath);
ctx.res.writeHead(200, {"Content-type": type});
ctx.res.end(data);
} else {
ctx.res.writeHead(404)
}
} catch (e) {
this.emit("error", e);
}
} else {
ctx.res.writeHead(404)
}
} else {
ctx.res.writeHead(404)
}
}
/**
* 分发请求的文件
* @param ctx
* @returns {Promise<void>}
* @private
*/
async _dispatch(ctx) {
let pathname = url.parse(ctx.req.url).pathname.substring(1);
let extendName = path.extname(pathname);
if (!extendName) return;//抛弃不是请求资源的路由
if (getStaticPath(pathname, this.folds)) {
await this._send(ctx, getStaticPath(pathname, this.folds));
} else {
ctx.res.writeHead(404);
}
}
/**
* ctx.render("index") ==> ctx.send("pages/index/index.html")
* @param ctx
* @param fold
* @returns {Promise<void>}
* @private
*/
async _render(ctx, fold) {
console.log(this.folds,fold);
let pathname = path.join(this.folds[0], fold, `${fold}.html`);
await this._send(ctx, pathname)
}
dispatch() {
return async (ctx, next) => {
await this._dispatch(ctx);
await next()
}
}
//提供对外的挂载接口 app.mount(name,this.send())
send() {
let that = this;
return async function (pathname) {
return that._send(this,pathname)
}
}
render() {
let that = this;
return async function (page) {
return that._render(this,page)
}
}
};
到此为止,web服务器的基本功能就实现的差不多了。
还可以继续编写其他中间件,不过太多的中间件自然会影响程序效率的,本文章中写了3个中间件来处理基本的web请求,剩下的中间件,读者可以自己尝试编写。
最后使用一下这个框架,编写了个小示例,效果图如下
示例已存放在我的github上了,欢迎下载学习,github地址:https://github.com/sundial-dreams/nodeServer