将传统 ASP.NET 应用迁移到 .NET Core

现在越来越多的人在谈论. NET Core。诚然,.NET Core 是未来, 但是.NET Framework 仍在支持, 因为大量的应用程序无法在短时间内迁移。

.NET Core 和 .NET Framework 就像电动汽车和汽油动力汽车。汽油车是成熟的,你可以毫无任何问题驾驶它,但电动车有它们的优势,并正在取代汽油车。所以,不要误会,你应该从今天开始迁移到. NET Core。

将传统 ASP.NET 应用迁移到 .NET Core

我已经迁移了几个运行在完整.NET Framework 和 IIS 上的传统ASP.NET/MVC 项目到ASP.NET Core 2.x,可以运行在 IIS 或非 IIS 环境下。

我的博客是其中之一。这是一个有 10 年历史的博客系统,最初由 ASP.NET 2.0 Web Form 以及 Visual Basic 编写。从 2008 年起,我一直在面向最新的.NET 技术更新代码库。.NET Core 版本的博客系统将在今年年底到来。我写这篇文章,记录我遇到的路障和如何解决它们的方法。

这篇文章针对的是新接触.NET Core,但有.NET Framework 经验的开发人员,帮助他们将现有的应用更平滑的过渡到.NET Core 上。 将传统 ASP.NET 应用迁移到 .NET Core

将传统 ASP.NET 应用迁移到 .NET Core
1

迁移或重写

有时候,我更喜欢用“重写“而不是”迁移“这个词,因为在有些情况下,.NET Core 和.NET Framework 是完全不同的两个东西。

根据我的经验,大部分前端代码可以只做少量修改就直接移植到.NET Core,因为它们的本质毕竟是服务器技术无关的,天生跨平台的技术。至于后端代码,迁移成本取决于它们对 Windows 及 IIS 的耦合程度。我理解,有些应用会充分利用 Windows 及 IIS 的特性,这样开发者就可以避免自己费力去实现一些功能。这些包括计划任务、注册表、活动目录或 Windows 服务等。这些并不能够直接迁移,因为.NET Core 是跨平台的。对于这些部分,你可能需要考虑从重新设计业务逻辑,想一种可以实现相同功能,但不依赖于 Windows 或 IIS 组件的方法。

对于无法迁移的历史遗留代码,你可能需要考虑重新设计整个应用的架构,将这些功能作为 REST API 暴露出来,可以使用.NET Framework 上的ASP.NET Web API 来实现。这样的话,你的ASP.NET Core 应用得以继续使用这些 API 并继续完成业务功能。

即使你的应用使用了 WCF 服务,甚至更老的 ASMX 服务,也是可以搞的。因为.NET Core 目前有 WCF 客户端可以调用 WCF。

2

NuGet 包管理

请确保你需要使用的 NuGet 包支持 .NET Core 或 .NET Standard。如果不支持,那么你需要研究有没有可以替换的 NuGet 包,或者你是否能够自己写代码去实现相同的功能。

.NET Standard 意味着这个包可以同时使用在.NET Framework 4.6.1+ 以及.NET Core,这是取代老的 Portable Class Library (PCL)的技术。所以,如果你看到一个包的依赖项里有.NET Standard,这意味着你能够将它安装到你的.NET Core 工程中。

部分包,比如 NLog 有专门的.NET Core 版本,比如 NLog.Web.AspNetCore,你应该选择使用这样的版本。

将传统 ASP.NET 应用迁移到 .NET Core

你依然可以在.NET Core 工程里引用一个.NET Framework 的包,但是这会让你的应用只能跑在 Windows 上将传统 ASP.NET 应用迁移到 .NET Core,不推荐这么做。

我列出了一些热门使用的 NuGet 包,它们都已经支持.NET Core:

NLog.Web.AspNetCore

Newtonsoft.Json

HtmlAgilityPack

RestSharp

NUnit

Dapper

AutoMapper

Moq

对于客户端包,比如 jQuery,请不要使用 NuGet 将它们安装到.NET Core 工程中,参见本文的 “客户端包管理” 章节。

