《asp.netcore6框架揭秘》实例演示[10]:options基本编程模式

dotNET跨平台 dotNET跨平台     2022-12-01     166

关键词:

依赖注入使我们可以将依赖的功能定义成服务,最终以一种松耦合的形式注入消费该功能的组件或者服务中。除了可以采用依赖注入的形式消费承载某种功能的服务,还可以采用相同的方式消费承载配置数据的Options对象,这篇文章演示几种典型的编程模式。[本文节选《ASP.NET Core 6框架揭秘》第6章]

[601]将配置绑定为Options对象(源代码)
[602]具名Options的注册和提取(源代码)
[603]Options与配置源的实时同步(匿名Options)(源代码)
[604]Options与配置源的实时同步(具名Options)(源代码)
[605]用代码方式初始化Options(匿名Options)(源代码)
[606]用代码方式初始化Options(具名Options)(源代码)
[607]针对依赖服务的Options设置(源代码)
[608]验证Options的有效性(源代码)

[601]将配置绑定为Options对象

Options模式采用依赖注入的方式提供Options对象,但是由依赖注入容提供的是一个IOptions<TOptions>对象,后者为我们提供承载配置选项的Options对象。Options模式的核心接口和类型定义在“Microsoft.Extensions.Options”这个NuGet包。在为创建的控制台项目添加了该NuGet包的引用后,我们定义了如下这个Profile类型。

public class Profile

    public Gender Gender  get; set; 
    public int Age  get; set; 
    public ContactInfo? ContactInfo  get; set; 


public class ContactInfo

    public string? EmailAddress  get; set; 
    public string? PhoneNo  get; set; 


public enum Gender

    Male,
    Female

我们在项目根目录下创建一个名为profile.json的配置文件,并在启动定义了如下的内容。为了使该文件能够在编译后自动复制到输出目录,我们需要将“Copy to Output Directory”属性设置为“Copy Always”。


    "gender": "Male",
    "age": "18",
    "contactInfo": 
        "emailAddress" : "foobar@outlook.com",
        "phoneNo": "123456789"
    

在如下演示的程序中。我们调用AddJsonFile扩展方法将针对JSON配置文件(profile.json)的配置源注册到创建的ConfigurationBuilder对象上,并最终将IConfiguration对象构建出来。我们接下来创建了一个ServiceCollection对象,通过调用它的AddOptions扩展方法注册Options模式的核心服务。我们然后将创建的IConfiguration对象作为参数调用了ServiceCollection对象Configure<Profile>扩展方法,其目的在于利用这个IConfiguration对象来绑定作为Options的Profile对象。扩展方法Configure<TOptions>定义在“Microsoft.Extensions.Options.ConfigurationExtensions”这个NuGet包中,所以我们还得为演示程序添加该包的引用。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var configuration = new ConfigurationManager();
configuration.AddJsonFile("profile.json");
var profile = new ServiceCollection()
    .AddOptions()
    .Configure<Profile>(configuration)
    .BuildServiceProvider()
    .GetRequiredService<IOptions<Profile>>().Value;
Console.WriteLine(
    $"Gender: profile.Gender");
Console.WriteLine(
    $"Age: profile.Age");
Console.WriteLine(
    $"Email Address: profile.ContactInfo?.EmailAddress");
Console.WriteLine(
    $"Phone No: profile.ContactInfo?.PhoneNo");

在成功构建出作为依赖注入容器的IServiceProvider对象后,我们调用其GetRequiredService<T>扩展方法得到一个IOptions<Profile>对象,后者利用其Value属性提供所需的Profile对象。我们将这个Profile承载的相关信息输出到控制台上。程序运行后将在控制台上输出如图1所示结果。


图1 绑定配置生成的Profile对象

[602]具名Options的注册和提取

