1. 程式人生 > >【.NET Core專案實戰-統一認證平臺】第六章 閘道器篇-自定義客戶端授權

【.NET Core專案實戰-統一認證平臺】第六章 閘道器篇-自定義客戶端授權

原文: 【.NET Core專案實戰-統一認證平臺】第六章 閘道器篇-自定義客戶端授權

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

上篇文章我們介紹了閘道器使用Redis進行快取,並介紹瞭如何進行快取實現,快取資訊清理介面的使用。本篇我們將介紹如何實現閘道器自定義客戶端授權,實現可以為不同的接入客戶端設定不同的訪問許可權。

.netcore專案實戰交流群(637326624),有興趣的朋友可以在群裡交流討論。

一、功能描述

閘道器重點功能之一鑑權,需要實現對不同的客戶端進行授權訪問,禁止訪問未經授權的路由地址,且需要對無權訪問的請求,返回通用的格式。
比如閘道器有1-10個可用路由,客戶端A只能訪問1-5,客戶端B只能訪問6-10,這時我們就無法通過Ocelot

配置授權來進行自定義認證,這塊就需要我們增加自定義的認證管道來實現功能,儘量不影響閘道器已有的功能。

下面我們就該功能如何實現展開講解,希望大家先理解下功能需求,然後在延伸到具體實現。

二、資料庫設計

我在第三章 閘道器篇-資料庫儲存配置(1)中講解了我們閘道器配置資訊設計,本篇將在那個基礎上增加客戶端認證需要用到的表的相關設計,設計客戶端授權結構如下。其中客戶端使用的IdentityServer4客戶端表結構。

設計好概念模型後,我們生成物理模型,然後生成資料庫指令碼。

設計思想為可以新增自定義的授權組,為每一個授權分配能夠訪問的路由,然後為閘道器授權的客戶端分配一個或多個授權組,每次客戶端請求時,如果路由設定了授權訪問,就校驗客戶端是否存在路由訪問許可權,如果無訪問許可權,直接返回401未授權提醒。

感覺是不是很簡單呢?有了這個自定義的客戶端認證,那麼我們後端服務可以專注於自己的業務邏輯而無需再過多了進行許可權處理了。

三、功能實現

1、功能開啟配置

閘道器應該支援自定義客戶端授權中介軟體是否啟用,因為一些小型專案是不需要對每個客戶端進行單獨授權的,中型和大型專案才有可能遇到自定義配置情況,所以我們需要在配置檔案增加配置選項。在AhphOcelotConfiguration.cs配置類中增加屬性,預設不開啟,而且需要知道客戶端標識名稱。

/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 是否啟用客戶端授權,預設不開啟
/// </summary>
public bool ClientAuthorization { get; set; } = false;

/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 客戶端授權快取時間,預設30分鐘
/// </summary>
public int ClientAuthorizationCacheTime { get; set; } = 1800;
/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 客戶端標識,預設 client_id
/// </summary>
public string ClientKey { get; set; } = "client_id";

那我們如何把自定義的授權增加到閘道器流程裡呢?這塊我們就需要訂製自己的授權中介軟體。

2、實現客戶端授權中介軟體

首先我們定義一個自定義授權中介軟體AhphAuthenticationMiddleware,需要繼承OcelotMiddleware,然後我們要實現Invoke方法,詳細程式碼如下。