如果你使用 Visual Studio Code 做 .NET Core 开发,请注意,安装 NuGet 包的命令不是 Install-Package,那是给 Visual Studio 的 PowerShell host 用的,在 VSCode 里,你需要使用 dotnet CLI 工具,比如:

dotnet add package Newtonsoft.Json

参见 https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package

将传统 ASP.NET 应用迁移到 .NET Core

3

客户端包管理

ASP.NET Core 曾经使用 Bower 去管理客户端包。但在最新的 ASP.NET Core 2.1 里,Bower 已经被移除了,因为作者不干了将传统 ASP.NET 应用迁移到 .NET Core。因此,微软默认使用自家的包管理器 “Library Manager” 也叫 “libman” 去管理前端包。它能够在 Visual StudioVisual Studio Code 中使用,甚至也能用 CLI 在命令行下使用。

将传统 ASP.NET 应用迁移到 .NET Core

libman.json 可以直接编辑,也能在 UI 中更改,都有智能感知支持。我的建议是,如果你的应用不是重客户端的话,使用 libman 去管理前端包,因为其他技术比如 NPM 太重量级了。你会希望在你的编译服务器上安装和配置 NodeJS 以及其他一切东西,仅仅为了拉取一个 jQuery 库。

更多详情可参见官方文档 https://docs.microsoft.com/en-us/aspnet/core/client-side/libman/?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

4

Html / JavaScript / CSS

你可以直接将这些文件复制到.NET Core 工程里。但是请确保你已经把文件路径修改正确,比如 CSS 里的图片文件路径。因为传统 ASP.NET / MVC 模板默认使用 “/Content/” 目录,而.NET Core 模板使用“/css/”, “/js/”, “/lib/” 等目录,这并不是强制的,只是约定俗成的规范。

将传统 ASP.NET 应用迁移到 .NET Core

如果你希望捆绑并压缩 CSS 和 JS 文件,有许多工具可以办到。我个人喜欢用 VS 的一款插件,叫做 “Bundler & Minifier” ,你可以从这里获取 https://github.com/madskristensen/BundlerMinifier.

这款插件可以在开发时生成捆绑及压缩的文件,但非编译或运行时。

将传统 ASP.NET 应用迁移到 .NET Core

5

App_Data 文件夹

在传统 ASP.NET/MVC 应用中,你可以将数据文件保存到一个名为“App_Data”的特殊文件夹中,但这个东西在.NET Core 里不复存在了。为了实现类似的功能,你需要自己创建一个名为“App_Data” 的文件夹,但位于“wwwroot”目录之外。

将传统 ASP.NET 应用迁移到 .NET Core

然后像这样使用

public void Configure(IApplicationBuilder app, IHostingEnvironment env)

{

    // set

    string baseDir = env.ContentRootPath;

    AppDomain.CurrentDomain.SetData(“DataDirectory”, Path.Combine(baseDir, “App_Data”));

    // use

    var feedDirectoryPath = $”{AppDomain.CurrentDomain.GetData(“DataDirectory”)}\feed”;

}

将传统 ASP.NET 应用迁移到 .NET Core

6

自定义 Http Headers

在传统 ASP.NET 里,你可以在 Web.Config 里像这样为每个响应都配置自定义的 HTTP Header:

<httpProtocol>

  <customHeaders>

    <add name=”X-Content-Type-Options” value=”nosniff” />

  </customHeaders>

</httpProtocol>

而在.NET Core 里,如果你希望脱离 Windows 去部署你的应用,不可以使用 Web.config 文件。因此,你需要一个三方的 NuGet 包来完成这个功能:NetEscapades.AspNetCore.SecurityHeaders

app.UseSecurityHeaders(new HeaderPolicyCollection()

    .AddCustomHeader(“X-UA-Compatible”, “IE=edge”)

    .AddCustomHeader(“X-Developed-By”, “Edi Wang”)

);

详情参考 https://github.com/andrewlock/NetEscapades.AspNetCore.SecurityHeaders

将传统 ASP.NET 应用迁移到 .NET Core

