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

《ASP.NET Core 6框架揭秘实例演示[11]:诊断跟踪的几种基本编程方式》介绍了四种常用的诊断日志框架。其实除了微软提供的这些日志框架,还有很多第三方日志框架可供我们选择,比如Log4Net、NLog和Serilog 等。虽然这些框架大都采用类似的设计,但是它们采用的编程模式具有很大的差异。为了对这些日志框架进行整合,微软创建了一个用来提供统一的日志编程模式的日志框架。[本文节选《ASP.NET Core 6框架揭秘》第8章]

[S801]将日志输出到控制台和调试窗口(源代码)
[S802]利用ILoggerFactory工厂创建Ilogger<T>对象(源代码)
[S803]注入Ilogger<T>对象(源代码)
[S804]TraceSource和EventSource的日志输出(源代码)
[S805]针对等级的日志过滤(源代码)
[S806]针对等级和类别的日志过滤(源代码)
[S807]针对等级、类别和ILoggerProvider类型的日志过滤(源代码)

[S801]将日志输出到控制台和调试窗口

我们通过一个简单的实例来演示如何将具有不同等级的日志消息输出到当前控制台和Visual Studio的调试窗口。如下所示的两个NuGet包提供了针对这两种日志输出渠道的支持,所以演示程序需要添加针对它们的引用。

  • Microsoft.Extensions.Logging.Console

  • Microsoft.Extensions.Logging.Debug

应用程序一般使用ILoggerFacotry工厂创建的ILogger对象来记录日志,下面的演示实例利用依赖注入容器来提供ILoggerFactory对象。如代码片段所示,我们创建了一个ServiceCollection对象,并调用AddLogging扩展方法注册了与日志相关的核心服务,作为依赖注入容器的IServiceProvider对象被构建出来后,我们从中提取出ILoggerFactory对象。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;var logger = new ServiceCollection().AddLogging(builder => builder.AddConsole().AddDebug()).BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger("Program");var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {0} log message.", level));
Console.Read();

在调用AddLogging扩展方法时,我们利用提供的Action<ILoggingBuilder>委托完成了针对ConsoleLoggerProvider和DebugLoggerProvider的注册。具体来说,前者由ILoggingBuilder接口的AddConsole扩展方法注册,后者则由AddDebug扩展方法进行注册。我们通过指定日志类别(“Program”)调用ILoggerFactory接口的CreateLogger方法将对应的ILogger对象创建出来。每个ILogger对象都对应一个确定的类别,我们倾向于将当前写入日志的组件、服务或者类型名称作为日志类别,所以需要指定的是当前类型的名称“Program”。

我们通过调用ILogger的Log方法针对每个有效的日志等级分发了六个日志事件,事件的ID分别被设置成1~6的整数。我们在调用Log方法时通过指定一个包含占位符({0})的消息模板和对应参数的方式来格式化最终输出的消息内容。程序启动后,相应的日志会以图1所示的形式同时输出到控制台和Visual Studio的调试窗口。

8c6783b08a7814c7675dc48f9ad10c4c.png
图1 针对控制台和Debugger的日志输出

[S802]利用ILoggerFactory工厂创建Ilogger<T>对象

在前面演示的实例中,我们将字符串形式表示的日志类别“Program”作为参数调用ILoggerFactory工厂的CreateLogger方法来创建对应的ILogger对象,实际上我们还可以调用泛型的CreateLogger<T>方法创建一个ILogger<T>对象来完成相同的工作。如果调用这个方法,我们就不需要额外提供日志类别,因为日志类别会根据泛型参数类型T自动解析出来。在如下的代码片段中,我们调用了ILoggerFactory工厂的CreateLogger<Program>方法将对应的 ILogger<Program>对象创建出来。作为日志负载内容的消息模板除了可以采用{0},{1},...,{n}这样的占位符,还可以使用任意字符串(“{level}”)来表示。启动改写的程序之后,输出到控制台和调试输出窗口的内容与图1完全一致的。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;var logger = new ServiceCollection().AddLogging(builder => builder.AddConsole().AddDebug()).BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger<Program>();
var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));
Console.Read();

[S803]注入Ilogger<T>对象

除了利用ILoggerFactory工厂来创建泛型的ILogger<Program>对象之外,我们还具有更简洁的方式,那就是按照如下的方式直接利用IServiceProvider对象来提供这个ILogger<Program>对象。换句话说,ILogger<T>实际上是可以作为依赖服务注入到消费它的类型中。

