2024年2月

前言:

在上一篇中,我们了解了如何下载、配置和运行 Taurus.MVC WebMVC 框架。

现在,让我们开始编写一个简单的页面并进行呈现。

步骤1:创建控制器

首先,我们需要创建一个控制器来处理页面呈现的逻辑。

在 Visual Studio 中,右键单击项目文件夹,选择「添加」-> 「类」。

在弹出的对话框中,命名为「HomeController」。点击「添加」按钮以完成控制器的创建。

步骤2:编写页面呈现方法

在 HomeController.cs 文件中,定义以下内容。

 public classHomeController : Taurus.Mvc.Controller
{

public voidIndex()
{

}
public voidAbout() { }
public voidContact() { }
}

在上述代码中,我们定义了三个方法,用于显示首页,关于我们、联系我们。

关于我们、联系我们,在示例后续没有用到,因此是附赠的。

同时,控制器需要继承自Taurus.Mvc.Controller 这个基类。

这个方法将用于加载对应的视图,并处理对应页面的呈现。

例如:当前的.Net Core 工程目录如下:

记住,在本系列入门教程中,控制器被放在和工程目录一起,这是为了入门教程使用。

实际项目中,都会单独新建一个控制器项目,以单独存放,以起到分层清晰明目的效果。

步骤3:创建视图

接下来,我们需要创建一个与控制器方法对应的视图。

在 Visual Studio 中,在根目录(或 wwwroot)下,需要添加 Views 文件夹(不存在则创建)。

右键单击「Views」文件夹,选择「添加」-> 「新建文件夹」。在弹出的对话框中,输入视图名称为「Home」,对应控制器名称,并点击「添加」按钮。

右键单击「Home」文件夹,选择「添加」-> 「新建项」。在弹出的对话框中,选择 HTML页,输入名称为「Index」,对应控制器的方法名称,并点击「添加」按钮。

可重复添加「About」、「Contact」两个html文件。

在 Index.html 视图文件中,我们可以使用 html 语法编写页面内容。以下是一个简单的示例:

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>欢迎来到 Taurus.MVC WebMVC</title> </head> <body> <h1>欢迎来到 Taurus.MVC WebMVC</h1> <p>这是一个简单的页面呈现示例。</p> </body> </html>

在上述代码中,我们创建了一个基本的 HTML 页面结构,并在页面中添加了一个标题和一段文字内容。

例如:当前工程项目像如下图:

步骤4:运行应用程序

现在,我们已经完成了控制器和视图的编写。让我们来运行应用程序并查看页面的呈现效果。

在 Visual Studio 中,按下「F5」键或点击工具栏上的「开始调试」按钮,等待应用程序启动。

一旦应用程序启动完成,浏览器默认可能是:http://localhost:5191

这时,需要在浏览器中输入控制器的请求地址如:/home/index,如下图

您将在浏览器中看到 Taurus.MVC WebMVC 的欢迎页面。

恭喜!您已经成功创建并呈现了一个简单的页面。

设置默认页:

如果不想每次运行都要手动设置访问页,可以通过配置 Mvc.DefaultUrl,来设置默认页:

如:

{"AppSettings": {"Mvc.DefaultUrl": "/home/index"}
}

总结:

在本篇教程中,我们学习了如何使用 Taurus.MVC WebMVC 框架创建一个简单的页面。

我们创建了一个控制器并编写了一个用于呈现页面的方法,然后创建了对应的视图,并最终成功运行了应用程序。

在下一篇教程中,我们将继续探索 Taurus.MVC WebMVC 框架的更多功能和用法。

本系列的目录大纲为:

复制代码
Taurus.MVC WebMVC 入门开发教程1:框架下载环境配置与运行

Taurus.MVC WebMVC 入门开发教程2:一个简单的页面呈现

Taurus.MVC WebMVC 入门开发教程3:数据绑定Model

Taurus.MVC WebMVC 入门开发教程4:数据列表绑定List<Model>

Taurus.MVC WebMVC 入门开发教程5:表单提交与数据验证

Taurus.MVC WebMVC 入门开发教程6:路由配置与路由映射

Taurus.MVC WebMVC 入门开发教程7:部分视图和页面片段
复制代码

我个人觉得这是ASP.NET Core MVC框架体系最核心的部分。原因很简单,MVC框架建立在ASP.NET Core路由终结点上,它最终的目的就是将每个Action方法映射为一个或者多个路由终结点,路由终结点根据附加在Action上的若干元数据构建而成。为了构建描述当前应用所有Action的元数据,MVC框架会提取出定义在当前应用范围内的所有Controller类型,并进一步构建出基于Controller的应用模型。应用模型不仅仅是构建Action元数据的基础,承载API的应用还可以利用它自动生成API开发文档,一些工具甚至可以利用应用模型自动生成消费API的客户端代码。这篇文章大概是两年之前写的,可能一些技术细节在最新版本的ASP.NET Core MVC已经发生了改变,但总体设计依然如此。

不论是面向Controller的MVC编程模型,还是面向页面的Razor Pages编程模型,客户端请求访问的目标都是某个Action,所以MVC框架的核心功能就是将请求路由到正确的Action,并通过执行目标Action的方式完成请求当前请求的处理。目标Action应该如何执行由描述它的元数据来决定,而这样的元数据是通过
ApplicationModel
类型标识的应用模型构建出来的。应用模型为MVC应用构建了一个基于Controller的蓝图,我们先从宏观的角度来看看这张蓝图是如何绘制的。

一、 总体设计
二、ApplicationModel
三、IApplicationModelProvider
四、IApplicationModelConvention
五、其他约定
六、ApplicationModelFactory

一、 总体设计

图1基本体现了MVC框架构建应用模型的总体设计。代表使用模型的ApplicationModel对象是通过作为工厂的ApplicationModelFactory对象构建的,但是具体的构建任务却落在注册的一系列IApplicationModelProvider和IApplicationModelConvention对象上。

clip_image002

图1 ApplicationModel的构建模型

具体来说,ApplicationModelFactory工程会先创建一个空的ApplicationModel对象,并利用注册的IApplicationModelProvider对象对这个对象进行完善和修正。在此之后,代表默认约定的一系列IApplicationModelConvention对象会依次被执行,它们会将针对应用模型的约定规则应用到同一个ApplicationModel对象上。经过这两个加工环节之后得到的ApplicationModel最终成为描述应用模型的蓝图。