using Ctr.AhphOcelot.Configuration;
using Microsoft.AspNetCore.Http;
using Ocelot.Configuration;
using Ocelot.Logging;
using Ocelot.Middleware;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace Ctr.AhphOcelot.Authentication.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 自定義授權中介軟體
    /// </summary>
    public class AhphAuthenticationMiddleware : OcelotMiddleware
    {
        private readonly OcelotRequestDelegate _next;
        private readonly AhphOcelotConfiguration _options;
        private readonly IAhphAuthenticationProcessor _ahphAuthenticationProcessor;
        public AhphAuthenticationMiddleware(OcelotRequestDelegate next,
            IOcelotLoggerFactory loggerFactory,
            IAhphAuthenticationProcessor ahphAuthenticationProcessor,
            AhphOcelotConfiguration options)
            : base(loggerFactory.CreateLogger<AhphAuthenticationMiddleware>())
        {
            _next = next;
            _ahphAuthenticationProcessor = ahphAuthenticationProcessor;
            _options = options;
        }

        public async Task Invoke(DownstreamContext context)
        {
            if (!context.IsError && context.HttpContext.Request.Method.ToUpper() != "OPTIONS" && IsAuthenticatedRoute(context.DownstreamReRoute))
            {
                if (!_options.ClientAuthorization)
                {
                    Logger.LogInformation($"未啟用客戶端授權管道");
                    await _next.Invoke(context);
                }
                else
                {
                    Logger.LogInformation($"{context.HttpContext.Request.Path} 是認證路由. {MiddlewareName} 開始校驗授權資訊");
                    #region 提取客戶端ID
                    var clientId = "client_cjy";
                    var path = context.DownstreamReRoute.UpstreamPathTemplate.OriginalValue; //路由地址
                    var clientClaim = context.HttpContext.User.Claims.FirstOrDefault(p => p.Type == _options.ClientKey);
                    if (!string.IsNullOrEmpty(clientClaim?.Value))
                    {//從Claims中提取客戶端id
                        clientId = clientClaim?.Value;
                    }
                    #endregion
                    if (await _ahphAuthenticationProcessor.CheckClientAuthenticationAsync(clientId, path))
                    {
                        await _next.Invoke(context);
                    }
                    else
                    {//未授權直接返回錯誤
                        var errResult = new ErrorResult() { errcode=401, errmsg= "請求地址未授權" };
                        var message = errResult.ToJson();
                        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
                        await context.HttpContext.Response.WriteAsync(message);
                        return;
                    }
                }
            }
            else
            {
                await _next.Invoke(context);
            }

        }
        private static bool IsAuthenticatedRoute(DownstreamReRoute reRoute)
        {
            return reRoute.IsAuthenticated;
        }
    }
}

有了這個中介軟體,那麼如何新增到Ocelot的管道里呢?這裡就需要檢視Ocelot原始碼了,看是如何實現管道呼叫的,OcelotMiddlewareExtensions實現管道部分如下,BuildOcelotPipeline裡具體的流程。其實我在之前的Ocelot原始碼解讀裡也講解過原理了,奈斯,既然找到了,那麼我們就加入我們自定義的授權中介軟體即可。

public static async Task<IApplicationBuilder> UseOcelot(this IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var configuration = await CreateConfiguration(builder);

    ConfigureDiagnosticListener(builder);

    return CreateOcelotPipeline(builder, pipelineConfiguration);
}

private static IApplicationBuilder CreateOcelotPipeline(IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var pipelineBuilder = new OcelotPipelineBuilder(builder.ApplicationServices);

    pipelineBuilder.BuildOcelotPipeline(pipelineConfiguration);

    var firstDelegate = pipelineBuilder.Build();

    /*
            inject first delegate into first piece of asp.net middleware..maybe not like this
            then because we are updating the http context in ocelot it comes out correct for
            rest of asp.net..
            */

    builder.Properties["analysis.NextMiddlewareName"] = "TransitionToOcelotMiddleware";

    builder.Use(async (context, task) =>
                {
                    var downstreamContext = new DownstreamContext(context);
                    await firstDelegate.Invoke(downstreamContext);
                });

    return builder;
}

新增使用自定義授權中介軟體擴充套件AhphAuthenticationMiddlewareExtensions,程式碼如下。

using Ocelot.Middleware.Pipeline;
using System;
using System.Collections.Generic;
using System.Text;

namespace Ctr.AhphOcelot.Authentication.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 使用自定義授權中介軟體
    /// </summary>
    public static class AhphAuthenticationMiddlewareExtensions
    {
        public static IOcelotPipelineBuilder UseAhphAuthenticationMiddleware(this IOcelotPipelineBuilder builder)
        {
            return builder.UseMiddleware<AhphAuthenticationMiddleware>();
        }
    }
}

