原生nodejs編寫個web框架
接觸nodejs挺久了,之前一直用nodejs的一些web框架做開發,如koa,express等,現在想自己寫個簡易的nodejs web框架,我使用es6和es2017的async/await實現個類似於Koa的web框架,文章中的程式碼將會存放到我的github上,歡迎下載學習。
前言
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請求,剩下的中介軟體,讀者可以自己嘗試編寫。
最後使用一下這個框架,編寫了個小示例,效果圖如下