IOptions<TOptions>对象在整个应用范围内只能提供一个单一的Options对象,但是在很多情况下我们需要利用多个同类型的Options对象来承载不同的配置。就拿演示实例中用来表示个人信息的Profile类型来说,应用程序中可能会使用它来表示不同用户的信息,如张三、李四和王五。为了解决这个问题,我们可以在调用Configure<TOptions>方法对配置选项进行设置的时候指定一个具体的名称,然后使用IOptionsSnapshot<TOptions>来代替IOptions<TOptions>以提供指定名称的Options对象。为了演示提供针对不同用户的Profile对象,我们通过修改profile.json文件使之包含两个用户(“foo”和“bar”)的信息,具体内容如下所示。


  "foo": 
    "gender": "Male",
    "age": "18",
    "contactInfo": 
      "emailAddress": "foo@outlook.com",
      "phoneNo": "123"
    
  ,
  "bar": 
    "gender": "Female",
    "age": "25",
    "contactInfo": 
      "emailAddress": "bar@outlook.com",
      "phoneNo": "456"
    
  

具名Options的注册和提取体现在如下的演示程序中。如代码片段所示,在调用IServiceCollection接口的Configure<TOptions>扩展方法时,我们将注册的映射关系分别命名为“foo”和“bar”,提供原始配置数据的IConfiguration对象也由原来的ConfigurationRoot对象变成它的两个子配置节。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var configuration = new ConfigurationManager();
configuration.AddJsonFile("profile.json");
var serviceProvider = new ServiceCollection()
    .AddOptions()
    .Configure<Profile>("foo", configuration.GetSection("foo"))
    .Configure<Profile>("bar", configuration.GetSection("bar"))
    .BuildServiceProvider();

var optionsAccessor = serviceProvider
    .GetRequiredService<IOptionsSnapshot<Profile>>();
Print(optionsAccessor.Get("foo"));
Print(optionsAccessor.Get("bar"));

static void Print(Profile profile)

    Console.WriteLine(
        $"Gender: profile.Gender");
    Console.WriteLine(
        $"Age: profile.Age");
    Console.WriteLine(
        $"Email Address: profile.ContactInfo?.EmailAddress");
    Console.WriteLine(
        $"Phone No: profile.ContactInfo?.PhoneNo\\n");

我们调用IServiceProvider对象的GetRequiredService<TService>扩展方法得到一个IOptionsSnapshot<TOptions>服务,并将用户名作为参数调用其Get方法得到对应的Profile对象。程序运行后,针对两个用户的基本信息将以图2所示的形式输出到控制台上。


图2 根据用户名提取对应的Profile对象

[603]Options与配置源的实时同步(匿名Options)

前面演示的第一个实例利用JSON文件定义了一个单一Profile对象的信息,我们现在对它做相应的修改来演示如何监控这个JSON文件,并在文件更新之后加载新的内容来生成对Profile对象进行绑定的IConfiuration对象。如下面的代码片段所示,我们在调用AddJsonFile扩展方法注册对应配置源时应将该方法的参数reloadOnChange设置为True,从而开启对对应配置文件的监控功能。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var configuration = new ConfigurationManager();
configuration.AddJsonFile(
    path : "profile.json",
    optional : false,
    reloadOnChange : true);

new ServiceCollection()
    .AddOptions()
    .Configure<Profile>(configuration)
    .BuildServiceProvider()
    .GetRequiredService<IOptionsMonitor<Profile>>()
    .OnChange(profile =>
    
        Console.WriteLine(
            $"Gender: profile.Gender");
        Console.WriteLine(
            $"Age: profile.Age");
        Console.WriteLine(
            $"Email Address: profile.ContactInfo?.EmailAddress");
        Console.WriteLine(
            $"Phone No: profile.ContactInfo?.PhoneNo\\n");
    );
Console.Read();

我们利用作为依赖注入容器得到IOptionsMonitor<TOptions>对象,并调用它的OnChange方法注册了一个类型为Action<TOptions>的委托作为回调。该回调会在Options内容发生变化时自动执行,而作为输入的正是重新生成的Options对象。程序启动后针对配置文件的任何修改都会导致新的数据被打印在控制台上。比如我们先后修改了年龄(25)和性别(Female),新的数据将按照图3所示的形式反映在控制台上。

图3 及时提取新的Profile对象并应用到程序中(匿名Options)

[604]Options与配置源的实时同步(具名Options)