有了這個中介軟體擴充套件後,我們就在管道的合適地方加入我們自定義的中介軟體。我們新增我們自定義的管道擴充套件OcelotPipelineExtensions,然後把自定義授權中介軟體加入到認證之後。

using System;
using System.Threading.Tasks;
using Ctr.AhphOcelot.Authentication.Middleware;
using Ocelot.Authentication.Middleware;
using Ocelot.Authorisation.Middleware;
using Ocelot.Cache.Middleware;
using Ocelot.Claims.Middleware;
using Ocelot.DownstreamRouteFinder.Middleware;
using Ocelot.DownstreamUrlCreator.Middleware;
using Ocelot.Errors.Middleware;
using Ocelot.Headers.Middleware;
using Ocelot.LoadBalancer.Middleware;
using Ocelot.Middleware;
using Ocelot.Middleware.Pipeline;
using Ocelot.QueryStrings.Middleware;
using Ocelot.RateLimit.Middleware;
using Ocelot.Request.Middleware;
using Ocelot.Requester.Middleware;
using Ocelot.RequestId.Middleware;
using Ocelot.Responder.Middleware;
using Ocelot.WebSockets.Middleware;

namespace Ctr.AhphOcelot.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 閘道器管道擴充套件
    /// </summary>
    public static class OcelotPipelineExtensions
    {
        public static OcelotRequestDelegate BuildAhphOcelotPipeline(this IOcelotPipelineBuilder builder,
            OcelotPipelineConfiguration pipelineConfiguration)
        {
            // This is registered to catch any global exceptions that are not handled
            // It also sets the Request Id if anything is set globally
            builder.UseExceptionHandlerMiddleware();

            // If the request is for websockets upgrade we fork into a different pipeline
            builder.MapWhen(context => context.HttpContext.WebSockets.IsWebSocketRequest,
                app =>
                {
                    app.UseDownstreamRouteFinderMiddleware();
                    app.UseDownstreamRequestInitialiser();
                    app.UseLoadBalancingMiddleware();
                    app.UseDownstreamUrlCreatorMiddleware();
                    app.UseWebSocketsProxyMiddleware();
                });

            // Allow the user to respond with absolutely anything they want.
            builder.UseIfNotNull(pipelineConfiguration.PreErrorResponderMiddleware);

            // This is registered first so it can catch any errors and issue an appropriate response
            builder.UseResponderMiddleware();

            // Then we get the downstream route information
            builder.UseDownstreamRouteFinderMiddleware();

            //Expand other branch pipes
            if (pipelineConfiguration.MapWhenOcelotPipeline != null)
            {
                foreach (var pipeline in pipelineConfiguration.MapWhenOcelotPipeline)
                {
                    builder.MapWhen(pipeline);
                }
            }

            // Now we have the ds route we can transform headers and stuff?
            builder.UseHttpHeadersTransformationMiddleware();

            // Initialises downstream request
            builder.UseDownstreamRequestInitialiser();

            // We check whether the request is ratelimit, and if there is no continue processing
            builder.UseRateLimiting();

            // This adds or updates the request id (initally we try and set this based on global config in the error handling middleware)
            // If anything was set at global level and we have a different setting at re route level the global stuff will be overwritten
            // This means you can get a scenario where you have a different request id from the first piece of middleware to the request id middleware.
            builder.UseRequestIdMiddleware();

            // Allow pre authentication logic. The idea being people might want to run something custom before what is built in.
            builder.UseIfNotNull(pipelineConfiguration.PreAuthenticationMiddleware);

            // Now we know where the client is going to go we can authenticate them.
            // We allow the ocelot middleware to be overriden by whatever the
            // user wants
            if (pipelineConfiguration.AuthenticationMiddleware == null)
            {
                builder.UseAuthenticationMiddleware();
            }
            else
            {
                builder.Use(pipelineConfiguration.AuthenticationMiddleware);
            }

            //新增自定義授權中間 2018-11-15 金焰的世界
            builder.UseAhphAuthenticationMiddleware();

            // Allow pre authorisation logic. The idea being people might want to run something custom before what is built in.
            builder.UseIfNotNull(pipelineConfiguration.PreAuthorisationMiddleware);

            // Now we have authenticated and done any claims transformation we 
            // can authorise the request
            // We allow the ocelot middleware to be overriden by whatever the
            // user wants
            if (pipelineConfiguration.AuthorisationMiddleware == null)
            {
                builder.UseAuthorisationMiddleware();
            }
            else
            {
                builder.Use(pipelineConfiguration.AuthorisationMiddleware);
            }

            // Allow the user to implement their own query string manipulation logic
            builder.UseIfNotNull(pipelineConfiguration.PreQueryStringBuilderMiddleware);

            // Get the load balancer for this request
            builder.UseLoadBalancingMiddleware();

            // This takes the downstream route we retrieved earlier and replaces any placeholders with the variables that should be used
            builder.UseDownstreamUrlCreatorMiddleware();

            // Not sure if this is the best place for this but we use the downstream url 
            // as the basis for our cache key.
            builder.UseOutputCacheMiddleware();

            //We fire off the request and set the response on the scoped data repo
            builder.UseHttpRequesterMiddleware();

            return builder.Build();
        }

        private static void UseIfNotNull(this IOcelotPipelineBuilder builder,
            Func<DownstreamContext, Func<Task>, Task> middleware)
        {
            if (middleware != null)
            {
                builder.Use(middleware);
            }
        }
    }
}