...
var logger = new ServiceCollection().AddLogging(builder => builder.AddConsole().AddDebug())
.BuildServiceProvider()
.GetRequiredService<ILogger<Program>>();
...

[S804]TraceSource和EventSource的日志输出

除了控制台和调试器这两种输出渠道,日志框架还提供针对其他输出渠道的支持。第7章重点介绍了针对TraceSource和EventSource的日志框架也是默认支持的两种输出渠道。针对这两种输出渠道的整合由如下两个NuGet包提供的。

  • Microsoft.Extensions.Logging.TraceSource

  • Microsoft.Extensions.Logging.EventSource

在添加了上述两个NuGet包的引用之后,我们对演示实例作了如下的修改。为了捕捉由EventSource分发的日志事件,我们自定义了一个FoobarEventListener类型。我们在应用启动的时候创建了这个FoobarEventListener对象并分别注册了它的EventSourceCreated和EventWritten事件。一个名为“Microsoft-Extensions-Logging”的EventSource会帮助我们完成日志的输出,所以EventSourceCreated事件的处理程序专门订阅了这个EventSource。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Diagnostics.Tracing;var listener = new FoobarEventListener();
listener.EventSourceCreated += (sender, args) =>
{if (args.EventSource?.Name == "Microsoft-Extensions-Logging"){listener.EnableEvents(args.EventSource, EventLevel.LogAlways);}
};listener.EventWritten += (sender, args) =>
{var payload = args.Payload;var payloadNames = args.PayloadNames;if (args.EventName == "FormattedMessage" && payload != null && payloadNames !=null){var indexOfLevel = payloadNames.IndexOf("Level");var indexOfCategory = payloadNames.IndexOf("LoggerName");var indexOfEventId = payloadNames.IndexOf("EventId");var indexOfMessage = payloadNames.IndexOf("FormattedMessage");Console.WriteLine(@$"{(LogLevel)payload[indexOfLevel],-11}: 
{ payload[indexOfCategory]}[{ payload[indexOfEventId]}]");Console.WriteLine($"{"",-13}{payload[indexOfMessage]}");}
};var logger = new ServiceCollection().AddLogging(builder => builder.AddTraceSource(new SourceSwitch("default", "All"), new DefaultTraceListener { LogFileName = "trace.log" }).AddEventSourceLogger()).BuildServiceProvider().GetRequiredService<ILogger<Program>();var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));internal class FoobarEventListener : EventListener
{ }

上述的EventSource对象在进行日志分发的时候,它会采用不同的方式对将日志消息进行格式化,最终将格式化后的内容作为荷载内容的一部分通过多个事件分发出去,EventWritten事件处理程序选择的是一个名为FormattedMessage的事件,它会将包括格式化日志消息在内的内容荷载信息输出到控制台上。

基于TraceSource和EventSource日志框架的输出渠道是调用ILoggingBuilder的AddTraceSource和AddEventSourceLogger扩展方法进行注册的。针对AddTraceSource扩展方法的调用提供了两个参数,前者是作为全局过滤器的SourceSwitch对象,后者则是注册的DefaultTraceListener对象。由于我们为注册的DefaultTraceListener指定了日志文件的路径,所以输出的日志消息最终会被写入指定的文件中。程序运行后,日志消息会以如图2示的形式同时输出到控制台和指定的日志文件中(trace.log)。
d90e055df96eef2bbfd604b763b35069.png
图2 对TraceSource和EventSource的日志输出

[S805]针对等级的日志过滤

对于使用ILogger或者ILogger<T>对象分发的日志事件,并不能保证都会进入最终的输出渠道,因为注册的ILoggerProvider对象会对日志进行过滤,只有符合过滤条件的日志消息才会被真正地输出到对应的渠道。每一个分发的日志事件都具有一个确定的等级。一般来说,日志消息的等级越高,表明对应的日志事件越重要或者反映的问题越严重,自然就越应该被记录下来,所以在很多情况下我们指定的过滤条件只需要一个最低等级,所有不低于(等于或者高于)该等级的日志都会被记录下来。最低日志等级在默认情况下被设置为Information,这就是前面演示实例中等级为Trace和Debug的两条日志没有被真正输出的原因。如果需要将这个作为输出“门槛”的日志等级设置得更高或者更低,我们只需要将指定的等级作为参数调用ILoggingBuilder接口的SetMinimumLevel方法即可。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;var logger = new ServiceCollection().AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace).AddConsole()).BuildServiceProvider()
.GetRequiredService<ILogger<Program>>();var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));
Console.Read();

