文章出處

ASP.NET Core管道雖然在結構組成上顯得非常簡單,但是在具體實現上卻涉及到太多的對象,所以我們在 《ASP.NET Core管道深度剖析[共4篇]》 中圍繞著一個經過極度簡化的模擬管道講述了真實管道構建的方式以及處理HTTP請求的流程。在這個系列 中,我們會還原構建模擬管道時刻意舍棄和改寫的部分,想讀者朋友們呈現一個真是的HTTP請求處理管道。

ASP.NET Core 的請求處理管道由一個Server和一組有序排列的中間件構成,前者僅僅完成基本的請求監聽、接收和響應的工作,請求接收之后和響應之前的所有工作都交給注冊的中間件來完成。ASP.NET Core的中間件通過一個類型Func<RequestDelegate, RequestDelegate>的委托對象來表示,而RequestDelegate也是一個委托,它代表一項請求處理任務。

目錄
一、一個重要的委托:RequestDelegate
二、描述當前請求的上下文:HttpContext
    封裝“特性”的容器:FeatureCollection
     HttpContext的默認實現:DefaultHttpContext
    上下文的創建者:HttpContextFactory
三、管道的注冊者和構建者:ApplicationBuilder
     ApplicationBuilder的創建者:ApplicationBuilderFactory

一、一個重要的委托:RequestDelegate

當Server接受到抵達的HTTP請求之后,會構建一個描述當前請求的原始上下文,Server的類型決定了這個原始上下文的類型,比如在我們模擬管道默認采用的HttpListenerServer由于采用HttpListener來監聽、接收并響應請求,所以它對應的原始上下文是一個HttpListenerContext對象。但是對于管道的后續部分,即由注冊的中間件構建的鏈表,它們需要采用統一的方式來處理請求,所以Server最終會根據原始的上下文來創建一個抽象的HTTP上下文,后者通過抽象類HttpContext來表示。

我們不僅可以利用這個HttpContext獲取描述當前請求的上下文信息,同樣可以利用它來實現對響應的控制。針對當前請求的任何處理操作總是在么一個上下文中進行,所以一項請求處理任務完全可以抽象成一個類型Func<HttpContext,Task>的委托來表示,實際上具有如下定義的RequestDelegate委托具有類似的定義。

   1: public delegate Task RequestDelegate(HttpContext context);

每個中間件都承載著獨立的請求處理任務,它本質上也體現了在當前HttpContext下針對請求的處理操作,那么為什么中間件不直接通過一個RequestDelegate對象來表示,而是表示為一個類型為Func<RequestDelegate, RequestDelegate>的委托對象呢?原因很簡單,中間件并不孤立地存在,所有注冊的中間件最終會根據注冊的先后順序組成一個鏈表,每個中間件不僅僅需要完成各自的請求處理任務外,還需要驅動鏈表中的下一個中間件。

clip_image002

如右圖所示,對于一個由多個Func<RequestDelegate, RequestDelegate>對象組成的中間鏈表來說,某個中間件會將后一個Func<RequestDelegate, RequestDelegate>對象的返回值作為輸入,而自身的返回值則作為前一個中間件的輸入。某個中間件執行之后返回的RequestDelegate對象不僅僅體現了自身對請求的處理操作,而是體現了包含自己和后續中間件一次對請求的處理。那么對于第一個中間件來說,它執行后返回的RequestDelegate對象實際上體現了整個應用對請求的處理邏輯。

二、描述當前請求的上下文:HttpContext

對當前上下文的抽象解除了管道對具體服務器類型的依賴, 這使我們為ASP.NET Core應用自由地選擇寄宿方式,而不是像傳統的ASP.NET應用一樣只能寄宿在IIS之中。抽象HTTP上下文的目的是為了實現對請求處理流程的抽象,只有這樣我們才能將針對請求的某項操作體現在一個標準的中間件上,有這個這個標準化的中間件才有所謂的請求處理管道。