7

获取客户端 IP 地址以及 HttpContext

在传统 ASP.NET 里,我们能够通过 Request.UserHostAddress 来获取客户端 IP 地址。但这个属性在 ASP.NET Core 2.x 里是不存在的。我们需要通过另一种方式获取 HTTP 请求信息。

1. 在你的 MVC 控制器里定义一个私有变量

private IHttpContextAccessor _accessor;

2.  使用构造函数注入初始化它

public SomeController(IHttpContextAccessor accessor)

{

    _accessor = accessor;

}

3. 获取客户端 IP 地址

_accessor.HttpContext.Connection.RemoteIpAddress.ToString()

就是如此简单。

如果你的 ASP.NET Core 工程是用 MVC 默认模板创建的,针对HttpContextAcccessor 依赖注入注册应该在Startup.cs 中完成:

services.AddHttpContextAccessor();

services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();

RemoteIpAddress 的类型是 IPAddress 并不是 string。它包含 IPv4, IPv6 以及其他信息。这和传统 ASP.NET 不太一样,对我们更加有用一些。

将传统 ASP.NET 应用迁移到 .NET Core

如果你希望在 Razor 视图(cshtml) 里使用,只需要用 @inject 指令注入到 view 中:

@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContextAccessor

使用方法:

Client IP: @HttpContextAccessor.HttpContext.Connection.RemoteIpAddress.ToString()

将传统 ASP.NET 应用迁移到 .NET Core

8

JsonResult

默认情况下,ASP.NET Core 会使用 camelCase 序列化 JsonResult ,而传统 ASP.NET MVC 使用的是 PascalCase,这会导致依赖 Json 结果的 JavaScript 代码爆掉。

例如以下代码:

public IActionResult JsonTest()

{

    return Json(new { Foo = 1, Goo = true, Koo = “Test” });

}

它会返回 camelCase 的 Json 给客户端:

将传统 ASP.NET 应用迁移到 .NET Core

如果你有大量JavaScript 代码并不能及时改为使用 camelCase,你仍然可以配置 ASP.NET Core 向客户端输出 PascalCase 的 Json

public void ConfigureServices(IServiceCollection services)

{

    // Add framework services.

    services.AddMvc()

        .AddJsonOptions(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver());

}

现在,之前的代码会返回 PascalCase 的结果:

将传统 ASP.NET 应用迁移到 .NET Core
将传统 ASP.NET 应用迁移到 .NET Core
1

HttpModules 和 HttpHandlers

这两者在 ASP.NET Core 中被替换为了 Middleware。但在迁移之前,你可以考虑使用别的方法,在一个普通 ASP.NET Core Controller 中实现这些功能。

例如,我的老博客系统里有个名为“opml.axd” 的HttpHandler 作用是向客户端输出一个 XML 文档,这其实完全可以用 Controller 来实现:

public async Task<IActionResult> Index()

{

    var opmlDataFile = $”{AppDomain.CurrentDomain.GetData(Constants.DataDirectory)}\opml.xml”;

    if (!System.IO.File.Exists(opmlDataFile))

    {

        Logger.LogInformation($”OPML file not found, writing new file on {opmlDataFile}”);

        await WriteOpmlFileAsync(HttpContext);

        if (!System.IO.File.Exists(opmlDataFile))

        {

            Logger.LogInformation($”OPML file still not found, something just went very very wrong…”);

            return NotFound();

        }

    }

    string opmlContent = await Utils.ReadTextAsync(opmlDataFile, Encoding.UTF8);

    if (opmlContent.Length > 0)

    {

        return Content(opmlContent, “text/xml”);

    }

    return NotFound();

}

我也曾经使用 HttpHandler 完成 Open Search,RSS/Atom 等功能,它们也能够被 重写为 Controller。

对于其他一些不能够被重写为 MVC Controller 的组件,例如处理特殊拓展名的请求。请参见:

https://docs.microsoft.com/en-us/aspnet/core/migration/http-modules?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

10

IIS URL Rewrite