二、ApplicationModel

表示应用模型的ApplicationModel对象不仅是常见Action元数据的依据,同时还有其他重要的用途。由于ApplicationModel对象绘制了整个应用的蓝图,我们经常不仅可以利用它来生成结构化API文档(比如Swagger),还可以利用它提供的元数据生成调用API的客户端代码。通过ApplicationModel表示的应用模型总体上具有如图2所示的结构:
一个ApplicationModel对象包含多个描述Controller的ControllerModel对象,一个ControllerModel包含多个ActionModel和PropertyModel对象,ActionModel和PropertyModel是对定义在Controller类型中的Action方法和属性的描述。表示Action方法的ActionModel对象利用ParameterModel描述其参数

clip_image004

图2 应用模型总体结构

三、IApplicationModelProvider

在软件设计中我们经常会遇到这样的场景:我们需要构建一个由若干不同元素组成的复合对象,不同的组成元素具有不同的构建方式,MVC框架几乎基于采用了同一种模式来处理这样的场景。举个简单的例子:对象Foo需要实现的功能需要委托一组Bar对象来实现。MVC框架针对这种需求大都采用如图3所示模式来实现:Foo先创建一个上下文,并提供必要的输入,然后驱动每个Bar对象在这个上下文中完成各自的处理任务。所有Bar对象针对数据和状态的修改,以及产生的输出均体现在这个共享的上下文中,所有对象最终通过这个上下文就可以得到应有的状态或者所需的输出。

clip_image006

图3 基于共享上下文的多对象协作模式(单操作)

有时候我们甚至可以将Bar对象的操作分成两个步骤进行,比如我们将针对这两个步骤的操作分别命名为Executing和Executed。如图4所示,在创建共享上下文之后,Foo对象先按序执行每一个Bar对象的Executing操作,最后再反向执行每个Bar对象的Executed操作,所有的操作均在同一个上下文中执行。

clip_image008

图4 基于共享上下文的多对象协作模式(两阶段)

了解了上面所述的基于共享上下文的多对象协作对象构建模式之后,读者朋友们对于IApplicationModelProvider接口定义就很好理解了。如下面的代码片段所示,IApplicationModelProvider接口定了Order属性来决定了自身的执行顺序,而OnProvidersExecuting和OnProvidersExecuted方法分别完成针对Action元数据构建的两阶段任务。

public interface IApplicationModelProvider
{
    int Order { get; }
    void OnProvidersExecuted(ApplicationModelProviderContext context);
    void OnProvidersExecuting(ApplicationModelProviderContext context);
}

这里作为构建应用模型的执行上下文通过如下这个ApplicationModelProviderContext类型表示。如代码片段所示,ApplicationModelProviderContext类型定义了两个属性,其中ControllerTypes属性表示的列表提供了当前应用所有有效的Controller类型,而Result属性返回的ApplicationModel对象自然代表“待改造”的应用模型。

public class ApplicationModelProviderContext
{
    public IEnumerable<TypeInfo> 	ControllerTypes { get; }
    public ApplicationModel 		Result { get; }

    public ApplicationModelProviderContext(IEnumerable<TypeInfo> controllerTypes);
}

MVC框架提供如下所示的几个针对IApplicationModelProvider接口的实现类型。对于最终用于描绘当前MVC应用的ApplicationModel对象,其承载的元数据绝大部分是由DefaultApplicationModelProvider对象提供的。AuthorizationApplicationModelProvider和CorsApplicationModelProvider主要提供针对授权和。而ApiBehaviorApplicationModelProvider则负责提供与API相关的描述信息。这些具体实现类型都是内部类型。

  • DefaultApplicationModelProvider:提供构成应用模型的绝大部分元数据。
  • AuthorizationApplicationModelProvider:提供与授权相关元数据。
  • CorsApplicationModelProvider:提供与跨域资源共享(CORS)相关的元数据。
  • ApiBehaviorApplicationModelProvider:提供与API行为相关的元数据
  • TempDataApplicationModelProvider:为定义在Controller类型中标注了TempDataAttribute特性的属性提供与临时数据保存相关的元数据。
  • ViewDataAttributeApplicationModelProvider:为定义在Controller类型中标注了ViewDataAttribute特性的属性提供与视图数据保存相关的元数据。

IApplicationModelProvider对象针对应用模型的构建是通过ApplicationModelFactory工厂驱动实施的,供这个工厂对象驱策的IApplicationModelProvider对象只需要预先注册到依赖注入容器框架即可。为MVC框架注册基础服务的AddMvcCore扩展方法具有针对DefaultApplicationModelProvider和ApiBehaviorApplicationModelProvider类型以及ApplicationModelFactory的服务注册。IServiceCollection接口的AddControllers扩展方法会添加针对AuthorizationApplicationModelProvider和 CorsApplicationModelProvider类型的注册。针对TempDataApplicationModelProvider ViewDataAttributeApplicationModelProvider类型的服务注册是在IServiceCollection接口的AddControllersWithViews扩展方法中被注册的。

四、IApplicationModelConvention

除了通过在依赖注入框架中注册自定义的IApplicationModelProvider实现类型或者对象方式来定制最终生成的应用模型之外,相同的功能还可以通过注册相应的IApplicationModelConvention对象来完成。顾名思义,IApplicationModelConvention对象旨在帮助我们为应用模型设置一些基于约定的元数据。如下面的代码片段所示,IApplicationModelConvention接口定义了唯一的Apply方法将实现在该方法的约定应用到指定的ApplicationModel对象上。

public interface IApplicationModelConvention
{
    void Apply(ApplicationModel application);
}

与IApplicationModelProvider对象或者实现类型的注册不同,供ApplicationModelFactory工厂使用的IApplicationModelConvention对象需要注册到作为MVC应用配置选项的MvcOptions对象上。具体来说,我们需要将注册的IApplicationModelConvention对象添加到MvcOptions如下所示的Conventions属性上。

public class MvcOptions : IEnumerable<ICompatibilitySwitch>
{
    public IList<IApplicationModelConvention> Conventions { get; }
}

五、其他约定