有了這個自定義的管道擴充套件後,我們需要應用到閘道器啟動裡,修改我們建立管道的方法如下。

private static IApplicationBuilder CreateOcelotPipeline(IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var pipelineBuilder = new OcelotPipelineBuilder(builder.ApplicationServices);

    //pipelineBuilder.BuildOcelotPipeline(pipelineConfiguration);
    //使用自定義管道擴充套件 2018-11-15 金焰的世界
    pipelineBuilder.BuildAhphOcelotPipeline(pipelineConfiguration);

    var firstDelegate = pipelineBuilder.Build();

    /*
            inject first delegate into first piece of asp.net middleware..maybe not like this
            then because we are updating the http context in ocelot it comes out correct for
            rest of asp.net..
            */

    builder.Properties["analysis.NextMiddlewareName"] = "TransitionToOcelotMiddleware";

    builder.Use(async (context, task) =>
                {
                    var downstreamContext = new DownstreamContext(context);
                    await firstDelegate.Invoke(downstreamContext);
                });

    return builder;
}

現在我們完成了閘道器的擴充套件和應用,但是是否注意到了,我們的閘道器介面還未實現呢?什麼介面呢?

IAhphAuthenticationProcessor這個介面雖然定義了,但是一直未實現,現在開始我們要實現下這個介面,我們回看下我們使用這個介面的什麼方法,就是檢查客戶端是否有訪問路由的許可權。

3、結合資料庫實現校驗及快取

每次請求都需要校驗客戶端是否授權,如果不快取此熱點資料,那麼對閘道器開銷很大,所以我們需要增加快取。

新建AhphAuthenticationProcessor類來實現認證介面,程式碼如下。

using Ctr.AhphOcelot.Configuration;
using Ocelot.Cache;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace Ctr.AhphOcelot.Authentication
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 實現自定義授權處理器邏輯
    /// </summary>
    public class AhphAuthenticationProcessor : IAhphAuthenticationProcessor
    {
        private readonly IClientAuthenticationRepository _clientAuthenticationRepository;
        private readonly AhphOcelotConfiguration _options;
        private readonly IOcelotCache<ClientRoleModel> _ocelotCache;
        public AhphAuthenticationProcessor(IClientAuthenticationRepository clientAuthenticationRepository, AhphOcelotConfiguration options, IOcelotCache<ClientRoleModel> ocelotCache)
        {
            _clientAuthenticationRepository = clientAuthenticationRepository;
            _options = options;
            _ocelotCache = ocelotCache;
        }
        /// <summary>
        /// 校驗當前的請求地址客戶端是否有許可權訪問
        /// </summary>
        /// <param name="clientid">客戶端ID</param>
        /// <param name="path">請求地址</param>
        /// <returns></returns>
        public async Task<bool> CheckClientAuthenticationAsync(string clientid, string path)
        {
            var enablePrefix = _options.RedisKeyPrefix + "ClientAuthentication";
            var key = AhphOcelotHelper.ComputeCounterKey(enablePrefix, clientid, "", path);
            var cacheResult = _ocelotCache.Get(key, enablePrefix);
            if (cacheResult!=null)
            {//提取快取資料
                return cacheResult.Role;
            }
            else
            {//重新獲取認證資訊
                var result = await _clientAuthenticationRepository.ClientAuthenticationAsync(clientid, path);
                  //新增到快取裡
                  _ocelotCache.Add(key, new ClientRoleModel() { CacheTime = DateTime.Now,Role=result }, TimeSpan.FromMinutes(_options.ClientAuthorizationCacheTime), enablePrefix);
                return result;
            }
        }
    }
}

