受欢迎的博客标签

ASP.NET Core 2.0 下进行插件化开发

Published
 

研究环境

.NET Core 和 ASP.NET Core 都不是新鲜事,但版本 2 绝对是新鲜事。按微软及其它一些软件公司的习惯,通常都是第 2 个版本才趋于稳定(比如 WinMe 后的 WinXP,Office 2007 后的 2010,还有 Sun 的 Java 2 等)。以前对 .NET Core 1.x 和 ASP.NET Core 1.x 一直是学习、了解和观望的态度,随着 2.0 的发布,是时候将态度变得主动一些了。

ASP.NET 2.0 带来了一个令人激动的新特性,Razor Page。创建 ASP.NET Core Web 应用程序的时候,可以选择基于 Razor Page 的 Web 应用程序和加入了 MVC 框架的 Web 应用程序 (模型视图控制器)

如果选择前者,之后想引入 MVC 框架,可以通过 NuGet 安装 MVC 相关的程序包即可。如果选择后者,又想使用 Razor Page,那只需要建立合适的 Pages 目录结构就好。总的来说,给 MVC 应用支持 Razor Page 支持更为容易一些。本文中我们从一个 MVC 应用开始。

创建插件化的项目结构

现创建两个 ASP.NET Core 2.0 MVC 应用,分别命名如下

  • WebPlatform
  • Plugin1

很显然,前者是主程序,后者是一个插件。

准备插件

我们的插件项目是一个完整的 ASP.NET Core MVC 项目,所以它是可以独立运行的。将其设置为启动项目,使用 F5(调试) 或 Ctrl+F5(不调试) 就能运行起来。

我们可以先在插件自身这个项目中调试好了再把它集成到平台项目(主 Web 项目)中去。

使用区域(Area)

按照之前的经验,我们使用区域(Areas)来作为插件的基础。所在在 Plugin1 项目中建立如下目录结构:

[Plugin1 项目目录]
    \---Areas
        \---Plugin1
            \---Controllers

Controllers 同一级的本来应该还有 ViewsModels 等,我们在需要的时候再创建。

创建控制器

然后在 Controllers 下创建一个控制器,懒得改名,就叫 DefaultController 好了,文件内容是从模板生成的,我们只需要给它加个 [Area("Plugin1")] 属性,声明它属于 Plugin1 这个区域就好:

using Microsoft.AspNetCore.Mvc;

namespace Plugin1.Areas.Plugin1.Controllers
{
    [Area("Plugin1")]
    public class DefaultController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}

创建视图

然后还要添加 Razor 视图。在 Index() 方法中任意位置点击右键,选择“添加视图”,VS 会在 Areas/Plugin1 下创建 Views/Default 目录,并添加 Index.cshtml 视图文件。修改这个文件的标题,使之显示“Plugin1 默认页面”

@{
    ViewData["Title"] = "Index";
}
<h2>Plugin1 默认页面</h2>

添加路由

有了控制器和视图,运行起来仍然不能访问,因为缺少路由。

来到 Startup.cs 文件中,找到 app.UseMvc() 一行,这里已经包含了一个普通的控制器路由,我们再添加一个区域控制器路由。

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

    // ☛ 添加区域控制器路由 ↴
    routes.MapRoute(
        name: "areas",
        template: "{area:exists}/{controller=Default}/{action=Index}/{id?}");
});

既然我们偷懒直接使用了 DefaultController 作为控制器名称,配置路由的时候,记得将默认控制器写成 {controller=Default},而不是 Home

现在运行,并修改访问的 URL 到 /plugin1/plugin1/default/plugin1/default/index 都可以看到正确的结果:

clipboard.png

准备主项目 WebPlatform

尝试把 Plugin1 直接应用于 WebPlatform

Plugin1 项目已经准备好了,不妨先试试把它拷贝到 WebPlatform 中是否能正确运行:

  • 在 WebPlatform 下创建 Areas/Plugin1 目录
  • 把 Plugin1 的 Areas/Plugin1/Views 目录拷贝到 WebPlatform 中的相同位置
  • 把 Plugin1 项目 bin/Debug/netcoreapp2.0 下的 plugin1.dll 拷贝到 WebPlatform 项目的相同位置
  • Startup 中添加 Area 路由 (前面已经讲过如何添加)

将 WebPlatform 设置为启动项目,运行,并在地址修改目标 URL 为 /plugin1。满杯期待的等待,等到的却是一个悲剧……好吧,看来得注册 plugin1 的 Assembly。

注册插件的 Assembly

注册插件的 Assembly 在上一次研究Asp.Net MVC 插件化开发简化方案的时候已经干过。其过程大致是:

  • 搜索插件目录下指定位置 (bin) 下的 .dll
  • 将这些 .dll 文件拷贝到一个缓存目录,称为 Shadow Copy
  • 加载缓存目录下的 .dll,得到 Assembly 对象
  • 注册这些 Assembly