除了利用自定义的IApplicationModelConvention实现类型对整个应用模型进行定制之外,我们还可以针组成应用模型的某种“节点类型”(Controller类型、Action方法、方法参数等)定义相应的约定,这些约定都具有相应的接口。应用模型分别利用ControllerModel、ActionModel和ParameterModel类型来描述Controller类型、Action方法以及方法参数。我们可以分别实现如下的接口定义相应特性,并将它们分别标注到Controller类型、Action方法或者方法参数上,ApplicationModelFactory对象会自动提取这些特性并将它们提供的约定应用到对应类型的模型节点上。

public interface IControllerModelConvention
{
    void Apply(ControllerModel controller);
}

public interface IActionModelConvention
{
    void Apply(ActionModel action);
}

public interface IParameterModelConvention
{
    void Apply(ParameterModel parameter);
}

描述Controller类型属性的PropertyModel类型的最终目的是为了能够采用模型绑定的方式来完整针对对应属性的绑定,这与针对Action方法参数的绑定是一致的,所以PropertyModel和描述Action方法参数的ParameterModel类型具有相同的基类ParameterModelBase。为了定制Controller类型属性和Action方法参数类型的应用模型节点,MVC框架为我们定义了如下这个IParameterModelBaseConvention接口。

public interface IParameterModelBaseConvention
{
    void Apply(ParameterModelBase parameter);
}

我们可以和上面一样将实现类型定义成标注到属性和参数上特性,也可以让实现类型同时也实现IApplicationModelConvention接口。值得一提的是,MVC框架并没有提供一个针对PropertyModel类型的IPropertyModelConvention接口,针对Action方法参数的IParameterModelConvention接口和IParameterModelBaseConvention接口之间也不存在继承关系。

六、ApplicationModelFactory

如下所示的是作为应用模型创建工厂的ApplicationModelFactory类型的定义。如代码片段所示,ApplicationModelFactory是一个内部类型。ApplicationModelFactory利用在构造函数中注入的参数得到所有注册的IApplicationModelProvider和IApplicationModelConvention对象。

internal class ApplicationModelFactory
{
    private readonly IApplicationModelProvider[] 		_providers;
    private readonly IList<IApplicationModelConvention> 	_conventions;

    public ApplicationModelFactory(IEnumerable<IApplicationModelProvider> providers, IOptions<MvcOptions> options)
    {
        _providers 	= providers.OrderBy(it => it.Order).ToArray();
        _conventions 	= options.Value.Conventions;
    }

    public ApplicationModel CreateApplicationModel(IEnumerable<TypeInfo> controllerTypes)
    {
        var context = new ApplicationModelProviderContext(controllerTypes);
        for (var index = 0; index < _providers.Length; index++)
        {
            _providers[index].OnProvidersExecuting(context);
        }
        for (int index = _providers.Length - 1; index >= 0; index--)
        {
            _providers[index].OnProvidersExecuted(context);
        }
        ApplicationModelConventions.ApplyConventions(context.Result, _conventions);
        return context.Result;
    }
}

ApplicationModelFactory针对应用模型的构建体现在它的CreateApplicationModel方法上。如上面的代码片段所示,ApplicationModelFactory对象先根据提供的Controller类型列表创建出一个ApplicationModelProviderContext上下文对象。接下来,ApplicationModelFactory将这个上下文作为参数,按照Order属性确定的顺序调用每个IApplicationModelProvider对象的OnProvidersExecuting方法,然后再逆序调用它们的OnProvidersExecuted方法。ApplicationModelFactory最后会将通过所有IApplicationModelProvider对象参与构建的ApplicationModel从ApplicationModelProviderContext上下文中提取出来,并将各种方式注册的约定应用在该对象上,具体的实现体现在如下这个ApplyConventions方法上。

internal static class ApplicationModelConventions
{
    public static void ApplyConventions(ApplicationModel applicationModel, IEnumerable<IApplicationModelConvention> conventions)
    {
        foreach (var convention in conventions)
        {
            convention.Apply(applicationModel);
        }

        var controllers = applicationModel.Controllers.ToArray();
        foreach (var controller in controllers)
        {
            var controllerConventions = controller.Attributes.OfType<IControllerModelConvention>().ToArray();

            foreach (var controllerConvention in controllerConventions)
            {
                controllerConvention.Apply(controller);
            }

            var actions = controller.Actions.ToArray();
            foreach (var action in actions)
            {
                var actionConventions = action.Attributes.OfType<IActionModelConvention>().ToArray();

                foreach (var actionConvention in actionConventions)
                {
                    actionConvention.Apply(action);
                }

                var parameters = action.Parameters.ToArray();
                foreach (var parameter in parameters)
                {
                    var parameterConventions = parameter.Attributes.OfType<IParameterModelConvention>().ToArray();

                    foreach (var parameterConvention in parameterConventions)
                    {
                        parameterConvention.Apply(parameter);
                    }

                    var parameterBaseConventions = GetConventions<IParameterModelBaseConvention>(conventions, parameter.Attributes);
                    foreach (var parameterConvention in parameterBaseConventions)
                    {
                        parameterConvention.Apply(parameter);
                    }
                }
            }

            var properties = controller.ControllerProperties.ToArray();
            foreach (var property in properties)
            {
                var parameterBaseConventions = GetConventions<IParameterModelBaseConvention>(conventions, property.Attributes);

                foreach (var parameterConvention in parameterBaseConventions)
                {
                    parameterConvention.Apply(property);
                }
            }
        }
    }

    private static IEnumerable<TConvention> GetConventions<TConvention>(IEnumerable<IApplicationModelConvention> conventions, IReadOnlyList<object> attributes)
    {
        return Enumerable.Concat(conventions.OfType<TConvention>(), attributes.OfType<TConvention>());
    }
}

如上面的代码片段所示,注册在MvcOptions配置选项上的IApplicationModelConvention对象提供的约定会直接应用到ApplicationModel对象上。除此之外,Controller类型、Action方法和方法参数上标注的相应约定特性会被提取出来,它们承载的约定规则会分别应用到对应的ControllerModel、ActionModel和ParameterModel对象上。

对于表示Action方法参数的ParameterModel对象和表示Controller类型属性的ProperrtyModel对象来说,应用在对应参数和属性上实现了IParameterModelBaseConvention接口的特性,以及同时实现了IParameterModelBaseConvention接口的IApplicationModelConvention对象,会被提取出来并将它们承载的约定应用到对应的参数或者属性节点上。


