3

从 Asp.Net MVC 到 Web Form 这看起来有点奇怪,大家都研究如何从 Web Form 到 MVC 的时候,为什么会出现一个相反的声音?从研究的角度来说,对反向过程进行研究有助于理解正向过程。通过对 MVC 转 Web Form 的研究,可以推导出:如果想把一个 Web Form 应用转换为 MVC 应用,可能需要进行怎么样的准备,应该从哪些方面去考虑重构?

当然研究不是我们最真实的目的,项目需要才是非常有力的理由——在我们用 MVC 框架已经初步完成项目第一阶段的时候准备试运行的时候,客户要求必须使用 Web Form——这不是客户的原因,只是我们前期调研得不够仔细。

产生这样的需求有很多历史原因,这不是今天要讨论的范围。我们要讨论的是如何快速的把 MVC 框架改回 Web Form 框架。要完成这个任务,需要做哪些事情?

  • 在 Web Form 中 渲染 Razor 模板……如果不行,就得按 Razor 重写 Aspx
  • 所有 Ajax 调用的 Controller 都必须改用 Ashx 来实现
  • MVC 的路由配置得取消,URL 与原始的目录路径结构强相关
  • 前端变化不大,但是要小心 Web Form 对元素 ID 和控件名称(name)的强制处理

Razor 框架 → Aspx 框架

很不幸,没找到现成的工具在 Web Form 框架中渲染 Razor 模板。所以这部分工作只是能手工完成了。幸好 Aspx 框架可以定义 Master 页面,而且 Master 可以嵌套,其它一些框架元素也可以在 aspx 框架中找到对应的元素来解决:

  • layout 布局页 → Master 母板页
  • cshtml 模板页 → aspx 页面
  • @section → asp:ContentPlaceHolder
  • @helper → ascx 控件

基于前后端分享的 MVC 框架没有用到 aspx 的事件机制,可以直接在 web.config 里禁用 ViewState,顺便设置 clientIDModeStatic,免得 Web Form 乱改 ID 名称。

<system.web>
    <pages clientIDMode="Static"
           enableSessionState="true"
           enableViewState="false"
           enableViewStateMac="false">
    </pages>
</system.web>

说起来轻松,但这部分工作需要大量的人工操作,所以其实是最累也最容易出错的。

移植 Controller

Controller 是 MVC 中的概念,但实际上可以把 Controller 看作是一个 Action 的集合,而 Action 在 RPC 的概念中对应于过程(Procedure)名称以及对应的参数定义。

由于前面对 Razor 的移植,所有返回 View() 的 Action 都被换成了 .aspx 页面访问。所以先把这部分 Action 从 Controller 中剔除掉。剩下的大部分是返回 JsonNetResult 的 Action,用于 Ajax 调用。现在不得不庆幸没有使用 RESTful 风格,完全不用担心 HTTP Method 的处理。

RESTful 很好,但不要迷信它,这种风格并不适应所有场景,有兴趣可以看看 oschina 上的一篇协同翻译文章 理解面向 HTTP API 的 REST 和 RPC

可能有些人能猜测到 JsonNetResult 是个什么东西,不过我觉得还是有必要说一下

介绍 JsonNetResult

MVC API Controller 使用了 Newtonsoft Json.Net 来实现 JsonResultSystem.Web.Http.Results.JsonResult<T>,在 System.Web.Http.dll 中)。而普通 Controller 是用微软自己的 JavaScriptSerializer 来实现的的 JsonResultSystem.Web.Mvc.JsonResult,在 System.Web.Mvc.dll 中)。因为 JavaScriptSerializer 不如 Json.Net 好用,所以在写普通的 MVC Controller 的时候,会用 Json.Net 自己实现一个 JsonNetResult,在网上有很多实现,下面也会有一段类似的代码,所以就不贴了。

入口

在 MVC 中,路由系统可以找到指定的 Controller 和 Action,但在 Web Form 中没有路由系统,自己写个 HttpModule 是可以实现,不过工作量不小。既然剩下的几乎都是请求数据的 HTTP API,比较合适的选择是 IHttpHandler,即 ashx 页面。

只需要定义一个 Do.ashx,通过参数指定 Controller 和 Action,把 Do.ashx 作为所有 Ajax 及类似请求的入口。

