【.NET Core項目實戰-統一認證平臺】第十章 受權篇-客戶端受權

原文 【.NET Core項目實戰-統一認證平臺】第十章 受權篇-客戶端受權javascript

【.NET Core項目實戰-統一認證平臺】開篇及目錄索引

上篇文章介紹瞭如何使用Dapper持久化IdentityServer4(如下簡稱ids4)的信息,並實現了sqlservermysql兩種方式存儲,本篇將介紹如何使用ids4進行客戶端受權。html

.netcore項目實戰交流羣(637326624),有興趣的朋友能夠在羣裏交流討論。java

1、如何添加客戶端受權?

在瞭解如何進行客戶端受權時,咱們須要瞭解詳細的受權流程,在【.NET Core項目實戰-統一認證平臺】第八章 受權篇-IdentityServer4源碼分析一篇中我大概介紹了客戶端的受權方式,本篇再次回憶下客戶端的受權方式,老規則,上源碼。mysql

首先查看獲取token的方式,核心代碼以下。sql

private async Task<IEndpointResult> ProcessTokenRequestAsync(HttpContext context) { _logger.LogDebug("Start token request."); // 一、驗證客戶端及受權信息結果 var clientResult = await _clientValidator.ValidateAsync(context); if (clientResult.Client == null) { return Error(OidcConstants.TokenErrors.InvalidClient); } // 二、驗證請求結果 var form = (await context.Request.ReadFormAsync()).AsNameValueCollection(); _logger.LogTrace("Calling into token request validator: {type}", _requestValidator.GetType().FullName); var requestResult = await _requestValidator.ValidateRequestAsync(form, clientResult); if (requestResult.IsError) { await _events.RaiseAsync(new TokenIssuedFailureEvent(requestResult)); return Error(requestResult.Error, requestResult.ErrorDescription, requestResult.CustomResponse); } // 三、建立輸出結果 _logger.LogTrace("Calling into token request response generator: {type}", _responseGenerator.GetType().FullName); var response = await _responseGenerator.ProcessAsync(requestResult); await _events.RaiseAsync(new TokenIssuedSuccessEvent(response, requestResult)); LogTokens(response, requestResult); // 四、返回結果 _logger.LogDebug("Token request success."); return new TokenResult(response); }

咱們須要詳細分析下第一步客戶端受權信息是如何驗證的?核心代碼以下。數據庫

/// <summary> ///驗證客戶端受權結果 /// </summary> /// <param name="context">請求上下文</param> /// <returns></returns> public async Task<ClientSecretValidationResult> ValidateAsync(HttpContext context) { _logger.LogDebug("Start client validation"); var fail = new ClientSecretValidationResult { IsError = true }; //經過請求上下文和配置信息獲取校驗方式,從這裏咱們能夠知道客戶端請求的幾種方式。 var parsedSecret = await _parser.ParseAsync(context); if (parsedSecret == null) { await RaiseFailureEventAsync("unknown", "No client id found"); _logger.LogError("No client identifier found"); return fail; } // 根據客戶端ID獲取客戶端相關信息。(配合持久化篇查看) var client = await _clients.FindEnabledClientByIdAsync(parsedSecret.Id); if (client == null) { await RaiseFailureEventAsync(parsedSecret.Id, "Unknown client"); _logger.LogError("No client with id '{clientId}' found. aborting", parsedSecret.Id); return fail; } SecretValidationResult secretValidationResult = null; if (!client.RequireClientSecret || client.IsImplicitOnly()) { _logger.LogDebug("Public Client - skipping secret validation success"); } else { //校驗客戶端受權和請求的是否一致 secretValidationResult = await _validator.ValidateAsync(parsedSecret, client.ClientSecrets); if (secretValidationResult.Success == false) { await RaiseFailureEventAsync(client.ClientId, "Invalid client secret"); _logger.LogError("Client secret validation failed for client: {clientId}.", client.ClientId); return fail; } } _logger.LogDebug("Client validation success"); var success = new ClientSecretValidationResult { IsError = false, Client = client, Secret = parsedSecret, Confirmation = secretValidationResult?.Confirmation }; await RaiseSuccessEventAsync(client.ClientId, parsedSecret.Type); return success; }

這裏幾個方法能夠從寫的說明備註裏就能夠知道什麼意思,可是 var parsedSecret = await _parser.ParseAsync(context);這句話可能很多人有疑問,這段是作什麼的?如何實現不一樣的受權方式?c#

這塊就須要繼續理解Ids4的實現思路,詳細代碼以下。後端

/// <summary> /// 檢查上下文獲取受權信息 /// </summary> /// <param name="context">The HTTP context.</param> /// <returns></returns> public async Task<ParsedSecret> ParseAsync(HttpContext context) { // 遍歷全部的客戶端受權獲取方式,提取當前哪個知足需求 ParsedSecret bestSecret = null; foreach (var parser in _parsers) { var parsedSecret = await parser.ParseAsync(context); if (parsedSecret != null) { _logger.LogDebug("Parser found secret: {type}", parser.GetType().Name); bestSecret = parsedSecret; if (parsedSecret.Type != IdentityServerConstants.ParsedSecretTypes.NoSecret) { break; } } } if (bestSecret != null) { _logger.LogDebug("Secret id found: {id}", bestSecret.Id); return bestSecret; } _logger.LogDebug("Parser found no secret"); return null; }

就是從注入的默認實現裏檢測任何一個實現ISecretParser接口方法,經過轉到實現,能夠發現有PostBodySecretParser、JwtBearerClientAssertionSecretParser、BasicAuthenticationSecretParser三種方式,而後再查看下注入方法,看那些實現被默認注入了,這樣就清楚咱們使用Ids4時支持哪幾種客戶端受權方式。api

/// <summary> /// 添加默認的受權分析 /// </summary> /// <param name="builder">The builder.</param> /// <returns></returns> public static IIdentityServerBuilder AddDefaultSecretParsers(this IIdentityServerBuilder builder) { builder.Services.AddTransient<ISecretParser, BasicAuthenticationSecretParser>(); builder.Services.AddTransient<ISecretParser, PostBodySecretParser>(); return builder; }

從上面代碼能夠發現,默認注入了兩種分析器,咱們就能夠經過這兩個方式來作客戶端的受權,下面會分別演示兩種受權方式的實現。緩存

  1. BasicAuthenticationSecretParser

    public Task<ParsedSecret> ParseAsync(HttpContext context) { _logger.LogDebug("Start parsing Basic Authentication secret"); var notfound = Task.FromResult<ParsedSecret>(null); var authorizationHeader = context.Request.Headers["Authorization"].FirstOrDefault(); if (authorizationHeader.IsMissing()) { return notfound; } if (!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { return notfound; } var parameter = authorizationHeader.Substring("Basic ".Length); string pair; try { pair = Encoding.UTF8.GetString( Convert.FromBase64String(parameter)); } catch (FormatException) { _logger.LogWarning("Malformed Basic Authentication credential."); return notfound; } catch (ArgumentException) { _logger.LogWarning("Malformed Basic Authentication credential."); return notfound; } var ix = pair.IndexOf(':'); if (ix == -1) { _logger.LogWarning("Malformed Basic Authentication credential."); return notfound; } var clientId = pair.Substring(0, ix); var secret = pair.Substring(ix + 1); if (clientId.IsPresent()) { if (clientId.Length > _options.InputLengthRestrictions.ClientId || (secret.IsPresent() && secret.Length > _options.InputLengthRestrictions.ClientSecret)) { _logger.LogWarning("Client ID or secret exceeds allowed length."); return notfound; } var parsedSecret = new ParsedSecret { Id = Decode(clientId), Credential = secret.IsMissing() ? null : Decode(secret), Type = IdentityServerConstants.ParsedSecretTypes.SharedSecret }; return Task.FromResult(parsedSecret); } _logger.LogDebug("No Basic Authentication secret found"); return notfound; }

    因爲代碼比較簡單,就不介紹了,這裏直接模擬此種方式受權,打開PostMan,在Headers中增長Authorization的Key,並設置Value爲Basic YXBwY2xpZW50JTNBc2VjcmV0,其中Basic後爲client_id:client_secret值使用Base64加密。而後請求後顯示如圖所示結果,奈斯,獲得咱們受權的結果。

  2. PostBodySecretParser

    public async Task<ParsedSecret> ParseAsync(HttpContext context) { _logger.LogDebug("Start parsing for secret in post body"); if (!context.Request.HasFormContentType) { _logger.LogDebug("Content type is not a form"); return null; } var body = await context.Request.ReadFormAsync(); if (body != null) { var id = body["client_id"].FirstOrDefault(); var secret = body["client_secret"].FirstOrDefault(); // client id must be present if (id.IsPresent()) { if (id.Length > _options.InputLengthRestrictions.ClientId) { _logger.LogError("Client ID exceeds maximum length."); return null; } if (secret.IsPresent()) { if (secret.Length > _options.InputLengthRestrictions.ClientSecret) { _logger.LogError("Client secret exceeds maximum length."); return null; } return new ParsedSecret { Id = id, Credential = secret, Type = IdentityServerConstants.ParsedSecretTypes.SharedSecret }; } else { // client secret is optional _logger.LogDebug("client id without secret found"); return new ParsedSecret { Id = id, Type = IdentityServerConstants.ParsedSecretTypes.NoSecret }; } } } _logger.LogDebug("No secret in post body found"); return null; }

    此種認證方式就是從form_data提取client_idclient_secret信息,咱們使用PostMan繼續模擬客戶端受權,測試結果以下,也能夠獲得咱們想要的結果。

有了前面的兩個受權方式,咱們清楚了首先驗證客戶端的受權信息是否一致,再繼續觀察後續的執行流程,這時會發現TokenRequestValidator中列出了客戶端受權的其餘信息驗證,詳細定義代碼以下。

switch (grantType) { case OidcConstants.GrantTypes.AuthorizationCode: return await RunValidationAsync(ValidateAuthorizationCodeRequestAsync, parameters); //客戶端受權 case OidcConstants.GrantTypes.ClientCredentials: return await RunValidationAsync(ValidateClientCredentialsRequestAsync, parameters); case OidcConstants.GrantTypes.Password: return await RunValidationAsync(ValidateResourceOwnerCredentialRequestAsync, parameters); case OidcConstants.GrantTypes.RefreshToken: return await RunValidationAsync(ValidateRefreshTokenRequestAsync, parameters); default: return await RunValidationAsync(ValidateExtensionGrantRequestAsync, parameters); }

詳細的受權驗證代碼以下,校驗客戶端受權的通常規則。

private async Task<TokenRequestValidationResult> ValidateClientCredentialsRequestAsync(NameValueCollection parameters)
{
    _logger.LogDebug("Start client credentials token request validation"); ///////////////////////////////////////////// // 校驗客戶端Id是否開啓了客戶端受權 ///////////////////////////////////////////// if (!_validatedRequest.Client.AllowedGrantTypes.ToList().Contains(GrantType.ClientCredentials)) { LogError("{clientId} not authorized for client credentials flow, check the AllowedGrantTypes of the client", _validatedRequest.Client.ClientId); return Invalid(OidcConstants.TokenErrors.UnauthorizedClient); } ///////////////////////////////////////////// // 校驗客戶端是否有請求的scopes權限 ///////////////////////////////////////////// if (!await ValidateRequestedScopesAsync(parameters, ignoreImplicitIdentityScopes: true, ignoreImplicitOfflineAccess: true)) { return Invalid(OidcConstants.TokenErrors.InvalidScope); } if (_validatedRequest.ValidatedScopes.ContainsOpenIdScopes) { LogError("{clientId} cannot request OpenID scopes in client credentials flow", _validatedRequest.Client.ClientId); return Invalid(OidcConstants.TokenErrors.InvalidScope); } if (_validatedRequest.ValidatedScopes.ContainsOfflineAccessScope) { LogError("{clientId} cannot request a refresh token in client credentials flow", _validatedRequest.Client.ClientId); return Invalid(OidcConstants.TokenErrors.InvalidScope); } _logger.LogDebug("{clientId} credentials token request validation success", _validatedRequest.Client.ClientId); return Valid(); }

最終輸出詳細的校驗結果數據,如今整個客戶端受權的完整邏輯已經介紹完畢,那如何添加咱們的自定義客戶端受權呢?好比我要給客戶端A開放一個訪問接口訪問權限,下面就開通客戶端A爲案例講解。

開通客戶端受權

根據前面介紹的驗證流程,咱們清楚首先須要增長客戶端信息,這裏起名叫clienta,密碼設置成secreta。上一篇咱們介紹了Dapper持久化IdentityServer4的受權信息,因此這裏我就直接以SQL語句的方式來演示添加配置信息。詳細的語句以下:

/* 添加客戶端腳本 */ --一、添加客戶端信息 INSERT INTO Clients(AccessTokenLifetime,ClientId,ClientName,Enabled) VALUES(3600,'clienta','測試客戶端A',1); --二、添加客戶端密鑰,密碼爲(secreta) sha256 INSERT INTO ClientSecrets VALUES(21,'',null,'SharedSecret','2tytAAysa0zaDuNthsfLdjeEtZSyWw8WzbzM8pfTGNI='); --三、增長客戶端受權權限 INSERT INTO ClientGrantTypes VALUES(21,'client_credentials'); --四、增長客戶端可以訪問scope INSERT INTO ClientScopes VALUES(21,'mpc_gateway');

而後咱們來測試下新開通的客戶端受權,以下圖所示,能夠正常獲取受權信息了,另一種Basic受權方式可自行測試。

2、如何配合網關認證和受權?

前面使用的是項目本身進行驗證的,正式項目運行時,咱們會把請求放到網關中,統一由網關進行認證和受權等操做,內部api無需再次進行認證和受權,那如何實現網關認證和受權呢?

咱們能夠回憶下以前介紹網關篇時認證篇章,裏面介紹的很是清楚。這裏咱們參照剛纔添加的客戶端A爲案例增長網關受權,由於咱們對外暴露的是網關地址,而不是內部具體認證項目地址。

一、添加網關受權路由

本項目的網關端口爲7777,因此網關受權的地址爲http://localhost:7777/connect/token,因爲爲添加網關路由,直接訪問報401,咱們首先增長網關的路由信息。

-- 一、插入認證路由(使用默認分類) insert into AhphReRoute values(1,'/connect/token','[ "POST" ]','','http','/connect/token','[{"Host": "localhost","Port": 6611 }]', '','','','','','','',0,1); --二、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,3) --三、增長認證配置地址路由 insert into AhphReRoute values(1,'/.well-known/openid-configuration','[ "GET" ]','','http','/.well-known/openid-configuration','[{"Host": "localhost","Port": 6611 }]', '','','','','','','',0,1); --四、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,4); --五、增長認證配置地址路由 insert into AhphReRoute values(1,'/.well-known/openid-configuration/jwks','[ "GET" ]','','http','/.well-known/openid-configuration/jwks','[{"Host": "localhost","Port": 6611 }]', '','','','','','','',0,1); --六、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,5);

經過PostMan測試,能夠獲得咱們預期的受權信息結果。

而後繼續訪問咱們以前配置的受權路由,提示401未受權,這塊就涉及到前面網關篇的知識了,由於咱們的網關增長了受權,因此須要增長客戶端受權才能訪問。

二、添加客戶端受權訪問

還記得是如何添加客戶端受權的嗎?詳細介紹參考[【.NET Core項目實戰-統一認證平臺】第六章 網關篇-自定義客戶端受權 ,我直接把受權的腳本編寫以下:

--七、插入把客戶端加入測試路由組2 INSERT INTO AhphClientGroup VALUES(21,2)

使用咱們剛受權的信息,再次訪問以前配置的須要認證的路由,能夠獲得咱們預期的結果,奈斯,和網關篇的內容徹底一致。

注意:在配置完信息後須要清理緩存,由於咱們以前作網關時,不少配置信息的讀取使用了緩存。

3、如何統一輸出結果?

做爲一塊準備應用到生產環境的產品,可能爲各類第三方提供應用支持,那麼統一的輸出結果是必需要實現的,好比咱們使用微信sdk或其餘第三方sdk時,會發現它們都會列出出現錯誤的統一提示,由標識代碼和說明組成,這裏咱們就須要解決如何標準化輸出問題,本身業務系統輸出標準結果很容易,由於都是本身控制的結果輸出,那麼咱們網關集成Ocelot、認證集成IdentityServer4,這兩塊如何進行標準化輸出呢?

那開始咱們的改造之旅吧,首先咱們要明確若是遇到錯誤如何進行輸出,咱們定義一個輸出基類BaseResult,詳細的定義以下:

/// <summary> /// 金焰的世界 /// 2018-12-10 /// 信息輸出基類 /// </summary> public class BaseResult { public BaseResult(int _errCode,string _errMsg) { errCode = _errCode; errMsg = _errMsg; } public BaseResult() { } /// <summary> /// 錯誤類型標識 /// </summary> public int errCode { get; set; } /// <summary> /// 錯誤類型說明 /// </summary> public string errMsg { get; set; } } /// <summary> /// 金焰的世界 /// 2018-12-10 /// 默認成功結果 /// </summary> public class SuccessResult : BaseResult { public SuccessResult() : base(0, "成功") { } }

一、網關默認輸出改造

網關這段須要改造錯誤提示的代碼和內容以及異常的輸出結果,首先改造錯誤狀況的輸出結果,使用BaseResult統一輸出,這裏就須要重寫輸出中間件ResponderMiddleware,下面就開始重寫之旅吧。

新增自定義輸出中間件CzarResponderMiddleware,詳細代碼以下:

using Czar.Gateway.Configuration; using Microsoft.AspNetCore.Http; using Ocelot.Errors; using Ocelot.Logging; using Ocelot.Middleware; using Ocelot.Responder; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; namespace Czar.Gateway.Responder.Middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 統一輸出中間件 /// </summary> public class CzarResponderMiddleware: OcelotMiddleware { private readonly OcelotRequestDelegate _next; private readonly IHttpResponder _responder; private readonly IErrorsToHttpStatusCodeMapper _codeMapper; public CzarResponderMiddleware(OcelotRequestDelegate next, IHttpResponder responder, IOcelotLoggerFactory loggerFactory, IErrorsToHttpStatusCodeMapper codeMapper ) : base(loggerFactory.CreateLogger<CzarResponderMiddleware>()) { _next = next; _responder = responder; _codeMapper = codeMapper; } public async Task Invoke(DownstreamContext context) { await _next.Invoke(context); if (context.IsError) {//自定義輸出結果 var errmsg = context.Errors[0].Message; int httpstatus = _codeMapper.Map(context.Errors); var errResult = new BaseResult() { errcode = httpstatus, errmsg = errmsg }; var message = errResult.ToJson(); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK; await context.HttpContext.Response.WriteAsync(message); return; } else if (context.HttpContext.Response.StatusCode != (int)HttpStatusCode.OK) {//標記失敗,不作任何處理,自定義擴展時預留 } else if (context.DownstreamResponse == null) {//添加若是管道強制終止,不作任何處理,修復未將對象實例化錯誤 } else {//繼續請求下游地址返回 Logger.LogDebug("no pipeline errors, setting and returning completed response"); await _responder.SetResponseOnHttpContext(context.HttpContext, context.DownstreamResponse); } } private void SetErrorResponse(HttpContext context, List<Error> errors) { var statusCode = _codeMapper.Map(errors); _responder.SetErrorResponseOnContext(context, statusCode); } } }

而後添加中間件擴展,代碼以下。

using Ocelot.Middleware.Pipeline; namespace Czar.Gateway.Responder.Middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 應用輸出中間件擴展 /// </summary> public static class CzarResponderMiddlewareExtensions { public static IOcelotPipelineBuilder UseCzarResponderMiddleware(this IOcelotPipelineBuilder builder) { return builder.UseMiddleware<CzarResponderMiddleware>(); } } }

最後使用此擴展來接管默認的輸出中間件,詳細代碼以下。

//builder.UseResponderMiddleware(); builder.UseCzarResponderMiddleware();

好了,網關統一輸出中間件就完成了,是否是很簡單呢?咱們來測試下效果吧,PostMan閃亮登場,

奈斯,這纔是咱們須要的結果,那如何異常會輸出什麼呢??咱們來模擬下結果,我直接在服務端拋出異常測試。

默認狀況會支持輸出異常的堆棧信息。那如何捕獲服務端異常信息呢?咱們須要瞭解在哪裏發送了後端請求,經過源碼分析,發現是由HttpRequesterMiddleware中間件作後端請求,這時咱們只須要改造下此中間件便可完成統一異常捕獲。改造核心代碼以下:

public async Task Invoke(DownstreamContext context) { var response = await _requester.GetResponse(context); if (response.IsError) { Logger.LogDebug("IHttpRequester returned an error, setting pipeline error"); SetPipelineError(context, response.Errors); return; } else if(response.Data.StatusCode != System.Net.HttpStatusCode.OK) {//若是後端未處理異常,設置異常信息,統一輸出,防止暴露敏感信息 var error = new InternalServerError($"請求服務異常"); Logger.LogWarning($"路由地址 {context.HttpContext.Request.Path} 請求服務異常. {error}"); SetPipelineError(context, error); return; } Logger.LogDebug("setting http response message"); context.DownstreamResponse = new DownstreamResponse(response.Data); }

修改測試後端服務代碼以下,

// GET api/values/5 [HttpGet("{id}")] public ActionResult<string> Get(int id) { throw new Exception("測試異常"); }

而後經過網關訪問路由地址http://localhost:7777/ctr/values/1,輸出爲{"errcode":500,"errmsg":"請求服務異常"},獲得了預期的全部目標,網關統一輸出所有改造完畢。

二、認證的統一輸出改造

這裏爲了統一風格,咱們先查看下Ids4的錯誤提示方式和輸出結果,而後配合源碼能夠發現到輸出都是繼承IEndpointResult接口,並定義了各類方式的輸出,且校驗失敗時,輸出的狀態碼都不是200,那麼咱們能夠從這裏下手,在網關層增長獨立的判斷,來兼容自定義的輸出。改造代碼以下:

using Czar.Gateway.Errors; using Newtonsoft.Json.Linq; using Ocelot.Logging; using Ocelot.Middleware; using Ocelot.Requester; using System.Threading.Tasks; namespace Czar.Gateway.Requester.Middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 自定義請求中間件 /// </summary> public class CzarHttpRequesterMiddleware : OcelotMiddleware { private readonly OcelotRequestDelegate _next; private readonly IHttpRequester _requester; public CzarHttpRequesterMiddleware(OcelotRequestDelegate next, IOcelotLoggerFactory loggerFactory, IHttpRequester requester) : base(loggerFactory.CreateLogger<CzarHttpRequesterMiddleware>()) { _next = next; _requester = requester; } public async Task Invoke(DownstreamContext context) { var response = await _requester.GetResponse(context); if (response.IsError) { Logger.LogDebug("IHttpRequester returned an error, setting pipeline error"); SetPipelineError(context, response.Errors); return; } else if(response.Data.StatusCode != System.Net.HttpStatusCode.OK) {//若是後端未處理異常,設置異常信息,統一輸出,防止暴露敏感信息 if (response.Data.StatusCode == System.Net.HttpStatusCode.BadRequest) {//提取Ids4相關的異常(400) var result = await response.Data.Content.ReadAsStringAsync(); JObject jobj = JObject.Parse(result); var errorMsg = jobj["error"]?.ToString(); var error = new IdentityServer4Error(errorMsg??"未知異常"); SetPipelineError(context, error); return; } else { var error = new InternalServerError($"請求服務異常"); Logger.LogWarning($"路由地址 {context.HttpContext.Request.Path} 請求服務異常. {error}"); SetPipelineError(context, error); return; } } Logger.LogDebug("setting http response message"); context.DownstreamResponse = new DownstreamResponse(response.Data); } } }

改造完成後,咱們隨時請求認證記錄,最終顯示效果以下。

奈斯,輸出風格統一啦,這樣就完美的解決了兩個組件的輸出問題,終於能夠開心的使用啦。

4、內容總結

本篇咱們詳細的介紹了客戶端受權的原理和支持的兩個受權的方式,並各自演示了調用方式,而後知道了如何在數據庫端新開通一個客戶端的信息,而後介紹了配合網關實現客戶端的受權和認證,並再次介紹了網關端的路由配置狀況,最後介紹瞭如何把網關和認證統一輸出格式,便於咱們在正式環境的使用,涉及內容比較多,若是中間實現的有不對的地方,也歡迎你們批評指正。

相關文章
相關標籤/搜索