既然要走注册过程,原来拷贝到 WebPlatform/bin/Debug/netcoreapp2.0 下的 plugin1.dll 就不要啦,移到 WebPlatform/Areas/Plugin1/bin 中去,使之更符合单一职责原则。

定位插件目录需要使用 IHostingEnvironemntContentRootPath 属性来获取网站根目录的物理路径。在 Startup 中只有 ConfigureServices()(配置 DI) 之后,Configure() 中可以取到 IHostingEnvironment,所以我们在 Startup 中写一个 LoadPlugins() 方法,在 Configure() 中调用。

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    LoadPlugins(env);
    ....
}

private void LoadPlugins(IHostingEnvironment env)
{
    // TODO 找到插件 Assembly,并进行 Shadow Copy
    // TODO 加载插件 Assemlby 并注册到 MvcBuilder 中
}

我们需要使用 IMvcBuilder.AddApplicationPart 来注册插件 Assembly。而 IMvcBuilder 是从 service.AddMvc() 返回的,这是在配置 DI 服务的时候,还没有拿到 IHostingEnvironment 实例,所以只好给 Startup 定义一个私有成员来暂存 IMvcBuilder 实例了

public class Startup
{
    IMvcBuilder mvcBuilder;

    ...

    public void ConfigureServices(IServiceCollection services)
    {
        mvcBuilder = services.AddMvc();
    }

    ...

    private void LoadPlugins(IHostingEnvironment env)
    {
        // 定位到插件目录 Areas
        var plugins = env.ContentRootFileProvider.GetFileInfo("Areas");

        // 准备 Shadow Copy 的目标目录
        var target = Path.Combine(env.ContentRootPath, "app_data", "plugins-cache");
        Directory.CreateDirectory(target);

        // 找到插件目录下 bin 目录中的 .dll,拷贝
        Directory.EnumerateDirectories(plugins.PhysicalPath)
            .Select(path => Path.Combine(path, "bin"))
            .Where(Directory.Exists)
            .SelectMany(bin => Directory.EnumerateFiles(bin, "*.dll"))
            .ForEach(dll => File.Copy(dll, Path.Combine(target, Path.GetFileName(dll)), true));

        // 从 Shadow Copy 目录加载 Assembly 并注册到 Mvc 中
        Directory.EnumerateFiles(target, "*.dll")
            .Select(AssemblyLoadContext.Default.LoadFromAssemblyPath)
            .ForEach(mvcBuilder.AddApplicationPart);
    }
}

小插曲:IEnumerable<T>.ForEach()

上面代码中用到的 .ForEach() 扩展来自 Viyi.Util,其代码开源,托管在 Gitee 上。这是作者从平时的开发中提取出来的一些工具方法和扩展,欢迎大家使用、提议、贡献代码以及指出缺陷。

修改插件目录

在 ASP.NET MVC 5 中,我们的插件都放在 Areas 目录,因为如果要改目录需要自己实现视图引擎来重写查找视图的逻辑。不过在 ASP.NET Core 中方便得多,我们可以通过配置 RazorViewEngineOptions 来配置从哪些位置去查找视图。这个配置写在 ConfigureServices() 中。我们准备把插件目录改为 /Plugins,对应的配置如下:

public void ConfigureServices(IServiceCollection services)
{
    .....
 
    services.Configure<RazorViewEngineOptions>(options =>
    {
        options.AreaViewLocationFormats.Clear();
        options.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
        options.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
        options.AreaViewLocationFormats.Add("/Views/Shared/{0}.cshtml");
    });
}

模板字符串中的 {2} 表示区域(Area),{1} 表示控制器(Controller),{0} 则是视图名称。

当然相应的需要把 WebPlatform/Areas 改名为 WebPlatform/Plugins。相应的 LoadPlugins() 中搜索插件的根位置也需要修改(顺便再加上目录检查):

private void LoadPlugins(IHostingEnvironment env)
{
    // --------------------------------------------------- ⇣⇣⇣⇣⇣⇣⇣⇣⇣ --
    var plugins = env.ContentRootFileProvider.GetFileInfo("Plugins");
    if (!(plugins.Exists && plugins.IsDirectory))   // ☚
    {
        return;
    }

    ......
}

现在插件相关的目录结构如下

[WebPlatform]
    \---Plugins
        +---Plugin1
        |   +---bin
        |   \---Views
        +---Plugin2
        |   \---...
        \---...

运行,失败!因为找到多个 HomeController,不知道该用哪一个。当然,我们是知道的,Plugin 中的 HomeController 是多余的,删掉它之后重新编译,重新把 Plugin1 部署到 Plugins 目录中即可。

看看效果

clipboard.png

乍一看,很好。仔细一想,不对。不是应该使用 WebPlatform 的 _Layout 吗?怎么会没有样式。

还需要把 Plugins/Views 下面的 _ViewImports.cshtml_ViewStart.cshtml 拷贝过来,放在 WebPlatform/Plugins/Plugin1/Views 下面。再看效果,好了:

clipboard.png