有了入口,还得模拟 MVC 对 Controller 和 Action 的处理。这里有几个关键点需要注意:

  • 所有 Action 返回的是一个 ActionResult,由框架处理 ActionResult 对象来向 Response 进行输出。
  • Action 的参数会由 MVC 框架根据名称来解析

如果这些要点没处理好,Controller 就得进行结构上的变更。下面会根据这两个要点来介绍 ActionResult 、Controller 和 Do.ashx 的实现,它们也是本文的重点。

Controller 基类

所有的 Controller 都从基类 Controller 继承,看起来它很重要。但实际上 Controller 基类只是提供了一些工作方法,为所有 Controller 提供了统一扩展的基础。而所有重要的事情,都不是在这里面完成的。

参数的解析和自动赋值是在 Do.ashx 中完成的,当然,这个功能很重要,所以写了一些类来实现;业务过程是在它的子类中完成的;结果处理则是在 ActionResult 中完成的。把它们组合在一起,这才是 Controller 干的事情,而它必须要做的,就是提供一个基类,仅此而已。

IActionResult 和 ActionResult

从网上找到的 JsonNetResult 实现代码,基本上可以了解到,ActionResult 最终会通过 ExecuteResult(HttpContext) 方法将自身保存的参数或者数据,进行一定的处理之后,输出到 HttpContext.Response 对象。所以 IActionResult 接口比如简单,而 ActionResult 就是一个默认实现。

public interface IActionResult
{
    void ExecuteResult(HttpContext context);
}

不过重要的不是 IActionResultActionResult,而是具体的实现。从原有的程序功能来看,至少需要实现:

  • JsonNetResult,用于输出 JSON 结果
  • HttpStatsResult,用于输出指定的 Http 状态,比如 403
  • HttpNotFoundResult,用于输出 404 状态
  • FileResult,这是下载文件要用到的

JsonNetResult

这是最主要使用的一个 Result。它主要是设置 ContentType 为 "application/json",默认编码 UTF-8,然后就是用 Json.Net 将数据对象处理成 JSON 输出到 Response。

public class JsonNetResult : IActionResult
{
    private const string DEFAULT_CONTENT_TYPE = "application/json";

    // 指定 Response 的编码,未指定则使用全局指定的那个(UTF-8)
    public Encoding ContentEncoding { get; set; }
    
    // ContentType,未设置则使用 DEFAULT_CONTENT_TYPE
    public string ContentType { get; set; }
    
    // 保存要序列化成 JSON 的数据对象
    public object Data { get; set; }

    public JsonNetResult()
    {
        Settings = JsonConvert.DefaultSettings();
    }

    // 为当前的 Json 序列化准备一个配置对象,
    // 如果有特殊需要,可以修改其配置项,不会影响全局配置
    public JsonSerializerSettings Settings { get; private set; }

    public void ExecuteResult(HttpContext context)
    {
        HttpResponse response = context.Response;

        if (ContentEncoding != null)
        {
            response.ContentEncoding = ContentEncoding;
        }

        if (Data == null)
        {
            return;
        }

        response.ContentType = string.IsNullOrEmpty(ContentType)
            ? DEFAULT_CONTENT_TYPE
            : ContentType;

        var scriptSerializer = JsonSerializer.Create(Settings);
        // Serialize the data to the Output stream of the response
        scriptSerializer.Serialize(response.Output, Data);
        response.Flush();
        // response.End() 加了会在后台抛一个异常,所以把它注释掉了
        // response.End();
    }
}

HttpStatusResult 和 HttpNotFoundResult

HttpNotFoundResult 其实就是 HttpStatusResult 的一个特例,所以只需要实现 HttpStatusResult 再继承一个 HttpNotFoundResult 出来就好

HttpStatusResult 最主要的是需要一个代码,StatusCode,像 404 啊,403 啊,505 啊之类的。另外 IIS 实现了子状态,所以还有一个子状态码 SubStatusCode。剩下的就是一个消息了,都不是必须的属性。实现起来非常简单

public class HttpStatusResult : IActionResult
{
    public int StatusCode;
    public int SubStatusCode;
    public string Status;
    public string StatusDescription { get; set; }

    public HttpStatusResult(int statusCode, string status = null)
    {
        StatusCode = statusCode;
        Status = status;
    }

    public void ExecuteResult(HttpContext context)
    {
        var response = context.Response;
        response.StatusCode = StatusCode;
        response.SubStatusCode = SubStatusCode;
        response.Status = Status ?? response.Status;
        response.StatusDescription = StatusDescription ?? response.StatusDescription;
        response.End();
    }
}