ASP.NET Core通過具有如下所示的HttpContext類來表示這么一個抽象的HTTP上下文。對于一個HttpContext對象來說,它的核心體現在用于描述請求和響應的Request和Response屬性之上。除此之外,我們還可以通過獲取與當前請求相關的其他上下文信息,比如用來控制用戶認證的AuthenticationManager對象和代表當前請求用戶的ClaimsPrincipal對象,描述當前HTTP連接的ConnectionInfo對象和用于控制WebSocket的WebSocketManager。我們還可以獲取并控制當前會話,也可以獲取或者設置調試追蹤的ID。

   1: public abstract class HttpContext
   2: {
   3:  
   4:     public abstract HttpRequest     Request { get; }
   5:     public abstract HttpResponse    Response { get; }
   6:  
   7:     public abstract AuthenticationManager             Authentication { get; }
   8:     public abstract ClaimsPrincipal                   User { get; set; }
   9:     public abstract ConnectionInfo                    Connection { get; } 
  10:     public abstract WebSocketManager                  WebSockets { get; } 
  11:     public abstract ISession                          Session { get; set; } 
  12:     public abstract string                            TraceIdentifier { get; set; }
  13:     public abstract CancellationToken                 RequestAborted { get; set; }  
  14:     public abstract IDictionary<object, object>       Items { get; set; }  
  15:  
  16:     public abstract IServiceProvider        RequestServices { get; set; }
  17:     public abstract IFeatureCollection      Features { get; }
  18: }

當需要中指對請求的處理時,我們可以通過為RequestAborted屬性設置一個CancellationToken對象從而將終止通知發送給管道。如果需要對整個管道共享一些與當前上下文相關的數據,我們可以將它保存在通過Items屬性表示的字典中。我們一再提到依賴注入被廣泛地應用ASP.NET Core管道中,HttpContext的RequestServices屬性返回利用應用啟動時設置的服務注冊信息創建的ServiceProvider,只要相應的服務被預先注冊到指定的服務接口上,我們就可能利用這個ServiceProvider根據這個接口得到對應的服務對象。

   1: public abstract class HttpRequest
   2: {
   3:     public abstract HttpContext         HttpContext { get; }
   4:     public abstract string              Method { get; set; }
   5:     public abstract string              Scheme { get; set; }
   6:     public abstract bool                IsHttps { get; set; }
   7:     public abstract HostString          Host { get; set; }
   8:     public abstract PathString          PathBase { get; set; }
   9:     public abstract PathString          Path { get; set; }
  10:     public abstract QueryString         QueryString { get; set; }
  11:     public abstract IQueryCollection    Query { get; set; }
  12:     public abstract string              Protocol { get; set; }
  13:     public abstract IHeaderDictionary   Headers { get; } >
  14:     public abstract IRequestCookieCollection      Cookies { get; set; }
  15:     public abstract string                        ContentType { get; set; }
  16:     public abstract Stream                        Body { get; set; }
  17:     public abstract bool                          HasFormContentType { get; }
  18:     public abstract IFormCollection               Form { get; set; }
  19:  
  20:     public abstract Task<IFormCollection> ReadFormAsync(CancellationToken cancellationToken);
  21: }

在了解HttpContext表示請求的抽象類HttpRequest之后,我們再來認識一個與之相對的HttpResponse類型。如下面的代碼片斷所示,HttpResponse依然是一個抽象類,我們可以通過定義在它之上的屬性和方法來控制對請求的響應。從原則上講,我們對請求的所做的任意類型的響應都可以利用它來說實現。

   1: public abstract class HttpResponse
   2: {
   3:     public abstract HttpContext           HttpContext { get; }
   4:     public abstract int                   StatusCode { get; set; }
   5:     public abstract IHeaderDictionary     Headers { get; }
   6:     public abstract Stream                Body { get; set; }
   7:     public abstract long?                 ContentLength { get; set; }
   8:     public abstract IResponseCookies      Cookies { get; }
   9:     public abstract bool                  HasStarted { get; }
  10:  
  11:     public abstract void OnStarting(Func<object, Task> callback, object state);
  12:     public virtual void OnStarting(Func<Task> callback);
  13:     public abstract void OnCompleted(Func<object, Task> callback, object state);
  14:     public virtual void RegisterForDispose(IDisposable disposable);
  15:     public virtual void OnCompleted(Func<Task> callback);
  16:     public virtual void Redirect(string location);
  17:     public abstract void Redirect(string location, bool permanent);
  18: }

