用 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 为支持用户提供了一个很好的起点,但尚未真正记录任何用户信息。在下一篇文章中,我们将添加后台功能和页面,允许用户从系统中登录和注销。

系列文章