如上面的代码片段所示,在调用AddLogging扩展方法时,我们调用ILoggingBuilder接口的SetMinimumLevel方法将最低日志等级设置为Trace。由于设置的是最低等级,所以所有的日志消息都会以图3所示的形式输出到控制台上。

ca15cb59bcf2c53fd5e5189085d3e4f5.png
图3 通过设置最低等级控制输出的日志

[S806]针对等级和类别的日志过滤

虽然“过滤不低于指定等级的日志消息”是常用的日志过滤规则,但过滤规则的灵活度并不限于此,很多时候还会同时考虑日志的类别。在创建对应ILogger时,由于一般将当前组件、服务或者类型的名称作为日志类别,所以日志类别基本上体现了日志消息来源。如果我们只希望输出由某个组件或者服务发出的日志事件,就需要针对类别对日志事件实施过滤。综上可知,日志过滤条件其实可以通过一个类型为Func<string, LogLevel, bool>的委托对象来表示,它的两个输入参数分别代表日志事件的类别和等级。下面通过提供这样一个委托对象对日志消息做更细粒度的过滤,所以需要对演示程序做如下修改。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;var loggerFactory = new ServiceCollection().AddLogging(builder => builder.AddFilter(Filter).AddConsole()).BuildServiceProvider().GetRequiredService<ILoggerFactory>();Log(loggerFactory, "Foo");
Log(loggerFactory, "Bar");
Log(loggerFactory, "Baz");Console.Read();static void Log(ILoggerFactory loggerFactory, string category)
{var logger = loggerFactory.CreateLogger(category);var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));levels = levels.Where(it => it != LogLevel.None).ToArray();var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {0} log message.", level));
}static bool Filter(string category, LogLevel level)
{return category switch{"Foo" => level >= LogLevel.Debug,"Bar" => level >= LogLevel.Warning,"Baz" => level >= LogLevel.None,_ => level >= LogLevel.Information,};
}

如上面的代码片段所示,作为日志过滤器的Func<string, LogLevel, bool>对象定义的过滤规则如下:对于日志类别Foo和Bar,我们只会选择输出等级不低于Debug和Warning的日志;对于日志类别Baz,任何等级的日志事件都不会被选择;至于其他日志类别,我们采用默认的最低等级Information。在执行AddLogging扩展方法时,我们调用ILoggerBuilder接口的AddFilter方法将Func<string, LogLevel, bool>对象注册为全局过滤器。我们利用依赖注入容器提供的ILoggerFactory工厂创建了三个ILogger对象,它们采用的类别分别为“Foo”、“Bar”和“Baz”。我们最后利用这三个ILogger对象分发针对不同等级的六次日志事件,满足过滤条件的日志消息会以图4所示的形式输出到控制台上。

c40a0b018da5ada2517370c7df9b2eb4.png
图4 针对类别和等级的日志过滤

[S807]针对等级、类别和ILoggerProvider类型的日志过滤

不论是通过调用ILoggerBuilder接口的SetMinimumLevel方法设置的最低日志等级,还是通过调用AddFilter扩展方法提供的过滤器,设置的日志过滤规则针对的都是所有注册的ILoggerProvider对象,但是有时需要将过滤规则应用到某个具体的ILoggerProvider对象上。如果将ILoggerProvider对象引入日志过滤规则中,那么日志过滤器就应该表示成一个类型为Func<string, string, LogLevel, bool>的委托对象,该委托的三个输入参数分别表示ILoggerProvider类型的全名、日志类别和等级。为了演示针对LoggerProvider的日志过滤,可以将演示程序做如下改动。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using Microsoft.Extensions.Logging.Debug;var logger = new ServiceCollection().AddLogging(builder => builder.AddFilter(Filter).AddConsole().AddDebug()).BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger("App.Program");var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++,"This is a/an {0} log message.", level));
Console.Read();static bool Filter(string provider, string category, LogLevel level) => provider switch
{var p when p == typeof(ConsoleLoggerProvider).FullName => level >= LogLevel.Debug,var p when p == typeof(DebugLoggerProvider).FullName => level >= LogLevel.Warning,_ => true,
};