當我們通過表示當前上下文的HttpContext對象得到表示響應的HttpResponse之后,我們可以不僅僅將希望的內容寫入響應消息的主體,還可以設置響應狀態碼以及添加相應的首部。表2列出了定義在HttpResponse中的所有屬性和方法所代表的含義。

封裝“特性”的容器:FeatureCollection

HttpContext的另一個只讀屬性Features體現了HTTP上下文抽象的實現方式。Server在接收到請求之后會創建一個原始的上下文,管道不僅僅利用這個原始上下文來獲取與請求相關的信息,它對請求的最終響應實際上也是通過這個原始上下文來完成的。所以對一個HttpContext對象來說,有它描述的上下文信息不僅僅來源于這個原始的上下文,我們針對HttpContext所做的任何響應操作最終都需要分發給這個原始上下文來完成, 否則是不會生效的。HttpContext和由Server創建的原始上下文之間的“雙向綁定”究竟是如何實現的呢?

這個所謂的“雙向綁定”即使其實很簡單。當原始上下文被創建出來之后,Server會將它封裝成一系列標準的特性對象,HttpContext正式針對這些特性對象而創建的。這些對象所對應的類型均實現了標準的接口,接口中不僅僅定義相應的屬性來讀寫原始上下文中描述的信息,還定義了相應的方法來操作原始上下文。HttpContext的屬性Features返回的就是這組特性對象的集合,它的返回類型為IFeatureCollection,我們將實現了該接口的類型以及對應的對象統稱為FeatureCollection。

   1: public interface IFeatureCollection : IEnumerable<KeyValuePair<Type, object>>
   2: {
   3:     TFeature Get<TFeature>();
   4:     void Set<TFeature>(TFeature instance);
   5:  
   6:     bool       IsReadOnly { get; }
   7:     object     this[Type key] { get; set; }
   8:     int        Revision { get; }
   9: }

一個FeatureCollection對象本質上就是一個Key和Value分別為Type和Object類型的字段。我們通過調用Set方法將一個特性對象針對指定的類型(一般為特性接口)注冊到這個字典對象上,并通過Get方法根據注冊的類型獲取它,特性對象的注冊和獲取也可以利用定義的索引來完成。如果IsReadOnly屬性返回True,我們將不能注冊新的特性或者修改已經注冊的特性。 整數類型的之都屬性Revision可以視為整個FeatureCollection對象的版本,不論是采用何種方式注冊新的特性還是修改現有的特性,這個屬性的值都將改變。

具有如下定義的FeatureCollection類實現了IFeatureCollection接口,我們默認使用的FeatureCollection就是這么一個類型的對象。FeatureCollection具有兩個構造函數重載,默認無參構造函數幫助我們創建一個空的特性集合,另一個構造函數則需要指定一個FeatureCollection對象來提供默認特性。對于采用第二個構造函數重載創建的 FeatureCollection對象來說,如果我們通過指定某個特性接口類型試圖獲取對應的特性對象時,如果對應的特性沒有注冊到當前FeatureCollection對象上,而是注冊到提供默認特性的FeatureCollection對象上,后者將會與提供最終的特性。

   1: public class FeatureCollection : IFeatureCollection
   2: {   
   3:     //其他成員
   4:     public FeatureCollection();
   5:     public FeatureCollection(IFeatureCollection defaults);
   6: }

對于FeatureCollection類型來說,它 的IsReadOnly總是返回False,所以它永遠是可讀可寫的。對于調用默認無參構造函數創建的FeatureCollection對象來說,它 的Revision默認返回零。如果我們通過指定另一個FeatureCollection對象為參數調用第二個構造函數來創建一個FeatureCollection對象,前者的Revision屬性值將成為后者同名屬性的默認值。不論我們采用何種形式(調用Set方法或者索引)添加一個新的特性或者改變了一個已經注冊的特性,FeatureCollection對象的Revision屬性都將自動遞增。上述的這些關于FeatureCollection的特性都體現在如下所示的代碼片段中。

   1: FeatureCollection defaults = new FeatureCollection();
   2: Debug.Assert(defaults.Revision == 0);
   3:  
   4: defaults.Set<IFoo>(new Foo());
   5: Debug.Assert(defaults.Revision == 1);
   6:  
   7: defaults[typeof(IBar)] = new Bar();
   8: Debug.Assert(defaults.Revision == 2);
   9:  
  10: FeatureCollection features = new FeatureCollection(defaults);
  11: Debug.Assert(features.Revision == 2);
  12: Debug.Assert(features.Get<IFoo>().GetType() == typeof(Foo));
  13:  
  14: features.Set<IBaz>(new Baz());
  15: Debug.Assert(features.Revision == 3);