程式碼很簡單,就是從快取中查詢看是否有資料,如果存在直接返回,如果不存在,就從倉儲中提取訪問許可權,然後寫入快取,寫入快取的時間可由配置檔案寫入,預設為30分鐘,可自行根據業務需要修改。

現在我們還需要解決2個問題,這個中介軟體才能正常執行,第一IClientAuthenticationRepository介面未實現和注入;第二IOcelotCache<ClientRoleModel>未注入,那我們接下來實現這兩塊,然後就可以測試我們第一個中介軟體啦。

新建SqlServerClientAuthenticationRepository類,來實現IClientAuthenticationRepository介面,實現程式碼如下。

using Ctr.AhphOcelot.Authentication;
using Ctr.AhphOcelot.Configuration;
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Text;
using System.Threading.Tasks;
using Dapper;
namespace Ctr.AhphOcelot.DataBase.SqlServer
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-16
    /// 使用sqlserver實現客戶端授權倉儲
    /// </summary>
    public class SqlServerClientAuthenticationRepository : IClientAuthenticationRepository
    {
        private readonly AhphOcelotConfiguration _option;
        public SqlServerClientAuthenticationRepository(AhphOcelotConfiguration option)
        {
            _option = option;
        }
        /// <summary>
        /// 校驗獲取客戶端是否有訪問許可權
        /// </summary>
        /// <param name="clientid">客戶端ID</param>
        /// <param name="path">請求路由</param>
        /// <returns></returns>
        public async Task<bool> ClientAuthenticationAsync(string clientid, string path)
        {
            using (var connection = new SqlConnection(_option.DbConnectionStrings))
            {
                string sql = @"SELECT COUNT(1) FROM  AhphClients T1 INNER JOIN AhphClientGroup T2 ON T1.Id=T2.Id INNER JOIN AhphAuthGroup T3 ON T2.GroupId = T3.GroupId INNER JOIN AhphReRouteGroupAuth T4 ON T3.GroupId = T4.GroupId INNER JOIN AhphReRoute T5 ON T4.ReRouteId = T5.ReRouteId WHERE Enabled = 1 AND ClientId = @ClientId AND T5.InfoStatus = 1 AND UpstreamPathTemplate = @Path";
                var result= await connection.QueryFirstOrDefaultAsync<int>(sql, new { ClientId = clientid, Path = path });
                return result > 0;
            }
        }
    }
}

現在需要注入下實現,這塊應該都知道在哪裡加入了吧?沒錯ServiceCollectionExtensions擴充套件又用到啦,現在梳理下流程感覺是不是很清晰呢?

builder.Services.AddSingleton<IClientAuthenticationRepository, SqlServerClientAuthenticationRepository>();

builder.Services.AddSingleton<IAhphAuthenticationProcessor, AhphAuthenticationProcessor>();

再新增快取的注入實現,到此我們的第一個中介軟體全部新增完畢了,現在可以開始測試我們的中介軟體啦。

builder.Services.AddSingleton<IOcelotCache<ClientRoleModel>, InRedisCache<ClientRoleModel>>();

4、測試授權中介軟體