如上面的代码片段所示,我们注册的过滤器体现的过滤规则如下:ConsoleLoggerProvider,和DebugLoggerProvider的最低日志等级分别设置为Debug和Warning,至于其他的ILoggerProvider类型则不做任何的过滤。我们演示程序同时注册了ConsoleLoggerProvider和DebugLoggerProvider,对于分发的12条日志消息,5条会在控制台上输出,3条会出现在Visual Studio的调试输出窗口中。

aa6844c30fd9c5d233abb5c0db25d92f.png
图5 对ILoggerProvider类型的日志过滤

《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.NET Core 6框架揭秘》实例演示[10]:Options基本编程模式
《ASP.NET Core 6框架揭秘》实例演示[11]:诊断跟踪的几种基本编程方式 
《ASP.NET Core 6框架揭秘》实例演示[12]:诊断跟踪的进阶用法

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/283661.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Caffine Cache 及在SpringBoot中的使用

这一篇我们将要谈到一个新的本地缓存框架&#xff1a;Caffeine Cache。它也是站在巨人的肩膀上-Guava Cache&#xff0c;借着他的思想优化了算法发展而来。 本篇博文主要介绍Caffine Cache 的使用方式&#xff0c;以及Caffine Cache在SpringBoot中的使用。 1. Caffine Cache 在…

C#深入.NET平台的软件系统分层开发

今天我们来讲讲分层开发&#xff0c;你从标题能不能简单的认识一下什么是分层呢&#xff1f; 不懂也没关系&#xff0c;接下来我来给你讲讲。 第一章 软件系统的分层开发 &#xff08;1&#xff09;其实分层模式可以这样定义&#xff1a;将解决方案中功能不同的模块分到不同的项…

productFlavors设置signingConfig不管用的问题

2019独角兽企业重金招聘Python工程师标准>>> 在buildTypes release里面添加&#xff1a; productFlavors.dev_.signingConfig signingConfigs.devSign productFlavors.alphaTest_.signingConfig signingConfigs.devSign productFlavors.betaTest_.signingConfig si…

Linux学习之服务器搭建——DHCP服务器

通过前面基础网络配置已经将两台虚拟机连接起来了&#xff0c;在windows 下是将它和Centos设为统一网段&#xff0c;在DHCP里同样不变&#xff0c;改变的是将windows 所配置的静态IP全部换成“自动获取DHCP”而在接下来的操作&#xff0c;就是让我的windows 自动获取来自Linux …

WPF 动态切换黑|白皮肤

WPF 动态切换黑|白皮肤WPF 使用 WPFDevelopers.Minimal 如何动态切换黑|白皮肤作者&#xff1a;WPFDevelopersOrg原文链接&#xff1a; https://github.com/WPFDevelopersOrg/WPFDevelopers.Minimal框架使用大于等于.NET40&#xff1b;Visual Studio 2022;项目使用 MIT 开源…

中小企业虚拟化解决方案-VMware vSphere 6.5-日常管理入口v0.0.1

中小企业虚拟化解决方案-VMware vSphere 6.5日常管理入口v0.0.1本文目的&#xff1a;针对中小企业虚拟化的平台管理&#xff0c;涉及到很多管理入口&#xff0c;普通管理员未必知道从哪里管理?本文将从最底层到最高层进行简单的介绍&#xff0c;最终让普通管理员快速了解管理入…

Svn服务器的搭建与配置

本文由ilanniweb提供友情赞助&#xff0c;首发于烂泥行天下想要获得更多的文章&#xff0c;可以关注我的微信ilanniweb要把svn代码同步到git服务器上&#xff0c;本来是想通过subgit直接同步进行就行了。但是自已以前没有搭建过svn服务器&#xff0c;所以有了这篇文章。我们就来…

JAVA Future类详解

1. Future的应用场景 在并发编程中&#xff0c;我们经常用到非阻塞的模型&#xff0c;在之前的多线程的三种实现中&#xff0c;不管是继承thread类还是实现runnable接口&#xff0c;都无法保证获取到之前的执行结果。通过实现Callback接口&#xff0c;并用Future可以来接收多线…

最新 .NET 社区工具包, 推出MVVM 源代码生成器!

点击上方蓝字关注我们&#xff08;本文阅读时间&#xff1a;10分钟)我们很高兴地宣布正式推出新的 .NET 社区工具包&#xff0c;现在已经在NuGet上发布了8.0.0版本&#xff01;这是一个重要版本&#xff0c;包括大量新功能、改进、优化、错误修复&#xff0c;许多反映了全新项目…

Java并发编程:Executor、Executors、ExecutorService

