使用Node.js實現簡易MVC框架

使用Node.js搭建靜態資源服務器一文中咱們完成了服務器對靜態資源請求的處理,但並未涉及動態請求,目前還沒法根據客戶端發出的不一樣請求而返回個性化的內容。單靠靜態資源豈能撐得起這些複雜的網站應用,本文將介紹如何使用Node處理動態請求,以及如何搭建一個簡易的 MVC 框架。由於前文已經詳細介紹過靜態資源請求如何響應,本文將略過全部靜態部分。html

一個簡單的示例

先從一個簡單示例入手,明白在 Node 中如何向客戶端返回動態內容。node

假設咱們有這樣的需求:git

  1. 當用戶訪問 /actors時返回男演員列表頁
  2. 當用戶訪問 /actresses時返回女演員列表

能夠用如下的代碼完成功能:github

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。匹配成功時處理相應的邏輯。數據庫

simple request

上面的代碼顯然並不通用,並且在僅有兩種路由匹配候選項(且還未區分請求方法),以及還沒有使用數據庫以及模板文件的前提下,代碼都已經有些糾結了。所以接下來咱們將搭建一個簡易的MVC框架,使數據、模型、表現分離開來,各司其職。npm

搭建簡易MVC框架

MVC 分別指的是:json

  1. M: Model (數據)
  2. V: View (表現)
  3. C: Controller (邏輯)

在 Node 中,MVC 架構下處理請求的過程以下:數組

  1. 請求抵達服務端
  2. 服務端將請求交由路由處理
  3. 路由經過路徑匹配,將請求導向對應的 controller
  4. controller 收到請求,向 model 索要數據
  5. model 給 controller 返回其所需數據
  6. controller 可能須要對收到的數據作一些再加工
  7. controller 將處理好的數據交給 view
  8. view 根據數據和模板生成響應內容
  9. 服務端將此內容返回客戶端

以此爲依據,咱們須要準備如下模塊:瀏覽器

  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框架