public sealed class HttpNotFoundResult : HttpStatusResult, IActionResult
{
    public HttpNotFoundResult()
        : base(404, "404 Resource not found")
    {
    }
}

FileResult

对于文件来说,有三个主要的属性:MIME、文件流和文件名。配置好 Response 的头之后,简单的把文件流拷贝到 Response 的输出流就解决问题

public class FileResult : IActionResult
{
    const string DEFAULT_CONTENT_TYPE = "application/octet-stream";
    public string ContentType { get; set; }

    readonly string filename;
    readonly Stream stream;

    public FileResult(Stream stream, string filename = null)
    {
        this.filename = filename;
        this.stream = stream;
    }

    public void ExecuteResult(HttpContext context)
    {
        var response = context.Response;
        response.ContentType = string.IsNullOrEmpty(ContentType)
            ? DEFAULT_CONTENT_TYPE
            : ContentType;

        if (!string.IsNullOrEmpty(filename))
        {
            response.AddHeader("Content-Disposition",
               string.Format("attachment; filename=\"{0}\"", filename));
        }

        response.AddHeader("Content-Length", stream.Length.ToString());

        stream.CopyTo(response.OutputStream);
        stream.Dispose();
        response.End();
    }
}

Do.ashx

上面已经提到了 Do.ashx 是一个入口,它的首要工作是选择正确的 Controller 和 Action。Action 的指定是通过参数实现的,我们得定义一个特别的参数,思考再三,将参数名定义为 $,因为它够特殊,而且比 action 或者 _action 短。而这个参数的值,就延用 MVC 中路由的结构 /controller/action/id

幸好原来路由结构就不复杂,不然解析函数就难写了。

MVC 框架中有一个 ActionDescriptor 类保存了 Controller 和 Action 的信息。所以我们模拟一个 ActoinDescriptor,然后 Do.ashx 就只需要对每次请求生成一个 ActionDescriptor 对象,让它来解析参数,选择 Controller 和 Action,再调用找到的 Action,处理结果……明白了吧,它才是真正的调度中心!

ActionDescriptor 要干的第一件事就是解析 $ 参数。因为在 Controller 和 Action 不明确之后,ActionDescriptor 对象就没必要存在,所以我们定义了一个静态方法:

static ActionDescriptor Parse(string action)

幸好我们原来的路由定义得并不复杂,所以这里的解析函数也可以写得很简单,只是按分隔符 / 拆成几段分别赋值给新对象的 ControllerActionId 属性就好。

internal static ActionDescriptor Parse(string action)
{
    if (string.IsNullOrWhiteSpace(action))
    {
        return null;
    }

    var parts = action
        .Trim('/', ' ')
        .Split(SPLITERS, StringSplitOptions.RemoveEmptyEntries);

    return new ActionDescriptor {
        Controller = parts[0],
        Action = parts.Length > 1 ? parts[1] : "index",
        Id = parts.Length > 2 ? parts[2] : null
    };
}

Router 反射工具类

虽然没有路由系统,但是上面得到了 ControllerAction 这两个名称之后,还需要找到对应的 Controller 类,以及对应于 Action 的方法——这一些都需要用反射来完成。

Router 就是定义来干这个事情,所以它是一个反射工具类。它所做的事情,只是把类和方法找出来,即一个 Type 对象,一个 MethodInfo 对象。

Router 类有 60 多行代码,不算大也不算小。限于篇幅,代码我就不准备贴了,因为它干的事情实在很简单,只要有反射的基础知识,写出来也就是分分钟的事情。

ActionDescriptor.Do(HttpContext)

Router 把 Controller 的类,一个 Type 对象,以及 Action 对应的方法,一个 MethodInfo 对象找出来之后,还需要实例化并对实例调用方法,得到一个 IActionResult,再调用它的 ExecuteResult(HttpContext) 方法将结果输出到 Response。

这一整个过程就是 ActionDescriptor.Do() 干的事情,非常清晰也非常简单。用伪代码描述出来就是

var tuple = Router.Get(controllerName, actionName);
// tuple.Item1 是 Type 对象
// tuple.Item2 是 MethodInfo 对象

var instance = Activator.CreateInstance(tuple.Item1);
var result = method.Invoke(c, GetArguments(method, context.Request));

