###### tags: `tutorial` 應用程式設計 === :dart: 本文描述基於網際網路通訊協定([RFC 791](https://www.rfc-editor.org/rfc/rfc791))提供服務的服務端應用程式的一般原則、設計方法與開發要求。與應用程式有關的觀念與原則將於後面章節內闡明。這些內容將有助於引導你掌握下面內容: 1. 應用程式的基本要求。 2. 構成應用程式的 6 個主體元件(component)。 3. 應用程式的啟動與關閉作業流程。 4. 應用程式配置與配置屬性的規劃原則。 <!-- 4. 你不需要擔心的特色。 5. 你需要注意的項目。 6. 你需要的撰寫的項目。 --> --- ## 基本要求 ⠿ 應用程式從無到有會跨越*開發*(develop)、*除錯*(debug)、*測試*(test)、*部署*(deploy)與*監控*(monitor)等數個階段。因此在設計階段時,須考量如何滿足各階段的需求。以下條列應用程式應該具備的基本要求: 1. 應用程式能結合 CI/CD 工作流。 2. 具備同時運行於目標作業系統與容器相容環境(如:Docker、Kubernetes)。 3. 具備正常終止機制(graceful shutdown)。 4. 具備同時使用系統環境變數、.env 檔案、命令列參數、文件配置檔…等配置應用程式的資源與設定值。 5. 提供健康檢查機制(heathy check)。 6. 日誌時間使用 UTC+0 表示。 $~$ ## 架構與組成 ⠿ 應用程式的主體架構由 6 個元件(component)組成,分別是 **Host**、**App**、**Config**、**ServiceProvider**、**Router** 與 **RequestHandler**。 ```plantuml @startuml skinparam class { BackgroundColor<<Main>> LightBlue BorderColor<<Main>> MidnightBlue FontColor<<Main>> MidnightBlue StereotypeFontColor<<Main>> MidnightBlue } class Main <<Main>> { +<color:Olive>{static} main(argv) : void</color> } class Host { +Start() : void +Stop() : void -init() : void } class App { -host -config -serviceProvider -router +Run() : void -configureConfig() : void -configureServiceProvider() : void -configureServiceMiddlewares(middlewares) : void } class ServiceProvider { -init(Config) : void -stop() : void } class Config { -init() : void } class RoutePath { +Method +Path } class Router { -requestHandlers : Map<String, RequestHandler> +GetHandler(RoutePath) : RequestHandler +AddHandler(RoutePath, RequestHandler) : void } class RequestHandler { -serviceProvider +ProcessRequest(ctx) : void } Main -down-> App : > run App *-right- Host : -host App *-down- ServiceProvider : -serviceProvider App *-down- Config : -config App *-left- Router : -router ServiceProvider .right. Config : > use RequestHandler o-right- ServiceProvider : -serviceProvider Router .down. RequestHandler : > use Router .up. RoutePath : > use @enduml ``` $~$ 這些元件的功能說明如下: * **Host** - 應用程式的網路引擎。決定應用程式所使用的網路協定與網路運作模式;如:socket、TCP、IP、HTTP(s)、TLS、web socket、Paxos、raft……等。其功能包含**網路協定控制**、**封包處理**、**網路連線狀態管理**、**共識機制**(consensus)。 * **App** - 應用程式主框架。負責應用程式的啟動與關閉的控制。其主要控制作業如下: * 應用程式就緒前依序進行下面的初始化作業: 1. 載入配置。 2. 初始化服務提供元件。 3. 註冊中繼元件(middlewares)。 4. 初始化路由並註冊處理函式。 5. 開始監聽請求。 * 應用程式收到關閉命令時會依序進行下面清理作業: 1. 停止監聽請求並關閉路由。 2. 等待未完成的處理函式結束作業。 3. 呼叫服務提供元件的中止程序。 4. 停止應用程式。 * **Config** - 應用程式的配置。整合應用程式運行所需的外部配置。如:系統環境變數、YAML檔案、JSON檔案、CSV檔案、Sqlite檔案、二進制檔案、命令列參數……等。 * **ServiceProvider** - 應用程式的服務提供元件。提供應用程式服務的外部資源,如:MariaDB數據庫元件、Redis操作元件、自訂的session管理元件、特定的WebAPI操作元件、支付API操作元件……等。 * **Router** - 應用程式路由。依照所註冊規則,決定並呼叫處理函式處理請求。 * **RequestHandler** - 應用程式的處理函式。依業務需求實作處理請求的處理邏輯。 $~$ ## 啟動與關閉 ⠿ 應用程式的啟動與關閉作業流程如下: ```plantuml @startuml participant Main participant App participant Host participant Config participant ServiceProvider participant Router participant RequestHandler == start application == [-> Main : main() activate Main Main -> App : Run() activate App App -> Config : init() activate Config App <-- Config App -> App : configureConfig() App -> ServiceProvider : init() activate ServiceProvider App <-- ServiceProvider App -> App : configureServiceProvider() App -> App : configureServiceMiddlewares(...) App -> Router : <<create>> activate Router App <-- Router App -> Router : AddHandler(...) App <-- Router App -> App : <<start stop listener>> activate App App -> Host : init() activate Host App <-- Host App --> Config : <<destory>> destroy Config App -> Host : Start() Host -> Host : <<start request listener>> activate Host == running == [-> App : <<send request>> App -> Router : GetHandler() App <-- Router App -> RequestHandler : ProcessRequest() activate RequestHandler == stop application == [-> Main : <<send exit>> Main -> App : <<notify stop>> App -> Host : Stop() Host --> Router : <<destory>> destroy Router Host -> Host : <<waiting RequestHandler finished>> activate Host App <-- RequestHandler : <<response>> Host <-- RequestHandler : <<end request>> deactivate Host deactivate Host deactivate Host [<-- App : <<send response>> destroy RequestHandler App -> ServiceProvider : stop() App <-- ServiceProvider deactivate ServiceProvider deactivate App Main <-- App deactivate App Main -> Main !! : <<os exit()>> destroy Main @enduml ``` $~$ ## 應用程式配置 ⠿ 應用程式的屬性配置決定應用程式的運作方式與資源操作。比如一個應用程式需要Redis快取服務(cache),因此必須在應用程式啟動時便設置好相關的服務IP地址、連接埠(port)與密碼。否則應用程式將無法使用快取服務。 ### 配置類型 ⠿ 無論在計算機作業系統或容器環境,都能提供應用程式配置屬性的操作方式,不同的配置方式有不同的特性,下面說明主要的三種配置類型: 1. **環境變數** 環境變數提供運作於環境中的不同應用程式與背景服務使用的共用全域變數(global variables)。環境變數由環境本身的系統管理,具備持久性,但不具備可移植性;配置屬性於不同的環境會有不同的值。下圖說明一個應用程式,分別於電腦作業系統 `local on linux`、`dev on kubernates` 與 `prod on kubernates` 三個不同環境中,從環境變數 `CacheAddress` 配置中取得正確的 redis 服務連線位址。 ```plantuml allowmixing rectangle "local on linux" as local { json "env" as env_local { "CacheAddress":"127.0.0.1:6379" } component "application" as app_local #wheat database "redis" as redis_local } rectangle "dev on kubernates" as dev { json "config map/env" as env_dev { "CacheAddress":"192.168.54.32:6380" } component "application" as app_dev #wheat database "redis" as redis_dev } rectangle "prod on kubernates" as pord { json "config map/env" as env_prod { "CacheAddress":"10.217.5.38:6380" } component "application" as app_prod #wheat database "redis" as redis_prod } env_local .down. app_local : > import app_local <-right-> redis_local env_dev .down. app_dev : > import app_dev <-right-> redis_dev env_prod .down. app_prod : > import app_prod <-right-> redis_prod ``` > :pushpin: .env(dot env)檔案開始運用於應用程式與服務中使用,就其性質而言,並不屬於**環境變數**,可以歸類為後面提到的**應用程式配置檔**。 2. **應用程式配置檔** 應用程式配置檔是提供專屬應用程式配置參數的文件檔,其檔案格式是依照應用程式所需量身訂做。檔案是配合應用程式開發時一同製作的,因此具備持久性與可移植性,故無論在任何環境下都檔案的內容大多是相同的。檔案常見的格式有 xml、ini、json、yaml…等等。下圖說明一個應用程式,分別於電腦作業系統 `local on linux`、`dev on kubernates` 與 `prod on kubernates` 三個不同環境中,從指定的 config.yaml 配置檔中取得 `CacheDatabase` 的配置來操作指定的 redis 數據庫紀錄。 ```plantuml allowmixing rectangle "local on linux" as local { component "application" as app_local #wheat { json "config.yaml" as config_local { "CacheDatabase":"5" } } database "redis" as redis_local } rectangle "dev on kubernates" as dev { component "application" as app_dev #wheat { json "config.yaml" as config_dev { "CacheDatabase":"5" } } database "redis" as redis_dev } rectangle "prod on kubernates" as pord { component "application" as app_prod #wheat { json "config.yaml" as config_prod { "CacheDatabase":"5" } } database "redis" as redis_prod } app_local <-right-> redis_local app_dev <-right-> redis_dev app_prod <-right-> redis_prod ``` > :pushpin: 配置檔可視為將應用程式寫死(hard code)的設定值或常數值轉移的目標儲存體的替代方式,故避免狀將機敏性資訊保留,比如:帳戶、密碼、私鑰…等。 3. **命令列啟動參數** 命令列啟動參數在系統命令行隨同啟動應用程式命令一同設定,經由應用程式剖析與載入。因此命令列參數不具備持久性,一旦應用程式重啟,就必須重新設定。 > :bomb: 多數初學者常犯的錯誤,是將配置參數以寫死(hard coding)的方式置於原始碼中。這種配置方式將會破壞應用程式的可移植性(portability),使得應用程式將難以在不同的環境下運行。 $~$ ### 階層式配置 ⠿ 應用程式從無到完成會跨越數個執行環境——本機、內部網路環境、kubernates…等等。因此在任何階段或任何環境,應用程式都需要有適當的配置才能運作。階層式配置方案結合各種配置方式的特質,這個方案可以相容本機和主機叢集(host cluster)系統,無論在開發階段或部署作業都使用一致的配置方式,添加新的環境設定不會影響到原本存在的環境配置內容,環境服務的異動與調整無需異動應用程式的配置且不需要重新部署,以及密碼等有關安全性配置隔離管理…等各方面都達到微妙的平衡。 有關階層式配置將區分*概念*與*規劃設計*兩部份進行說明;這個小節就概念部份,說明應用程式如何整合不同的配置方式與優先級;下個小節說明配置屬性的規劃原則。 下圖說明應用程式運用階層式配置的情況與不同配置方式的互動關係: ```plantuml component Application #wheat usecase "environmane variables" as EnvVar file config.yaml usecase "cli arguments" as CliArgs rectangle "config.${env}.yaml" as config.env.yaml { file config.local.yaml file config.dev.yaml file config.test.yaml file config.uat.yaml file config.prod.yaml file config.xxx.yaml } Application -up-> EnvVar Application -left-> CliArgs Application -down-> config.yaml Application -right-> config.env.yaml ``` $~$ 應用程式階層式配置的操作方式是依順序載入**環境變數**、**應用程式配置檔**及作為**應用程式配置檔**延伸的**具環境標籤的應用程式配置檔**,最後載入**命令列啟動參數**。配置的屬性名稱若相同,會以載入順序較晚的取代,即具有相同配置屬性名稱,載入順序較晚的優先權較高。 > **範例1**:下圖說明一個應用程式在一個作業系統中,使用命令啟動兩個處理序(process)時,應用程式 Config 所呈現的配置屬性值。 > > :warning: 命名為 `ListenPort` 的屬性值同時配置於 *config.yaml* 與 Process 2 的*命令列啟動參數*中。於 Process 2 中運行的應用程式依優先級,最終採用 `22222` 這個值作為 `ListenPort` 的屬性值。 ```plantuml @startuml allowmixing rectangle OperationSystem #seashell { json "**evironment variables**" as EvironmentVariables { "**Environment**" :"dev", "**CacheHost**" :"192.168.56.34", "**CachePort**" :1234, "**CachePassword**" :"P@ssw0rd" } file "config.yaml" as config.yaml_file #khaki { json "**content**" as config.yaml { "**CacheNamespace**" :"myspace", "**ListenPort**" :11122 } } rectangle "Process 2" as Process_02 #beige { json "**cli arguments**" as Arg_02 { "**UseCompression**" :false, "**ListenPort**" :22222 } component Application as Application_02 #wheat { json "**Config**" as Config_02 #lightblue-azure { "**Environment**" :"dev", "**CacheHost**" :"192.168.56.34", "**CachePort**" :1234, "**CachePassword**" :"P@ssw0rd", "**CacheNamespace**":"myspace", "**ListenPort**" :22222, "**UseCompression**":false } } } rectangle "Process 1" as Process_01 #beige { json "**cli arguments**" as Arg_01 { "**UseCompression**" :true } component Application as Application_01 #wheat { json "**Config**" as Config_01 #lightblue-azure { "**Environment**" :"dev", "**CacheHost**" :"192.168.56.34", "**CachePort**" :1234, "**CachePassword**" :"P@ssw0rd", "**CacheNamespace**":"myspace", "**ListenPort**" :11122, "**UseCompression**":true } } } } Config_01 <-up- EvironmentVariables #red Config_02 <-up- EvironmentVariables #blue Config_01 <-up- config.yaml #red Config_02 <-up- config.yaml #blue Config_01 <-down- Arg_01 #red Config_02 <-down- Arg_02 #blue @enduml ``` $~$ > **範例2**:下圖說明應用程式在命名為 dev 的 kubernates 運行時,應用程式 Config 所呈現的配置屬性值。由於應用程式所在的dev環境配置了 `Environment` 的屬性值,應用程式在載入 *config.yaml* 後,便接著載入命名為 *config.dev.yaml* **具環境標籤的應用程式配置檔**。而 *config.prod.yaml* 則因為不匹配環境 `prod` ,故忽略。 ```plantuml @startuml allowmixing rectangle "kubernates on dev" as Kubernates_dev #seashell { json "**ConfigMap**" as ConfigMap_dev #mistyrose { "**Environment**" :"dev", "**CacheHost**" :"192.168.56.34", "**CachePort**" :1234, "**CachePassword**" :"P@ssw0rd" } json "**config.yaml**" as config.yaml_dev #khaki { "**CacheNamespace**" :"myspace", "**ListenPort**" :11122, "**UseCompression**" :false } json "**config.dev.yaml**" as config.dev.yaml_dev #khaki { "**BacklogSize**" :8 } json "**config.prod.yaml**" as config.prod.yaml_dev { "**BacklogSize**" :32, "**UseCompression**" :true } component Application as Application_dev #wheat { json "**Config**" as Config_dev #lightblue-azure { "**Environment**" :"dev", "**CacheHost**" :"192.168.56.34", "**CachePort**" :1234, "**CachePassword**" :"P@ssw0rd", "**CacheNamespace**":"myspace", "**ListenPort**" :11122, "**UseCompression**":false, "**BacklogSize**" :8 } } } Config_dev <-up- ConfigMap_dev #blue Config_dev <-down- config.yaml_dev #blue Config_dev <-down- config.dev.yaml_dev #blue Config_dev x.down.x config.prod.yaml_dev #red;text:red : unused @enduml ``` $~$ > **範例3**:下圖說明應用程式在命名為 prod 的 kubernates 運行時,應用程式 Config 所呈現的配置屬性值。由於應用程式所在的dev環境配置了 `Environment` 的屬性值,應用程式在載入 *config.yaml* 後,便接著載入命名為 *config.prod.yaml* **具環境標籤的應用程式配置檔**。而 *config.dev.yaml* 則因為不匹配環境 `dev` ,故忽略。 > > :warning: *config.prod.yaml* 的 `UseCompression` 的值被採用了,取代 *config.yaml* 所定義的值。 ```plantuml @startuml allowmixing rectangle "kubernates on prod" as Kubernates_prod #seashell { json "**ConfigMap**" as ConfigMap_prod #mistyrose { "**Environment**" :"prod", "**CacheHost**" :"10.259.7.184", "**CachePort**" :1234, "**CachePassword**" :"C3Gg0y6YuI" } json "**config.yaml**" as config.yaml_prod #khaki { "**CacheNamespace**" :"myspace", "**ListenPort**" :11122, "**UseCompression**" :false } json "**config.dev.yaml**" as config.dev.yaml_prod { "**BacklogSize**" :8 } json "**config.prod.yaml**" as config.prod.yaml_prod #khaki { "**BacklogSize**" :32, "**UseCompression**" :true } component Application as Application_prod #wheat { json "**Config**" as Config_prod #lightblue-azure { "**Environment**" :"prod", "**CacheHost**" :"10.259.7.184", "**CachePort**" :1234, "**CachePassword**" :"C3Gg0y6YuI", "**CacheNamespace**":"myspace", "**ListenPort**" :11122, "**UseCompression**":true, "**BacklogSize**" :32 } } } Config_prod <-up- ConfigMap_prod #blue Config_prod <-down- config.yaml_prod #blue Config_prod x.down.x config.dev.yaml_prod #red;text:red : unused Config_prod <-down- config.prod.yaml_prod #blue @enduml ``` $~$ ### 配置屬性規劃 ⠿ 應用程式所需要的資源,通常需要多組屬性才能提供應用程式完成配置;然而每項配置屬性若僅由應用程式的視角來判斷,是無法鑑別出差異的,故往往將全部屬性無差別地安排在同一配置類型,如:*環境變數*、*應用程式配置檔* 或 *命令列啟動參數* …等。這種配置策略在應用程式於多樣環境中運行時,需要不同程度的調整後,應用程式才能運作。對於這樣的配置屬性規劃,即使解決許多配置上的困難點,但顯然不盡理想。 > :bomb: 多數接觸應用程式的開發者,經常忽略了客觀事實的判定,習以為常地將需要的屬性值直接添加到配置檔內。配置檔雖然有良好的可攜性(portability),方便隨著應用程式遷移到其他開發環境或是擴充。但這種方式面對不同環境時的適應性(adaptive)不佳,部署到新環境的應用程式,其配置檔內有關的資源位址、存取權限大多無法使用,屬性值必須進行不同程度的調整,並且這樣的調整通常伴隨著許多瑣碎的操作,因此人為錯誤也經常一同出現。 前面說明了配置屬性規劃的發生原因與造成的結果,這個小節說明如何合理地安排配置屬性,配置屬性的規劃應該有下面幾項原則: 1. **決定屬性應該安排於哪個配置類型時**,不是整組考量、整組無差別安排,而**是每個配置屬性項目個別安排,並且從客觀事實的角度來判定**。如: * 這個配置屬性的管理是來自於環境?還是應用程式決定? * 這個配置屬性是否需要保存?或是每次啟動都不同? * 這個配置屬性是否是必要的?或是有預設值? * 這個配置屬性是否允許不同的環境,有不同的屬性值? 上述所說的**客觀事實的角度**並不是很好理解,下面有個簡單的判斷方式幫助理解: | 配置屬性名稱 | 用途 | 環境A | 環境B | 可用的配置類型 | |:----------------|:-------------|:------------|:------------|:-------------| | CacheAddress | 快取服務IP位址 | 127.0.0.1 | 10.169.3.84 | 環境變數、命令列啟動參數 | | CacheUsername | 快取服務帳戶 | admin | hackapp | 環境變數、命令列啟動參數 | | CachePassword | 快取服務密碼 | P@ssw0rd | Y8cwagm37m | 環境變數、命令列啟動參數 | | CacheDatabase | 快取數據庫名 | hackapp | hackapp | 配置檔、命令列啟動參數 | | CacheTimeout | 快取連線逾時值 | 30 | 30 | 配置檔、命令列啟動參數 | | CacheRetry | 快取連線重試 | 5 | 5 | 配置檔、命令列啟動參數 | 上面設想應用程式所需的快取服務屬性分別配置於*環境A*與*環境B*的情境。當兩個環境可能會有不同的值時,則考慮配置到*環境變數*;若兩個環境可能有相同的值時,則考慮配置到*應用程式配置檔*;上面的狀況也可以依需求添加配置到*命令列啟動參數*中。 2. **應用程式配置檔不要保存密碼等機敏性資料**。如:帳戶、密碼、私鑰…等。 3. **同一個屬性可以同時配置於多個配置類型**。如:`CacheAddress` 可同時配置於*環境變數*、*應用程式配置檔* 和 *命令列啟動參數* …等。 **結論**: 應用程式配置的處理,從來就不是業務需求,而是系統需求。且應用程式配置與業務處理的管理、要求、條件等各方面客觀事實截然不同,故不能期待使用業務處理的策略或操作方式能夠理想地解決應用程式配置的需求。 $~$ ## 服務提供元件設計 ### ServiceProvier 元件的角色與作用 ⠿ ServiceProvider 元件在應用程式的作用是負責處理應用程式外部資源的存取與操作。ServiceProvider 會在應用程式啟動階段進行初始化作業,初始化後的實例(instance),將提供給處理請求的 RequestHandler 使用,一般有下面的幾種方式: 1. ServiceProvider 註冊到**應用程式場景環境**(Application Context)元件,使得 ServiceProvider 同應用程式場景環境提供的存取範圍內使用。 > :bulb: Application Context 元件提供一組操作應用程式場景環境的方法,視需要開放給應用程式內各元件使用。有些場合由於應用程式資源的保護要求,會藉由另外提供的 Request Context 來操作場景環境,避免干涉應用程式的運作。 2. ServiceProvider 註冊到 **RequestHandler** 元件,提供處理請求時使用。 3. ServiceProvider 使用類似 Singleton Pattern 的設計模式,元件開放全域使用。 > :warning: 全域變數雖然一樣可以開放全域使用,但其特性本身特性是全域皆可以讀寫,因此應用程式無法對 ServiceProvider 提供保護。 ```plantuml @startuml actor browser rectangle Application #wheat { component Config component ServiceProvider #tomato component RequestHandler #gray } rectangle resources #line.dashed { database DB file files cloud "SMS/email\nworkstation" } browser .right.> RequestHandler #gray;text:gray : request ServiceProvider <.left. Config #gray;text:gray : use RequestHandler .up.> ServiceProvider #blue;text:blue : use ServiceProvider <-right-> resources #blue;text:blue : read/write/send @enduml ``` $~$ ### ServiceProvider 元件的生命週期 ⠿ $~$ ### ServiceProvider 元件設計原則 ⠿ 下面範例的 **EventCacheProvider** 型別,作為應用程式內存取外部活動資源的 ServiceProvider 元件,使用專屬的 **EventCacheProviderConfig** 作為型別的初始化配置內容。 ```java //java import lombok.Builder; import lombok.Getter; @Builder @Getter class EventCacheProviderConfig { private final String address; private final int connectTimeout; private final int maxAttempts; } class EventCacheProvider { private final String address; private final int connectTimeout; private final int maxAttempts; public EventCacheProvider(EventCacheProviderConfig conf) { this.address = conf.getAddress(); this.connectTimeout = conf.getConnectTimeout(); this.maxAttempts = conf.getMaxAttempts(); } } ``` <!-- $~$ ### ServiceProvider 元件的生命週期 ⠿ $~$ ### ServiceProvider 元件的設計原則 * 類別設計須考量應用程式關閉階段時,進行資源清理作業。 * ServiceProvider 類別應保有獨立的 Config ,不應直接使用應用程式的 Config 元件作為初始化配置。 * ServiceProvider 在對外部資源進行寫入操作時,須考量業務對資料一致性的要求。 * ServiceProvider 須依業務要求提供操作方法,並且也要處理異常情況;異常依嚴重性層級: * 若發生網路異常時,比如:連線逾時(timeout)錯誤或連線被拒(connect refused)錯誤時,可先進行重試,若仍失敗再抛出錯誤。 * 若發生操作服務異常訊息時, --> <!-- 服務提供元件 中介 應用程式與系統資源操作 啟動階段,關閉階段 設計 錯誤處理/自動重試 日誌 一致性 異常 1. 網路連線 2. 警告 3. 數據 4. 語法 5. 權限 6. 參數 7. 8. 操作錯誤 內部錯誤 --- ## ?? 有狀態 *6. 能夠從版本控制取得版本號。 這個框架要怎麼使用? 具備哪些功能? 我應該做什麼? 1. 你應該處理的 1. DI / IoC 2. ServiceProvier 的角色與作用 1. 設計原則 init(), deinit(), object model 2. 錯誤處理原則 連線錯誤/業務操作失敗/可否重試 3. 時間變數使用帶入參數而非直接使用 Now() 4. SOLID 3. RequestHandler 設計原則 1. 使用型別定義 arg 輸入參數,且包含序列化與參數驗證。 2. 注意交易一致性。 3. 錯誤處理原則 業務操作失敗 --- ## 服務提供元件設計原則 ⠿ ## RequestHandler 設計原則 1. 輸入參數驗證、型別轉換於 arg 內處理 2. 區分不同用途的參數模型(VO, PO, DTO) <-- 數據流角度 ## 注意事項 1. 使用檔案流而非檔案作為日誌輸出 2. 錯誤處理 -->