具名Options同样可以采用类似的编程模式来。我们在前面演示程序的基础上做了如下修改。如代码片段所示,在得到IOptionsMonitor<TOptions>服务之后,我们调用另一个OnChange方法重载注册了类型为Action<TOptions, String>的委托作为回调,该委托的第二个参数表示的正是在注册Configure<TOptions>指定的Options名称。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var configuration = new ConfigurationManager();
configuration.AddJsonFile(
    path : "profile.json",
    optional : false,
    reloadOnChange : true);

new ServiceCollection()
    .AddOptions()
    .Configure<Profile>("foo", configuration.GetSection("foo"))
    .Configure<Profile>("bar", configuration.GetSection("bar"))
    .BuildServiceProvider()
    .GetRequiredService<IOptionsMonitor<Profile>>()
    .OnChange((profile, name) =>
    
        Console.WriteLine(
            $"Name: name");
        Console.WriteLine(
            $"Gender: profile.Gender");
        Console.WriteLine(
            $"Age: profile.Age");
        Console.WriteLine(
            $"Email Address: profile.ContactInfo?.EmailAddress");
        Console.WriteLine(
            $"Phone No: profile.ContactInfo?.PhoneNo\\n");
    );
Console.Read();

改动后的程序启动之后,针对配置文件所作的任何更新都会体现在控制台上。比如我们分别修改了用户foo的年龄(25)和用户bar的性别(Male),新的内容将以图4所示的形式及时呈现在控制台上。

图4 及时提取新的Profile对象并应用到程序中(具名Options)

[605]用代码方式初始化Options(匿名Options)

前面演示的几个实例具有一个共同的特征,那就是都采用承载配置的IConfiguration对象来绑定Options对象。实际上Options是一个完全独立于配置系统的框架,利用配置绑定的形式来对Options对象进行初始化仅仅是该框架提供的一个小小的扩展而已。我们现在摒弃配置文件,转而采用编程的方式直接对Options进行初始化。如下面的代码片段所示,在调用IServiceCollection接口的Configure<Profile>扩展方法时,我们指定一个Action<Profile>委托来对作为Options的Profile对象进行初始化。修改后的程序运行后会同样在控制台上产生图1所示的输出结果。

using App;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var profile = new ServiceCollection()
    .AddOptions()
    .Configure<Profile>(it =>
    
        it.Gender = Gender.Male;
        it.Age = 18;
        it.ContactInfo = new ContactInfo
        
            PhoneNo = "123456789",
            EmailAddress = "foobar@outlook.com"
        ;
    )
    .BuildServiceProvider()
    .GetRequiredService<IOptions<Profile>>()
    .Value;

Console.WriteLine(
    $"Gender: profile.Gender");
Console.WriteLine(
    $"Age: profile.Age");
Console.WriteLine(
    $"Email Address: profile.ContactInfo?.EmailAddress");
Console.WriteLine(
    $"Phone No: profile.ContactInfo?.PhoneNo\\n");

[606]用代码方式初始化Options(具名Options)

具名Options同样可以采用类似的编程方式。如果需要根据指定的名称对Options进行初始化,那么调用方法时就需要指定一个Action<TOptions,String>类型的委托对象,该委托对象的第二个参数表示Options的名称。在如下所示的代码片段中,我们通过类似的方式设置了两个用户(“foo”和“bar”)的信息,然后利用IOptionsSnapshot<Profile>服务将它们分别提取出来。该程序运行后会在控制台上产生图6-2所示的输出结果。(S606)

using App;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

var optionsAccessor = new ServiceCollection()
    .AddOptions()
    .Configure<Profile>("foo", it =>
    
        it.Gender = Gender.Male;
        it.Age = 18;
        it.ContactInfo = new ContactInfo
        
            PhoneNo = "123",
            EmailAddress = "foo@outlook.com"
        ;
    )
    .Configure<Profile>("bar", it =>
    
        it.Gender = Gender.Female;
        it.Age = 25;
        it.ContactInfo = new ContactInfo
        
            PhoneNo = "456",
            EmailAddress = "bar@outlook.com"
        ;
    )
    .BuildServiceProvider()
    .GetRequiredService<IOptionsSnapshot<Profile>>();

