本篇提供的20个简单的演示实例基本涵盖了ASP.NET Core 6基本的编程模式,我们不仅会利用它们来演示针对控制台、API、MVC、gRPC应用的构建与编程,还会演示Dapr在.NET 6中的应用。除此之外,这20个实例还涵盖了针对依赖注入、配置选项、日志记录的应用。[本篇节选自《ASP.NET Core 6框架揭秘》第一章]
[101]利用命令行创建.NET程序(源代码)
[102]采用Minimal API构建ASP.NET Core程序(源代码)
[103]一步创建WebApplication对象(源代码)
[104]使用原始形态的中间件(源代码)
[105]使用中间件委托变体(1)(源代码)
[106]使用中间件委托变体(2)(源代码)
[107]定义强类型中间件类型(源代码)
[108]定义基于约定的中间件类型(构造函数注入)(源代码)
[109]定义基于约定的中间件类型(方法注入)(源代码)
[110]配置的应用(源代码)
[111]Options的应用(源代码)
[112]日志的应用(源代码)
[101]利用命令行创建.NET程序
我们按照图1所示的方式执行“dotnet new”命令(dotnet new console -n App)创建一个名为“App”的控制台程序。该命令执行之后会在当前工作目录创建一个由指定应用名称命名的子目录,并将生成的文件存放在里面。
图1 执行“dotnet new”命令创建一个控制台程序
.csproj文件最终是为MSBuild服务的,该文件提供了相关的配置来控制MSBuild针对当前项目的编译和发布行为。如下所示的就是App.csproj文件的全部内容,如果你曾经查看过传统.NET Framework下的.csproj文件,你会惊叹于这个App.csproj文件内容的简洁。.NET 6下的项目文件的简洁源于对SDK的应用。不同的应用类型会采用不同的SDK,比如我们创建的这个控制台应用采用的SDK为“Microsoft.NET.Sdk”,ASP.NET应用会采用另一个名为“Microsoft.NET.Sdk.Web”的SDK。SDK相等于为某种类型的项目制定了一份面向MSBuild的基准配置,如果在项目文件的<Project>根节点设置了具体的SDK,意味着直接将这份基准配置继承下来。
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net6.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable>
</PropertyGroup></Project>t>
如上面的代码片段所示,与项目相关的属性可以分组定义在项目文件的<PropertyGroup>节点下。这个App.csproj文件定义了四个属性,其中OutputType和TargetFramework属性表示编译输出类型与采用的目标框架。由于我们创建的是一个针对 .NET 6的可执行控制台应用,所以TargetFramework和OutputType分别设置为“net6.0”和“Exe”。项目的ImplicitUsings属性与C# 10提供的一个叫做“全局命名空间”新特性有关,另一个名为Nullable的属性与C#与一个名为“空值(Null)验证”的特性有关。
如下所示的就是项目目录下的生成的Program.cs文件的内容。可以看出整个文件只有两行文字,其中一行还是注释。这唯一的一行代码调用了Console类型的静态方法将字符串“Hello, World!”输出到控制台上。这里体现了C# 10另一个被称为“顶级语句(Top-level Statements)”的新特性——入口程序的代码可以作为顶层语句独立存在。
// See https://aka.ms/new-console-template for more informationConsole.WriteLine("Hello, World!"););
针对 .NET应用的编译和运行同样可以执行“dotnet.exe”命令行完成的。如图2所示,在将项目根目录作为工作目录后,我们执行“dotnet build”命令对这个控制台应用实施编译。由于默认采用Debug编译模式,所以编译生成的程序集会保存在“\bin\Debug\”目录下。同一个应用可以采用多个目标框架,针对不同目标框架编译生成的程序集是会放在不同的目录下。由于我们创建的是针对 .NET 6.0的应用程序,所以最终生成的程序集被保存在“\bin\Debug\net6.0\”目录下。
图2 执行“dotnet build”命令编译一个控制台程序
如果查看编译的输出目录,可以发现两个同名(App)的程序集文件,一个是App.dll,另一个是App.exe,后者在尺寸上会大很多。App.exe是一个可以直接运行的可执行文件,而App.dll仅仅是一个单纯的动态链接库,需要借助命令行dotnet才能执行。如图3所示,当我们执行“dotnet run”命令后,编译后的程序随即被执行,“Hello, World!”字符串被直接打印在控制台上。执行“dotnet run”命令启动程序之前其实无须显式执行“dotnet build”命令对源代码实施编译,因为该命令会自动触发编译操作。在执行“dotnet”命令启动应用程序集时,我们也可以直接指定启动程序集的路径(“dotnet bin\Debug\net6.0\App.dll”)。实际上dotnet run主要用在开发测试中,dotnet {AppName}.dll的方式才是部署环境(比如Docker容器)中采用的启动方式。
图3 执行dotnet命令运行一个控制台程序
[102]采用Minimal API构建ASP.NET Core程序
前面利用dotnet new命令创建了一个简单的控制台程序,接下来我们将其改造成一个ASP.NET Core应用。我们在前面已经说过,不同的应用类型会采用不同的SDK,所以我们直接修改App.csproj文件将SDK设置为“Microsoft.NET.Sdk.Web”。由于不需要利用生成的.exe文件来启动ASP.NET Core应用,所以应该将XML元素<OutputType>Exe</OutputType>从<PropertyGroup>节点中删除。
<Project Sdk="Microsoft.NET.Sdk.Web"><PropertyGroup><TargetFramework>net6.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable>
</PropertyGroup></Project>
ASP.NET Core (Core)应用的承载(Hosting)经历了三次较大的变迁,由于最新的承载方式提供的API最为简洁且依赖最小,我们将它称为 “Minimal API” 。本书除了在第16章 “应用承载(上)” 会涉及到其他两种承载模式外,本书提供的所有演示实例均会使用Minimal API。如下所示的是我们采用这种编程模式编写的第一个Hello World程序。
RequestDelegate handler = context => context.Response.WriteAsync("Hello, World!");
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
app.Run(handler: handler);
app.Run();
上面的代码片段涉及到三个重要的对象,其中WebApplication对象表示承载的应用,Minimal API采用“构建者(Builder)”模式来构建它,此构建者体现为一个WebApplicationBuilder对象。如代码片段所示,我们调用WebApplication类型的静态工厂方法CreateBuilder创建了一个WebApplicationBuilder对象,该方法的参数args代表命令行参数数组。在调用此该对象的Build方法将WebApplication对象构建出来后,我们调用了它的Run扩展方法并使用一个RequestDelegate对象作为其参数。RequestDelegate虽然是一个简单的委托类型,但是它在ASP.NET Core框架体系中地位非凡,我们现在先来对它做一个简单的介绍。
当一个ASP.NET Core启动之后,它会使用注册的服务器绑定到指定的端口进行请求监听。当接收抵达的请求之后,一个通过HttpContext对象表示的上下文对象会被创建出来。我们不仅可以从这个上下文中提取出所有与当前请求相关的信息,还能直接使用该上下文完成对请求的响应。关于这一点完全可以从HttpContext这个抽象类如下两个核心属性Request和Response看出来。
public abstract class HttpContext{ public abstract HttpRequest Request { get } public abstract HttpResponse Response { get }...}
由于ASP.NET Core应用针对请求的处理总是在一个HttpContext上下文中进行,所以针对请求的处理器可以表示为一个Func<HttpContext, Task>类型的委托。由于这样的委托会被广泛地使用,所以ASP.NET Core直接定义了一个专门的委托类型,就是我们在程序中使用到的RequestDelegate。从如下所示的针对RequestDelegate类型的定义可以看出,它本质上就是一个Func<HttpContext, Task>委托。
public delegate Task RequestDelegate(HttpContext context);
再次回到演示程序。我们首先创建了一个RequestDelegate委托,对应的目标方法会在响应输出流中写入字符串 “Hello, World!” 。我们将此委托作为参数调用WebApplication对象的Run扩展方法,这个调用可以理解为将这个委托作为所有请求的处理器,接收到的所有请求都将通过这个委托来处理。演示程序最后调用WebApplication另一个无参Run扩展方法是为了启动承载的应用。在Visual Studio下,我们可以直接按F5(或者Ctrl + F5)启动该程序,当然针对命令行 “dotnet run” 命令的应用启动方式依然有效,本书提供的演示实例大都会采用这种方式。
如图4所示,我们以命令行方式启动程序后,控制台上会出现ASP.NET Core框架输出的日志,通过日志表明应用已经开始在默认的两个终结点(http://localhost:5000和https://localhost:5001)监听请求了。我们使用浏览器针对这两个终结点发送了两个请求,均得到一致的响应。从响应的内容可以看出应用正是利用我们指定的RequestDelegate委托处理请求的。
图4 启动应用程序并利用浏览器进行访问
[103]一步创建WebApplication对象
上面演示的程序先调用定义在WebApplication类型的静态工厂方法CreateBuilder创建一个WebApplicationBuilder对象,再利用后者构建一个代表承载应用的WebApplication对象。WebApplicationBuilder提供了很多用来对构建WebApplication进行设置的API,但是我们的演示实例并未使用到它们,此时我们可以直接调用静态工厂方法Create将WebApplication对象创建出来。在如下所示的改写程序中,我们直接将请求处理器定义成一个本地静态方法HandleAsync。
var app = WebApplication.Create(args);
app.Run(handler: HandleAsync);
app.Run();
static Task HandleAsync(HttpContext httpContext)
=> httpContext.Response.WriteAsync("Hello, World!");
[104]使用原始形态的中间件
承载的ASP.NET Core应用最终体现为由注册中间件构建的请求处理管道。在服务器接收到请求并将成功构建出HttpContext上下文之后,会将请求交付给这个管道进行处理。待管道完成了处理任务之后,控制权再次回到服务器的手中,它会将处理的结果转换成响应发送出去。从应用编程的角度来看,这个管道体现为上述的RequestDelegate委托,组成它的单个中间件则体现为另一个类型为Func<RequestDelegate,RequestDelegate>的委托,该委托的输入和输出都是一个RequestDelegate对象,前者表示由后续中间件构建的管道,后者代表将当前中间件纳入此管道后生成的新管道。
在上面演示的实例中,我们将一个RequestDelegate委托作为参数调用了WebApplication的Run扩展方法,我们当时说这是为应用设置一个请求处理器。其实这种说法不够准确,该方法仅仅是注册一个中间件而已。说得更加具体一点,这个方法用于注册处于管道末端的中间件。为了让读者体验到中间件和管道针对请求的处理,我们对上面演示应用进行了如下的改写。
var app = WebApplication.Create(args);
IApplicationBuilder appBuilder = app;
appBuilder.Use(middleware: HelloMiddleware).Use(middleware: WorldMiddleware);
app.Run();
static RequestDelegate HelloMiddleware(RequestDelegate next)=> async httpContext => {await httpContext.Response.WriteAsync("Hello, ");await next(httpContext);
};static RequestDelegate WorldMiddleware(RequestDelegate next)
=> httpContext => httpContext.Response.WriteAsync("World!");
由于中间件体现为一个Func<RequestDelegate,RequestDelegate>委托,所以我们利用上面定义的两个与该委托类型具有一致声明的本地静态方法HelloMiddleware和WorldMiddleware来表示对应的中间件。我们将完整的文本“Hello, World!”拆分为“Hello, ”和“World!”两段,分别由上述两个终结点写入响应输出流。在创建出代表承载应用的WebApplication对象之后,我们将它转换成IApplicationBuilder接口类型,并调用其Use方法完成了对上述两个中间件的注册(由于WebApplication类型显式实现了定义在IApplicationBuilder接口中的Use方法,我们不得不进行类型转换)。如果利用浏览器采用相同的地址请求启动后的应用,我们依然可以得到如图4所示的响应内容。
[105]使用中间件委托变体(1)
虽然中间件最终总是体现为一个Func<RequestDelegate,RequestDelegate>委托,但是我们在开发过程中可以采用各种不同的形式来定义中间件,比如我们可以将中间件定义成如下两种类型的委托。这两个委托内容分别使用作为输入参数的RequestDelegate和Func<Task>完整对后续管道的调用。
Func<HttpContext, RequestDelegate, Task>
Func<HttpContext, Func<Task>, Task>
我们现在来演示如何使用Func<HttpContext, RequestDelegate, Task>委托的形式来定义中间件。如下面的代码片段所示,我们将HelloMiddleware和WorldMiddleware替换成了与Func<HttpContext, RequestDelegate, Task>委托类型具有一致声明的本地静态方法。
var app = WebApplication.Create(args);
app.Use(middleware: HelloMiddleware).Use(middleware: WorldMiddleware);
app.Run();
static async Task HelloMiddleware(HttpContext httpContext, RequestDelegate next)
{await httpContext.Response.WriteAsync("Hello, ");await next(httpContext);
};
static Task WorldMiddleware(HttpContext httpContext, RequestDelegate next) => httpContext.Response.WriteAsync("World!");
[106]使用中间件委托变体(2)
下面的程序以类似的方式将这两个中间件替换成与Func<HttpContext, Func<Task>, Task>委托类型具有一致声明的本地方法。当我们调用WebApplication的Use方法将这两种“变体”注册为中间件的时候,该方法内部会将提供的委托转换成Func<RequestDelegate,RequestDelegate>类型。
var app = WebApplication.Create(args);
app.Use(middleware: HelloMiddleware).Use(middleware: WorldMiddleware);
app.Run();
static async Task HelloMiddleware(HttpContext httpContext, Func<Task> next)
{await httpContext.Response.WriteAsync("Hello, ");await next();
};
static Task WorldMiddleware(HttpContext httpContext, Func<Task> next) => httpContext.Response.WriteAsync("World!");
[107]定义强类型中间件类型
当我们试图利用一个自定义中间件来完成某种请求处理功能时,其实很少会将中间件定义成上述的这三种委托形式,基本上都会将其定义成一个具体的类型。中间件类型有定义方式,一种是直接实现IMiddleware接口,本书将其称为“强类型”的中间件定义方式。我们现在就采用这样的方式定义一个简单的中间件类型。不论在定义中间件类型,还是定义其他的服务类型,如果它们具有对其他服务的依赖,我们都会采用依赖注入(Dependency Injection)的方式将它们整合在一起。整个ASP.NET Core框架就建立在依赖注入框架之上,依赖注入已经成为ASP.NET Core最基本的编程方式 。我们接下来会演示依赖注入在自定义中间件类型中的应用。
在前面演示的实例中,我们利用中间件写入以“硬编码”方式指定的问候语“Hello, World!”,现在我们选择由如下这个IGreeter接口表示的服务根据指定的时间来提供对应的问候语,Greeter类型是该接口的默认实现。这里需要提前说明一下,本书提供的所有的演示实例都以“App”命名,独立定义的类型默认会定义在约定的“App”命名空间下。为了节省篇幅,接下来提供的类型定义代码片段将不再提供所在的命名空间,当启动应用程序出现针对“App”命名空间的导入时不要感到奇怪。
namespace App
{ public interface IGreeter{ string Greet(DateTimeOffset time);} public class Greeter : IGreeter{ public string Greet(DateTimeOffset time) => time.Hour switch{var h when h >= 5 && h < 12 => "Good morning!",var h when h >= 12 && h < 17 => "Good afternoon!",_ => "Good evening!"};}
}
我们定义了如下这个名为GreetingMiddleware的中间件类型。如代码片段所示,该类型实现了IMiddleware接口,针对请求的处理实现在InvokeAsync方法中。我们在GreetingMiddleware类型的构造函数中注入了IGreeter对象,并利用它在实现的InvokeAsync方法中根据当前时间来提供对应的问候语,后者将作为请求的响应内容。
public class GreetingMiddleware : IMiddleware
{ private readonly IGreeter _greeter; public GreetingMiddleware(IGreeter greeter) => _greeter = greeter; public Task InvokeAsync(HttpContext context, RequestDelegate next)=> context.Response.WriteAsync(_greeter.Greet(DateTimeOffset.Now));
}
针对GreetingMiddleware中间件的应用体现在如下的程序中。如代码片段所示,我们调用了WebApplication对象的UseMiddleware<GreetingMiddleware>扩展方法注册了这个中间件。由于强类型中间件实例是由依赖注入容器在需要的时候实时提供的,所以我们必须预先将它注册为服务。注册的注册最终会添加到WebApplicationBuilder的Services属性返回的IServiceCollection对象上,我们在得到这个对象后通过调用它的AddSingleton< GreetingMiddleware >方法将该中间件注册为“单例服务”。由于中间件依赖IGreeter服务,所以我们调用AddSingleton<IGreeter, Greeter>扩展方法对该服务进行了注册。
using App;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter, Greeter>().AddSingleton<GreetingMiddleware>();
var app = builder.Build();
app.UseMiddleware<GreetingMiddleware>();
app.Run();
该程序启动之后,针对它的请求会得到根据当前时间的生成问候语。如图5所示,由于目前的时间为晚上七点,所以浏览器上显示“Good evening!”。
图5 自定义中间件返回的问候语
[108]定义基于约定的中间件类型(构造函数注入)
中间件类型其实并不一定非得实现某个接口,或者继承某个基类,按照既定的约定进行定义即可。按照ASP.NET Core的约定,中间件类型需要定义成一个公共实例类型(静态类型无效),其构造函数可以注入任意的依赖服务,但必须包含一个RequestDelegate类型的参数,该参数表示由后续中间件构建的管道,当前中间件利用它将请求分发给后续管道作进一步处理。针对请求的处理实现在一个命名为InvokeAsync或者Invoke的方法中,该方法返回类型为Task, 第一个参数并绑定为当前的HttpContext上下文,所以GreetingMiddleware中间件类型可以改写成如下的形式。
public class GreetingMiddleware
{ private readonly IGreeter _greeter; public GreetingMiddleware(RequestDelegate next, IGreeter greeter) => _greeter = greeter; public Task InvokeAsync(HttpContext context) => context.Response.WriteAsync(_greeter.Greet(DateTimeOffset.Now));
}
强类型的中间件实例是在对请求进行处理的时候由依赖注入容器实时提供的,按照约定定义的中间件实例则不同,当我们在注册中间件的时候就已经利用依赖注入容器将它创建出来,所以前者可以采用不同的生命周期模式,后者总是一个单例对象。也正是因为这个原因,我们不需要将中间件注册为服务。
using App;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter, Greeter>();
var app = builder.Build();
app.UseMiddleware<GreetingMiddleware>();
app.Run();
[109]定义基于约定的中间件类型(方法注入)
对于按照约定定义的中间件类型,依赖服务不一定非要注入到构造函数中,它们选择直接注入到InvokeAsync或者Invoke方法中,所以上面这个GreetingMiddleware中间件也可以定义成如下的形式。对于按照约定定义的中间件类型,构造函数注入和方法注入并不是等效,两者之间的差异会在第18章“应用承载(下)”中进行介绍。
public class GreetingMiddleware
{ public GreetingMiddleware(RequestDelegate next){} public Task InvokeAsync(HttpContext context, IGreeter greeter) => context.Response.WriteAsync(greeter.Greet(DateTimeOffset.Now));
}
[110]配置的应用
开发ASP.NET Core应用过程会广泛使用到配置(Configuration),ASP.NET Core采用了一个非常灵活的配置框架,我们可以存储在任何载体的数据作为配置源。我们还可以将结构化的配置转换成对应的选项(Options)类型,以强类型的方式来使用它们。针对配置选项的系统介绍被放在第5章“配置选项(上)”和第6章“配置选项(下)”中,我们先在这里“预热”一下。在前面演示的实例中,Greeter类型针对指定时间提供的问候语依然是以“硬编码”的方式提供的,现在我们选择将它们放到配置文件以方便进行调整中。为此我们在项目根目录下添加一个名为“appsettings.json”的配置文件,并将三条问候语以如下的形式定义在这个JSON文件中。
{"greeting": {"morning": "Good morning!","afternoon": "Good afternoon!","evening": "Good evening!"}
}
ASP.NET Core应用中的配置通过IConfiguration对象表示,我们可以采用依赖注入的形式“自由”地使用它。对于演示的程序来说,我们只需要按照如下的方式将IConfiguration对象注入到Greeter类型的构造函数中,然后调用其GetSection方法得到定义了上述问候语的配置节(“greeting”)。在实现的Greet方法中,我们以索引的方式利用指定的Key(“morning”、“afternoon”和“evening”)提取对应的问候语。由于应用启动的时候会自动加载这个按照约定命名的(“appsettings.json”)配置文件,所以演示程序的其他地方不要作任何修改。
public class Greeter : IGreeter
{ private readonly IConfiguration _configuration; public Greeter(IConfiguration configuration) => _configuration = configuration.GetSection("greeting"); public string Greet(DateTimeOffset time) => time.Hour switch {var h when h >= 5 && h < 12 => _configuration["morning"],var h when h >= 12 && h < 17=> _configuration["afternoon"],_ => _configuration["evening"],};
}
[111]Options的应用
正如前面所说,将结构化的配置转换成对应类型的Options对象,以强类型的方式来使用它们是更加推荐的编程模式。为此我们为配置的三条问候语定义了如下这个GreetingOptions配置选项类型。
public class GreetingOptions
{ public string Morning { get; set; } public string Afternoon { get; set; } public string Evening { get; set; }
}
虽然Options对象不能直接以依赖服务的形式进行注入,但却可以由注入的IOptions<TOptions>对象来提供。如下面的代码片段所示,我们在Greeter类型的构造函数中注入了IOptions<GreetingOptions>对象,并利用其Value属性中得到了我们需要的GreetingOptions对象。在有了这个对象后,实现的Greet方法中只需要从对应的属性中获取相应的问候语就可以了。
public class Greeter : IGreeter
{ private readonly GreetingOptions _options; public Greeter(IOptions<GreetingOptions> optionsAccessor) => _options = optionsAccessor.Value; public string Greet(DateTimeOffset time) => time.Hour switch{var h when h >= 5 && h < 12 => _options.Morning,var h when h >= 12 && h < 17 => _options.Afternoon,_ => _options.Evening};
}
由于IOptions<GreetingOptions>对象提供的配置选项不能无中生有(实际上存在于配置中),我们需要将对应的配置节(“greeting”)绑定到GreetingOptions对象上。这项工作其实也属于服务注册的范畴,具体可以按照如下的形式调用IServiceCollection对象的Configure<TOptions>扩展方法来完成。如代码片段所示,代表应用整体配置的IConfiguration对象来源于WebApplicationBuilder的Configuration属性。
using App;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter, Greeter>().Configure<GreetingOptions>(builder.Configuration.GetSection("greeting"));
var app = builder.Build();
app.UseMiddleware<GreetingMiddleware>();
app.Run();
[112]日志的应用
诊断日志对于纠错排错必不可少。ASP.NET Core采用的诊断日志框架强大、易用且灵活。在我们演示的程序中,Greeter类型会根据指定的时间返回对应的问候语,现在我们将时间和对应的问候语以日志的方式记录下来看看两者是否匹配。我们在前面曾说过,依赖注入是ASP.NET Core应用最基本的编程模式。我们将涉及的功能(不论是业务相关的还是业务无关的)进行拆分,最终以具有不同粒度的服务将整个应用化整为零,服务之间的依赖关系直接以注入的方式来解决。我们在前面演示了针对配置选项的注入,接下来我们用来记录日志的ILogger对象依然采用注入的方式获得。如下面的代码片段所示,我们在Greeter类型的构造函数中注入了ILogger<Greeter>对象。在实现的Greet方法中,我们调用该对象的LogInformation扩展方法记录了一条Information等级的日志,日志内容体现了时间与问候语文本之间的映射关系。
public class Greeter : IGreeter
{ private readonly GreetingOptions _options; private readonly ILogger _logger; public Greeter(IOptions<GreetingOptions> optionsAccessor, ILogger<Greeter> logger){_options = optionsAccessor.Value;_logger = logger;} public string Greet(DateTimeOffset time){var message = time.Hour switch{var h when h >= 5 && h < 12 => _options.Morning,var h when h >= 12 && h < 17 => _options.Afternoon,_ => _options.Evening};_logger.LogInformation("{time} => {message}",time, message); return message;}
}
采用Minimal API编写的ASP.NET Core应用会默认将诊断日志整合进来,所以整个演示程序的其它地方都不要修改。当修改后的应用启动之后,针对每一个请求都会通过日志留下“痕迹”。由于控制台是默认开启的日志输出渠道之一,日志内容直接会输出到控制台上。图5所示的是以命令行形式启动应用的控制台,上面显示的都是以日志形式输出的内容。在众多系统日志中,我们发现有一条是由Greeter对象输出的。
图5 输出到控制台上的日志