HttpContext的默認實現:DefaultHttpContext

ASP.NET Core默認使用的HttpContext類型為DefaultHttpContext,上面我們介紹的針對描述原始上下文“特性集合”來創建HttpContext的策略就體現在這個類型之上。DefaultHttpContext具有一個如下的構造函數,作為參數的FeatureCollection對象就是這么一個特性集合。

   1: public class DefaultHttpContext : HttpContext
   2: {
   3:     public DefaultHttpContext(IFeatureCollection features);
   4: }

不論是對于組成管道的中間件還是建立在管道上的應用,在默認的情況下都是利用這個DefaultHttpContext對象來獲取請求的相關信息,同時也是利用這個對象來控制最終發送的響應。但是DefaultHttpContext對象這個這個過程中僅僅是一個“代理”,針對它的調用(屬性或者方法)最終都需要轉發給由具體Server創建的那個原始上下文,在構造函數中指定的這個FeatureCollection對象所代表的特性集合成為了兩者溝通的唯一渠道。對應定義在DefaultHttpContext中的所有屬性,它們幾乎都具有一個對應的特性,這些特性都對應著一個接口。表3列出了部分特性接口以及DefaultHttpContext對應的屬性。

描述原始HTTP上下文的特性接口

接口

屬性

描述

IHttpRequestFeature

Request

獲取描述請求的基本信息

IHttpResponseFeature

Response

控制對請求的響應

IHttpAuthenticationFeature

AuthenticationManger/User

提供完成用戶認證的AuthenticationHandler對象和表示當前用戶的ClaimsPrincipal對象

IHttpConnectionFeature

Connection

提供描述當前HTTP連接的基本信息。

IItemsFeature

Items

提供用戶存放針對當前請求的對象容器。

IHttpRequestLifetimeFeature

RequestAborted

傳遞請求處理取消通知和中止當前請求處理。

IServiceProvidersFeature

RequestServices

提供根據服務注冊創建的ServiceProvider。

ISessionFeature

Session

提供描述當前會話的Session對象。

IHttpRequestIdentifierFeature

TraceIdentifier

為追蹤日志(Trace)提供針對當前請求的唯一標識。

IHttpWebSocketFeature

WebSockets

管理WebSocket

對于上面列出的眾多特性接口,在后續相關章節中都會涉及到,所以我們只需要了解一下兩個最重要的特性接口,即表示請求和響應的IHttpRequestFeature和IHttpResponseFeature。從下面給出的代碼片斷我們不難看出,這兩個接口的定義分別與抽象類HttpRequest和HttpResponse具有一致的定義。對于DefaultHttpContext類型來說,它的Request和Response屬性分別返回的是一個DefaultHttpRequest和DefaultHttpResponse對象。DefaultHttpRequest和DefaultHttpResponse分別繼承自HttpRequest和HttpResponse,它們分別利用這個兩個特性實現了從基類繼承下來的所有抽象成員。

   1: public interface IHttpRequestFeature
   2: {
   3:     Stream             Body { get; set; }
   4:     IHeaderDictionary  Headers { get; set; }
   5:     string             Method { get; set; }
   6:     string             Path { get; set; }
   7:     string             PathBase { get; set; }
   8:     string             Protocol { get; set; }
   9:     string             QueryString { get; set; }
  10:     string             Scheme { get; set; }
  11: }
  12:  
  13: public interface IHttpResponseFeature
  14: {
  15:     Stream             Body { get; set; }
  16:     bool               HasStarted { get; }
  17:     IHeaderDictionary  Headers { get; set; }
  18:     string             ReasonPhrase { get; set; }
  19:     int                StatusCode { get; set; }
  20:  
  21:     void OnCompleted(Func<object, Task> callback, object state);
  22:     void OnStarting(Func<object, Task> callback, object state);
  23: }

