本篇内容主要讲解“ASP.NET Core静态文件怎么处理源码”,感兴趣的朋友不妨来看看。本文介绍的方法操作简单快捷,实用性强。下面就让小编来带大家学习“ASP.NET Core静态文件怎么处理源码”吧!
让客户满意是我们工作的目标,不断超越客户的期望值来自于我们对这个行业的热爱。我们立志把好的技术通过有效、简单的方式提供给客户,将通过不懈努力成为客户在信息化领域值得信任、有价值的长期合作伙伴,公司提供的服务项目有:域名与空间、网页空间、营销软件、网站建设、定结网站维护、网站推广。静态文件(如 HTML、CSS、图像和 JavaScript)等是Web程序的重要组成部分。传统的ASP.NET项目一般都是部署在IIS上,IIS是一个功能非常强大的服务器平台,可以直接处理接收到的静态文件处理而不需要经过应用程序池处理,所以很多情况下对于静态文件的处理程序本身是无感知的。ASP.NET Core则不同,作为Server的Kestrel服务是宿主到程序上的,由宿主运行程序启动Server然后可以监听请求,所以通过程序我们直接可以处理静态文件相关。静态文件默认存储到项目的wwwroot目录中,当然我们也可以自定义任意目录去处理静态文件。总之,在ASP.NET Core我们可以处理静态文件相关的请求。
通常我们在说道静态文件相关的时候会涉及到三个话题分别是启用静态文件、默认静态页面、静态文件目录浏览,在ASP.NET Core分别是通过UseStaticFiles、UseDefaultFiles、UseDirectoryBrowser三个中间件去处理。只有配置了相关中间件才能去操作对应的处理,相信大家对这种操作已经很熟了。静态文件操作相关的源码都位于GitHub aspnetcore仓库中的https://github.com/dotnet/aspnetcore/tree/v3.1.6/src/Middleware/StaticFiles/src目录。接下来我们分别探究这三个中间件的相关代码,来揭开静态文件处理的神秘面纱。
UseStaticFiles中间件使我们处理静态文件时最常使用的中间件,因为只有开启了这个中间件我们才能使用静态文件,比如在使用MVC开发的时候需要私用js css html等文件都需要用到它,使用的方式也比较简单
//使用默认路径,即wwwroot app.UseStaticFiles(); //或自定义读取路径 var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles"); app.UseStaticFiles(new StaticFileOptions { RequestPath="/staticfiles", FileProvider = fileProvider });
我们直接找到中间件的注册类StaticFileExtensions[点击查看StaticFileExtensions源码]
public static class StaticFileExtensions { public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app) { return app.UseMiddleware(); } public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, string requestPath) { return app.UseStaticFiles(new StaticFileOptions { RequestPath = new PathString(requestPath) }); } public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, StaticFileOptions options) { return app.UseMiddleware (Options.Create(options)); } }
一般我们最常用到的是无参的方式和传递自定义StaticFileOptions的方式比较多,StaticFileOptions是自定义使用静态文件时的配置信息类,接下来我们大致看一下具体包含哪些配置项[点击查看StaticFileOptions源码]
public class StaticFileOptions : SharedOptionsBase { public StaticFileOptions() : this(new SharedOptions()) { } public StaticFileOptions(SharedOptions sharedOptions) : base(sharedOptions) { OnPrepareResponse = _ => { }; } ////// 文件类型提供程序,也就是我们常用的文件名对应MimeType的对应关系 /// public IContentTypeProvider ContentTypeProvider { get; set; } ////// 设置该路径下默认文件输出类型 /// public string DefaultContentType { get; set; } public bool ServeUnknownFileTypes { get; set; } ////// 文件压缩方式 /// public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress; ////// 准备输出之前可以做一些自定义操作 /// public ActionOnPrepareResponse { get; set; } } public abstract class SharedOptionsBase { protected SharedOptionsBase(SharedOptions sharedOptions) { SharedOptions = sharedOptions; } protected SharedOptions SharedOptions { get; private set; } /// /// 请求路径 /// public PathString RequestPath { get { return SharedOptions.RequestPath; } set { SharedOptions.RequestPath = value; } } ////// 文件提供程序,在.NET Core中如果需要访问文件相关操作可使用FileProvider文件提供程序获取文件相关信息 /// public IFileProvider FileProvider { get { return SharedOptions.FileProvider; } set { SharedOptions.FileProvider = value; } } }
我们自定义静态文件访问时,最常用到的就是RequestPath和FileProvider,一个设置请求路径信息,一个设置读取文件信息。如果需要自定义MimeType映射关系可通过ContentTypeProvider自定义设置映射关系
var provider = new FileExtensionContentTypeProvider(); provider.Mappings[".myapp"] = "application/x-msdownload"; provider.Mappings[".htm3"] = "text/html"; app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = provider, //可以在输出之前设置输出相关 OnPrepareResponse = ctx => { ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age=3600"); } });
接下来我们步入正题直接查看StaticFileMiddleware中间件的代码[点击查看StaticFileMiddleware源码]
public class StaticFileMiddleware { private readonly StaticFileOptions _options; private readonly PathString _matchUrl; private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IFileProvider _fileProvider; private readonly IContentTypeProvider _contentTypeProvider; public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptionsoptions, ILoggerFactory loggerFactory) { _next = next; _options = options.Value; //设置文件类型提供程序 _contentTypeProvider = options.Value.ContentTypeProvider ?? new FileExtensionContentTypeProvider(); //文件提供程序 _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv); //匹配路径 _matchUrl = _options.RequestPath; _logger = loggerFactory.CreateLogger (); } public Task Invoke(HttpContext context) { //判断是够获取到终结点信息,这也就是为什么我们使用UseStaticFiles要在UseRouting之前 if (!ValidateNoEndpoint(context)) { } //判断HttpMethod,只能是Get和Head操作 else if (!ValidateMethod(context)) { } //判断请求路径是否存在 else if (!ValidatePath(context, _matchUrl, out var subPath)) { } //根据请求文件名称判断是否可以匹配到对应的MimeType,如果匹配到则返回contentType else if (!LookupContentType(_contentTypeProvider, _options, subPath, out var contentType)) { } else { //执行静态文件操作 return TryServeStaticFile(context, contentType, subPath); } return _next(context); } private Task TryServeStaticFile(HttpContext context, string contentType, PathString subPath) { var fileContext = new StaticFileContext(context, _options, _logger, _fileProvider, contentType, subPath); //判断文件是否存在 if (!fileContext.LookupFileInfo()) { _logger.FileNotFound(fileContext.SubPath); } else { //静态文件处理 return fileContext.ServeStaticFile(context, _next); } return _next(context); } }
关于FileExtensionContentTypeProvider这里就不作讲解了,主要是承载文件扩展名和MimeType的映射关系代码不复杂,但是映射关系比较多,有兴趣的可以自行查看FileExtensionContentTypeProvider源码,通过上面我们可以看到,最终执行文件相关操作的是StaticFileContext类[点击查看StaticFileContext源码]
internal struct StaticFileContext { private const int StreamCopyBufferSize = 64 * 1024; private readonly HttpContext _context; private readonly StaticFileOptions _options; private readonly HttpRequest _request; private readonly HttpResponse _response; private readonly ILogger _logger; private readonly IFileProvider _fileProvider; private readonly string _method; private readonly string _contentType; private IFileInfo _fileInfo; private EntityTagHeaderValue _etag; private RequestHeaders _requestHeaders; private ResponseHeaders _responseHeaders; private RangeItemHeaderValue _range; private long _length; private readonly PathString _subPath; private DateTimeOffset _lastModified; private PreconditionState _ifMatchState; private PreconditionState _ifNoneMatchState; private PreconditionState _ifModifiedSinceState; private PreconditionState _ifUnmodifiedSinceState; private RequestType _requestType; public StaticFileContext(HttpContext context, StaticFileOptions options, ILogger logger, IFileProvider fileProvider, string contentType, PathString subPath) { _context = context; _options = options; _request = context.Request; _response = context.Response; _logger = logger; _fileProvider = fileProvider; _method = _request.Method; _contentType = contentType; _fileInfo = null; _etag = null; _requestHeaders = null; _responseHeaders = null; _range = null; _length = 0; _subPath = subPath; _lastModified = new DateTimeOffset(); _ifMatchState = PreconditionState.Unspecified; _ifNoneMatchState = PreconditionState.Unspecified; _ifModifiedSinceState = PreconditionState.Unspecified; _ifUnmodifiedSinceState = PreconditionState.Unspecified; //再次判断请求HttpMethod if (HttpMethods.IsGet(_method)) { _requestType = RequestType.IsGet; } else if (HttpMethods.IsHead(_method)) { _requestType = RequestType.IsHead; } else { _requestType = RequestType.Unspecified; } } ////// 判断文件是否存在 /// public bool LookupFileInfo() { //判断根据请求路径是否可以获取到文件信息 _fileInfo = _fileProvider.GetFileInfo(_subPath.Value); if (_fileInfo.Exists) { //获取文件长度 _length = _fileInfo.Length; //最后修改日期 DateTimeOffset last = _fileInfo.LastModified; _lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime(); //ETag标识 long etagHash = _lastModified.ToFileTime() ^ _length; _etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"'); } return _fileInfo.Exists; } ////// 处理文件输出 /// public async Task ServeStaticFile(HttpContext context, RequestDelegate next) { //1.准备输出相关Header,主要是获取和输出静态文件输出缓存相关的内容 //2.我们之前提到的OnPrepareResponse也是在这里执行的 ComprehendRequestHeaders(); //根据ComprehendRequestHeaders方法获取到的文件状态进行判断 switch (GetPreconditionState()) { case PreconditionState.Unspecified: //处理文件输出 case PreconditionState.ShouldProcess: //判断是否是Head请求 if (IsHeadMethod) { await SendStatusAsync(Constants.Status200Ok); return; } try { //判断是否包含range请求,即文件分段下载的情况 if (IsRangeRequest) { await SendRangeAsync(); return; } //正常文件输出处理 await SendAsync(); _logger.FileServed(SubPath, PhysicalPath); return; } catch (FileNotFoundException) { context.Response.Clear(); } await next(context); return; case PreconditionState.NotModified: await SendStatusAsync(Constants.Status304NotModified); return; case PreconditionState.PreconditionFailed: await SendStatusAsync(Constants.Status412PreconditionFailed); return; default: var exception = new NotImplementedException(GetPreconditionState().ToString()); throw exception; } } ////// 通用文件文件返回处理 /// public async Task SendAsync() { SetCompressionMode(); ApplyResponseHeaders(Constants.Status200Ok); string physicalPath = _fileInfo.PhysicalPath; var sendFile = _context.Features.Get(); //判断是否设置过输出特征操作相关,比如是否启动输出压缩,或者自定义的输出处理比如输出加密等等 if (sendFile != null && !string.IsNullOrEmpty(physicalPath)) { await sendFile.SendFileAsync(physicalPath, 0, _length, CancellationToken.None); return; } try { //不存在任何特殊处理的操作作,直接读取文件返回 using (var readStream = _fileInfo.CreateReadStream()) { await StreamCopyOperation.CopyToAsync(readStream, _response.Body, _length, StreamCopyBufferSize, _context.RequestAborted); } } catch (OperationCanceledException ex) { _context.Abort(); } } /// /// 分段请求下载操作处理 /// internal async Task SendRangeAsync() { if (_range == null) { ResponseHeaders.ContentRange = new ContentRangeHeaderValue(_length); ApplyResponseHeaders(Constants.Status416RangeNotSatisfiable); _logger.RangeNotSatisfiable(SubPath); return; } //计算range相关header数据 ResponseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length); _response.ContentLength = length; //设置输出压缩相关header SetCompressionMode(); ApplyResponseHeaders(Constants.Status206PartialContent); string physicalPath = _fileInfo.PhysicalPath; var sendFile = _context.Features.Get(); //判断是否设置过输出特征操作相关,比如是否启动输出压缩,或者自定义的输出处理比如输出加密等等 if (sendFile != null && !string.IsNullOrEmpty(physicalPath)) { _logger.SendingFileRange(_response.Headers[HeaderNames.ContentRange], physicalPath); await sendFile.SendFileAsync(physicalPath, start, length, CancellationToken.None); return; } try { using (var readStream = _fileInfo.CreateReadStream()) { readStream.Seek(start, SeekOrigin.Begin); _logger.CopyingFileRange(_response.Headers[HeaderNames.ContentRange], SubPath); //设置文件输出起始位置和读取长度 await StreamCopyOperation.CopyToAsync(readStream, _response.Body, length, _context.RequestAborted); } } catch (OperationCanceledException ex) { _context.Abort(); } } }
关的读取设置和处理,其此次是针对正常返回和分段返回的情况,在返回之前判断是否有对输出做特殊处理的情况,比如输出压缩或者自定义的其他输出操作的IHttpResponseBodyFeature,分段返回和正常返回相比主要是多了一部分关于Http头Content-Range相关的设置,对于读取本身其实只是读取的起始位置和读取长度的差别。
目录浏览允许在指定目录中列出目录里的文件及子目录。出于安全方面考虑默认情况下是关闭的可以通过UseDirectoryBrowser中间件开启指定目录浏览功能。通常情况下我们会这样使用
//启用默认目录浏览,即wwwroot app.UseDirectoryBrowser(); //或自定义指定目录浏览 var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/MyImages"); app.UseDirectoryBrowser(new DirectoryBrowserOptions { RequestPath = "/MyImages", FileProvider = fileProvider });
开启之后当我们访问https://
/MyImages地址的时候将会展示如下效果,通过一个表格展示目录里的文件信息等
找
到中间件注册类[点击查看DirectoryBrowserExtensions源码]
public static class DirectoryBrowserExtensions { public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app) { return app.UseMiddleware(); } public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app, string requestPath) { return app.UseDirectoryBrowser(new DirectoryBrowserOptions { RequestPath = new PathString(requestPath) }); } public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app, DirectoryBrowserOptions options) { return app.UseMiddleware (Options.Create(options)); } }
这个中间件启用的重载方法和UseStaticFiles类似最终都是在传递DirectoryBrowserOptions,接下来我们就看DirectoryBrowserOptions传递了哪些信息[点击查看DirectoryBrowserOptions源码]
public class DirectoryBrowserOptions : SharedOptionsBase { public DirectoryBrowserOptions() : this(new SharedOptions()) { } public DirectoryBrowserOptions(SharedOptions sharedOptions) : base(sharedOptions) { } ////// 目录格式化提供,默认是提供表格的形式展示,课自定义 /// public IDirectoryFormatter Formatter { get; set; } }
无独有偶这个类和StaticFileOptions一样也是集成自SharedOptionsBase类,多了IDirectoryFormatter操作,通过它我们可以自定义展示到页面的输出形式,接下来我们就重点看下DirectoryBrowserMiddleware中间件的实现
public class DirectoryBrowserMiddleware { private readonly DirectoryBrowserOptions _options; private readonly PathString _matchUrl; private readonly RequestDelegate _next; private readonly IDirectoryFormatter _formatter; private readonly IFileProvider _fileProvider; public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptionsoptions) : this(next, hostingEnv, HtmlEncoder.Default, options) { } public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, HtmlEncoder encoder, IOptions options) { _next = next; _options = options.Value; //默认是提供默认目录的访问程序 _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv); //默认传递的是HtmlDirectoryFormatter类型,也就是我们看到的输出表格的页面 _formatter = options.Value.Formatter ?? new HtmlDirectoryFormatter(encoder); _matchUrl = _options.RequestPath; } public Task Invoke(HttpContext context) { //1.IsGetOrHeadMethod判断是否为Get或Head请求 //2.TryMatchPath判断请求的路径和设置的路径是否可以匹配的上 //3.TryGetDirectoryInfo判断根据匹配出来的路径能否查找到真实的物理路径 if (context.GetEndpoint() == null && Helpers.IsGetOrHeadMethod(context.Request.Method) && Helpers.TryMatchPath(context, _matchUrl, forDirectory: true, subpath: out var subpath) && TryGetDirectoryInfo(subpath, out var contents)) { //判断请求路径是否是/为结尾 if (!Helpers.PathEndsInSlash(context.Request.Path)) { //如果不是以斜线结尾则重定向(个人感觉直接在服务端重定向就可以了,为啥还要返回浏览器在请求一次) context.Response.StatusCode = StatusCodes.Status301MovedPermanently; var request = context.Request; var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString); context.Response.Headers[HeaderNames.Location] = redirect; return Task.CompletedTask; } //返回展示目录的内容 return _formatter.GenerateContentAsync(context, contents); } return _next(context); } /// /// 根据请求路径匹配到物理路径信息是否存在,存在则返回路径信息 /// private bool TryGetDirectoryInfo(PathString subpath, out IDirectoryContents contents) { contents = _fileProvider.GetDirectoryContents(subpath.Value); return contents.Exists; } }
这个操作相对简单了许多,主要就是判断请求路径能否和预设置的路径匹配的到,如果匹配到则获取可以操作当前目录内容IDirectoryContents然后通过IDirectoryFormatter输出如何展示目录内容,关于IDirectoryFormatter的默认实现类HtmlDirectoryFormatter这里就不展示里面的代码了,逻辑非常的加单就是拼接成table的html代码然后输出,有兴趣的同学可自行查看源码[点击查看HtmlDirectoryFormatter源码],如果自定义的话规则也非常简单,主要看你想输出啥
public class TreeDirectoryFormatter: IDirectoryFormatter { public Task GenerateContentAsync(HttpContext context, IEnumerablecontents) { //遍历contents实现你想展示的方式 } }
然后在UseDirectoryBrowser的时候给Formatter赋值即可
app.UseDirectoryBrowser(new DirectoryBrowserOptions { Formatter = new TreeDirectoryFormatter() });
很多时候出于安全考虑或者其他原因我们想在访问某个目录的时候返回一个默认的页面或展示,这个事实我们就需要使用UseDefaultFiles中间件,当我们配置了这个中间件,如果命中了配置路径,那么会直接返回默认的页面信息,简单使用方式如下
//wwwroot目录访问展示默认文件 app.UseDefaultFiles(); //或自定义目录默认展示文件 var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles"); app.UseDefaultFiles(new DefaultFilesOptions { RequestPath = "/staticfiles", FileProvider = fileProvider });
老规矩,我们查看下注册UseDefaultFiles的源码[点击查看DefaultFilesExtensions源码]
public static class DefaultFilesExtensions { public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app) { return app.UseMiddleware(); } public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app, string requestPath) { return app.UseDefaultFiles(new DefaultFilesOptions { RequestPath = new PathString(requestPath) }); } public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app, DefaultFilesOptions options) { return app.UseMiddleware (Options.Create(options)); } }
使用方式和UseStaticFiles、UseDirectoryBrowser是一样,最终都是调用传递DefaultFilesOptions的方法,我们查看一下DefaultFilesOptions的大致实现[点击查看源码]
public class DefaultFilesOptions : SharedOptionsBase { public DefaultFilesOptions() : this(new SharedOptions()) { } public DefaultFilesOptions(SharedOptions sharedOptions) : base(sharedOptions) { //系统提供的默认页面的名称 DefaultFileNames = new List{ "default.htm", "default.html", "index.htm", "index.html", }; } /// /// 通过这个属性可以配置默认文件名称 /// public IListDefaultFileNames { get; set; } }
和之前的方法如出一辙,都是继承自SharedOptionsBase,通过DefaultFileNames我们可以配置默认文件的名称,默认是default.html/htm和index.html/htm。我们直接查看中间件DefaultFilesMiddleware的源码[点击查看源码]
public class DefaultFilesMiddleware { private readonly DefaultFilesOptions _options; private readonly PathString _matchUrl; private readonly RequestDelegate _next; private readonly IFileProvider _fileProvider; public DefaultFilesMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptionsoptions) { _next = next; _options = options.Value; _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv); _matchUrl = _options.RequestPath; } public Task Invoke(HttpContext context) { //1.我们使用UseDefaultFiles中间件的时候要置于UseRouting之上,否则就会不生效 //2.IsGetOrHeadMethod判断请求为Get或Head的情况下才生效 //3.TryMatchPath判断请求的路径和设置的路径是否可以匹配的上 if (context.GetEndpoint() == null && Helpers.IsGetOrHeadMethod(context.Request.Method) && Helpers.TryMatchPath(context, _matchUrl, forDirectory: true, subpath: out var subpath)) { //根据匹配路径获取物理路径对应的信息 var dirContents = _fileProvider.GetDirectoryContents(subpath.Value); if (dirContents.Exists) { //循环配置的默认文件名称 for (int matchIndex = 0; matchIndex < _options.DefaultFileNames.Count; matchIndex++) { string defaultFile = _options.DefaultFileNames[matchIndex]; //匹配配置的启用默认文件的路径+遍历到的默认文件名称的路径是否存在 var file = _fileProvider.GetFileInfo(subpath.Value + defaultFile); if (file.Exists) { //判断请求路径是否已"/"结尾,如果不是则从定向(这个点个人感觉可以改进) if (!Helpers.PathEndsInSlash(context.Request.Path)) { context.Response.StatusCode = StatusCodes.Status301MovedPermanently; var request = context.Request; var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString); context.Response.Headers[HeaderNames.Location] = redirect; return Task.CompletedTask; } //如果匹配的上,则将配置的启用默认文件的路径+遍历到的默认文件名称的路径组合成新的Path交给_next(context) //比如将组成类似这种路径/staticfiles/index.html向下传递 context.Request.Path = new PathString(context.Request.Path.Value + defaultFile); break; } } } } return _next(context); } }
这个中间件的实现思路也非常简单主要的工作就是,匹配配置的启用默认文件的路径+遍历到的默认文件名称的路径是否存在,如果匹配的上,则将配置的启用默认文件的路径+遍历到的默认文件名称的路径组合成新的Path(比如/staticfiles/index.html)交给后续的中间件去处理。这里值得注意的是UseDefaultFiles 必须要配合UseStaticFiles一起使用,而且注册位置要出现在UseStaticFiles之上。这也是为什么UseDefaultFiles只需要匹配到默认文件所在的路径并重新赋值给context.Request.Path既可的原因。
当然我们也可以自定义默认文件的名称,因为只要能匹配的到具体的文件既可
var defaultFilesOptions = new DefaultFilesOptions { RequestPath = "/staticfiles", FileProvider = fileProvider }; //我们可以清除掉系统默认的默认文件名称 defaultFilesOptions.DefaultFileNames.Clear(); defaultFilesOptions.DefaultFileNames.Add("mydefault.html"); app.UseDefaultFiles(defaultFilesOptions);
到此,相信大家对“ASP.NET Core静态文件怎么处理源码”有了更深的了解,不妨来实际操作一番吧!这里是创新互联建站,更多相关内容可以进入相关频道进行查询,关注我们,继续学习!