Update: I have tweaked the interface a bit to allow for the encapsulation of Routes and Routers, and also added the ability to serve up static files. I have set up a GitHub account to host these various projects (see list on right). I hope that this is able to help some people out. Please feel free to comment (or even better, contribute)
I’ve personally found that the best way to truly dive into any web programming environment is to build a basic MVC framework from scratch. Yes, it’s true that there are most likely MVC frameworks out there already, but building one from scratch presents the programmer with a an extensive cross-section of problems to solve. These include, but are not limited to:
- Request parameter parsing and validation
- Error handling
- Code organization
- Interface design
- Class autoloading
So, I dove back into node.js tonight to see what I could throw together as a basic MVC framework. Since it’s always a good idea to start writing code with the interface in mind, here’s what I came up with for setting up your routes:
var mvc = require('./mvc.js');
var Router = mvc.Router;
var Routes = mvc.Routes;
var http = require('http');
Routes
.add("/MySite/{controller}/{action}", function(data) { return this.exec(data.controller, data.action, data._qs);})
.add("/{controller}/{action}", function(data) { return this.exec(data.controller, data.action, data._qs); })
.addStaticDirectory("/includes")
.addStaticDirectory("/content")
.setDefault("/", function(data) { return this.exec("Default","Init", data._qs); });
http.createServer(function (req, res) {
var router = Routes.getRouter(req, res);
router.dispatch(req.url);
res.end();
}).listen(1337, "127.0.0.1");
As you can see, once we have our webserver set up, we can start defining the patterns of the different URLs that will be accessing our site. You can use any placeholder terms you want (although I’d probably recommend {controller} and {action}, since those are pretty standardized). From there, the values that are extracted out of that URL (passed through as ‘data’), can be passed along to the ‘exec’ method. Now, let’s take a look at our mvc.js file:
/*
MVC ClassLoader
*/
var ClassLoader = (function() {
var self = this;
var CONTROLLER_PATH = "./Controllers/";
var MODEL_PATH = "./Models/";
var VIEW_PATH = "./Views/";
var NODE_EXT = ".js";
var DEFAULT_CONTENT_TYPE = 'text/html';
var attachControllerMethods = function(className, instance) {
instance._className = className;
for(var name in BaseController) {
instance[name] = BaseController[name];
}
};
return {
CONTROLLER_PATH : CONTROLLER_PATH,
MODEL_PATH : MODEL_PATH,
VIEW_PATH : VIEW_PATH,
NODE_EXT : NODE_EXT,
DEFAULT_CONTENT_TYPE : DEFAULT_CONTENT_TYPE,
get : function(className, request, response) {
try {
var fileName = className + "Controller" + NODE_EXT;
var constructor = require(CONTROLLER_PATH + fileName);
constructor = constructor[className+"Controller"];
var instance = new constructor();
attachControllerMethods(className, instance);
instance.setServerVars(request, response);
return instance;
} catch(e) {
console.log("Failed to open controller " + className);
}
return null;
}
};
})();
exports.ClassLoader = ClassLoader;
/*
MVC Base Controller
*/
var BaseController = {
setServerVars : function(request, response) {
this.Request = request;
this.Response = response;
},
getModelConstructor : function(modelName) {
var modelName = modelName || this._className;
var filePath = ClassLoader.MODEL_PATH + modelName + ClassLoader.NODE_EXT;
try {
var constructor = require(filePath)[modelName];
return constructor;
} catch(e) { console.log("Failed to load model."); }
return null;
},
getModelInstance : function(modelName) {
var constructor = this.getModelConstructor(modelName);
return constructor ? new constructor() : null;
},
getViewConstructor : function(viewName) {
var viewName = viewName || this._className;
var filePath = ClassLoader.VIEW_PATH + viewName + ClassLoader.NODE_EXT;
try {
var constructor = require(filePath)[viewName];
this.Response.setHeader("Content-Type", constructor.ContentType || ClassLoader.DEFAULT_CONTENT_TYPE);
return constructor;
} catch(e) { console.log("Failed to load view"); }
return null;
},
getViewInstance : function(viewName) {
var constructor = this.getViewConstructor(viewName);
return constructor ? new constructor() : null;
}
}
BaseController.getModel = BaseController.getModelInstance;
BaseController.getView = BaseController.getViewInstance;
/*
MVC Routes
*/
var Routes = exports.Routes = new (function() {
var routes = {},
defaultRoutePattern = "/",
defaultRouteHandler = function() {},
parsedPatternData = {},
staticPaths = [];
this.add = function(pattern, func) {
routes[pattern] = func;
return this;
};
this.setDefault = function(pattern, func) {
defaultRoutePattern = pattern;
defaultRouteHandler = func;
return this;
};
this.addStaticDirectory = function(path) {
path = path || "";
if(path.substr(-1) != "/") { path+="/"; }
staticPaths.push(path);
return this;
};
this.get = function() { return routes; };
this.getStaticPaths = function() { return staticPaths; };
this.getDefault = function() {
return {
pattern : defaultRoutePattern,
func : defaultRouteHandler
};
};
this.parseQueryString = function(qs) {
var nv = {};
var parts = (qs || "").split('&');
var eqPos;
for(var i=0; i<parts.length; i++) {
eqPos = parts[i].indexOf('=');
if(!~eqPos) { continue; }
nv[parts[i].substr(0,eqPos)] = parts[i].substr(eqPos+1);
}
return nv;
};
this.parsePattern = function(pattern, url) {
var patternData;
if(!(patternData = parsedPatternData[pattern])) {
var params = [];
var result = pattern.replace(/\{(.*?)\}/g, function(match, sub1, pos, whole) {
params.push(sub1);
return "([^\/]+?)";
});
result = "^"+result+"(\\/?\$|\\/?\\?.*$)";
parsedPatternData[pattern] = patternData = {
regex : (new RegExp(result)),
params : params
};
}
var counter = 0,
urlParts = null,
regex = patternData.regex,
params = patternData.params;
url.replace(regex, function(match) {
urlParts = {};
var i=0;
for(; i<params.length; i++) {
urlParts[params[i]] = arguments[i+1];
}
urlParts._qs = Routes.parseQueryString((arguments[i+1] || "").replace(/^\/?\??/,""));
});
return urlParts;
};
});
/*
MVC Router
*/
var StaticResourceHandler = new function() {
var contentTypes = {
'.json': 'application/json',
'.js': 'application/javascript',
'.gif': 'image/gif',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.css': 'text/css',
'.html': 'text/html',
'.txt': 'text/plain',
'.xml': 'text/xml'
};
this.serve = function(path, resp) {
path = path || "";
var ext = path.substr(-4);
if(!(ext in contentTypes)) {
resp.statusCode = 500;
resp.end();
return false;
}
resp.writeHead(200, { 'Content-Type' : contentTypes[ext] });
resp.write(require('fs').readFileSync(path));
return true;
};
};
var Router = exports.Router = function() {
var self = this,
request = null,
response = null;
this.exec = function(controller, method, data) {
var instance = ClassLoader.get(controller, request, response);
if(!instance) { return false; }
if(instance[method]) {
instance[method](data);
} else if(instance.onActionUnavailable) {
instance.onActionUnavailable(method, data);
} else {
throw new Error("Action Not Found!");
}
return true;
};
this.init = function(req, res) {
request = req;
response = res;
};
this.dispatch = function(url) {
var url = url || req.url,
result,
verdict,
routes = Routes.get(),
staticPaths = Routes.getStaticPaths();
//check static paths first
var verdict;
for(var i=0; i<staticPaths.length; i++) {
if(url.indexOf(staticPaths[i]) == 0) {
verdict = StaticResourceHandler.serve("."+url, response);
if(verdict) { return };
}
}
for(var pattern in routes) {
// test the pattern
result = Routes.parsePattern(pattern, url);
// if the pattern was successfully matched...
if(result) {
//call the handler for that route, which will inevitably call 'exec'
// if 'exec' was able to open the controller, the verdict will be 'true'
// in which case we need to stop processing more controllers.
// if the verdict is false, we should continue to find a route that works.
verdict = routes[pattern].call(self, result);
if(verdict) { return; }
}
}
var defaultRoute = Routes.getDefault();
result = Routes.parsePattern(defaultRoute.pattern, url);
return result && defaultRoute.func.call(self,result);
};
};
Routes.getRouter = function(req, res) {
var r = new Router();
r.init(req, res);
return r;
}
First, let’s talk about the Routes and Router. The ‘Routes’ object is a collection of all these url patterns the programmer has defined for the application. Encapsulated in this object are also the methods used for parsing out the regular expressions and url parameters. On the Router object, the ‘dispatch’ method is the one that will do the heavy-lifting upfront. This is going to utilize the Routes object and do its decision-making based on the output (mainly, “check each route and see if it matched the pattern”). Once the Router has determined what controller to load up, the ClassLoader jumps in to do its job.
The ClassLoader is configurable based on how your code base is organized and the file extensions you are using for your node.js files. Relative to the mvc.js file, I created three folders in the same directory:
- /mvc.js - /Controllers/ - /Views/ - /Models/
I chose the convention that all files in the ‘Controllers’ directory would be of the format SomeNameController.js, and the objects that those files contained would be named similarly (SomeNameController.js). Views and Models do not need to follow that convention. Once the controller is instantiated, the “BaseController” methods are attached to the object (getModelConstructor(), getModelInstance(), getModel(), getViewConstructor(), getViewInstance(), getView()).
Soon to come… an MVC framework built in python on top of CherryPy!
Any compelling reason not to combine the Routes and Router into a single object?
// Server initialization
var router = (new require(‘./mvc.js’).Router())
.add(‘your’, ‘route’)
.add(‘another’, ‘route’)
.default(‘go somewhere);
// Server request callback:
require(‘http’).createServer(function(req, res) {
router.dispatch(req, res);
}).listen(1337);
I’m also always a sucker for chainable methods…
Consider it done