你依然可以使用和旧应用里完全一样的配置文件,不管你的 .NET Core 应用是否部署在 IIS 上。

例如,在应用根目录底下创建一个名为”UrlRewrite.xml“的文件,内容如下:

<rewrite>

  <rules>

    <rule name=”Redirect Misc Homepage URLs to canonical homepage URL” stopProcessing=”false”>

      <match url=”(index|default).(aspx?|htm|s?html|php|pl|jsp|cfm)”/>

      <conditions logicalGrouping=”MatchAll” trackAllCaptures=”false”>

        <add input=”{REQUEST_METHOD}” pattern=”GET”/>

      </conditions>

      <action type=”Redirect” url=”/”/>

    </rule>

  </rules>

</rewrite>

注意:你必须把这个文件设置为 always copy 到输出目录,不然无效!将传统 ASP.NET 应用迁移到 .NET Core

<ItemGroup>

  <None Update=”UrlRewrite.xml”>

    <CopyToOutputDirectory>Always</CopyToOutputDirectory>

  </None>

</ItemGroup>

打开 Startup.cs,在Configure 方法中添加如下代码:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)

{

    …

    using (var urlRewriteStreamReader = File.OpenText(“UrlRewrite.xml”))

    {

        var options = new RewriteOptions().AddIISUrlRewrite(urlRewriteStreamReader);

        app.UseRewriter(options);

    }

    …

}

这在我之前的文章中提到过 https://edi.wang/post/2018/9/18/prevent-image-hotlink-aspnet-core.

更多选项和用法可以参考 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/url-rewriting?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

11

Web.config

Web.config 文件并没有完全消亡。在 In .NET Core 里,一个 web.config 文件仍然用于在 IIS 环境下部署网站。在这种场景下,Web.config 里的配置仅作用于 IIS,和你的应用代码没有任何关系。可以参考 https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/index?view=aspnetcore-2.1#configuration-of-iis-with-webconfig

一个典型的 IIS 下部署 ASP.NET Core 应用的 web.config 文件如下:

<?xml version=”1.0″ encoding=”utf-8″?>

<configuration>

  <location path=”.” inheritInChildApplications=”false”>

    <system.webServer>

      <handlers>

        <add name=”aspNetCore” path=”*” verb=”*” modules=”AspNetCoreModule” resourceType=”Unspecified” />

      </handlers>

      <aspNetCore processPath=”dotnet” arguments=”.Moonglade.Web.dll” stdoutLogEnabled=”false” stdoutLogFile=”\?%home%LogFilesstdout” />

    </system.webServer>

  </location>

</configuration>

曾经的 AppSettings 节点可迁移到 appsettings.json,在这篇文章中有详解:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

12

Session 和 Cookie

ASP.NET Core 默认没有开启Session支持,你必须手工添加Session 支持。

services.AddDistributedMemoryCache();

services.AddSession(options =>

{

    options.IdleTimeout = TimeSpan.FromMinutes(20);

    options.Cookie.HttpOnly = true;

});

以及

app.UseSession();

设定和获取Session值:

HttpContext.Session.SetString(“CaptchaCode”, result.CaptchaCode);

HttpContext.Session.GetString(“CaptchaCode”);

清除值:

context.Session.Remove(“CaptchaCode”);

详情参见:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

13

Html.Action

我们曾经使用 Html.Action 去调用一个 Action ,返回一个 Partial View ,然后放在主要的 View 中显示,比如 layout 页。这在 Layout 页面中的应用非常广泛,比如在一个博客系统中显示分类列表之类的小部件。

@Html.Action(“GetTreeList”, “Category”)

在 ASP.NET Core 里,它被替换为了 ViewComponents,参见 https://docs.microsoft.com/en-us/aspnet/core/mvc/views/view-components

一个要注意的地方是 Invoke 方法只能是 async 签名的将传统 ASP.NET 应用迁移到 .NET Core

async Task<IViewComponentResult> InvokeAsync()

但如果你的代码并不是天生异步的,为了不让编译器警报,你可以加入这行代码:

await Task.CompletedTask;

