NuGet包“Microsoft.AspNetCore.Diagnostics”中提供了几个与异常处理相关的中间件,我们可以利用它们将原生的或者定制的错误信息作为响应内容发送给客户端。《错误页面的N种呈现方式》演示了几个简单的实例使读者大致了解这些中间件的作用,现在我们来演示几个高阶用法。本文提供的示例演示已经同步到《ASP.NET Core 6框架揭秘-实例演示版》)

[S2108]利用IDeveloperPageExceptionFilter定制开发者异常页面 (源代码

[S2109]针对编译异常的处理(默认)(源代码

[S2110]针对编译异常的处理(定义源代码输出行数)(源代码

[S2111]利用IExceptionHandlerFeature特性提供错误信息(源代码

[S2112]清除缓存响应报头(源代码

[S2113]针对404响应的处理(源代码

[S2114]利用IStatusCodePagesFeature特性忽略异常处理(源代码

[2108]利用IDeveloperPageExceptionFilter定制开发者异常页面

DeveloperExceptionPageMiddleware中间件在默认情况下总是会呈现一个包含详细信息的错误页面,但是我们可以利用注册的IDeveloperPageExceptionFilter对象在呈现错误页面之前做一些额外的异常处理操作,甚至完全“接管”整个异常处理任务。IDeveloperPageExceptionFilter接口定义了如下所示的HandleExceptionAsync方法进行异常处理。

public interface IDeveloperPageExceptionFilter
{
Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next);
} public class ErrorContext
{
public HttpContext HttpContext { get; }
public Exception Exception { get; } public ErrorContext(HttpContext httpContext, Exception exception) ;
}

HandleExceptionAsync方法定义了errorContext和next两个参数,前者提供的ErrorContext对象是对HttpContext上下文的封装,并利用Exception属性提供待处理的异常;后者提供的Func<ErrorContext, Task>委托代表后续的异常处理任务。如果某个IDeveloperPageExceptionFilter对象没有将异常处理任务向后分发,开发者处理页面将不会呈现出来。如下的演示实例通过实现IDeveloperPageExceptionFilter接口定义了一个FakeExceptionFilter类型,并将其注册为依赖服务。

using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<IDeveloperPageExceptionFilter, FakeExceptionFilter>();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception..."));
app.Run(); public class FakeExceptionFilter : IDeveloperPageExceptionFilter
{
public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
=> errorContext.HttpContext.Response.WriteAsync("Unhandled exception occurred!");
}

在FakeExceptionFilter类型实现的HandleExceptionAsync方法仅在响应的主体内容中写入了一条简单的错误消息(“Unhandled exception occurred!”),所以DeveloperExceptionPageMiddleware中间件默认提供的错误页面并不会呈现出来,取而代之的就是图1所示的由注册FakeExceptionFilter定制的错误页面。

图1 由注册IDeveloperPageExceptionFilter定制的错误页面

[2109]针对编译异常的处理(默认)

我们编写的ASP.NET应用会编译成程序集进行部署,为什么运行过程中还会出现“编译异常”呢?这是因为处理这种“预编译”模式,ASP.NET还支持运行时动态编译。以MVC应用为例,我们可以在运行时修改它的视图文件,这样的修改就会触发动态编译。如果修改的内容没法通过编译,就会抛出编译异常。DeveloperExceptionPageMiddleware中间件在处理编译异常的时候会在错误页面中呈现不同的内容。

我们接下来利用一个MVC应用来演示DeveloperExceptionPageMiddleware中间件针对编译异常的处理。为了支持运行动态编译,我们为MVC项目添加了针对 “Microsoft.AspNetCore.Mvc.Razor. RuntimeCompilation”这个NuGet包的依赖,并通过修改项目文件将PreserveCompilationReferences属性设置为True。

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<PreserveCompilationReferences>true</PreserveCompilationReferences>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.0" />
</ItemGroup>
</Project>

如下所示演示程序注册了DeveloperExceptionPageMiddleware中间件。为了支持针对Razor视图文件的运行时编译,在调用AddControllersWithViews扩展方法得到返回的IMvcBuilder对象之后,我们进一步调用该对象的AddRazorRuntimeCompilation扩展方法。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapControllers();
app.Run();

我们定义了如下所示的HomeController,它的Action方法Index会直接调用View方法将默认的视图呈现出来。根据约定,Action方法Index呈现出来的视图文件对应的路径应该是“~/views/home/index.cshtml”,我们先不提供这个视图文件的内容。

public class HomeController : Controller
{
[HttpGet("/")]
public IActionResult Index() => View();
}

我们这个MVC应用启动再将视图文件的内容定义成如下的形式,为了让动态编译失败,这里指定的Foobar类型其实根本不存在。

@{
var value = new Foobar();
}

当我们利用浏览器请求根路径时,获得到如图2所示的错误页面。这个错误页面显示的内容和结构与前面演示的实例是完全不一样的,在这里我们不仅可以得到导致编译失败的视图文件的路径“Views/Home/Index.cshtml”,还可以看到导致编译失败的代码。这个错误页面还直接将参与编译的源代码呈现出来。

图2 显示在错误页面中的编译异常信息

[2110]针对编译异常的处理(定义源代码输出行数)

动态编译过程中抛出的异常类型一般会实现如下这个ICompilationException接口,该接口定义的CompilationFailures属性返回一个元素类型为CompilationFailure的集合。编译失败的相关信息被封装在一个CompilationFailure对象之中,我们可以利用它得到源文件的路径(SourceFilePath属性)和内容(SourceFileContent属性),以及源代码转换后交付编译的内容。如果在内容转换过程已经发生错误,在这种情况下的SourceFileContent属性可能返回Null。

public interface ICompilationException
{
IEnumerable<CompilationFailure> CompilationFailures { get; }
} public class CompilationFailure
{
public string SourceFileContent { get; }
public string SourceFilePath { get; }
public string CompiledContent { get; }
public IEnumerable<DiagnosticMessage> Messages { get; }
...
}

CompilationFailure类型的Messages属性返回一个元素类型为DiagnosticMessage的集合,DiagnosticMessage对象承载着一些描述编译错误的诊断信息。我们不仅可以借助该对象的相关属性得到描述编译错误的消息(Message和FormattedMessage属性),还可以得到发生编译错误所在源文件的路径(SourceFilePath)及范围,StartLine属性和StartColumn属性分别表示导致编译错误的源代码在源文件中开始的行与列。EndLine属性和EndColumn属性分别表示导致编译错误的源代码在源文件中结束的行与列(行数和列数分别从1与0开始计数)。

public class DiagnosticMessage
{
public string SourceFilePath { get; }
public int StartLine { get; }
public int StartColumn { get; }
public int EndLine { get; }
public int EndColumn { get; } public string Message { get; }
public string FormattedMessage { get; }
...
}

从图21-8可以看出,错误页面会直接将导致编译失败的相关源代码显示出来。令我们更感到惊喜的是,它不仅将直接导致失败的源代码实现出来,还显示前后相邻的源代码。至于相邻源代码应该显示多少行,实际上是通过配置选项DeveloperExceptionPageOptions的SourceCodeLineCount属性控制的,而源文件的读取则是由该配置选项的FileProvider属性提供的IFileProvider对象完成的。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions { SourceCodeLineCount = 3});
app.MapControllers();
app.Run();

对于前面演示的这个实例来说,如果将前后相邻的三行代码显示在错误页面上,我们可以采用如上所示的方式为DeveloperExceptionPageMiddleware中间件指定DeveloperExceptionPageOptions配置选项,并将它的SourceCodeLineCount属性设置为3。我们可以将视图文件(index.cshtml)改写成如下所示的形式,在导致编译失败的那一行代码前后分别添加4行代码。

1:
2:
3:
4:
5:@{ var value = new Foobar();}
6:
7:
8:
9:

对于定义在视图文件中的9行代码,根据在注册DeveloperExceptionPageMiddleware中间件时指定的规则,最终显示在错误页面上的应该是第2行至第8行。如果利用浏览器访问相同的地址,这7行代码会以图3所示的形式出现在错误页面上。如果我们没有对SourceCodeLineCount属性做显式设置,它的默认值为6。

图3 根据设置显示相邻源代码

[2111]利用IExceptionHandlerFeature特性提供错误信息

在ExceptionHandlerMiddleware中间件将代表当前请求的HttpContext上下文传递给处理器之前,它会按照如下所示的方式创建一个ExceptionHandlerFeature特性并附着到当前HttpContext上下文中中。当整个请求处理流程完全结束之后,该中间件还会将请求路径恢复成原始值,以免对前置中间件的后续处理造成影响。

public class ExceptionHandlerMiddleware
{

public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
var edi = ExceptionDispatchInfo.Capture(ex);
var originalPath = context.Request.Path;
try
{
var feature = new ExceptionHandlerFeature()
{
Error = ex,
Path = originalPath,
Endpoint = context.GetEndpoint(),
RouteValues = context.Features.Get<IRouteValuesFeature>()?.RouteValues
};
context.Features.Set<IExceptionHandlerFeature>(feature);
context.Features.Set<IExceptionHandlerPathFeature>(feature); context.Response.StatusCode = 500;
context.Response.Clear();
if (_options.ExceptionHandlingPath.HasValue)
{
context.Request.Path = _options.ExceptionHandlingPath;
}
var handler = _options.ExceptionHandler ?? _next;
await handler(context); if (context.Response.StatusCode == 404 && !_options.AllowStatusCode404Response)
{
throw edi.SourceException;
}
}
finally
{
context.Request.Path = originalPath;
}
}
}
}

在进行异常处理时,我们可以从当前HttpContext上下文中提取ExceptionHandlerFeature特性对象,进而获取抛出的异常和原始请求路径。如下面的代码片段所示,我们利用HandleError方法来呈现一个定制的错误页面。在这个方法中,我们正是借助ExceptionHandlerFeature特性得到抛出的异常的,并将其类型、消息及堆栈追踪信息显示出来。

using Microsoft.AspNetCore.Diagnostics;

var app = WebApplication.Create();
app.UseExceptionHandler("/error");
app.MapGet("/error", HandleError);
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception"));
app.Run(); static IResult HandleError(HttpContext context)
{
var ex = context.Features.Get<IExceptionHandlerPathFeature>()!.Error;
var html = $@"
<html>
<head><title>Error</title></head>
<body>
<h3>{ex.Message}</h3>
<p>Type: {ex.GetType().FullName}</p>
<p>StackTrace: {ex.StackTrace}</p>
</body>
</html>";
return Results.Content(html, "text/html");
}

上面演示程序为路径 “/error”注册了一个采用HandleError作为处理方法的终结点。注册的ExceptionHandlerMiddleware中间件将该“/error”作为重定向路径。那么针对根路径的请求将会得到图4所示的错误页面。

图4 定制的错误页面

[2112]清除缓存响应报头

由于相应缓存缓存在大部分情况下只适用于成功状态的响应,如果服务端在处理请求过程中出现异常,之前设置的缓存报头是不应该出现在响应报文中的。对于ExceptionHandlerMiddleware中间件来说,清除缓存报头也是它负责的一项重要工作。在如下所示的演示程序中,针对根路径的请求有50%的可能会抛出异常。不论是返回正常的响应内容还是抛出异常,这个方法都会先设置一个Cache-Control的响应报头,并将缓存时间设置为1小时(Cache-Control: max-age=3600)。注册的ExceptionHandlerMiddleware中间件在处理异常时会响应一个内容为“Error occurred!”的字符串。

using Microsoft.Net.Http.Headers;

var _random = new Random();
var app = WebApplication.Create();
app.UseExceptionHandler(app2 => app2.Run(httpContext => httpContext.Response.WriteAsync("Error occurred!")));
app.MapGet("/", (HttpResponse response) => {
response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
MaxAge = TimeSpan.FromHours(1)
}; if (_random.Next() % 2 == 0)
{
throw new InvalidOperationException("Manually thrown exception...");
}
return response.WriteAsync("Succeed...");
});
app.Run();

如下所示的两个响应报文分别对应正常响应和抛出异常的情况,我们会发现程序中设置的缓存报头Cache-Control: max-age=3600只会出现在状态码为“200 OK”的响应中。在状态码为“500 Internal Server Error”的响应中,则会出现三个与缓存相关的报头(Cache-Control、Pragma和Expires),它们的目的都是禁止缓存或者将缓存标识为过期(S2112)。

HTTP/1.1 200 OK
Date: Mon, 08 Nov 2021 12:47:55 GMT
Server: Kestrel
Cache-Control: max-age=3600
Content-Length: 10 Succeed...
HTTP/1.1 500 Internal Server Error
Date: Mon, 08 Nov 2021 12:48:00 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Content-Length: 15 Error occurred!

[2113]针对404响应的处理

ExceptionHandlerOptions 配置选项的AllowStatusCode404Response属性则表示该中间件是否允许最终返回状态码为404的响应。该属性默认值为false,这意味着在默认情况下,为该中间件指定的异常处理器不能返回404响应,此时该中间件会将原始的异常抛出来。如果404响应就应该是最终的异常处理结果,我们必须将ExceptionHandlerOptions配置选项的AllowStatusCode404Response属性设置为True。

以如下的程序为例,我们为路径“/foo”和“/bar”注册了对应的终结点,针对它们的处理器最终都会抛出一个异常。我们将DeveloperExceptionPageMiddleware中间件注册到这两个路由分支上,采用的异常处理器都会将响应状态码设置为404。但是ExceptionHandlerOptions配置选项的AllowStatusCode404Response属性的是不同的,前者采用默认值False,后者显式设置为True。

var app = WebApplication.Create();
app.MapGet("/foo", BuildHandler(app, false));
app.MapGet("/bar", BuildHandler(app, true));
app.Run(); static RequestDelegate BuildHandler(IEndpointRouteBuilder endpoints, bool allowStatusCode404Response)
{
var options = new ExceptionHandlerOptions
{
ExceptionHandler = httpContext =>
{
httpContext.Response.StatusCode = 404;
return Task.CompletedTask;
},
AllowStatusCode404Response = allowStatusCode404Response
};
var app = endpoints.CreateApplicationBuilder();
app
.UseExceptionHandler(options)
.Run(httpContext => Task.FromException(new InvalidOperationException("Manually thrown exception.")));
return app.Build();
}

该演示程序启动之后,针对两个路由分支的路径的请求会得到不同的输出结果。如图5所示,针对路径“/foo”的请求返回依然是状态码为500的响应,异常处理器返回的404响应在针对路径“/bar”的请求中被正常返回了。

图5 是否允许404响应

[2114]利用IStatusCodePagesFeature特性忽略异常处理

如果某些内容已经被写入响应的主体部分,或者响应的媒体类型已经被预先设置,StatusCodePagesMiddleware中间件就不会再执行任何错误处理操作。但是应用程序往往具有自身的异常处理策略,也许在某些情况下就应该回复一个状态码在400~599区间内的响应,该中间件就不应该对当前响应做任何干预的。为了解决这种情况,我们必须赋予后续中间件能够阻止StatusCodePagesMiddleware中间件进行错误处理的能力。这项能力是借助IStatusCodePagesFeature特性来实现的。如下面的代码片段所示,该接口定义了唯一的Enabled属性表示是否希望StatusCodePagesMiddleware中间件参与当前的异常处理。StatusCodePagesFeature类型是对该接口的默认实现,它的Enabled属性默认返回True。

public interface IStatusCodePagesFeature
{
bool Enabled { get; set; }
} public class StatusCodePagesFeature : IStatusCodePagesFeature
{
public bool Enabled { get; set; } = true ;
}

如下面的代码片段所示,StatusCodePagesMiddleware中间件在将请求交付给后续管道处理之前,它会创建一个StatusCodePagesFeature特性并附着到当前HttpContext上下文上。后面的中间件如果希望StatusCodePagesMiddleware中间件能够“放行”,只需要将此特性的Enabled属性设置为False就可以了。

public class StatusCodePagesMiddleware
{
...
public async Task Invoke(HttpContext context)
{
var feature = new StatusCodePagesFeature();
context.Features.Set<IStatusCodePagesFeature>(feature); await _next(context);
var response = context.Response;
if ((response.StatusCode >= 400 && response.StatusCode <= 599) &&
!response.ContentLength.HasValue && string.IsNullOrEmpty(response.ContentType) && feature.Enabled)
{
await _options.HandleAsync(new StatusCodeContext(context, _options, _next));
}
}
}

下面的演示程序将针对根路径“/”请求的处理实现在Process方法中,该方法会将响应状态码为“401 Unauthorized”。我们通过随机数让这个方法在50%的概率下将StatusCodePagesFeature特性的Enabled属性设置为False。注册的StatusCodePagesMiddleware中间件会直接将“Error occurred!”文本作为响应内容。

using Microsoft.AspNetCore.Diagnostics;

var random = new Random();
var app = WebApplication.Create();
app.UseStatusCodePages(HandleAsync);
app.MapGet("/", Process);
app.Run(); static Task HandleAsync(StatusCodeContext context) => context.HttpContext.Response.WriteAsync("Error occurred!"); void Process(HttpContext context)
{
context.Response.StatusCode = 401;
if (random.Next() % 2 == 0)
{
context.Features.Get<IStatusCodePagesFeature>()!.Enabled = false;
}
}

针对根路径的请求会得到如下两种不同的响应。没有主体内容的响应是通过Process方法产生的,这种情况发生在StatusCodePagesMiddleware中间件通过StatusCodePagesFeature特性被屏蔽的时候。有主体内容的响应则是Process方法和StatusCodePagesMiddleware中间件共同作用的结果(S2114)。

HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:20 GMT
Server: Kestrel
Content-Length: 15 Error occurred!
HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:34 GMT
Server: Kestrel
Content-Length: 0

ASP.NET Core 6框架揭秘实例演示[33]:异常处理高阶用法的更多相关文章

  1. ASP.NET Core 6框架揭秘实例演示[07]:文件系统

    ASP.NET Core应用具有很多读取文件的场景,如读取配置文件.静态Web资源文件(如CSS.JavaScript和图片文件等).MVC应用的视图文件,以及直接编译到程序集中的内嵌资源文件.这些文 ...

  2. ASP.NET Core 6框架揭秘实例演示[08]:配置的基本编程模式

    .NET的配置支持多样化的数据源,我们可以采用内存的变量.环境变量.命令行参数.以及各种格式的配置文件作为配置的数据来源.在对配置系统进行系统介绍之前,我们通过几个简单的实例演示一下如何将具有不同来源 ...

  3. ASP.NET Core 6框架揭秘实例演示[09]:配置绑定

    我们倾向于将IConfiguration对象转换成一个具体的对象,以面向对象的方式来使用配置,我们将这个转换过程称为配置绑定.除了将配置树叶子节点配置节的绑定为某种标量对象外,我们还可以直接将一个配置 ...

  4. ASP.NET Core 6框架揭秘实例演示[10]:Options基本编程模式

    依赖注入使我们可以将依赖的功能定义成服务,最终以一种松耦合的形式注入消费该功能的组件或者服务中.除了可以采用依赖注入的形式消费承载某种功能的服务,还可以采用相同的方式消费承载配置数据的Options对 ...

  5. ASP.NET Core 6框架揭秘实例演示[11]:诊断跟踪的几种基本编程方式

    在整个软件开发维护生命周期内,最难的不是如何将软件系统开发出来,而是在系统上线之后及时解决遇到的问题.一个好的程序员能够在系统出现问题之后马上定位错误的根源并找到正确的解决方案,一个更好的程序员能够根 ...

  6. ASP.NET Core 6框架揭秘实例演示[12]:诊断跟踪的进阶用法

    一个好的程序员能够在系统出现问题之后马上定位错误的根源并找到正确的解决方案,一个更好的程序员能够根据当前的运行状态预知未来可能发生的问题,并将问题扼杀在摇篮中.诊断跟踪能够帮助我们有效地纠错和排错&l ...

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

    <诊断跟踪的几种基本编程方式>介绍了四种常用的诊断日志框架.其实除了微软提供的这些日志框架,还有很多第三方日志框架可供我们选择,比如Log4Net.NLog和Serilog 等.虽然这些框 ...

  8. ASP.NET Core 6框架揭秘实例演示[14]:日志的进阶用法

    为了对各种日志框架进行整合,微软创建了一个用来提供统一的日志编程模式的日志框架.<日志的基本编程模式>以实例演示的方式介绍了日志的基本编程模式,现在我们来补充几种"进阶" ...

  9. ASP.NET Core 6框架揭秘实例演示[15]:针对控制台的日志输出

    针对控制台的ILogger实现类型为ConsoleLogger,对应的ILoggerProvider实现类型为ConsoleLoggerProvider,这两个类型都定义在 NuGet包"M ...

随机推荐

  1. 2021.05.04【NOIP提高B组】模拟 总结

    T1 题目大意, \(S_{i,j}=\sum_{k=i}^j a_k\) ,求 \(ans=\min\{ S_{i,j}\mod P|S_{i,j}\mod P\ge K \}\) 其中 \(i\l ...

  2. 并发bug之源(一)-可见性

    CPU三级缓存 要聊可见性,这事儿还得从计算机的组成开始说起,我们都知道,计算机由CPU.内存.磁盘.显卡.外设等几部分组成,对于我们程序员而言,写代码主要关注CPU和内存两部分.放几张马士兵老师的图 ...

  3. Visual Studio Installer下载速度为0处理办法

    DNS改为:223.5.5.5即可. 223.5.5.5 下载完成后记得改回来.

  4. .NET中测试代码运行时间

    更新记录 本文迁移自Panda666原博客,原发布时间:2021年6月29日. 计算代码运行的时间,除了呆萌地用秒表去计时,或者可以通过Visual Studio来查看,还可以在.NET代码中使用St ...

  5. 将Hexo搭建到自己的服务器上

    http://xybin.top/posts/9373.html 第一部分:服务器端的操作 1.安装git 和nginx yum install -y nginx git 2.添加一个git用户 #添 ...

  6. mysql调优学习笔记

    性能监控 使用show profile查询剖析工具,可以指定具体的type 此工具默认是禁用的,可以通过服务器变量在绘画级别动态的修改 set profiling=1; 当设置完成之后,在服务器上执行 ...

  7. SpringCloudAlibaba分布式流量控制组件Sentinel实战与源码分析-中

    实战示例 控制台初体验 Sentinel的控制台启动后,控制台页面的内容数据都是空的,接下来我们来逐步操作演示结合控制台的使用,在上一节也已说明整合SpringCloud Alibaba第一步先加入s ...

  8. 使用 NSProxy 实现消息转发

    一.简介 ​ 在 iOS 应用开发中,自定义一个类一般需要继承自 NSObject 类或者 NSObject 子类,但是,NSProxy 类不是继承自 NSObject 类或者 NSObject 子类 ...

  9. Java 常用Set集合和常用Map集合

    目录 常用Set集合 Set集合的特点 HashSet 创建对象 常用方法 遍历 常用Map集合 Map集合的概述 HashMap 创建对象 常用方法 遍历 HashMap的key去重原理 常用Set ...

  10. Tapdata Cloud 2.1.2 来啦:大波细节已就绪!字段类型可批量修改、支持微信扫码登录、新增支持 Vika 为目标

    Tapdata Cloud cloud.tapdata.net 让数据实时可用 Tapdata Cloud 是国内首家异构数据库实时同步云平台,目前支持 Oracle.MySQL.PG.SQL Ser ...