對于實現請求監聽、接收和響應的Server來說,它們都需要通過實現上面這些特性接口來定義針對性的特性類。如下圖所示,當成功接收到請求之后,Server會創建相應的特性并將它們組合成一個FeatureCollection對象,最后創建出一個DefaultHttpContext對象,我們注冊的所有中間件針對這個DefaultHttpContext完成各自的請求處理工作。 clip_image004

上下文的創建者:HttpContextFactory

在Server接收到抵達的請求時,它并不會直接利用原始的上下文去創建HttpContext對象,HttpContext在管道中的創建是間接地通過HttpContextFactory來完成的。 HttpContextFactory是對所有實現了IHttpContextFactory接口的所有類型及其對象的統稱,如下面的代碼片段所示,IHttpContextFactory接口除了定義創建HttpContext對象的Create方法之外,還定義了另一個方法Dispose來釋放指定的HttpContext對象。HttpContextFactory類是該接口的默認實現者,由它的Create方法創建并返回的自然是一個DefaultHttpContext對象。

   1: public interface IHttpContextFactory
   2: {
   3:     HttpContext Create(IFeatureCollection featureCollection);
   4:     void Dispose(HttpContext httpContext);
   5: }
   6:  
   7: public class HttpContextFactory : IHttpContextFactory
   8: {    
   9:     //省略其他成員
  10:     public HttpContext Create(IFeatureCollection featureCollection);
  11:     public void Dispose(HttpContext httpContext);
  12: }


三、管道的注冊者和構建者:ApplicationBuilder

以類型為Func<RequestDelegate, RequestDelegate>的委托對象表示的中間件需要在啟動的時候注冊到應用程序上,所有注冊的中間件最終會轉換成一個RequestDelegate對象,它們按照注冊順序對請求的處理流程最終體現在對這個委托對象的執行。不論是最終將中間件轉換成RequestDelegate對象,還是最初對它們的注冊,都是通過一個ApplicationBuilder對象來完成的。

ApplicationBuilder是我們對所有實現了IApplicationBuilder接口的所有類型以及對應對象的統稱。接口IApplicationBuilder定義如下,針對中間件的注冊和RequestDelegate對象的生成分別通過調用它的Use和Build方法來完成。除了這兩個核心方法,IApplicationBuilder接口還定義了三個屬性,其中ApplicationServices返回根據最初服務注冊生成的ServiceProvider對象,而ServerFeatures屬性返回的FeatureCollection對象是描述Server的特性集合。字典類型的Properties屬性用戶存儲任意自定義的屬性,而New方法會根據自己“克隆”出一個新的ApplicationBuilder對象,這兩個ApplicationBuilder對象應用具有相同的屬性集合。

   1: public interface IApplicationBuilder
   2: {
   3:     IServiceProvider             ApplicationServices { get; set; }
   4:     IFeatureCollection           ServerFeatures { get; }
   5:     IDictionary<string, object>  Properties { get; }
   6:  
   7:     RequestDelegate         Build();
   8:     IApplicationBuilder     New();
   9:     IApplicationBuilder     Use(Func<RequestDelegate, RequestDelegate> middleware);
  10: }