ASP.NET Core MVC应用模型的构建[1]: 应用的蓝图
ASP.NET Core MVC应用模型的构建[2]: 应用模型
ASP.NET Core MVC应用模型的构建[3]: Controller模型
ASP.NET Core MVC应用模型的构建[4]: Action模型

背景

之前在.NET 性能优化群内交流时,我们发现很多朋友对于高性能网络框架有需求,需要创建自己的消息服务器、游戏服务器或者物联网网关。但是大多数小伙伴只知道 DotNetty,虽然 DotNetty 是一个非常优秀的网络框架,广泛应用于各种网络服务器中,不过因为各种原因它已经不再有新的特性支持和更新,很多小伙伴都在寻找替代品。

这一切都不用担心,在.NET Core 以后的时代,我们有了更快、更强、更好的 Kestrel 网络框架,正如其名,Kestrel 中文翻译为
红隼(hóng sǔn)
封面就是红隼的样子,是一种飞行速度极快的猛禽。Kestrel 是 ASPNET Core 成为.NET 平台性能最强 Web 服务框架的原因之一,但是很多人还觉得 Kestrel 只是用于 ASPNET Core 的网络框架,但是其实它是一个高性能的通用网络框架。

我和拥有多个.NET 千星开源项目作者九哥一拍即合,为了让更多的人了解 Kestrel,计划写一系列的文章来介绍它,九哥已经写了一系列的文章来介绍如何使用Kestrel来创建网络服务,我觉得他写的已经很深入和详细了,于是没有编写的计划。

不过最近发现还是有很多朋友在群里面问这样的问题,还有群友提到如何使用Kestrel来实现一个RPC框架,刚好笔者在前面一段时间研究了一下这个,所以这一篇文章也作为Kestrel的应用篇写给大家,目前来说想分为几篇文章来发布,大体的脉络如下所示,后续看自己的时间和读者们感兴趣的点再调整内容。

  • 整体设计
  • Kestrel服务端实现
    • 请求、响应序列化及反序列化
    • 单链接多路复用实现
    • 性能优化
  • Client实现
    • 代码生成技术
  • 待定……

项目

本文对应的项目源码已经开源在Github上,由于时间仓促,笔者只花了几天时间设计和实现这个RPC框架,所以里面肯定有一些设计不合理或者存在BUG的地方,还需要大家帮忙查缺补漏。

SatelliteRpc:
https://github.com/InCerryGit/SatelliteRpc

如果对您有帮助,欢迎点个star~
再次提醒注意:该项目只作为学习、演示使用,没有经过生产环境的检验。

项目信息

编译环境

要求 .NET 7.0 SDK 版本,Visual Studio 和 Rider 对应版本都可以。

目录结构

├─samples                   // 示例项目
│  ├─Client                 // 客户端示例
│  │  └─Rpc                 // RPC客户端服务
│  └─Server                 // 服务端示例
│      └─Services           // RPC服务端服务
├─src                       // 源代码
│  ├─SatelliteRpc.Client    // 客户端
│  │  ├─Configuration       // 客户端配置信息
│  │  ├─Extensions          // 针对HostBuilder和ServiceCollection的扩展
│  │  ├─Middleware          // 客户端中间件,包含客户端中间件的构造器
│  │  └─Transport           // 客户端传输层,包含请求上下文,默认的客户端和Rpc链接的实现
│  ├─SatelliteRpc.Client.SourceGenerator    // 客户端代码生成器,用于生成客户端的调用代码
│  ├─SatelliteRpc.Protocol  // 协议层,包含协议的定义,协议的序列化和反序列化,协议的转换器
│  │  ├─PayloadConverters   // 承载数据的序列化和反序列化,包含ProtoBuf
│  │  └─Protocol            // 协议定义,请求、响应、状态和给出的Login.proto
│  ├─SatelliteRpc.Server    // 服务端
│  │  ├─Configuration       // 服务端配置信息,还有RpcServer的构造器
│  │  ├─Exceptions          // 服务端一些异常
│  │  ├─Extensions          // 针对HostBuilder、ServiceCollection、WebHostBuilder的扩展
│  │  ├─Observability       // 服务端的可观测性支持,目前实现了中间件
│  │  ├─RpcService          // 服务端的具体Rpc服务的实现
│  │  │  ├─DataExchange     // 数据交换,包含Rpc服务的数据序列化
│  │  │  ├─Endpoint         // Rpc服务的端点,包含Rpc服务的端点,寻址,管理
│  │  │  └─Middleware       // 包含Rpc服务的中间件的构造器
│  │  └─Transport           // 服务端传输层,包含请求上下文,服务端的默认实现,Rpc链接的实现,链接层中间件构建器
│  └─SatelliteRpc.Shared    // 共享层,包含一些共享的类
│      ├─Application        // 应用层中间件构建基类,客户端和服务端中间件构建都依赖它
│      └─Collections        // 一些集合类
└─tests                     // 测试项目
    ├─SatelliteRpc.Protocol.Tests
    ├─SatelliteRpc.Server.Tests
    └─SatelliteRpc.Shared.Tests

演示

安装好SDK和下载项目以后,
samples
目录是对应的演示项目,这个项目就是通过我们的RPC框架调用Server端创建的一些服务,先启动Server然后再启动Client就可以得到如下的运行结果:

设计方案

下面简单的介绍一下总体的设计方案:

传输协议设计

传输协议的主要代码在
SatelliteRpc.Protocol
项目中,协议的定义在
Protocol
目录下。针对RPC的请求和响应创建了两个类,一个是
AppRequest
另一个是
AppResponse

在代码注释中,描述了协议的具体内容,这里简单的介绍一下,请求协议定义如下:

[请求总长度][请求Id][请求的路径(字符串)]['\0'分隔符][请求数据序列化类型][请求体]

响应协议定义如下:

[响应总长度][请求Id][响应状态][响应数据序列化类型][响应体]

其中主要的参数和数据在各自请求响应体中,请求体和响应体的序列化类型是通过
PayloadConverters
中的序列化器进行序列化和反序列化的。

在响应时使用了
请求Id
,这个请求Id是ulong类型,是一个
链接唯一的
自增的值,每次请求都会自增,这样就可以保证每次请求的Id都是唯一的,这样就可以在客户端和服务端进行匹配,从而找到对应的请求,从而实现多路复用的请求和响应匹配功能。