Print(optionsAccessor.Get("foo"));
Print(optionsAccessor.Get("bar"));

static void Print(Profile profile)

    Console.WriteLine(
        $"Gender: profile.Gender");
    Console.WriteLine(
        $"Age: profile.Age");
    Console.WriteLine(
        $"Email Address: profile.ContactInfo?.EmailAddress");
    Console.WriteLine(
        $"Phone No: profile.ContactInfo?.PhoneNo\\n");
;

[607]针对依赖服务的Options设置

在很多情况下我们需要针对某个依赖的服务动态地初始化Options的设置,比较典型的就是根据当前的承载环境(开发、预发和产品)对Options做动态设置。我们在第5章“配置选项(上)”中演示了一系列针对日期/时间输出格式的配置,下面沿用这个场景演示如何根据当前的承载环境设置对应的Options。我们将DateTimeFormatOptions的定义进行简化,只保留如下所示的表示日期和时间格式的两个属性。

public class DateTimeFormatOptions

    public string DatePattern  get; set; 
    public string TimePattern  get; set; 
    public override string ToString() 
    => $"Date: DatePattern; Time: TimePattern";

我们利用配置来提供当前的承载环境,具体采用的是基于命令行参数的配置源。.NET的服务承载系统通过IHostEnvironment接口表示承载环境,具体实现类型为HostingEnvironment(该类型定义在“Microsoft.Extensions.Hosting”NuGet包中,我们需要添加针对这个包的引用)。如下面的演示程序所示,我们创建了一个ServiceCollection对象,并添加了针对IHostEnvironment接口的服务注册,具体提供的是一个根据环境名称创建的HostingEnvironment对象。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Options;

var environment = new ConfigurationBuilder()
    .AddCommandLine(args)
    .Build()["env"];

var services = new ServiceCollection();
services
.AddSingleton<IHostEnvironment>(
    new HostingEnvironment  EnvironmentName = environment )
    .AddOptions<DateTimeFormatOptions>()
        .Configure<IHostEnvironment>(
        (options, env) => 
            if (env.IsDevelopment())
            
                options.DatePattern = "dddd, MMMM d, yyyy";
                options.TimePattern = "M/d/yyyy";
            
            else
            
                options.DatePattern = "M/d/yyyy";
                options.TimePattern = "h:mm tt";
            
        );

var options = services
    .BuildServiceProvider()
    .GetRequiredService<IOptions<DateTimeFormatOptions>>()
    .Value;

Console.WriteLine(options);

我们调用了ServiceCollection对象的AddOptions<DateTimeFormatOptions>扩展方法完成了针对Options框架核心服务的注册,并利用返回的OptionsBuilder<DateTimeFormatOptions>对象对作为配置选项的DateTimeFormatOptions作相应设置。具体来说,我们调用了它的Configure<IHostEnvironment>方法利用提供的Action<DateTimeFormatOptions, IHostEnvironment>委托针对开发环境和非开发环境设置了不同的日期与时间格式。我们采用命令行的方式启动这个应用程序,并利用命令行参数设置不同的环境名称,就可以在控制台上看到图5所示的针对DateTimeFormatOptions的不同设置。


图5 针对承载环境的Options设置

[608]验证Options的有效性

配置选项是整个应用的全局设置,如果对它进行了错误的设置可能会造成很严重的后果,所以最好能够在使用之前进行有效性验证。接下来我们将上面的程序做了如下改动,从而演示如何对设置的日期和时间格式进行验证。

using App;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Globalization;

var config = new ConfigurationBuilder()
    .AddCommandLine(args)
    .Build();
var datePattern = config["date"];
var timePattern = config["time"];

var services = new ServiceCollection();
services.AddOptions<DateTimeFormatOptions>()
    .Configure(options =>
    
        options.DatePattern = datePattern;
        options.TimePattern = timePattern;
    )
    .Validate(options => Validate(options.DatePattern) && 
        Validate(options.TimePattern), 
        "Invalid Date or Time pattern.");

