標籤: C#

.NetCore的ApiGateway +Consul的本機實驗

.NetCore的ApiGateway +Consul的本機實驗

一直以來都知道.netcore可以開始跨平台運作在linux上,因此運作在 docker的container中也是可行的

不過最近因為要研究apigateway,需要模擬infra的架構 ,因此需要在本機環境buildup起來多台主機的模式

沒想到入坑玩了一下,就發現,其實眉眉角角也不少,但來來回回操作後,其實是相對會愈來愈熟悉

首頁,我solution的構成包含了以下幾層 (apigateway還可做loadbalance,再加個nginx就可以, 不是這次的重點)

1.Redis(多台 instance作counter的cache)
2.WebApp(.net core)
3.ApiGateway(.net core + ocelot.net sdk)
4.Consul(service discovery service 作ha)

這次主要研究的機制為 3跟4之間的協作機制
基本上,有了3,預設走configuration就可以手動配置 ,但若要能做到動態ha的話,就必須加入service discovery的機制,剛好ocelot框架有支援 consul(還有其他套,但預設consul)

docker-compose

為了方便 重建,這次採用 docker-compose的yaml來維護(以下情境的container -name 都一般化了,只需要區分角色就好)

version: "3"

services:
  nginx:
    build: ./nginx
    container_name: core-app.local
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - core-app-gateway1
    # network_mode: "host"
    networks:
      - backend

  consul1:
    image: consul
    container_name: node1
    command: agent -server -bootstrap-expect=2 -node=node1 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    # network_mode: "host"
    networks:
      - backend

  consul2:
    image: consul
    container_name: node2
    command: agent -server -retry-join=node1 -node=node2 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    depends_on:
      - consul1
    # network_mode: "host"
    networks:
      - backend

  consul3:
    image: consul
    hostname: discover-center
    container_name: node3
    command: agent -retry-join=node1 -node=node3 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -ui
    ports:
      - 8500:8500
    depends_on:
      - consul1
      - consul2
    #network_mode: "host"
    networks:
      - backend

  redis:
    container_name: myredis
    image: redis:6.0.6
    restart: always
    ports:
      - 6379:6379
    privileged: true
    command: redis-server /etc/redis/redis.conf --appendonly yes
    volumes:
      - $PWD/data:/data
      - $PWD/conf/redis.conf:/etc/redis/redis.conf
    networks:
      - backend

  core-app-discover-a1:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp1
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a1
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a2:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp2
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a2
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a3:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp3
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a3
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-b:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-b
    environment:
      - HOSTNAME=http://0.0.0.0:50001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-b
      - CLIENT_PORT=50001
      - SERVICE_NAME=core-app-discover-b
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50001:50001"
    networks:
      - backend

  core-app-discover-c:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-c
    environment:
      - HOSTNAME=http://0.0.0.0:50002
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-c
      - CLIENT_PORT=50002
      - SERVICE_NAME=core-app-discover-c
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50002:50002"
    networks:
      - backend

  core-app-gateway1:
    build: ./dotnetapp/dotnetapp-gateway
    hostname: gateway1
    environment:
      - HOSTNAME=http://0.0.0.0:20001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://192.168.1.115
      - CLIENT_PORT=20001
      - SERVICE_NAME=core-app-gateway1
    depends_on:
      - consul3
    ports:
      - 20001:20001
    networks:
      - backend

networks:
  backend:
    driver: bridge

以上yaml就會建立一個network讓 container之前可以用 hostname來通訊。

這次研究的過程,會發現一件事情就是若要綁service discovery,那麼每個末端的webapp都需要跟register center做註冊,並提供一個health接口。但是若要做到auto scale,那依賴docker-compose所做的scale就會綁定動態的host_name(有序號),因此在此不建議 用scale直接長n台容器,而是先用多個端點來建置(如我的a1、a2、a3三台)
這樣就可以指定 hostname跟port給 webapp,而不會被scaling的機制而無法抓到動態的port跟名稱

.netcore application的Dockerfile

以下是webapp的dockerfile (gateway的打包方式差不多,就不貼了)

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /src
COPY ["dotnetapp-discover.csproj", "dotnetapp-discover/"]
RUN dotnet restore "dotnetapp-discover/dotnetapp-discover.csproj"
COPY . ./dotnetapp-discover
WORKDIR "/src/dotnetapp-discover"
RUN dotnet build "dotnetapp-discover.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "dotnetapp-discover.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "dotnetapp-discover.dll"]

為了計數器功能與多台sharing cache,因此這次 還 模擬了一下連redis
而redis的connection string 可以透過 environment variable 注入進來(docker-compose可以另外用.env的檔案來管理不同環境,有興趣可以看 官方文件),而在.netcore的c#程式中,要抓到docker的參數的話可以使用GetEnvironmentVariable來取得設定值

    public static IServiceCollection AddCacheService(this IServiceCollection services)
    {
        var redisConnection = Environment.GetEnvironmentVariable("RedisConnection");
#if DEBUG
        redisConnection = "192.168.1.115:6379,password=xxxxxx,allowAdmin=true";
#endif
        
        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(redisConnection)
        );
        return services;
    }

註∶為了反覆測試,才加上allow admin,不然這個開關是很危險的,可以直接flush掉你的所有快取(清空)