当ulong类型的值超过最大值时,会从0开始重新计数,由于ulong类型的值是64位的,值域非常大,所以在正常的情况下,同一连接下不可能出现请求Id重复的情况。

客户端设计

客户端的层次结构如下所示,最底层是传输层的中间件,它由
RpcConnection
生成,它用于TCP网络连接和最终的发送接受请求,中间件构建器保证了它是整个中间件序列的最后的中间件,然后上层就是用户自定义的中间件。

默认的客户端实现
DefaultSatelliteRpcClient
,目前只提供了几个Invoke方法,用于不同传参和返参的服务,在这里会执行中间件序列,最后就是具体的
LoginClient
实现,这里方法定义和
ILoginClient
一致,也和服务端定义一致。

最后就是调用的代码,现在有一个
DemoHostedService
的后台服务,会调用一下方法,输出日志信息。

下面是一个层次结构图:

[用户层代码]
    |
[LoginClient]
    |
[DefaultSatelliteRpcClient]
    |
[用户自定义中间件]
    |
[RpcConnection]
    |
[TCP Client]

所以整个RCP Client的关键实体的转换如下图所示:

请求:[用户PRC 请求响应契约][CallContext - AppRequest&AppResponse][字节流]
响应:[字节流][CallContext - AppRequest&AppResponse][用户PRC 请求响应契约]

多路复用

上文提到,多路复用主要是使用ulong类型的Id来匹配Request和Response,主要代码在
RpcConnection
,它不仅提供了一个最终用于发送请求的方法,
在里面声明了一个
TaskCompletionSource
的字典,用于存储请求Id和
TaskCompletionSource
的对应关系,这样就可以在收到响应时,通过请求Id找到对应的
TaskCompletionSource
,从而完成请求和响应的匹配。

由于请求可能是并发的,所以在
RpcConnection
中声明了
Channel<AppRequest>
,将并发的请求放入到Channel中,然后在
RpcConnection
中有一个后台线程,用于从Channel单线程的中取出请求,然后发送请求,避免并发调用远程接口时,底层字节流的混乱。

扩展性

客户端不仅仅支持
ILoginClient
这一个契约,用户可以自行添加其他契约,只要保障服务端有相同的接口实现即可。也支持增加其它proto文件,Protobuf.Tools会自动生成对应的实体类。

中间件

该项目的扩展性类似ASP.NET Core的中间件,可以自行加入中间件处理请求和响应,中间件支持Delegate形式,也支持自定义中间件类的形式,如下代码所示:

public class MyMiddleware : IRpcClientMiddleware
{
    public async Task InvokeAsync(ApplicationDelegate<CallContext> next, CallContext next)
    {
        // do something
        await next(context);
        // do something
    }
}

在客户端中间件中,可以通过
CallContext
获取到请求和响应的数据,然后可以对数据进行处理,然后调用
next
方法,这样就可以实现中间件的链式调用。

同样也可以进行阻断操作,比如在某个中间件中,直接返回响应,这样就不会继续调用后面的中间件;或者记录请求响应日志,或者进行一些其他的操作,类似于ASP.NET Core中间件都可以实现。

序列化

序列化的扩展性主要是通过
PayloadConverters
来实现的,内部实现了抽象了一个接口
IPayloadConverter
,只要实现对应PayloadType的序列化和反序列化方法即可,然后注册到DI容器中,便可以使用。

由于时间关系,只列出了Protobuf和Json两种序列化器,实际上可以支持用户自定义序列化器,只需要在请求响应协议中添加标识,然后由用户注入到DI容器即可。

其它

其它一些类的实现基本都是通过接口和依赖注入的方式实现,用户可以很方便的进行扩展,在DI容器中替换默认实现即可。如:
IRpcClientMiddlewareBuilder

IRpcConnection

ISatelliteRpcClient
等。

另外也可以自行添加其他的服务,因为代码生成器会自动扫描接口,然后生成对应的调用代码,所以只需要在接口上添加
SatelliteRpcAttribute
,声明好方法契约,就能实现。

服务端设计

服务端的设计总体和客户端设计差不多,中间唯一有一点区别的地方就是服务端的中间件有两种:

  • 一种是针对连接层的
    RpcConnectionApplicationHandler
    中间件,设计它的目的主要是为了灵活处理链接请求,由于可以直接访问原始数据,还没有做路由和参数绑定,后续可观测性指标和一些性能优化在这里做会比较方便。
    • 比如为了应对RPC调用,定义了一个名为
      RpcServiceHandler

      RpcConnectionApplicationHandler
      中间件,放在整个连接层中间件的最后,这样可以保证最后执行的是RPC Service层的逻辑。
  • 另外一种是针对业务逻辑层的
    RpcServiceMiddleware
    ,这里就是类似ASP.NET Core的中间件,此时上下文中已经有了路由信息和参数绑定,可以在这做一些AOP编程,也能直接调用对应的服务方法。
    • 在RPC层,我们需要完成路由,参数绑定,执行目标方法等功能,这里就是定义了一个名为
      EndpointInvokeMiddleware
      的中间件,放在整个RPC Service层中间件的最后,这样可以保证最后执行的是RPC Service层的逻辑。

下面是一个层次结构图:

[用户层代码]
    |
[LoginService]
    |
[用户自定义的RpcServiceMiddleware]
    |
[RpcServiceHandler]
    |
[用户自定义的RpcConnectionApplicationHandler]
    |
[RpcConnectionHandler]
    |
[Kestrel]

整个RPC Server的关键实体的转换如下图所示:

请求:[字节流][RpcRawContext - AppRequest&AppResponse][ServiceContext][用户PRC Service 请求契约]
响应:[用户PRC Service 响应契约][ServiceContext][AppRequest&AppResponse][字节流]

多路复用

服务端对于多路复用的支持就简单的很多,这里是在读取到一个完整的请求以后,直接使用Task.Run执行后续的逻辑,所以能做到同一链接多个请求并发执行,
对于响应为了避免混乱,使用了
Channel<HttpRawContext>
,将响应放入到Channel中,然后在后台线程中单线程的从Channel中取出响应,然后返回响应。

终结点