try

    var options = services
        .BuildServiceProvider()
        .GetRequiredService<IOptions<DateTimeFormatOptions>>().Value;
    Console.WriteLine(options);

catch (OptionsValidationException ex)

    Console.WriteLine(ex.Message);


static bool Validate(string format)

    var time = new DateTime(1981, 8, 24, 2, 2, 2);
    var formatted = time.ToString(format);
    return DateTimeOffset.TryParseExact(
        formatted, format,null, DateTimeStyles.None, out var value) 
        && (value.Date == time.Date || value.TimeOfDay == time.TimeOfDay);

上述演示实例借助配置系统以命令行的形式提供了日期和时间格式化字符串。在创建了OptionsBuilder<DateTimeFormatOptions>对象并对DateTimeFormatOptions做了相应设置之后,我们调用Validate<DateTimeFormatOptions>方法利用提供的Func<DateTimeFormatOptions,bool>委托对最终的设置进行验证。运行该程序并按照图6所示的方式指定不同的格式化字符串,系统会根据我们指定的规则来验证其有效性。


图6 验证Options的有效性

《ASP.NET Core 6框架揭秘》实例演示[01]:编程初体验
《ASP.NET Core 6框架揭秘》实例演示[02]:各种形式的API开发
《ASP.NET Core 6框架揭秘》实例演示[03]:Dapr初体验
《ASP.NET Core 6框架揭秘》实例演示[04]:自定义依赖注入框架
《ASP.NET Core 6框架揭秘》实例演示[05]:依赖注入基本编程模式
《ASP.NET Core 6框架揭秘》实例演示[06]:依赖注入框架设计细节
《ASP.NET Core 6框架揭秘》实例演示[07]:文件系统
《ASP.NET Core 6框架揭秘》实例演示[08]:配置的基本编程模式
《ASP.NET Core 6框架揭秘》实例演示[09]:将配置绑定为对象

asp.netcore6框架揭秘实例演示[01]:编程初体验

本篇提供的20个简单的演示实例基本涵盖了ASP.NETCore6基本的编程模式,我们不仅会利用它们来演示针对控制台、API、MVC、gRPC应用的构建与编程,还会演示Dapr在.NET6中的应用。除此之外,这20个实例还涵盖了针对依赖注... 查看详情

netcore6揭秘怎么样

...的后台服务...2022年3月16日这篇文章主要介绍了ASP.NETCore6框架揭秘实例演示之如何承载你的后台服6框架揭秘实例演示之如何承载你的后台服务...2022年3月16日这篇文章主要介绍了ASP.NETCore6框架揭秘实例演示之如何承载... 查看详情

《asp.netcore6框架揭秘》实例演示[20]:“数据保护”框架基于文件的密钥存储...