将传统 ASP.NET 应用迁移到 .NET Core

14

检查运行环境是 Debug 或 Release

在我的老系统里,我使用 HttpContext.Current.IsDebuggingEnabled 去检查当前运行环境是否为 Debug,并在标题栏上显示 “(Debug)” 字样。

@if (HttpContext.Current.IsDebuggingEnabled)

{

    <text>(Debug)</text>

}

在 ASP.NET Core 里,我们可以使用新的razor tag helper 去完成这件事

<environment include=”Development”>

    (Debug)

</environment>

在下面的章节里,你会看到更多razor tag helper 的用法。

将传统 ASP.NET 应用迁移到 .NET Core

15

新的 Razor Tag Helpers

Tag helper 可以帮助你讲老的 HTML helper 简化为更加面向 HTML 可读的代码,例如一个表单,我们曾经要这样写:

将传统 ASP.NET 应用迁移到 .NET Core

转换为 Tag Helpers 的结果是这样的:

将传统 ASP.NET 应用迁移到 .NET Core

我个人最喜欢的功能是给 JS 或 CSS 文件自动增加版本字符串:

<script src=”~/js/app/ediblog.app.min.js” asp-append-version=”true”></script>

它的结果是:

<script src=”/js/app/ediblog.app.min.js?v=lvNJVuWBoD_RVZwyBT15T_i3_ZuEIaV_w0t7zI_UYxY”></script>

新的 razor 语法能够兼容以前的 HTML helpers,也就是说,你依然能在 ASP.NET Core 中毫无问题的使用老的 HTML helpers。如果你的应用迁移时间紧迫,你可以尽管先使用老代码,随后再逐步转换到 Tag Helpers。

完整的介绍和语法列表,可参见 https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

16

Anti-Forgery Token

Anti-forgery token 有一些改进。首先,你能够自定义 cookie 以及字段的名字了。

services.AddAntiforgery(options =>

{

    options.Cookie.Name = “X-CSRF-TOKEN-MOONGLADE”;

    options.FormFieldName = “CSRF-TOKEN-MOONGLADE-FORM”;

});

第二,你再也不需要手工给每一个表单都增加这行代码了:

@Html.AntiForgeryToken()

如果你使用新的 form tag helper,那么 anti-forgery 字段会自动在输出到客户端时自动加上。

将传统 ASP.NET 应用迁移到 .NET Core

但你依然需要在后台对应的 Action 上加上 [ValidateAntiForgeryToken] 属性。

然而,有另一种自动给每一个 POST 请求都验证 anti-forgery token 的办法。

services.AddMvc(options =>

options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));

或者你可以单独给一个 Controller 加上这个属性。

[Authorize]

[AutoValidateAntiforgeryToken]

public class ManageController : Controller

将传统 ASP.NET 应用迁移到 .NET Core

17

对非 Controller 使用依赖注入

ASP.NET Core 有自带的 DI 框架可以用在 Controller 上。我们可以修改一个Controller 的构造函数去注入它运行所依赖的服务。

public class HomeController : Controller

{

    private readonly IDateTime _dateTime;

    public HomeController(IDateTime dateTime)

    {

        _dateTime = dateTime;

    }

}

但这不意味着自带的 DI 框架只能用在Controller 上。对于其他类,你可以使用完全一样的 DI,例如,我自定义的类,也可以使用构造函数注入:

public class CommentService : MoongladeService

{

    private readonly EmailService _emailService;

    public CommentService(MoongladeDbContext context,

        ILogger<CommentService> logger,

        IOptions<AppSettings> settings,

        EmailService emailService) : base(context, logger, settings)

    {

        _emailService = emailService;

    }

       // ….

}

方法是,只要你把自定义的类注册到Startup.cs中的 DI 容器里即可。

services.AddTransient<CommentService>();

更多 ASP.NET Core 依赖注入的使用方法参见 https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/dependency-injection?view=aspnetcore-2.1

将传统 ASP.NET 应用迁移到 .NET Core

18

API 行为不一致