在服务端中有一个终结点的概念,这个概念和ASP.NET Core中的概念类似,它具体的实现类是
RpcServiceEndpoint
;在程序开始启动以后;
便会扫描入口程序集(当然这块可以优化),然后找到所有的
RpcServiceEndpoint
,然后注册到DI容器中,然后由
RpcServiceEndpointDataSource
统一管理,
最后在进行路由时有
IEndpointResolver
根据路径进行路由,这只提供了默认实现,用户也可以自定义实现,只需要实现
IEndpointResolver
接口,然后替换DI容器中的默认实现即可。

扩展性

服务端的扩展性也是在
中间件

序列化

其它接口
上,可以通过DI容器很方便的替换默认实现,增加AOP切面等功能,也可以直接添加新的Service服务,因为会默认去扫描入口程序集中的
RpcServiceEndpoint
,然后注册到DI容器中。

优化

现阶段做的性能优化主要是以下几个方面:

  • Pipelines
    • 在客户端的请求和服务端处理(Kestrel底层使用)中都使用了Pipelines,这样不仅可以降低编程的复杂性,而且由于直接读写Buffer,可以减少内存拷贝,提高性能。
  • 表达式树
    • 在动态调用目标服务的方法时,使用了表达式树,这样可以减少反射的性能损耗,在实际场景中可以设置一个快慢阈值,当方法调用次数超过阈值时,就可以使用表达式树来调用方法,这样可以提高性能。
  • 代码生成
    • 在客户端中,使用了代码生成技术,这个可以让用户使用起来更加简单,无需理解RPC的底层实现,只需要定义好接口,然后使用代码生成器生成对应的调用代码即可;另外实现了客户端自动注入,避免运行时反射注入的性能损耗。
  • 内存复用
    • 对于RPC框架来说,最大的内存开销基本就在请求和响应体上,创建了PooledArray和PooledList,两个池化的底层都是使用的ArrayPool,请求和响应的Payload都是使用的池化的空间。
  • 减少内存拷贝
    • RPC框架消耗CPU的地方是内存拷贝,上文提到了客户端和服务端均使用Pipelines,在读取响应和请求的时候直接使用
      ReadOnlySequence<byte>
      读取网络层数据,避免拷贝。
    • 客户端请求和服务端响应创建了PayloadWriter类,通过
      IBufferWriter<byte>
      直接将序列化的结果写入网络Buffer中,减少内存拷贝,虽然会引入闭包开销,但是相对于内存拷贝来说,几乎可以忽略。
    • 对于这个优化实际应该设置一个阈值,当序列化的数据超过阈值时,才使用PayloadWriter,否则使用内存拷贝的方式,需要Benchmark测试支撑阈值设置。

其它更多的性能优化需要Benchmark的数据支持,由于时间比较紧,没有做更多的优化。

待办

计划做,但是没有时间去实现的:

  • 服务端代码生成
    • 现阶段服务端的路由是通过字典匹配实现,方法调用使用的表达式树,实际上这一块可以使用代码生成来实现,这样可以提高性能。
    • 另外一个地方就是Endpoint注册是通过反射扫描入口程序集实现的,实际上这一步可以放在编译阶段处理,在编译时就可以读取到所有的服务,然后生成代码,这样可以减少运行时的反射。
  • 客户端取消请求
    • 目前客户端的请求取消只是在客户端本身,取消并不会传递到服务端,这一块可以通过协议来实现,在请求协议中添加一个标识,传递Cancel请求,然后在服务端进行判断,如果是取消请求,则服务端也根据ID取消对应的请求。
  • Context 和 AppRequest\AppResponse 池化
    • 目前的Context和AppRequest\AppResponse都是每次请求都会创建,对于这些小对象可以使用池化的方式来实现复用,其中AppRequest、AppResponse已经实现了复用的功能,但是没有时间去实现池化,Context也可以实现池化,但是目前没有实现。
  • 堆外内存、FOH管理
    • 目前的内存管理都是使用的堆内存,对于那些有明显作用域的对象和缓存空间可以使用堆外内存或FOH来实现,这样可以减少GC在扫描时的压力。
  • AsyncTask的内存优化
    • 目前是有一些地方使用的ValueTask,对于这些地方也是内存分配的优化方向,可以使用
      PoolingAsyncValueTaskMethodBuilder
      来池化ValueTask,这样可以减少内存分配。
    • TaskCompletionSource也是可以优化的,后续可以使用
      AwaitableCompletionSource
      来降低分配。
  • 客户端连接池化
    • 目前客户端的连接还是单链接,实际上可以使用连接池来实现,这样可以减少TCP链接的创建和销毁,提高性能。
  • 异常场景处理
    • 目前对于服务端和客户端来说,没有详细的测试,针对TCP链接断开,数据包错误,服务器异常等场景的重试,熔断等策略都没有实现。

wmproxy

wmproxy
已用
Rust
实现
http/https
代理,
socks5
代理, 反向代理, 负载均衡, 静态文件服务器,
websocket
代理,四层TCP/UDP转发,内网穿透等,会将实现过程分享出来,感兴趣的可以一起造个轮子

项目地址

国内: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

设计目标

让系统拥有acme的能力,即可以领取
Let's Encrypt
的证书签发,快速实现上线部署。

acme是什么?

ACME(Automated Certificate Management Environment)
是一个用于自动化管理SSL/TLS证书的协议。它通过自动获取、自动更新和自动拒绝等功能,可以大大提高SSL证书的管理和更新效率,降低错误风险,提高网站的安全性和稳定性。

当ACME服务器发布不安全的SSL证书时,可以通过ACME协议自动拒绝证书,确保网站始终使用安全的SSL证书。此外,ACME协议还支持自动续期功能,这意味着在证书到期之前,系统可以自动申请并获取新的证书,从而避免了因证书过期而导致的网站访问中断或安全风险。

acme的定义

acme是一个可以自动获取 TLS证书的协议,acmev1已经被正式弃用,现行的acme在
rfc8666666
定义。其中定义了SSL如何获取的整个过程,包括其中最重要的权限鉴定。

以下是两种acme判定权限拥有者的鉴权方式,以下是
wmproxy.net
做为域名来举例。

HTTP-01 方式鉴定

HTTP-01 的校验原理是访问给你域名指向的 HTTP 服务增加一个临时 location,
Let’s Encrypt
会发送 http 请求到
http://wmproxy.net/.well-known/acme-challenge/