Executors 在Java 5之后&#xff0c;并发编程引入了一堆新的启动、调度和管理线程的API。Executor框架便是Java 5中引入的&#xff0c;其内部使用了线程池机制&#xff0c;它在java.util.cocurrent 包下&#xff0c;通过该框架来控制线程的启动、执行和关闭&#xff0c;可以简化…

IOTCS+Ekuiper搭建物联网边缘计算平台

背景介绍IOTCS 是专为物联网平台而设计的工业智能网关。自从 2020 年 10 月以来&#xff0c;我们从需求调研&#xff0c;设计&#xff0c;定型&#xff0c;研发&#xff0c;测试经过漫长的沉淀与孵化&#xff0c;最终顺利实现工业智能网关最初的设想。我们凭借创新设计理念、快…

JMX 使用指南一 Java Management Extensions

1. 什么是 JMX JMX&#xff0c;全称 Java Management Extensions&#xff0c;是在 J2SE 5.0 版本中引入的一个功能。提供了一种在运行时动态管理资源的框架&#xff0c;主要用于企业应用程序中实现可配置或动态获取应用程序的状态。JMX 提供了一种简单、标准的监控和管理资源的…

多种方法实现自适应布局

最近切了几个手机端的网页&#xff0c;第一次切的是美团的首页&#xff0c;为了自适应不同的手机分辨率&#xff0c;需要用到自适应布局&#xff0c;切图的时候是用的第一中方法&#xff0c;用到了定位&#xff0c;后来查找了一些其他方法&#xff0c;现在就介绍几种自适应布局…

hivesql优化的深入解析

转载&#xff1a;https://www.csdn.net/article/2015-01-13/2823530 一个Hive查询生成多个Map Reduce Job&#xff0c;一个Map Reduce Job又有Map&#xff0c;Reduce&#xff0c;Spill&#xff0c;Shuffle&#xff0c;Sort等多个阶段&#xff0c;所以针对Hive查询的优化可以大致…

如何用一行 CSS 实现 10 种现代布局

现代 CSS 布局使开发人员只需按几下键就可以编写十分有意义且强大的样式规则。上面的讨论和接下来的帖文研究了 10 种强大的 CSS 布局&#xff0c;它们实现了一些非凡的工作。 01. 超级居中&#xff1a;place-items: center 对于第一个“单行”布局&#xff0c;让我们解决所有 …

在.NET 6.0中使用不同的托管模型

本章是《定制ASP NET 6.0框架系列文章》的第六篇。在本章中&#xff0c;我们将讨论如何在ASP NET 6.0中自定义托管宿主。比如&#xff0c;托管选项和不同类型的托管&#xff0c;并了解一下IIS上的托管。限于篇幅&#xff0c;本章只是一个抛砖迎玉。本章涵盖主题包括&#xff1a…

TypeScript 与 JavaScript 的区别

TypeScript 是 JavaScript 的一个超集&#xff0c;支持 ECMAScript 6 标准&#xff08;ES6 教程&#xff09;。TypeScript 由微软开发的自由和开源的编程语言。TypeScript 设计目标是开发大型应用&#xff0c;它可以编译成纯 JavaScript&#xff0c;编译出来的 JavaScript 可以…

IO 和NIO的区别

1.IO和NIO的区别 NIO就是New IO在JDK1.4中引入。 IO和NIO有相同的作用和目的&#xff0c;但实现方式不同&#xff0c;NIO主要用到的是块&#xff0c;所以NIO的效率要比IO快不少。 在Java API中提供了两套NIO&#xff0c;一套针对标准输入输出NIO&#xff0c;另一套就是网络编程…

PerfView专题 (第四篇):如何寻找 C# 中程序集泄漏

一&#xff1a;背景 前两篇我们都聊到了非托管内存泄漏&#xff0c;一个是 HeapAlloc &#xff0c;一个是 VirtualAlloc&#xff0c;除了这两种泄漏之外还存在其他渠道的内存泄漏&#xff0c;比如程序集泄漏&#xff0c;这一篇我们就来聊一聊。二&#xff1a;程序集也会泄漏&am…

站立会议第九天

1.站立会议内容 昨天我们成功的将图片插进去了&#xff0c;在这里&#xff0c;图片是使用的png格式&#xff0c;长知识了。我们今天要继续把界面再优化一下。 照片&#xff1a; 2.任务展板 3.燃尽图 转载于:https://www.cnblogs.com/bk1246788/p/6852935.html