我們先在資料庫插入客戶端授權指令碼,指令碼如下。

--插入測試客戶端
INSERT INTO AhphClients(ClientId,ClientName) VALUES('client1','測試客戶端1')
INSERT INTO AhphClients(ClientId,ClientName) VALUES('client2','測試客戶端2')
--插入測試授權組
INSERT INTO AhphAuthGroup VALUES('授權組1','只能訪問/cjy/values路由',1);
INSERT INTO AhphAuthGroup VALUES('授權組2','能訪問所有路由',1);

--插入測試組許可權
INSERT INTO AhphReRouteGroupAuth VALUES(1,1);

INSERT INTO AhphReRouteGroupAuth VALUES(2,1);
INSERT INTO AhphReRouteGroupAuth VALUES(2,2);

--插入客戶端授權
INSERT INTO AhphClientGroup VALUES(1,1);
INSERT INTO AhphClientGroup VALUES(2,2);

--設定測試路由只有授權才能訪問
UPDATE AhphReRoute SET AuthenticationOptions='{"AuthenticationProviderKey": "TestKey"}' WHERE ReRouteId IN(1,2);

這塊設定了客戶端2可以訪問路由/cjy/values,客戶端1可以訪問路由/cjy/values 和 /ctr/values/{id},開始使用PostMan來測試這個中介軟體看是否跟我設定的一毛一樣,各種dotnet run啟動吧。啟動前別忘了在我們閘道器配置檔案裡,設定啟動客戶端授權 option.ClientAuthorization = true;,是不是很簡單呢?

為了測試授權效果,我們需要把閘道器專案增加認證,詳細看程式碼,裡面就是定義了授權認證,啟動我們預設的認證地址。

var authenticationProviderKey = "TestKey";
Action<IdentityServerAuthenticationOptions> gatewayoptions = o =>
{
o.Authority = "http://localhost:6611";
o.ApiName = "gateway";
o.RequireHttpsMetadata = false;
};

services.AddAuthentication()
.AddIdentityServerAuthentication(authenticationProviderKey, gatewayoptions);

測試結果如下,達到我們預期目的。



終於完成了我們的自定義客戶端授權啦,此處應該掌聲不斷。


5、增加mysql支援

看過我前面的文章應該知道,支援mysql太簡單啦,直接重寫IClientAuthenticationRepository實現,然後注入到UseMySql裡,問題就解決啦。感覺是不是不可思議,這就是.netcore的魅力,簡單到我感覺到我再貼程式碼就是侮辱智商一樣。

6、重構認證失敗輸出,保持與Ocelot一致風格

前面我們定義了未授權使用自定義的ClientRoleModel輸出,最後發現這樣太不優雅啦,我們需要簡單重構下,來保持與Ocelot預設管道一致風格,修改程式碼如下。

//var errResult = new ErrorResult() { errcode=401, errmsg= "請求地址未授權" };
//var message = errResult.ToJson();
//context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
//await context.HttpContext.Response.WriteAsync(message);
//return;
var error = new UnauthenticatedError($"請求認證路由 {context.HttpContext.Request.Path}客戶端未授權");
Logger.LogWarning($"路由地址 {context.HttpContext.Request.Path} 自定義認證管道校驗失敗. {error}");
SetPipelineError(context, error);

再測試下未授權,返回狀態為401,強迫症患者表示舒服多了。

四、總結及預告

本篇我們講解的是閘道器如何實現自定義客戶端授權功能,從設計到實現一步一步詳細講解,雖然只用一篇就寫完了,但是涉及的知識點還是非常多的,希望大家認真理解實現的思想,看我是如何從規劃到實現的,為了更好的幫助大家理解,從本篇開始,我的原始碼都是一個星期以後再開源,大家可以根據部落格內容自己手動實現下,有利於消化,如果在操作中遇到什麼問題,可以加.NET Core專案實戰交流群(QQ群號:637326624)諮詢作者。

下一篇開始講解自定義客戶端限流,在學習下篇前可以自己先了解下限流相關內容,然後自己試著實現看看,帶著問題學習可能事半功倍哦。