具有如下定義的ApplicationBuilder類型是對IApplicationBuilder接口的默認實現。ApplicationBuilder類型利用一個List<Func<RequestDelegate, RequestDelegate>>對象來保存注冊的中間件,所以Use方法只需要將指定的中間件添加到這個列表中即可,而Build方法只需要逆序調用這些注冊的中間件對應的Func<RequestDelegate, RequestDelegate>對象就能得到我們需要的RequestDelegate對象。值得一提的是,Build方法實際上在中間件鏈條的尾部添加了一個額外的中間件,該中間件會負責將響應狀態碼設置為404,如果我們沒有注冊一個中間件對請求作最終的響應(這樣的中間件將不會試圖調用后續中間件),整個管道比較回復一個狀態碼為404的響應。

   1: public class ApplicationBuilder : IApplicationBuilder
   2: {
   3:     private readonly IList<Func<RequestDelegate, RequestDelegate>> middlewares = new List<Func<RequestDelegate, RequestDelegate>>();
   4:  
   5:     public IDictionary<string, object> Properties { get; }
   6:  
   7:     public IServiceProvider ApplicationServices
   8:     {
   9:         get { return GetProperty<IServiceProvider>("application.Services"); }
  10:         set { SetProperty<IServiceProvider>("application.Services", value); }
  11:     }
  12:  
  13:     public IFeatureCollection ServerFeatures
  14:     {
  15:         get { return GetProperty<IFeatureCollection>("server.Features"); }
  16:     }
  17:  
  18:  
  19:     public ApplicationBuilder(IServiceProvider serviceProvider)
  20:     {
  21:         this.Properties = new Dictionary<string, object>();
  22:         ApplicationServices = serviceProvider;
  23:     }
  24:  
  25:     public ApplicationBuilder(IServiceProvider serviceProvider, object server) : this(serviceProvider)
  26:     {
  27:         SetProperty("server.Features", server);
  28:     }
  29:  
  30:     public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
  31:     {
  32:         middlewares.Add(middleware);
  33:         return this;
  34:     }
  35:  
  36:     public IApplicationBuilder New()
  37:     {
  38:         return new ApplicationBuilder(this);
  39:     }
  40:  
  41:     public RequestDelegate Build()
  42:     {
  43:         RequestDelegate app = context =>
  44:         {
  45:             context.Response.StatusCode = 404;
  46:             return Task.FromResult(0);
  47:         };
  48:         foreach (var component in middlewares.Reverse())
  49:         {
  50:             app = component(app);
  51:         }
  52:         return app;
  53: }
  54:  
  55:     private ApplicationBuilder(ApplicationBuilder builder)
  56:     {
  57:         this.Properties = builder.Properties;
  58:     }        
  59:  
  60:     private T GetProperty<T>(string key)
  61:     {
  62:         object value;
  63:         return Properties.TryGetValue(key, out value) ? (T)value : default(T);
  64:     }
  65:  
  66:     private void SetProperty<T>(string key, T value)
  67:     {
  68:         this.Properties[key] = value;
  69:     }
  70: }

通過上面的代碼片段我們不難看到,不論是通過ApplicationServices屬性返回的ServiceProvider對象,還是通過ServerFeatures屬性返回的用戶描述Server特性的FeatureCollection對象,它們實際上都保存在通過Properties屬性返回字典對象上。ApplicationBuilder具有兩個公共構造函數重載,它們具有一個公共的參數,及初始化ApplicationServices屬性的參數serviceProvider。

一個構造函數具有一個名為server的參數,但是這個參數并不是表示管道使用的Server,而是描述Server相關特性的FeatureCollection對象,不過這個參數類型并定義成Object,而不是IFeatureCollection接口。New方法直接調用私有構造函數創建出一個新的ApplicationBuilder對象,這個對象與自己的Properties屬性共享同一個字典對象,由于ApplicationServices和ServerFeatures屬性的返回值也存放在這個字典對象上,所以New方法得到的ApplicationBuilder對象與自身對象在功能上是等效的。

ApplicationBuilder的創建者:ApplicationBuilderFactory

ApplicationBuilderFactory是ASP.NET Core它用來創建ApplicationBuilder的工廠,它是對所有實現了接口IApplicationBuilderFactory的所有類型以及對應對象的統稱。如下面的代碼片段所示,該接口定義了唯一個方法CreateBuilder根據提供的FeatureCollection對象創建出對應的ApplicationBuilder對象,這個FeatureCollection對象正是表示描述Server的特性集合。ApplicationBuilderFactory類型是該接口的默認實現者,當CreateBuilder方法被調用的時候,它會直接將構造時提供ServiceProvider對象和serverFeatures參數表示的FeatureCollection對象來創建返回的ApplicationBuilder對象。

   1: public interface IApplicationBuilderFactory
   2: {
   3:     IApplicationBuilder CreateBuilder(IFeatureCollection serverFeatures);
   4: }
   5:  
   6: public class ApplicationBuilderFactory : IApplicationBuilderFactory
   7: {
   8:     private readonly IServiceProvider _serviceProvider;
   9:  
  10:     public ApplicationBuilderFactory(IServiceProvider serviceProvider)
  11:     {
  12:         this._serviceProvider = serviceProvider;
  13:     }
  14:  
  15:     public IApplicationBuilder CreateBuilder(IFeatureCollection serverFeatures)
  16:     {
  17:         return new ApplicationBuilder(this._serviceProvider, serverFeatures);
  18:     }
  19: }

文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()