if (typeof(IActionResult).IsAssignableFrom(result.GetType()))
{
    ((IActionResult)result).ExecuteResult(context);
}
else
{
    // 如果返回的不是 IActionResult,当作 JsonNetResult 的数据来处理
    // 这样相当于扩展了 Action,可以直接返回需要序列化成 JSON 的数据对象
    new JsonNetResult
    {
        Data = result
    }.ExecuteResult(context);
}

等一等,发现身份不明的东东——GetArguments() 这是干啥用的?

object[] GetArguments(MethodInfo, HttpRequest)

从签名就可以猜测 GetArguments() 要分析 Action 对应方法的参数定义,然后从 Reqeust 中取值,返回一个与 Action 方法参数定义一一对应的参数值列表(数组)……也就是 MethodInfo.Invoke() 方法的第二个参数。

GetArguments() 内部使用 ReqeustParser 来实现对每一个参数进行取值,它的主要过程只是对传入的 MethodInfo 对象的参数列表进行遍历

object[] GetArguments(MethodInfo method, HttpRequest request)
{
    var parser = new RequestParser(request);

    // 通过 Linq 的 Select 扩展来遍历参数列表,并依次通过 RequestParser 来取值
    return method.GetParameters()
        .Select(p => parser.ParseValue(p.Name, p.ParameterType))
        .ToArray();
}

这么一来,取值的重任就交给 RequestParser 了——你觉得任务不够重吗?如果只是对简单的数据类型,比如 int、string 取值,当然不重,但如果是一个数据模型呢?

RequestParser

ReqeustParser 首要实现的就是对简单类型取值,这是在 ParseValue() 方法中实现的,进行简单的分析之后调用 Convert.ChangeType() 就能解决问题。

但如果遇到一个数据模型,就需要用 ParseObject() 来处理了,它会遍历模型对象的所有属性,并依次递归调用 ParseValue() 来进行处理——这里偷懒了,只处理了属性,没有去处理字段——如果你需要,自己实现也不是难事

class RequestParser
{
    static bool IsConvertableType(Type type)
    {
        switch (type.FullName)
        {
            case "System.DateTime":
            case "System.Decimal":
                return true;
            default:
                return false;
        }
    }

    readonly HttpRequest request;

    internal RequestParser(HttpRequest request)
    {
        this.request = request;
    }

    internal object ParseValue(string name, Type type)
    {
        string value = request[name];
        if (type == typeof(string))
        {
            return value;
        }

        if (string.IsNullOrWhiteSpace(value))
        {
            value = null;
        }

        var vType = Nullable.GetUnderlyingType(type) ?? type;

        if (vType.IsEnum)
        {
            return value == null
                ? null
                : Enum.ToObject(
                    vType,
                    Convert.ChangeType(value, Enum.GetUnderlyingType(vType)));
        }

        if (vType.IsPrimitive || IsConvertableType(vType))
        {
            return value == null ? null : Convert.ChangeType(value, vType);
        }

        return ParseObject(vType);
    }

    internal object ParseObject(Type type)
    {
        const BindingFlags flags
            = BindingFlags.Instance
            | BindingFlags.SetProperty
            | BindingFlags.Public;

        object obj;
        try
        {
            obj = Activator.CreateInstance(type);
        }
        catch
        {
            return null;
        }

        foreach (var p in type.GetProperties(flags)
            .Where(p => p.GetIndexParameters().Length == 0))
        {
            var value = ParseValue(p.Name, p.PropertyType);
            if (value != null)
            {
                p.SetValue(obj, value, null);
            }
        }
        return obj;
    }
}

虽然一句注释都没有,但我相信你看得懂。如果实在不明白,请留言。

结束语

到此,从 MVC 转为 Web Form 的主要技术问题都已经解决了。其中一些处理方式是借鉴了 MVC 框架的实现思路。因此这个项目在切换框架的时候还不是特别复杂,所以要处理的事情也相对较少。对于一个成熟的 MVC 框架实现的项目来说,转换绝不是一件轻松的事情——相当于你得自己在 Web Form 中实现 MVC 框架,工作量大不说,稳定性也堪忧。

MVC 框架还有很重要的一个部分就是 Filter,对于 Filter 的简单实现,可以在 ActionDescriptor 中进行处理。但如果你想做这件事情,一定要谨慎,因为这涉及到一个相对复杂的生命周期,搞不好就可能刨个坑把自个儿埋了。


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!


引用和评论

0 条评论