wmproxy.net
就是被校验的域名,TOKEN 是 ACME 协议的客户端负责放置的文件,在这里 ACME 客户端就是
acme-lib
。Let’s Encrypt 会对比 TOKEN 是否符合预期,校验成功后就会颁发证书。不支持泛域名证书。成功后我们就可以拥有TLS证书了。

  • 优点
    配置简单通用
    任何DNS服务商均可

  • 缺点
    需要依赖HTTP服务器
    集群会无法申请的可能
    不支持泛域名

DNS-01 方式鉴定

在 ACME DNS 质询验证的自动化中,以下是一些关键步骤:

  1. 生成一个 DNS TXT 记录,如
    _acme-challenge
  2. 将 TXT 记录添加到 DNS 区域中。
  3. 通知 Let's Encrypt 验证 DNS 记录。
  4. 等待 Let's Encrypt 验证完成。
  5. 如果验证成功,则生成证书。
  6. 删除 DNS TXT 记录。

此方法不需要你的服务使用 Http 服务,并且支持泛域名证书。

  • 优点
    不需要HTTP服务器
    支持泛域名

  • 缺点
    各DNS服务商均不一致

acme在保证安全的情况下缩短了TLS证书的申请流程,可以自动化的进行部署,极大的缓解因证书过期带来的麻烦。

代码实现

依赖:
acme-lib
改造:之前是确定配置证书及密钥后直接生成完整的TLS信息
TlsAcceptor
,那么现在在未申请到证书前,不能确定完整的
TlsAcceptor
,需要对初始化对象进行重新改造处理。
源码:
wrap_tls_accepter
定义:

/// 为了适应acme, 重新改造Acceptor进行封装处理
#[derive(Clone)]
pub struct WrapTlsAccepter {
    pub last: Instant,
    pub domain: Option<String>,
    pub accepter: Option<TlsAcceptor>,
}

同样添加accept方法

#[inline]
    pub fn accept<IO>(&self, stream: IO) -> io::Result<Accept<IO>>
    where
        IO: AsyncRead + AsyncWrite + Unpin,
    {
        self.accept_with(stream, |_| ())
    }

    pub fn accept_with<IO, F>(&self, stream: IO, f: F) -> io::Result<Accept<IO>>
    where
        IO: AsyncRead + AsyncWrite + Unpin,
        F: FnOnce(&mut ServerConnection),
    {
        if let Some(a) = &self.accepter {
            Ok(a.accept_with(stream, f))
        } else {
            self.check_and_request_cert()
                .map_err(|_| io::Error::new(io::ErrorKind::Other, "load https error"))?;
            Err(io::Error::new(io::ErrorKind::Other, "try next https error"))
        }
    }


accepter
未初始化时,我们将会试图检查证书,查看是否能签发证书。

此处我们为了避免并发中,重复多次请求导致请求数过多导致的服务不可用,我们此处定义了全局静态变量。

lazy_static! {
    static ref CACHE_REQUEST: Mutex<HashMap<String, Instant>> = Mutex::new(HashMap::new());
}

在检查的时候,我们只允许一段时间内仅有一个请求进入申请证书的流程,其它的请求全部返回错误:

let mut map = CACHE_REQUEST
    .lock()
    .map_err(|_| io::Error::new(io::ErrorKind::Other, "Fail get Lock"))?;
if let Some(last) = map.get(self.domain.as_ref().unwrap()) {
    if last.elapsed() < Duration::from_secs(30) {
        return Err(io::Error::new(io::ErrorKind::Other, "等待上次请求结束").into());
    }
}
map.insert(self.domain.clone().unwrap(), Instant::now());

然后我们对该域名发起证书签名请求,此处我们会循环卡住整个线程,而非异步的请求,所以我们这里用了
thread::spawn
而非
tokio::spawn

let obj = self.clone();
thread::spawn(move || {
    let _ = obj.request_cert();
});

以下是请求证书的函数:

fn request_cert(&self) -> Result<(), Error> {
    // 使用let's encrypt签发证书
    let url = DirectoryUrl::LetsEncrypt;
    let path = Path::new(".well-known/acme-challenge");
    if !path.exists() {
        let _ = std::fs::create_dir_all(path);
    }

    // 使用内存的存储结构,存储自己做处理
    let persist = MemoryPersist::new();

    // 创建目录节点
    let dir = Directory::from_url(persist, url)?;

    // 设置请求的email信息
    let acc = dir.account("wmproxy@wmproxy.net")?;

    // 请求签发的域名
    let mut ord_new = acc.new_order(&self.domain.clone().unwrap_or_default(), &[])?;

    let start = Instant::now();
    // 以下域名的鉴权,需要等待let's encrypt确认信息
    let ord_csr = loop {
        // 成功签发,跳出循环
        if let Some(ord_csr) = ord_new.confirm_validations() {
            break ord_csr;
        }

        // 超时30秒,认为失败了
        if start.elapsed() > Duration::from_secs(30) {
            println!("获取证书超时");
            return Ok(());
        }

        // 获取鉴权方式
        let auths = ord_new.authorizations()?;

        // 以下是HTTP的请求方法,本质上是请求token的url,然后返回正确的值
        // 此处我们用的是临时服务器
        //
        // /var/www/.well-known/acme-challenge/<token>
        //
        // http://mydomain.io/.well-known/acme-challenge/<token>
        let chall = auths[0].http_challenge();

        // 将token存储在目录下
        let token = chall.http_token();
        let path = format!(".well-known/acme-challenge/{}", token);

        // 获取token的内容
        let proof = chall.http_proof();

        Helper::write_to_file(&path, proof.as_bytes())?;

        // 等待acme检测时间,以ms计
        chall.validate(5000)?;

        // 再尝试刷新acme请求
        ord_new.refresh()?;

    };

    // 创建rsa的密钥对
    let pkey_pri = create_rsa_key(2048);

    // 提交CSR获取最终的签名
    let ord_cert = ord_csr.finalize_pkey(pkey_pri, 5000)?;

    // 下载签名及证书,此时下载下来的为pkcs#8证书格式
    let cert = ord_cert.download_and_save_cert()?;
    Helper::write_to_file(
        &self.get_cert_path().unwrap(),
        cert.certificate().as_bytes(),
    )?;
    Helper::write_to_file(&self.get_key_path().unwrap(), cert.private_key().as_bytes())?;
    Ok(())
}