有些来自传统 ASP.NET 的代码可以无错误编译通过,但这不保证运行时能够成功。比如,这段来自 ASP.NET (.NET Framework) 的代码在 ASP.NET Core 中会抛出异常:

var buffer = new byte[context.Request.Body.Length];

context.Request.Body.Read(buffer, 0, buffer.Length);

var xml = Encoding.Default.GetString(buffer);

它的结果是:

System.NotSupportedException: Specified method is not supported.

at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestStream.get_Length()

在.NET Core 里,我们需要用一种不同的方式去实现:

var xml = await new StreamReader(context.Request.Body, Encoding.Default).ReadToEndAsync();

将传统 ASP.NET 应用迁移到 .NET Core

19

小心 GDPR 带来的问题

ASP.NET Core 2.1 默认添加了 GDPR 的支持,但也会给我们带来一些问题。关于 GDPR 可参见 https://docs.microsoft.com/en-us/aspnet/core/security/gdpr?view=aspnetcore-2.1

主要问题是,在用户接受 GDPR 协议之前,Cookie 是不起作用的。你需要检查哪些 Cookie 是你应用运行所必须的,即时用户没有接受 GDPR 协议,并且把它们标记为IsEssential

这是我博客系统中的一个例子:

private void SetPostTrackingCookie(CookieNames cookieName, string id)

{

    var options = new CookieOptions

    {

        Expires = DateTime.UtcNow.AddDays(1),

        SameSite = SameSiteMode.Strict,

        Secure = Request.IsHttps,

        // Mark as essential to pass GDPR

        // https://docs.microsoft.com/en-us/aspnet/core/security/gdpr?view=aspnetcore-2.1

        IsEssential = true

    };

    Response.Cookies.Append(cookieName.ToString(), id, options);

}

另一个问题是,如果你要使用 Session,那么用户必须接受 GDPR 策略,否则 Session 是不工作的。因为 Session 需要依赖 Cookie 在客户端保存 SessionID 。

将传统 ASP.NET 应用迁移到 .NET Core

20

热更新 Views

在传统 ASP.NET MVC 中,Views 文件夹默认不会编译到 DLL 文件中,所以我们能够不需要编译整个应用就能更新 razor 页面。这在不需要更新C#代码的情况下仅修改文字或一些 layout 修改的场景下非常实用。我有时候也利用这个特性直接向生产环境发布一些修改后的页面。

将传统 ASP.NET 应用迁移到 .NET Core

然而,ASP.NET Core 2.1 默认情况下会将我们的 Views 编译到 DLL 中以提高性能。因此,你无法在服务器上直接修改一个视图,因为文件夹中根本就不存在 Views,只有一个 *.Views.dll:

将传统 ASP.NET 应用迁移到 .NET Core

如果你仍然希望在 ASP.NET Core 中热更新 Views,需要手工修改 csproj 文件:

<PropertyGroup>

  <TargetFramework>netcoreapp2.1</TargetFramework>

  <RazorCompileOnBuild>false</RazorCompileOnBuild>

  <RazorCompileOnPublish>false</RazorCompileOnPublish>

</PropertyGroup>

将传统 ASP.NET 应用迁移到 .NET Core

21

编译版本号自增长

在传统 .NET 应用程序里,我们可以修改 “AssemblyInfo.cs” 在每次编译时自动增加版本号。这在编译服务器里十分常用。

[assembly: AssemblyVersion(“9.0.*”)]

结果是这样:

9.0.6836.29475

不幸的是,.NET Core 目前还没有一个自带的方法来完成这个操作。只有一个三方解决方案可能有用:https://github.com/BalassaMarton/MSBump

将传统 ASP.NET 应用迁移到 .NET Core

能看到这里的都是我的真爱粉啊……

结束

ASP.NET Core 相对传统 ASP.NET 有了不少区别,目前也有一定的限制。本文仅涵盖了我自己所遇到的问题,也一定还有很多我没有遇到过的情况。欢迎留言或 Email 给我交流你的发现。

© 版权声明

☆ END ☆
喜欢就点个赞吧
点赞0 分享
图片正在生成中,请稍后...