Redis記錄的Log型態

註冊Service Discovery

ApiGateway跟WebApp我都有跟Consul註冊Service Discovery
因此這次主要的寫法也是follow consul client sdk的套件,Consul的註冊管理上,同一個Name的會被Grouping起來,ID要給不一樣的,若ServiceName都不一樣,就算是一樣的程式,還是會被視為不同節點,這樣會無法作HA的判斷

static void RegisterConsul(string serviceName, string serverHostName , string serverPort, string clientHostName , string clientPort, IHostApplicationLifetime appLifeTime)
{
    using var consulClient = new Consul.ConsulClient(p => 
        { p.Address = new Uri($"{serverHostName}:{serverPort}"); });
    var registration = new AgentServiceRegistration()
    {
        Check = new AgentServiceCheck()
        {
            Interval = TimeSpan.FromSeconds(10),
            HTTP = $"{clientHostName}{(clientPort == string.Empty ? "" : $":{clientPort}")}/health",
            Timeout = TimeSpan.FromSeconds(5),
            Method = "GET",
            Name = "check",
        },
        ID = Guid.NewGuid().ToString(),
        Name = serviceName,
        Address = Dns.GetHostName(),
        Port = int.Parse(clientPort),
        
    };

    try
    {
        consulClient.Agent.ServiceRegister(registration).Wait();
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    //web服務停止時,向Consul解註冊
    consulClient.Agent.ServiceDeregister(registration.ID).Wait();
}

以上function可以在program.cs的startup時期先進行註冊,而這個function所有的參數一樣可以來自於 docker-compose
範例∶

自動生成HealthCheck端點

在webapp的startup可以使用以下語法就長出health接口

builder.Services.AddHealthChecks();

app.MapHealthChecks("/health",  new HealthCheckOptions()
{
    ResultStatusCodes =
    {
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Degraded] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
    }
});

但還是要視服務加上MapHealthChecks的ResultStatus的設定,以consul來說,就是回200就好了,若沒有加上這個設定,其實health的回應並不是200,所以會被視為失敗

正常情況打 health的接口

Ocelot的設定檔

最後就是ocelot的json設定,傳統的Ocelot自已就有Load-Balance機制,因此若有多台Downstream的服務,本來就可以用以下設定去管理服務