...对密钥的创建、撤销和回收的实现原理。[本文节选《ASP.NETCore6框架揭秘 查看详情

《asp.netcore6框架揭秘》实例演示[27]:asp.netcore6minimalapi的模拟实现

...的API,同时提供了与现有API的兼容。[本文节选《ASP.NETCore6框架揭秘》第17章]一、基础模型二、WebApplication三、WebApplicat 查看详情

《asp.netcore6框架揭秘》实例演示[13]:日志的基本编程模式

《ASP.NETCore6框架揭秘实例演示[11]:诊断跟踪的几种基本编程方式》介绍了四种常用的诊断日志框架。其实除了微软提供的这些日志框架,还有很多第三方日志框架可供我们选择,比如Log4Net、NLog和Serilog等。虽然这些... 查看详情

《asp.netcore6框架揭秘》实例演示[31]:路由高阶用法

ASP.NET的路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件协作完成的,它们在ASP.NET平台上具有举足轻重的地位,MVC和gRPC框架,Dapr的Actor和发布订阅编程模式都建立在路由系统之上。MinimalAPI更是将提升到了... 查看详情

《asp.netcore6框架揭秘》实例演示[04]:自定义依赖注入框架

ASP.NETCore框架建立在一个依赖注入框架之上,已注入的方式消费服务已经成为了ASP.NETCore基本的编程模式。为了使读者能够更好地理解原生的注入框架框架,我按照类似的设计创建了一个简易版本的依赖注入框架,并... 查看详情

《asp.netcore6框架揭秘》实例演示[22]:如何承载你的后台服务[补充]

...Core应用最终也体现为这样一个承载服务。[本文节选《ASP.NETCore6框架揭秘》第14章][S1407]利用IHostAppl 查看详情

《asp.netcore6框架揭秘》实例演示[19]:数据加解密与哈希

数据保护(DataProtection)框架旨在解决数据在传输与持久化存储过程中的一致性(Integrity)和机密性(confidentiality)问题,前者用于检验接收到的数据是否经过篡改,后者通过对原始的数据进行加密... 查看详情

asp.netcore6框架揭秘实例演示[30]:利用路由开发restapi

借助路由系统提供的请求URL模式与对应终结点之间的映射关系,我们可以将具有相同URL模式的请求分发给与之匹配的终结点进行处理。ASP.NET的路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件协作完成的,它们... 查看详情

《asp.netcore6框架揭秘》实例演示[28]:自定义一个服务器

作为ASP.NETCore请求处理管道的“龙头”的服务器负责监听和接收请求并最终完成对请求的响应。它将原始的请求上下文描述为相应的特性(Feature),并以此将HttpContext上下文创建出来,中间件针对HttpContext上下文的... 查看详情

《asp.netcore6框架揭秘》实例演示[34]:缓存整个响应内容

我们利用ASP.NET开发的大部分API都是为了对外提供资源,对于不易变化的资源内容,针对某个维度对其实施缓存可以很好地提供应用的性能。《内存缓存与分布式缓存的使用》介绍的两种缓存框架(本地内存缓存和分... 查看详情

《asp.netcore6框架揭秘》实例演示[25]:配置与承载环境的应用

与服务注册一样,针对配置的设置同样可以采用三种不同的编程模式。第一种是利用WebApplicationBuilder的Host属性返回的IHostBuilder对象,它可以帮助我们设置面向宿主和应用的配置。IWebHostBuilder接口上面同样提供了一系列用... 查看详情

《asp.netcore6框架揭秘》实例演示[26]:跟踪应用接收的每一次请求

很多人可能对ASP.NETCore框架自身记录的诊断日志并不关心,其实这些日志对纠错排错和性能监控提供了很有用的信息。如果需要创建一个APM(ApplicationPerformanceManagement)系统来监控ASP.NETCore应用处理请求的性能及出现的... 查看详情

asp.netcore6框架揭秘实例演示[29]:搭建文件服务器

通过HTTP请求获取的Web资源很多都来源于存储在服务器磁盘上的静态文件。对于ASP.NET应用来说,如果将静态文件存储到约定的目录下,绝大部分文件类型都是可以通过Web的形式对外发布的。“Microsoft.AspNetCore.StaticFiles”这... 查看详情

asp.netcore6框架揭秘实例演示[32]:错误页面的n种呈现方式

由于ASP.NET是一个同时处理多个请求的Web应用框架,所以在处理某个请求过程中出现异常并不会导致整个应用的中止。出于安全方面的考量,为了避免敏感信息外泄,客户端在默认情况下并不会得到详细的出错信息,这无疑会在... 查看详情

asp.netcore6框架揭秘实例演示[21]:如何承载你的后台服务(代码片段)

借助.NET提供的服务承载(Hosting)系统,我们可以将一个或者多个长时间运行的后台服务寄宿或者承载我们创建的应用中。任何需要在后台长时间运行的操作都可以定义成标准化的服务并利用该系统来承载,ASP.NETCore应用最终也... 查看详情

asp.netcore6框架揭秘实例演示[22]:如何承载你的后台服务[补充](代码片段)

借助.NET提供的服务承载(Hosting)系统,我们可以将一个或者多个长时间运行的后台服务寄宿或者承载我们创建的应用中。任何需要在后台长时间运行的操作都可以定义成标准化的服务并利用该系统来承载,ASP.NETCore应用最终也... 查看详情