在其中,我们跟acme服务器的时候我们需要架设临时文件服务器以使acme访问我们http服务器的时候
http://mydomain.io/.well-known/acme-challenge/<token>
能正确的返回正常的请求,我们将在绑定tls的时候,如果没有该证书信息时,我们将自动添加一个
.well-known/acme-challenge
的location以启用https的验证:

pub async fn bind(
    &mut self,
) -> ProxyResult<(Vec<Option<WrapTlsAccepter>>, Vec<bool>, Vec<TcpListener>)> {
    // ...
    for value in &mut self.server {
        // ... 
        if has_acme {
            let mut location = LocationConfig::new();
            let file_server = FileServer::new(
                ".well-known/acme-challenge".to_string(),
                "/.well-known/acme-challenge".to_string(),
            );
            location.rule = Matcher::from_str("/.well-known/acme-challenge/").expect("matcher error");
            location.file_server = Some(file_server);
            value.location.insert(0, location);
        }
    }
    Ok((accepters, tlss, listeners))
}

以启用远程acme能访问该链接的能力,也就意味着我们不能将敏感信息放置在
".well-known/acme-challenge"
目录下面,也就是我们使用
MemoryPersist
的原因。

测试是否可行

因为http-01的方式必须使acme能访问我们的服务器,所以此时测试需要公网环境下进行测试:
我们配置如下文件,reverse.toml:

# 反向代理相关,七层协议为http及https
[http]

# 反向代理中的具体服务,可配置多个多组
[[http.server]]
bind_addr = "0.0.0.0:80"
bind_ssl = "0.0.0.0:443"
up_name = "auto1.wmproxy.net"
root = ""

[[http.server.location]]
rule = "/"
static_response = "I'm Ok {client_ip}"

此时布置在我们的
auto1.wmproxy.net
的服务器上,我们运行

wmproxy run -c reverse.toml

此时当我们访问
https://auto1.wmproxy.net
的请求的时候,将会触发证书申请,成功后证书将放置在
".well-known"
下面,下次启动服务器的时候我们将自动加载已请求的tls证书以提供https服务。

频繁限制问题

在let's encrypt中,如果有早过5次成功后,需要2天后才能继续申请,他将无限返回429,得注意控制申请证书的频率。

总结

TLS证书在当今互联网中处于最重要的一环,他保护着我们的隐私数据的安全,也是最流行的加密方式之一。所以TLS证书的快速部署对于小而美的应用能让其快速的落地使用。

点击
[关注]

[在看]

[点赞]
是对作者最大的支持

你是否曾经希望有一个人工智能助手可以帮助你更快更好地编写代码?那就是 Visual Studio Copilot Chat 为您提供的:一个人工智能驱动的结对程序员,可以回答您的问题,建议代码片段,解释代码逻辑,并与您讨论您的项目。您可以使用 Copilot 更快更好地编写代码,因为它可以帮助您避免错误并学习新技能。

我们为最新的 Visual Studio Copilot Chat 扩展添加了两个新功能:斜杠命令(/),允许您指导 Copilot 执行特定任务,以及上下文变量(#),允许您指定 Copilot 的文件,以便将答案集中在上面。我们也有几个预览功能,您可以先睹为快。

斜杠命令

斜杠命令是可以在聊天中用于对代码执行特定操作的特殊命令。例如,你可以用:

    • /doc 添加文档注释

    • /explain 解释代码

    • /fix 对所选代码中的问题提出修复

    • /generate 生成代码来回答你的问题

    • /help 在Copilot Chat中寻求帮助

    • /optimize 分析和改进所选代码的运行时间

    • /tests 为选定代码创建单元测试

上下文变量

上下文变量特性允许您通过使用 # 符号轻松地将解决方案中的文件添加到问题中。当您引用一个文件时,Copilot 可以访问文件内容并回答有关它的更具体的问题。例如,您可以问“How does the #file:Main.cs file work?”或“What is the purpose of the #file:Calculator.cs file?”,并从 Copilot Chat 中获得相关答案。您可以在一个问题中添加多个文件。此特性允许您在不复制和粘贴的情况下包含项目中的内容,从而节省了时间和精力。

您可以在回答下面的聊天中看到引用的文件。只需要找到参考文献的链接,就能看到你在问题中添加了什么。如果你点击链接,它会把你带到包含的内容。

预览特性

除了 Visual Studio Copilot Chat 的主要发布特性外,我们还有一些令人兴奋的预览特性,您可以试用并给我们反馈。这些预览特性是实验性的,可能不会像预期的那样工作,但它们可以增强您使用 Visual Studio Copilot Chat 的开发体验。以下是一些预览特性及其简要描述。

调试器中的异常助手

Copilot 帮助您诊断和修复代码中的异常。Copilot 将为您提供有关异常的相关信息,例如原因、位置、堆栈跟踪和可能的解决方案。

关于断点表达式的建议

Copilot 帮助您在代码中设置条件断点。Copilot 将根据代码的当前上下文建议计算为 true 或 false 的表达式。

提交消息建议

Copilot 通过生成 Git 提交消息来帮助您描述更改集。然后,您可以用“why”进行更改来改善消息并提交。

解决方案引用

Copilot 帮助您找到并导航到解决方案中代码元素的引用。通过使用#solution,您可以让 Copilot 知道在您的解决方案中搜索与问题相关的代码。这些会自动添加到您的问题上下文中,这样 Copilot 就可以用它们来回答您的问题。

Performance Profiler 建议

Copilot 中的建议可以帮助您优化代码的性能。Copilot 将分析由 Visual Studio Performance Profiler 收集的性能数据,并提出提高代码速度、内存使用和响应性的方法。

测试失败分析

Copilot 可帮助您调试和修复单元测试的失败。Copilot 将为您提供有关测试失败的有用信息,例如断言消息、预期值和实际值、源代码和可能的解决方案。

我们想听听您的意见!

我们感谢到目前为止您提供的所有反馈,因为我们一直在创建 Copilot,并且很高兴看到下一波人工智能辅助生产力将带您创造伟大的软件。请尝试新功能并分享您的反馈。只需使用 Visual Studio “Send feedback”向我们发送您对 Visual Studio 中 GitHub Copilot 的建议,反馈或问题。

原文链接:https://devblogs.microsoft.com/visualstudio/copilot-chat-slash-commands-and-context-variables/