不過這樣做的缺點就是若隨時要增減 容器/主機時,都要再去重新配置設定檔。造成主機可能的reset與維運上的難度,因此透過Service Discovery的好處就浮上了臺面!以下的配置,只需要注意,不用定義DownStream的Host,只要給一個ServiceName,這個ServiceName要跟WebApp在Startup的時候註冊的一樣。並在最後GloablConfiguration有註冊Service DiscoveryProvider的設定,就可以打通ApiGateway跟Service Discovery之間的便利機制。

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/get",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "DownstreamHttpMethod": "DELETE",
      "UpstreamPathTemplate": "/newapi/reset",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }, 
    {
      "Key": "api1",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list1",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    },
    {
      "Key": "api2",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list2",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }
  ],
  "Aggregates": [
    {
      "RouteKeys": [
        "api1",
        "api2"
      ],
      "UpstreamPathTemplate": "/newapi/listall"
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://0.0.0.0:20001",
    "ServiceDiscoveryProvider": {
      "Scheme": "http",
      "Host": "192.168.1.115",
      "Port": 8500,
      "Type": "PollConsul",
      "PollingInterval": 100
    },
    "QoSOptions": {
      "ExceptionsAllowedBeforeBreaking": 0,
      "DurationOfBreak": 0,
      "TimeoutValue": 0
    },
    "LoadBalancerOptions": {
      "Type": "LeastConnection",
      "Key": null,
      "Expiry": 0
    },
    "DownstreamScheme": "http",
    "HttpHandlerOptions": {
      "AllowAutoRedirect": false,
      "UseCookieContainer": false,
      "UseTracing": false
    }
  }
}

上述的ApiGateway一樣用DockerFile把專案包成Image

整合測試

docker-compose up -d –force-recreate

會看到一次起來這麼多服務

http://localhost:20001/newapi/get(透過api gateway打到downstream的service)

這邊多請求幾次會發現machineName一直在webapp1~3跳來跑去,採用load-balance的機制,ocelot會採用你設定的策略進行請求分派

ApiGateway有Aggregation的功能,就是可以把多個Api請求Combine在一起,讓請求變成1次,減少頻寬的消耗,這邊我也做了配置,來試看看吧

出於Ocelot.json的設定檔段落:

透過api gateway的組合讓 多個 Api可以一次 回傳

最後,打了很多次請求後,來總覽一下所有的請求摘要(程式是自已另外撈Redis的Request記錄來統計)

http://localhost:20001/newapi/list(透過api gateway打到down stream的service)

嘗試Docker Stop某一台 Discover-a1的服務,Consul DashBoard馬上就看的到紅點了!

這個時候再去打 之前load-balance的端點http://localhost:20001/newapi/get,會發現machineName會避開stop掉的服務只剩下 webapp1跟webapp3在輪循,因此做到了後端服務品質的保護

小插曲

ID不小心用成了Guid.New,所以會導致重新啟動就又拿到新的ID,造成了多一個Instance。但舊的也活了,因為他們的HealthCheck是一樣的

其實ApiGateway的議題,從Infra的角度上,可以玩的東西還有很多。

像是他也支援RateLimiting, QualityOfService(Polly) , 服務聚合(Aggregation)等機制,都可以從他們的官方文件看到設定方式,算是配置上不算太複雜。

不過 Ocelot在.net這邊發展一陣子後,近一年好像停更了!取而代之的好像是其他語言寫的更強的Apigateway: Envoy。

Envoy資源
https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_envoy
https://www.netfos.com.tw/Projects/netfos/pages/solution/plan/20210111_plan.html

若只是要輕量級的,或是想參考其架構設計的,倒是可以翻github出來看看~

Github∶ https://github.com/ThreeMammals/Ocelot

這次Lab程式碼

若要拿去玩的,請把Ip, Password自行調整一下

一些文章的參考

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider#

https://www.uj5u.com/net/232249.html

.net throw ex 與 throw 的不同之處(重新理解)

.net throw ex 與 throw 的不同之處(重新理解)

前幾天保哥在.net社群提示一個人的詢問 throw ex跟throw差在哪,他寫了類似以下範例的程式,但沒有明說差別
仔細想想,我似乎也沒特別想過這個問題
以往例外處理的議題就是不要不處理就往最外面丟,或是不要都不做事把錯誤隱藏
但往外丟本身,我習慣確實是throw,而不是throw一個變數,通常我要丟變數的時候,要不就是重新包成有意義的new exception,例如把function的意義多做描述。
否則就是不會new一個似是而非的錯誤”訊息”。
實際查了一下,stackflow網站上有人提出來:
throw ex resets the stack trace (so your errors would appear to originate from HandleException)
throw doesn’t – the original offender would be preserved.
結果我實際寫了一個winform看了一下差別,一開始我還把這兩個例子都直接寫在”同”一個function裡,結果想說,沒有不見呀…
結果原來是在同一個function裡面看不出來,因為不算是跨stack

以下是看的出來的例子..

 //for test args = new[] {"appreset"};
            try
            {
                try
                {
                    test1();
                }
                catch (Exception b)
                {
                    throw b;
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }


            try
            {
                try
                {
                    test2();
                }
                catch (Exception ex)
                {
                    throw;
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }



   private static void test2()
        {
            try
            {
                throw new Exception("OK", new Exception("b"));
            }
            catch (Exception)
            {
                throw;
            }
        }

        private static void test1()
        {
            try
            {
                throw new Exception("OK", new Exception("b"));
            }
            catch (Exception a)
            {
                throw a;
            }
        }

C# 工作平行程式庫的例外處理機制

C# 工作平行程式庫的例外處理機制

最近2個月又重回C#懷抱了,昨晚追查一個底層ThreadPool Exception,然後會造成w3wp的Exception, 會造成集區重啟的異常

後來發現有一個非同步呼叫射後不理的部分,並沒有合理的包裝例外,然後又會造成.net framework底層無法Handle的例外

Legacy Code的用法是改一半的非同步呼叫方法

function method1()
{
       method2();
}

async Task<ResultObj> function method2()
{
       //略
}

後來查詢多方說法後(StackOverFlow網友建議),建議這種射後不理的整合寫法,可以採用TPL(工作型非同步執行庫)

正規TPL的非同步方法在Handle的時候如下:

其例外處理範例,因為Task的例外會拋出來的是AggregateException方法。因此可以例用以下方法來走訪所有的Exception

   public static void Main()
   {
      var task1 = Task.Run( () => { throw new CustomException("This exception is expected!"); } );

      try
      {
          task1.Wait();
      }
      catch (AggregateException ae)
      {
          foreach (var e in ae.InnerExceptions) {
              // Handle the custom exception.
              if (e is CustomException) {
                  Console.WriteLine(e.Message);
              }
              // Rethrow any other exception.
              else {
                  throw;
              }
          }
      }
   }

 

後續改寫一版後,能明確抓到射後不理的Exception,也不會造成集區崩潰就變成以下的版本了

Task listenerTask = Task.Run(() => proxy.callService<ResultObject>(inputObj)).ContinueWith( 
	t => { logException(t, proxy.ServiceName); }, TaskContinuationOptions.OnlyOnFaulted);

private static void logException(Task<BaseAddOutput> t, string serviceName)
{
	var message = string.Empty;
    if (t.Exception != null)
       message = t.Exception.Message;
    if (t.Exception.InnerException != null)
       message = t.Exception.InnerException.Message;
    Debug.Log(ConfigUtil.GetBoolean("Debug"), "system call " + serviceName + " error, msg:" + message);
}

 

當然,若不是要射後不理的話,建議還是要讓function async / wait化,來確保執行流程順序中的工作都有被完成

 

MS Docs參考

https://docs.microsoft.com/zh-tw/dotnet/standard/parallel-programming/task-based-asynchronous-programming

[Design Pattern] 簡單工廠與工廠方法模式

[Design Pattern] 簡單工廠與工廠方法模式

近期雖然已經從c#轉向python,但發現可以用到OO的地方,可以建造工廠的情境也非常地多,索性把之前的文章拿來反覆咀嚼,挺有趣

這一篇雖然是2010年寫的,介紹重量級的工廠模式-定義來自於深入淺出設計模式一書,而本例重新構思案例,適圖舉一反三 😛

 

前幾篇提到了三個不同型式的Design Pattern,分別是Strategy(角色扮演)、Observers(Blog Rss)以及Decorator(早餐店)。傳送門

可以發現,在現有的OO法則下,可以達到在類別設計與使用上的彈性與擴充

我們可以在執行期new出一個具有彈性的具像類別以及透過各種型式來動態地變更物件

 

因為透過介面來操作物件,在類別的操作變得彈性了

就像先前角色扮演的例子一樣,可以new一個Character類別,叫作弓箭手的”角色”

Character Mary = new Archer(); //宣告一個角色叫作Mary,給予他具象的弓箭手類別

上述看到,當『new』的時候,就要聯想到『具象』

什麼是具象?

1

上圖中抽象與具象剛好是一種相對的關係,我們透過不同的具象類別(不同職業的角色)來描述所謂的角色

具象類別就是我們要真正”實體化”的類別,當我們使用new的時候,雖然透過Character作為介面讓程式具有彈性,但是我們還是得建立具象類別(Archer)的實體。

所以用的確實是實踐,而不是介面。這是一個好問題,程式綁著具象類別,將導致程式容易損壞且沒有彈性!

 

為什麼??當有一群相關的具象類別的時候,通常會寫出這樣的程式:

  Character Mary;
  int select=1; //選擇不同職業的角色
   
         if (select==1)
         {
             Mary = new Archer();
         }else if(select==2)
         {
             Mary = new Knight();
         }else if(select==3)
         {
             Mary = new Magician();
         }

有一大堆不同的角色類別,但是必須要到執行期才知道該實體化哪一個職業!

當看到這樣的程式碼,一旦有變動或擴充,就必須打開這個源始碼,加以檢視與修改。通常這樣修改過的程式碼,將造成部份系統更難以維護與更新,而且也有犯錯的可能性。

 

然而,在技術上,new這個字眼沒有問題,真正的犯人是『需求的改變』,以及這個的改變對new的影響力!

回顧一下裝飾者模式所說的關閉開放法則。

類別應該開放,以便擴充;應該關閉,禁止修改

如果針對介面寫程式,可以隔離掉以後系統可能發生的一大堆改變。
介面可以透過多型進行新類別的實踐,而不需要動到原本的程式碼。
但是當程式碼使用了大量的具象類別時(例如角色的職業),那麼一互加入新的具象類別,就必須改變程式碼。

也就是說程式並非『對修改關閉』。當想要擴充新的職業,竟然要重新開放應該封閉的程式碼。

那麼解決方法並不是無跡可尋的,回想看看第一個OO守則

找出會改變的地方,然後將這部份抽離出來

剛好可以用來處理我們的老朋友『改變』!

 

首先,找出會改變的東西。

 

先來個情境:

現在假如我們仍然是一個手機的開發商,目前有一個提供手機製造並出貨的服務,現在準備發展手機銷售部門,為了實行需求導向

這間公司由不同的手機款式需求,建立原型,再實行製造流程並出單。

身為這間手機製造商的訂單系統開發者,程式碼可能會這麼寫:

  CellPhone orderCellPhone(){
      CellPhone cellphone= new CellPhone(); //建立手機原型(確定硬體設計圖與軟體規格)
    
      cellphone.design();  //動工設計
      cellphone.modeling(); //動工建模
      cellphone.combine(); //動工組合手機
      cellphone.box(); //動工包裝
      return cellphone;  //出貨
  }

註:為了讓系統有彈性,我們會希望CellPhone是一個抽象類別或介面,但是這樣的話,這些類別或介面就無法實體化了…註2,軟體的安裝在組合手機的以後會安裝上去(裡面又呼叫了install()方法)

但是當我們需要更多型式的手機…這時勢必增加一些程式碼,來決定適合的手機具象類別,然後製造這隻手機:

假如有三款型式,分別是Business(商用)、Sport(戶外防水)、Normal(陽春款)

程式碼就會變成:

 CellPhone orderCellPhone(String style){
     CellPhone cellphone;
     if (style.equals("Business")){
         cellphone= new BusinessPhone();  //實踐商用手機
     }else if (style.equals("Sport")){
         cellphone= new SportPhone();     //實踐運動手機
     }else if(style.equals("Normal")){
         cellphone= new NormalPhone();    //實踐陽春手機
     }
  
     cellphone.design(); //設計
     cellphone.modeling(); //建模
     cellphone.combine(); //組裝
     cellphone.box(); //包裝
     return cellphone;
 }

市場競爭日趨激烈,壓力來自於必須快速推出更多型式與型號的手機。而新款的Sport手機要上市,舊款的Sport也要因此從型錄中下市:

  CellPhone orderCellPhone(String style){
  CellPhone cellphone;
  if (style.equals("Business")){
      cellphone= new BusinessPhone();
  //}else if (style.equals("Sport")){  //舊款要下市的手機
  //    cellphone= new SportPhone();
  }else if (style.equals("Music")){   //新款的音樂手機
      cellphone= new MusicPhone();
  }else if (style.equals("Sport2")){  //2代的Sport手機
      cellphone= new Sport2Phone();   
  }else if(style.equals("Normal")){
      cellphone= new NormalPhone();
  }
   
  cellphone.design(); //設計
  cellphone.modeling(); //建模
  cellphone.combine(); //組裝
   cellphone.box(); //包裝
   
  return cellphone;
   
  }

此程式碼並沒有對修改封閉,看到了嗎?如果手機開發商一改變型錄,供應不同的機型,就得修改那些if else。這裡假以時日一遇到改變,就必須一改再改。

而不想改變的地方是設計、建模與組裝的流程,因為手機的製造流程都持續不變,所以這部分的程式碼不會改變,只有手機的需求的款式用途會改變。

所以很明顯,如果實體化某些具像類別(例如音樂手機、運動手機…etc)將使orderPhone( )出問題,而且也無法對它修改關閉,但是目前已經知道哪些會變哪些是不變的,所以是時候來使用封裝了!!

 

首先要先封裝建立物件的程式碼!記得,封裝的目標總是那些最常改變的地方,要把建立物件的程式碼從orderPhone方法中抽離,然後將部分的程式碼搬到另一個物件中,這個新物件只管如何製造手機的原型,如果一旦任何物件想要製造手機原型,找它就對了!

稱這個新物件為『工廠』:PhoneFactory

工廠是負責建立物件的細節,一旦有了PhoneFactory,OrderPhone就變成了PhoneFactory的客人,當需要建立手機的時候,就叫手機工廠做一個。

而以後orderPhone就不需要知道音樂手機還是商用手機,orderPhone只需要從工廠得到一隻手機原型,而這個手機實踐了CellPhone的介面,以呼叫設計、建模、組裝方法,來完成手機。

 

先來建立一個簡單的手機工廠,要先從工廠本身開始,定義一個類別,為所有手機封裝建立物件的程式碼。程式碼就像這樣:

   class PhoneFactory
   {
       public CellPhone createCellPhone(string style){
           CellPhone cellphone = null;
           if (style.equals("Business"))
           {
               cellphone = new BusinessPhone();
               //}else if (style.equals("Sport")){ //舊款要下市的手機
               //    phone = new SportPhone();
           }
           else if (style.equals("Music"))
           {   //新款的音樂手機
               cellphone = new MusicPhone();
           }
           else if (style.equals("Sport2"))
           {  //2代的Sport手機
               cellphone = new Sport2Phone();
           }
           else if (style.equals("Normal"))
           {
               cellphone = new NormalPhone();
           }
    
           return cellphone;
       }
   };

這樣做有什麼好處?似乎只是將問題搬到另一個物件罷了,問題依然存在啊?

提醒一下,OO中封裝還有一個好處就是reuse,雖然目前只看到orderCellPhone()方法是他的客人,然而,可能還有CellPhoneShopMenu類別,會利用這個工廠來取得手機的描述與單價。可能還有宅配的功能需要它,會有不同的方式來處理手機(CellPhoneShop)。所以把建立手機的程式碼包裝進一個類別,當以後實踐改變時,只需要這個類別即可。

 

接著是該來修改客人這一邊的程式碼了

  public class CellPhoneSale
  {
      CellPhoneFactory factory;
   
      public CellPhoneSale(CellPhoneFactory factory)
      {
          this.factory = factory;
      }
   
      public CellPhone orderCellPhone(String style)
      {
          CellPhone cellphone;
   
          cellphone = factory.createCellPhone(style);
   
          cellphone.design(); //設計
          cellphone.modeling(); //建模
          cellphone.combine(); //組裝
          cellphone.box();//包裝
   
          return cellphone;
      }
  }

 

看到第三行,我們為CellPhoneSale加上一個變數,以便記錄CellPhoneFactory,同時在建構式的時候,將一個Factory的參數帶入(就類似於前幾個Design Pattern實作時的方式。

再看到第14行,我們不再使用具象實體化,而是把new代換成工廠的方法了!

 

上述就是簡單工廠(SimpleFactory)!!但很遺憾,這不是設計模式,這只是一種coding的習慣!常有人誤認這個就是工廠模式的精髓。不過不要因為簡單工廠不是一個真正的模式,就忽略了它的用法。先來看看新版本的手機店的類別圖。

 

2

 

客戶–>工廠–>產品(抽象)—>產品實踐。這樣的關係可以讓具象產品都必須實踐CellPhone的介面(抽象類別也算,這邊是泛指實踐某個超型態(可以是介面或類別)),無呰以來就可以被工廠建立並傳回給客戶。

各種型式的手機都可以override 超類別的方法,實踐自己的製作方式。

 

工廠模式完了嗎?

接下來登場的是兩個重量級的模式,然後他們都是工廠…而手機只會愈來愈多…,接著看吧!

 

事業愈做愈大,手機開發經營有成,擊敗了其他的手機開發商,現在大家都希望產業移植,到大陸開分公司處理大陸的手機銷售,而大陸也有一個製造工廠(地區性質不一定),去賺更多的錢。產業準備複製,不過除了我們,經營者也會開始擔心!到大陸設廠的品質會不會下降呢?,會不會搞糟了原本的品質水準呢?因此希望大陸那邊都能利用自己的程式碼,好讓手機製造的流程不會改變。這樣在未來甚至可以把所有的生產流程都移到大陸,不過剛開始嘛,台灣工廠專門快速回應台灣的客戶,而大陸客戶由大陸工廠負責快速回應製作手機。

剛剛好呢,已經有一個作法,如果利用CellPhoneFactory,寫出二種不同的工廠,分別是ChinaCellPhoneFactory以及TaiwanCellPhoneFactory,那麼各地的手機商,就都有適合的工廠可以使用。這是一種作法…來看看會變成什麼樣子!

  TaiwanCellPhoneFactory twFactory = new TaiwanCellPhoneFactory();
  CellPhoneSale twSale = new CellPhoneSale(twFactory);
  twSale.orderCellPhone("MusicPhone");
   
  ChinaCellPhoneFactory caFactory = new ChinaCellPhoneFactory();
  CellPhoneSale caSale = new CellPhoneSale(caFactory);
  caSale.orderCellPhone("MusicPhone");

透過建立台灣工廠,然後建立一個台灣銷售部來處理台灣的訂單,將台灣工廠放到建構式參數中,當製造手機時,就會產生台灣區的手機

然而在推動不同地區的工廠時,經營者漸漸地發現,兩岸三地的喜好差異不小,所以公司在大陸建立了一個經營團隊,專門負責大陸手機的販售

他們調查大陸的需求以後,發現手機除了簡繁體外,他們對於手機的系統、功能、組件、包裝,配件都有不同的需求,擁有屬於自己地區性的手機原型(需求產生的),而推廣工廠的時候,發現不同的手機銷售部門雖然是用我們的手機,但是在製造上仍有不同的作法。所以我們希望建立一個框架,把不同的區域性的建立手機原型、製造與銷售手機綑綁在一起,卻又不失彈性。在建立簡單工廠之前,製造手機的程式碼綁在CellPhone Sale中,但這麼做卻沒有彈性。那麼怎麼做才能魚與熊掌兼得呢?

 

給不同銷售地區使用的原型框架

嗯…有個作法可以讓區域差異的手機活躍於CellPhoneSales類別,同時讓這些不同地區的分公司,依然可以自由製作該區域的產品組合。

 

首先,先把createCellPhone放回CellPhoneSale之中,不過要設定成抽象方法,然後要將地區性的銷售寫一個CellPhoneSale的次類別

來看看變了哪些東西。

  public abstract class CellPhoneSale
   {
       public CellPhone orderCellPhone(String style)
       {
           CellPhone cellphone;
  
           cellphone = createCellPhone(style);
  
           cellphone.design(); //設計
           cellphone.modeling(); //建模
           cellphone.combine(); //組裝
           cellphone.box();//包裝
  
           return cellphone;
       }
  
       public abstract CellPhone createCellPhone(String style);
   }

現在CreateCellPhone被移回了CellPhoneSale,而且工廠方法必須宣告為抽象方法。CellPhoneSale類別要宣告成抽象的

因此現在已經有一個CellPhoneSale作為超類別,讓每個區域型態(Taiwan、China),都繼承這個CellPhoneSale,每個次類別各自決定如何製造手機。看看要如何進行吧

 

目前處理手機銷售類別已經有一個不錯的訂單系統,由orderCellPhone()方法負責處理訂單,不過在地區性差異上,是要讓createPhone能因應改變,我們希望對訂單的處理能夠一致。

而各區域手機銷售單位之間的差異,在於製作手機原型上的差異(例如台灣有較高階的商務手機2代也有娛樂手機,但大陸沒有娛樂手機,而有運動手機)。

,做法就是讓大陸與台灣的分公司各個次類別去定義自己的createCellPhone()方法,所以會有一些CellPhoneSale的具象次類別,每個次類別都有自己區域不同的需求差異。

這仍然適用於CellPhoneSale框架,和orderCellPhone合作無間。

3

新的定義是每個次類別都會推翻createCellPhone方法,具有自主權地去定義自己區域需求建立原型的方法,同時使用CellPhoneSale所定義的orderCellPhone的方法,甚至可以把orderCellPhone宣告成final,以防止被分公司推翻訂購流程。

而利用不同地區的次類別,createCellPhone方法就會建立不同區域性的手機。

關於這方面,從CellPhoneSale的orderCellPhone方法觀點來看,此方法被宣告在抽象的類別內,但是在具象的次類別中實踐,雖然orderCellPhone對CellPhone物件做了許多事

但是它『並』不知道,這個CellPhone物件的具象類別為何。這正是鬆綁的一種行為(decouple)!當orderCellPhone呼叫createCellPhone()的時候,不同區域次銷售單位類別將負責透過不同的需求建立手機原型。而當然是由不同區域各自決定手機的需求來建立原型。

雖然次類別不是即時做出這樣的決定,但是嚴格說來,是由不同區域性的手機客戶決定到買,而決定了手機的區域需求。

來建立分公司吧…

  public abstract class CellPhoneSale
  {
      public CellPhone orderCellPhone(String style)
      {
          CellPhone cellphone;
 
          cellphone = createCellPhone(style);
 
          cellphone.design(); //設計
          cellphone.modeling(); //建模
          cellphone.combine(); //組裝
          cellphone.box();//包裝
 
          return cellphone;
      }
 
      public abstract CellPhone createCellPhone(String style);
  }
 
 
 
 
  public class ChinaCellPhoneSale :  CellPhoneSale
  {
      public override CellPhone createCellPhone(String style)
      {
          if (style.Equals("Business"))
          {
              return new caBusinessPhone(); //第1代的商務手機
          }
          else if (style.Equals("Music"))
          {   //音樂手機
              return new caMusicPhone();
          }
          else if (style.Equals("Sport"))
          {  //1代的Sport手機
              return new caSportPhone();
          }
          else
          {
              return null;
          }
      }
  }
 
  public class TaiwanCellPhoneSale : CellPhoneSale
  {
      public override CellPhone createCellPhone(String style)
      {
          if (style.Equals("Business2"))
          {
              return new twBusiness2Phone(); //第2代的商務手機
          }
          else if (style.Equals("Music"))
          {   //音樂手機
              return new twMusicPhone();
          }
          else if (style.Equals("Entertainment"))
          {  //娛樂手機
              return new twEntertainmentPhone();
          }
          else
          {
              return null;
          }
      }
  }

靠近一點來看…是在何時宣告了工廠方法?答案是…

abstract CellPhone createCellPhone(String style);

這一行專門處理物件的建立,並將這樣的行為包裝起來,放在次類別中。走類別的程式碼(orderCellPhone)會用到次類別的程式碼(createCellPhone),但是藉由工廠方法,超類別和次類別之間的關係被鬆綁了呢。

 

前面講了這麼久,有沒有發現一直都忽略了手機本身?如果手機本身沒有定義,那有店也賣不了東西。現在就來實踐手機:

  class CellPhone
  {
      //每款手機都有名稱、硬體規格(簡化)、作業系統軟體(簡化)
      public string Name;
      public string Hardware;
      public string OS;
 
      //總公司規定的基本流程。
      public void design()
      {
          Console.WriteLine("doing Design Process....");
      }
 
      public void modeling()
      {
          Console.WriteLine("doing Modeling Process....");
      }
 
      public void combine()
      {
          Console.WriteLine("doing Combine Process....");
      }
 
      public void box()
      {
          Console.WriteLine("doing Boxing Process....");
      }
 
      public string getName()
      {
          return Name;
      }
  }
 
  class caBusinessPhone : CellPhone
  {
      public caBusinessPhone()
      {
          base.Name = "China Business CellPhone";
          base.OS = "Android 1.5";
          base.Hardware = "電阻式營幕";
      }
 
      public override void box()
      {
          //大陸分公司銷售單位認為要推翻了原本的包裝方式,採用大陸的包裝流程,符合他們的風格。
          Console.WriteLine("doing China Boxing Process....");
      }
 
  }
 
  class twBusiness2Phone : CellPhone
  {
      public twBusiness2Phone()
      {
          base.Name = "Taiwan Business2 SmartPhone";
          base.OS = "Android 2.0";
          base.Hardware = "電容式營幕";
      }
  }

其他完整的程式則不實作,請以上類推即可。

等很久了嗎?手機該上市啦!來製造一些手機吧!

  class Program
  {
      static void Main(string[] args)
      {
          //建立兩間不同的分公司
          CellPhoneSale TaiwanSale = new TaiwanCellPhoneSale();
          CellPhoneSale ChinaSale = new ChinaCellPhoneSale();
  
          //
          CellPhone cellphone = TaiwanSale.orderCellPhone("Business2");
          Console.WriteLine("gipi 訂了一個" + cellphone.getName() + "手機!");
  
  
          CellPhone cellphone2 = ChinaSale.orderCellPhone("Business");
          Console.WriteLine("pou 訂了一個" + cellphone2.getName() + "手機!");
  
          Console.ReadKey();
      }
  }

是的,執行以後呢?

4

 

終於該來定義工廠模式了!當然假如都看到這邊了,你一定有一些想法:

工廠模式會把物件建立的過程封裝起來,來看看類別圖:

3

建立者(Creator)類別,其中能夠產生產品的類別稱為具象建立者。而當中的createCellPhone方法(具象類別中的哦),正是工廠方法,用來製造產品

 

而另外就是產品類別,CellPhone定義了手機,然後實際將要製造出來的手機原型都會繼承他。(像business、business2型的手機)

其實我們看到一個orderCellPhone將一個工廠方法聯合起來,然而這也可以視為是一種平行框架,他們同樣都是由抽象類別,再去實作許多具象的次類別

而每個次類別都有自己特定的實踐方式。TaiwanCellPhoneSale就是封裝台灣區域性手機的知識,大陸地區性手機的知識則由ChinaCellPhoneSale來封裝。

 

深入淺出一書定義:Factory Method Design Pattern (工廠方法模式)

定義了一個建立物件的介面,但由次類別決定要實體化的類別為何者,工廠方法讓類別把實體化的動作交由次類別進行。

5

為什麼常常會誤解呢?工廠方法的模式能夠封裝具象型態的實體化。如上圖,任何其它方法,都可能使用到這個工廠方法所製造出來的產品

但只有次類別真正實現了這個工廠方法,產生出物件。

常常有人會說,工廠方法讓次類別決定要實體化的類別為何者,但這邊所謂的決定並不是指模式允許次類別本身在執行期做決定。

而是指建立者類別的程式碼在撰寫時,不需要知道實際建立的產品是何者,選擇了使用哪個次類別,自然就決定了實際建立的產品為何了。

而要搞清楚的是,我們建立的大陸與台灣的分公司(TaiwanSale、ChinaSale),究竟與利用簡單工廠有何不同?它們真的很類似,但,用法不一樣。

雖然每個具象分公司的實踐看起來很像是簡單工廠,但是這裡工廠方法模式必須是繼承自一個類別,此類別有一個抽象方法createCellPhone()。

再由每個分公司自行負責createCellPhone()方法的行為。在簡單工廠中,工廠是另一個物件,由超類別CellPhoneSales所使用。

 

還不清楚嗎?次類別(TaiwanCellPhoneSale、ChinaCellPhoneSale)看起來真的很象簡單工廠吧?,簡單工廠不同的是,它把全部的事情都在一個地方處理掉了

然而工廠方法是建立一個框架,讓次類別決定要如何實踐。比如說好了,在工廠方法中orderCellPhone方法提供了一般的框架,以建立手機原型。

orderCellPhone依賴工廠方法建立具象類別,並產生出實際的手機,可以由繼承CellPhoneSale類別,決定實際製造出的手機是哪種區域特色。

簡單工廠的做法可以將物件的建立封裝起來,但是不具備工廠方法的彈性,因為簡單工廠不能改變正在建立的產品!

 

本例中採用了台灣跟大陸的區域性,既使只有一個concreteCreator,工廠方法模式仍然很有用,因為我們將產品的實踐與使用予以decouple(鬆綁)

如果增加了產品的實踐,Creator並不會受到影響,因為Creator與ConcreteProduct之間並沒有緊密的結合綑綁。

 

工廠將建立物件的程式碼封裝集中在一個物件或行為中,可以避免程式碼的重複,並更方便以後的修改,

也意味在進行物件實體化時,只會用到介面,而不再依賴具象類別。這正是幫助針對介面寫程式,而不是針對實踐寫程式。

然而,在工廠方法模式之中,不可避免的仍然必須使用具象類別,實體化真正的物件不是嗎?

 

事實上還有相依性的謎題沒有解呢…

 

我們常直接實體化一個物件,就是在依賴具象類別,如同本最一開始的角色扮演案例,或是手機分公司案例

假如不知道工廠方法模式的話,那就會在一個類別中去new新物件,去實體化所有的手機或是角色物件,而不是委由工廠製造。

以手機分公司為例,假如一個手機分公司完全依賴所有的手機物件(因為直接在CellPhoneSale類別去建立手機),那麼一旦類別的實踐方式改變了,就要去

修改這個手機分公司的程式。這正是依賴具象類別(產品類別)的實例,相依性如下圖。

6

 

本書精華來了…

OO守則:請依賴抽象類別,不要依賴具象類別!

這有一個專有名詞叫dependency Inversion Principle。

這很酷,程式碼減少對於具象類別的依賴是件好事,這守則聽起來滿像請對介面寫程式那個守則,然而,本守則是指不要讓高階元件(公司)依賴低階元件(產品)

不論高低階,兩者都應該相依於抽象類別!

所以這樣的相依性該如何解決呢?回想一下工廠方法,針對產品去建立一個抽象的類別,再由產品的具象類別去實作各自的方法

7

看看上面,目前高階元件(CellPhoneSale,以及這些手機(caMusicPhone、twBusiness2Phone等)都依賴了CellPhone抽象,而遵循這個OO守則

工廠方法模式正是最有威力的技巧之一。

那為什麼會有Inversion呢?走一次我們思考問題的流程吧!以書中的例子為例,假如現在要開一間pizza店

你第一個會想到的就是pizza店,那產品是什麼呢?因此要做出來的產品就是pizza,但是你不想讓pizza店依賴pizza產品,不然將全部依賴具象類別。

所以要開始反轉,先從pizza開始,能抽象成什麼類別呢?

對!就是pizza(抽象)類別,那要這麼做,必須靠一個工廠來取出這些pizza的具象類別,一旦這樣做,各種具象pizza的產品就只能依賴一個抽象了。

那pizza店呢?也會依賴那個抽象。

 

關於dependency Inversion Principle有一些指導方針(見原書)

1.變數不可以持有具象類別的參考(如果使用new,就會持有具象類別的參考,這時請改用工廠避開)

2.不要讓類別繼承自具象類別(請繼承自一個抽象或介面)

3.不要讓次類別中的方法override超類別中的方法。(如果這樣,就不是一個真正適合被繼承的抽象)

要盡量去達到,而不是隨時都要遵行,有趣的是我們常常直接實體化具象類別,那就是字串物件(字串型態是參考型別),但這個合理嗎?

很合理,因為字串不會改變(假如我們將指派新的字串時,其實只是將原本的指標指向新的字串物件的位址)

 

後記:工廠模式還沒完,本篇依然延續先前,在閱讀過深入淺出設計模式一書後,以C#實作程式碼並重新構思案例來輔佐自己理解,工廠模式是個精華

要吸收要反覆地閱讀與體會,不過很高興又學會了一個設計模式。然而為什麼工廠模式還沒完?因為還有一個抽象工廠模式等著我。

不過這波未平,先別躁進,能體會出本工廠方法模式以後,再進到下一個模式也永遠不遲呢。玩玩看吧。