到这里,我们之前在 ASP.NET MVC 5 中研究的插件式开发内容,在 ASP.NET MVC Core 2 中都已经实现了。但是既然 ASP.NET Core 2 中引入了 Razor Page,是否可以使用 Razor Page 实现的插件呢?

Razor Page 实现的插件

准备 Plugin2 项目

这次新建 ASP.NET Core 应用的时候,选择Web 应用程序,这样它不会添加对 MVC 相关的程序包。而且创建出来的项目中没有 MVC 相关的目录结构,只有一个 Pages 目录。

[Plugin2/Pages]
    +---_Layout.cshtml
    +---_ValidationScriptsPartial.cshtml
    +---_ViewImports.cshtml
    +---_ViewStart.cshtml
    +---About.cshtml
        \---About.cshtml.cs
    +---Contact.cshtml
        \---Contact.cshtml.cs
    +---Error.cshtml
        \---Error.cshtml.cs
    \---Index.cshtml
        \---Index.cshtml.cs

我们只把其中 Index.cshtml 改一下,让显示的内容简单一点

@page
@model IndexModel
@{
    ViewData["Title"] = "Plugin 2 Home";
}

<div>
    Plugin2 Page 页面内容
</div>

部署 Plugin2

理所当然地,我们应该在 WebPlatform/Plugins 下建个 Plugin2 目录,然后把 Plugin2 编译出来的 .dll 部署在其 bin 子目录下,再把 Plguin2/Pages 拷贝到其 Pages 目录。

部署完了,运行时并不能访问 /plugin2。需要访问 /plugin2/pages//plugin2/pages/index 才能访问到页面。得想办法把路径中间的 pages 去掉才行。通过对源码的研究,发现可以自己实现 IPageRouteModelProvider 来解决这个问题。

WebPlatform 中实现 IPageRouteModelProvider

这里实现一个 RazorPluginPageRouteModelProvider,代码参考了默认使用的 RazorProjectPageRouteModelProvider (当然,由于项目是活动的,所以你看到的代码和我当时看到的可能会有点不同)。

处理的关键点在于建立 PageRouteModel 的时候,要将其 viewEnginePath 中的 pages/ 去掉。因此,我们在 RazorPluginPageRouteModelProvider 中写一个 RemovePagesOfPlugins 来去掉路由中的 pages/

private static string RemovePagesOfPlugins(string path)
{
    var index = path.IndexOf("Pages/", StringComparison.OrdinalIgnoreCase);
    return index > 0 ? path.Remove(index, 6) : path;
}

RazorPluginPageRouteModelProvider 中的重点方法是 OnProvidersExecuting 事件方法。对其进行改进,使其即能按原来的逻辑处理 WebPlatform/Pages,又能处理 Plugins/.../Pages。这两个处理过程大致相同,区分在于两点:

  1. 搜索 Razor 页面的起始路径不同,一个是 WebPlatform/Pages,另一个是 WebPlatform/Plugins
  2. 处理虚拟路径的代码不同,一个不做特殊处理,一个需要去掉路径中间的 pages/

所以提取一个公共方法 ExecutePageProvider(),在 `OnProvidersExecuting() 中调用:

public void OnProvidersExecuting(PageRouteModelProviderContext context)
{
    ExecutePagesProvider(context, _pagesOptions.RootDirectory, s => s);
    ExecutePagesProvider(context, "/Plugins", RemovePagesOfPlugins);  // ☚
}

ExecutePageProvider() 几乎就是原来 OnProvidersExecuting() 中的内容,除了指定 root 和处理虚拟路径:

private void ExecutePagesProvider(
    PageRouteModelProviderContext context,
    string root,    
    Func<string, string> viewPathResolver)
{
    // ----------------------------------------- ⇣⇣⇣⇣⇣ -
    foreach (var item in _project.EnumerateItems(root))
    {
        ......

        var routeModel = new PageRouteModel(
            relativePath: item.CombinedPath,
            viewEnginePath: viewPathResolver(item.FilePathWithoutExtension));
        // ---------------- ⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡ -------------------------------
        ......
    }
}

最后别忘了在 StartupConfigureServices() 中去注册

public void ConfigureServices(IServiceCollection services)
{
    ......

    services.RemoveAll<IPageRouteModelProvider>()
        .TryAddEnumerable(
            ServiceDescriptor.Singleton<IPageRouteModelProvider,
                RazorPluginPageRouteModelProvider>());
}

Pages 插件化效果

clipboard.png

效果不错,但要注意一点:由于 .NET Core 项目在 Visual Studio 2017 中会自动加入项目目录下产生的新目录和新文件,所以 Plugins/Plugin2 中的所有 .cs 文件会被编译在 WebPlatform.dll 中。这会造成 LoadPlugin() 加载 .dll 的时候出现类型冲突。

解决办法是手工将 Plguins 目录从项目中移除,同时最好在拷贝 Plugin2 视图的时候,去掉 .cs 源代码问题。

后记

整个研究过程还是很愉快的,不过 .NET Core 的文档虽然完整,却并不细致,有些东西还是要从源码去分析。