用 C# 自己动手编写一个 Web 服务器,第五部分——视图引擎
视图引擎
在 上一篇文章 中,我们实现了 Session,并在过程中为 HttpListenerContext 提供了更高层的封装。在 Controller 返回的结果中我们可以看到服务器动态执行的结果,不过目前它们是以原始字符串的形式存在的。从基本原理来说,返回字符串并没有什么不妥————互联网早期的 CGI/Servlet 都是这么做的。问题在于这种接口过于底层了。设计者希望看到 HTML 页面,而不是苦哈哈的自己去拼接字符串。这就是视图引擎(View Engine)存在的理由。
视图引擎的存在已经有很长时间了。在其他框架的术语里,有时候也会称为模板引擎(Template Engine)或类似的说法,因为从理论上来讲它可以用来生成任何文本形式的内容,并非只能生成 HTML 文件。不过,用来生成页面一直是其最广泛的用法。最近,随着 Nodejs 的流行,各种模板引擎有爆炸性增长的趋势,在 Nodejs 中可用的视图引擎就不下几十种。另外一个有趣的现象是:视图引擎从早期的、依附于特定 Web 框架的“寄生”形态,逐渐发展为独立的、甚至可在多中不同语言和框架使用。例如,Razor 原本脱胎于 ASP.NET MVC,后来被爱好者改造成 vash, 可用于 Nodejs 的众多 Web 框架。Python 世界比较有名的 Jinja2 也被移植到 Nunjucks,同样可用于 Nodejs。前端还有大名鼎鼎的 Handlebars(其语言核心是 Mustache) 可以在多种场合下使用。
自己从头实现一个模板引擎是可能的。有几本早期的编程书籍讨论过这个主题,不过实现需要用去好几章的篇幅。对于本系列文章而言,这个规模有点太大了。我们知道,Razor 引擎早已有独立的版本 RazorEngine,因此在本文中,我们直接把它集成到自己的 Web 程序。
代码
本文的示例代码已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:
git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b 05-view-engine origin/05-view-engine
实现
到目前为止,我们的程序一直是个非常简单的控制台应用,并且除了 BCL 之外并没有引用其他外部资源。现在,为了使用 RazorEngine,我们必须把它包含进来。最简单的方法当然是通过 Nuget。在 Nuget 控制台输入如下命令:
install-package RazorEngine
然后等待 Nuget 下载和引用必要的库文件。
说明:按照 原书 的说法,在程序中直接引用 RazorEngine 的 DLL 文件会导致版本冲突,作者建议的做法是自行下载源代码编译。我自己实验的结果则是运行程序会看到警告信息,但程序还是可以正常运行。之所以会有不同的结果,可能和库的版本有关系(这本书已经发布有一段时间了)。我建议你首先用 Nuget 的方式,如果运行有问题的话,再按照作者的建议去做。
在前面的文章中,为了避免实现引入大量子类,我们让控制器(Controller)直接返回 string,直接输出 HTML 内容。不过现在,要使用视图引擎,这个问题无法再回避了。还是模仿 ASP.NET MVC 的接口,先定义基类:
public abstract class ActionResult
{
public abstract void Execute(HttpServerContext context);
}
ActionResult 的子类自行决定返回什么样的结果————这也是框架的灵活性之一,并且对单元测试更加友好。
有些情况下可能不需要经过视图引擎。例如,部分 Web API 会希望你直接返回一个 OK 甚至为空。不论是否使用视图引擎,它们的输出方式都是相同的,因此我们再添加一个辅助方法:
public static class HttpUtil
{
...
public static HttpListenerResponse Content(this HttpListenerResponse response,
string content, string mimeType)
{
var contentBytes = Encoding.UTF8.GetBytes(content);
response.ContentType = mimeType;
response.StatusCode = 200;
response.ContentLength64 = contentBytes.Length;
response.OutputStream.Write(contentBytes, 0, contentBytes.Length);
response.OutputStream.Close();
return response;
}
}
对于不使用视图引擎的场景,直接返回内容即可:
public class ContentResult : ActionResult
{
public ContentResult(string content, string mimeType = null)
{
_content = content ?? "";
_mimeType = mimeType ?? "text/html";
}
private readonly string _content;
private readonly string _mimeType;
public override void Execute(HttpServerContext context)
{
context.Response.Content(_content, _mimeType);
}
}
对于使用视图引擎的场景,我们需要决定视图文件的位置在什么地方。熟悉 ASP.NET MVC 的朋友应该知道,真正的 Razor 视图引擎对于视图的查找有一套复杂的规则,但是我们这里并不希望把问题搞得太复杂,因此按照常规的项目结构,直接查找命名格式为 Views/{controller}/{action}.cshtml 的文件。
public class ViewResult : ActionResult
{
public ViewResult(string controllerName, string viewName,
object model)
{
_viewLocation = FindViewLocation(controllerName, viewName);
_model = model;
}
private string _viewLocation;
private object _model;
private string FindViewLocation(string controllerName, string viewName)
{
var baseDir = Path.GetDirectoryName(GetType().Assembly.Location);
string filePath = Path.Combine(baseDir, "Views", controllerName, viewName + ".cshtml");
return filePath;
}
public override void Execute(HttpServerContext context)
{
var tpl = File.ReadAllText(_viewLocation, System.Text.Encoding.UTF8);
var result = Razor.Parse(tpl, _model);
context.Response.Content(result, "text/html");
}
}
还要记得一点,为了让执行程序能够找到视图文件的位置,请在文件属性中将 .cshtml 文件的输出方式改为 Copy if newer(或者 Copy always)。
ActionResult 和子类都已添加完成。接下来我们需要调整一下路由的执行逻辑,允许接受 ActionResult 返回:
public class Routing : IMiddleware
{
...
public MiddlewareResult Execute(HttpServerContext context)
{
foreach (var entry in _entries)
{
var routeValues = entry.Match(context.Request);
if (routeValues != null)
{
...
var result = GetActionResult(controller, actionMethod, routeValues);
result.Execute(context);
return MiddlewareResult.Processed;
}
}
return MiddlewareResult.Continue;
}
private ActionResult GetActionResult(IController controller, MethodInfo method,
RouteValueDictionary routeValues)
{
...
var result = method.Invoke(controller, paramValues);
var actionResult = result as ActionResult;
if (actionResult != null)
return actionResult;
else
return new ContentResult(Convert.ToString(result), "text/html");
}
}
这里略去不需要修改的部分,以免代码过长。
控制器基类再添加一个辅助方法,允许返回视图结果:
public abstract class Controller : IController
{
...
protected ViewResult View(string viewName, object model)
{
var controllerName = GetType().Name;
if (controllerName.EndsWith("Controller"))
controllerName = controllerName.Substring(0, controllerName.Length - 10);
return new ViewResult(controllerName, viewName, model);
}
}
现在,可以让控制器返回视图了:
public class HomeController : Controller
{
public ActionResult Index()
{
...
var model = new { title = "Homepage", counter = counter};
return View("Index", model);
}
}
最后,创建 Views/Home/Index.cshtml,显示控制器传来的数据模型:
@model dynamic
<!DOCTYPE html>
<html>
<head>
<title>@Model.title</title>
</head>
<body>
<h1>Counter = @Model.counter</h1>
</body>
</html>
要实现真正类似 ASP.NET MVC 那样完整的视图引擎,我们还需要实现很多东西,包括 ViewBag、ViewData、TempData 等等一系列属性。不过,这些已经属于实现层面的内容了,有兴趣的同学可以自己动手完成。我们的模板引擎也没有缓存视图的编译结果。每次访问的时候重新去编译视图肯定是存在性能问题的,这个问题同样留给有兴趣的同学自己完成吧。
到目前为止,我们实现的功能已经足够支撑一个简单的 Web 应用了。不过,对于大多数业务系统来说,还需要记录系统用户,并且允许用户进行常规的登陆/注销等操作。前面实现的 Session 为支持用户提供了一个很好的起点,但尚未真正记录任何用户信息。在下一篇文章中,我们将添加后台功能和页面,允许用户从系统中登录和注销。