CUBA:如何準備上線
「在我電腦上是好的呢!」現在看來,這句話更像是調侃開發人員的一個段子,但是「開發環境與生產環境」之間的矛盾依然存在。作為開發者,你需要記住,你寫的應用總會有在生產環境上線的一天。在本文中,我們將討論一些 CUBA 的特性,能幫助你避免在應用程序上線時遇到問題。
編碼準則
使用服務(Services)
幾乎每一個 CUBA 應用程序都會實現一些業務邏輯算法。實現業務邏輯的最佳實踐就是所有的業務代碼都在 CUBA 服務(CUBA Services)中實現。所有其他的類,包括界面控制器、應用程序監聽器等等,應當將業務邏輯全部交由服務來代理執行。這個方式的好處是:
1. 對於一個業務邏輯來說,只在一處實現。
2. 可以在不同的地方調用此業務邏輯,並能將其作為開放的 REST 服務。
請注意,業務邏輯中一般會包括條件、循環等。在理想情況下,對服務的調用應該是單線的。比如,我們假設在控制器中有如下代碼:
Item item = itemService.findItem(itemDate);
if (item.isOld()) {
itemService.doPlanA(item);
} else {
itemService.doPlanB(item);
}
如果你看到這種代碼,可以考慮在 itemService 服務中創建一個單獨的方法 processOldItem(Date date) ,將這段分支判斷邏輯遷移到服務中實現,因為這段代碼看上去更像是業務邏輯的一部分。
由於界面和 API 可能會是不同的團隊開發,所以保持業務邏輯只存在一個地方可以避免生產環境中應用程序行為的不一致。
使用無狀態
當開發 web 應用程序時,需要時刻記得將會有多個用戶同時使用你的程序。在代碼中,意味着有些代碼會被多個線程同時執行。幾乎所有的應用程序組件,比如服務、bean 以及事件監聽器都會受到多線程執行的影響。這裡的最佳實踐就是讓組件保持無狀態。 也就是說不要引入共享的可變類成員。 使用局部變量並將會話級特定的信息保留在用戶之間不共享的應用程序存儲中。 例如,可以在用戶會話中保留少量可序列化的數據。
如果需要共享更多的數據,可以使用數據庫或者專門的內存共享存儲,比如 Redis。
使用日誌
有時,生產環境會出一些問題。當問題產生時,很難搞清楚到底是什麼引起的問題,因為沒辦法在生產環境調試。所以為了使你自己、團隊以及支撐團隊將來的工作更加輕鬆,為了能明白問題怎麼發生的,以便能重現,最好在應用程序中添加適量的日誌。
此外,日誌還扮演了被動監測的角色。在應用程序重啟、升級或者重新配置之後,管理員通常通過查看日誌確保所有的程序都啟動成功。
日誌還能幫助解決可能不是在你的應用程序中發生的問題,而是應用程序集成的其他服務中。 例如,要弄清楚為什麼支付網關會拒絕某些交易,可能需要記錄所有數據,然後在與支撐團隊溝通時提供這些數據。
CUBA 使用了經過驗證的 slf4j 庫作為日誌切面和 logback 實現。 只需要向類代碼中注入日誌工具類,就可以用了:
@Inject
private Logger log;
然後直接調用:
log.info("Transaction for the customer {} has succeeded at {}", customer, transaction.getDate());
請記住,日誌消息需要有意義並包含足夠的信息以便了解在應用程序中到底發生了什麼。
還有,CUBA 自帶性能統計日誌,所以你可以看到應用程序對服務器資源的消耗情況。當收到用戶抱怨應用程序很慢時,這些日誌非常有用,用它們能更快的找到性能瓶頸。
異常處理
異常是非常重要的,在應用程序發生錯誤時,它們能提供非常有價值的信息。因此,第一條規則就是在代碼中永遠不要忽視任何異常。使用 log.error() 方法創建一個有含義的消息,添加上下文和 stack trace。這個消息是你能用來定位錯誤的唯一信息。
如果你有編碼規範,那麼添加一章關於錯誤處理的章節吧。
我們看一個例子 – 上傳一張用戶的資料照片至應用程序。這個資料照片會使用上傳 API 服務並存儲在 CUBA 的文件存儲中。
如果不處理異常,代碼是這樣:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd);
} catch (Exception e) {}
如果發生了錯誤,鬼才會知道。用戶會很奇怪,我的照片怎麼上不去?
下面這個好一些,但是還不理想:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd);
} catch (FileStorageException e) {
log.error (e.getMessage)
}
日誌中會保存一條錯誤信息,但是我們只捕獲了特定的異常類。也沒有提供有關上下文的信息:文件的名稱是什麼,誰曾嘗試上傳文件。 而且,由於沒有 stack trace 的信息,因此很難找到異常發生的位置。 還有,用戶根本不會知道這裡發生了錯誤。
下面這個應該是一個比較好的方式:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd); } catch (FileStorageException e) {
throw new RuntimeException("Error saving file to FileStorage", e);
}
我們知道錯誤是什麼,也沒有丟失原始異常,添加了一條有意義的消息文本。 調用方法將收到有關異常的通知。 我們可以將當前用戶名以及上傳文件名添加到消息中,以增加更多上下文數據。 這是 CUBA Web 模塊的示例。
在 CUBA 應用程序中,由於其分佈式特性,你可能在 Core 模塊和 Web 模塊具有不同的異常處理規則。 文檔中有一個關於異常處理的特殊部分。 在實現異常處理時,建議先閱讀該章節。
環境相關的配置
開發應用程序時,請試試將應用中與環境相關的部分獨立出來,然後試用功能開關和配置文件根據環境切換這些部分的功能。
使用恰當的服務實現
CUBA 中的任何服務都包含兩個部分:接口(服務 API)及其實現。有時候,服務的實現依賴於部署環境。我們拿文件存儲服務作為例子。
在 CUBA 中,可以使用文件存儲來保存發送至應用程序的文件,然後可以在服務中使用這些文件。其默認實現是使用服務器上的本地文件系統保存文件。
但是,當你部署應用程序至生產服務器時,如果使用了雲環境或者集群部署,這個實現就不一定能正常工作了。
如果要使用針對環境的不同服務實現,CUBA 支持運行時配置,可以根據啟動參數或者環境變量使用特定的服務實現。
對於上面這個例子,如果我們想用 Amazon S3 作為生產環境文件存儲的實現,可以用下面的方式指定這個 bean:
<beans profile="prod"><bean name="cuba_FileStorage" class="com.haulmont.addon.cubaaws.s3.AmazonS3FileStorage"/></beans>
然後,在設置了下面這個屬性時,會自動啟用基於 S3 的實現:
spring.profiles.active=prod
因此,當開發 CUBA 應用程序時,嘗試將環境相關的服務先定義出來,然後為每種環境啟用適當的服務實現。不要寫下面這種代碼:
If (「prod」.equals(getEnvironment())) {
executeMethodA();
} else {
executeMethodB();
}
此時,需要實現一個單獨服務 myService 帶有一個方法 executeMethod() 和兩個實現類,然後通過配置文件來配置。這樣在調用此服務的地方,只有:
myService.executeMethod();
乾淨、簡單、易維護。
將配置參數外部化
如果可能的話,將應用程序的配置提取到屬性文件中。 如果參數將來可以修改(即使概率很小),也需要將其外部化。代碼中應盡量避免將連接URL、主機名等作為純字符串,並且千萬不要到處複製粘貼。 在代碼中更改硬編碼(hardcode)值的成本要高得多。 郵件服務器地址、用戶的照片縮略圖大小、沒有網絡連接時的重試次數,等等所有這類配置都是需要外部化的屬性。 使用配置接口,並在你的類中注入即可獲取配置值。
使用運行時配置將環境相關的屬性放在單獨的文件中。
還是拿支付網關舉個例子。 首先,我們不會在開發過程中使用真正的鈔票來測試功能。 因此,會有一個用於本地環境的網關存根,一個在網關端用於預發佈測試環境的測試 API 和一個用於生產環境的真實網關。 顯然,這些環境的網關地址不同。
別這麼寫代碼:
If (「prod」.equals(getEnvironment())) {
gatewayHost = 「gateway.payments.com」;
} else if (「test」.equals(getEnvironment())) {
gatewayHost = 「testgw.payments.com」;
} else {
gatewayHost = 「localhost」;
}
connectToPaymentsGateway(gatewayHost);
而是,定義三個屬性文件:dev-app.properties、test-app.properties 以及 prod-app.properties,分別在其中定義 payment.gateway.host.name 的值。之後,在項目中添加一個配置接口:
@Source(type = SourceType.DATABASE)
public interface PaymentGwConfig extends Config {
@Property("payment.gateway.host.name")
String getPaymentGwHost();
}
然後可以在代碼中注入該接口並使用:
@Inject
PaymentGwConfig gwConfig;
// 其它代碼
connectToPaymentsGateway(gwConfig.getPaymentGwHost());
這樣的話,代碼會很簡單,所有的設置都在屬性文件中,如果環境有變,也不需要在代碼中查找了。
添加網絡超時處理
開發時,要始終認為通過網絡進行的服務調用不可靠。當前用於 Web 服務調用的大多數第三方庫都基於同步阻塞通信模型。也就是說,如果從主執行線程調用 Web 服務,則應用程序將暫停直到收到響應為止。
即使在單獨的線程中執行 Web 服務調用,由於網絡超時,該線程也有可能永遠無法恢復執行。
有兩種類型的超時:連接超時和數據讀取超時。
在應用程序中,這些超時類型應分開處理。我們還是看看支付網關的例子。此時,讀取超時可能明顯長於連接超時。銀行交易可以處理很長的時間,數十秒,最多幾分鐘。但是連接應該很快,那麼這裡我們可以將連接超時設置為一個稍長的時間,比如10秒。
超時時限的配置可以移至屬性文件,這基本上是一個最典型的例子了。為需要通過網絡交互的所有服務設置該值。以下是服務bean定義的示例:
<bean id="paymentGwConfig" class="com.global.api.serviceConfigs.GatewayConfig"><property name="connectionTimeout" value="${xxx.connectionTimeoutMillis}"/><property name="readTimeout" value="${xxx.readTimeoutMillis}"/></bean>
在你的代碼中,需要包含一段專門用來處理超時的代碼。
數據庫準則
數據庫是幾乎所有應用程序的核心。 在部署和更新生產環境時,不破壞數據庫是非常重要的。 除此之外,工作負載方面,開發人員工作站上的數據庫顯然與生產服務器不同。 因此,可能要實施以下描述的一些做法。
生成特定環境的數據庫腳本
CUBA 中,我們能生成創建和更新應用程序數據庫的 SQL 腳本。在生產環境第一次數據庫創建之後,只要數據模型發生改動,CUBA 框架都會生成更新腳本。
文檔中有一個章節專門介紹生產環境的數據庫更新。請在第一次部署生產環境之前仔細閱讀。
最終建議,在更新數據庫之前先做備份。這樣做能節省很多時間,並且也不用擔心出問題。
考慮多租戶
如果你的項目是一個多租戶項目,請在項目開始時就將多租戶的場景設計好。
CUBA 通過擴展插件支持多租戶,該擴展會引入一些應用程序數據模型以及數據庫查詢語句邏輯的改動。比方說,對於租戶場景下的實體,會添加 tanantId 這一列。因此,所有查詢這些實體的查詢語句都被隱式的做了修改,以便能使用該列作為查詢條件。也就是說,如果你需要寫自定義 SQL 查詢,需要考慮此列的存在。
請注意,由於上面提到的特定功能,向在生產環境中運行的應用程序添加多租戶功能可能很棘手。 為了簡化遷移,請將所有自定義查詢保留在同一應用程序層中,最好保留在服務中或單獨的數據訪問層中。
安全性考慮
對於可以被多個用戶訪問的應用程序,安全性起着重要的作用。 為避免數據泄漏、未經授權的訪問等,需要認真考慮安全性。下面會介紹一些原則,這些原則可以幫助改善系統的安全性。
安全的編碼
提到安全性,首先就要寫出能阻止問題產生的代碼。 可以在此處找到Oracle提供的有關安全編碼的參考。 下面我們介紹其中的一些(也許很明顯的)建議。
準則 3-2 / INJECT-2: 避免動態 SQL
大家都知道,動態創建的 SQL 語句(帶有不受信任的輸入)容易受到命令注入的攻擊。 在 CUBA 中,你很可能需要執行 JPQL 語句,因此,也請避免使用動態 JPQL。 如果需要添加參數,請使用適當的類和語句語法:
try (Transaction tx = persistence.createTransaction()) {
// get EntityManager for the current transaction
EntityManager em = persistence.getEntityManager();
// create and execute Query
Query query = em.createQuery(
"select sum(o.amount) from sample_Order o where o.customer.id = :customerId");
query.setParameter("customerId", customerId);
result = (BigDecimal) query.getFirstResult();
// commit transaction
tx.commit();
}
準則 5-1 / INPUT-1: 驗證輸入
在使用前,必須驗證來自不受信任來源的輸入。 惡意的輸入可能會導致問題,無論是通過方法參數還是外部流。 比如整數值溢出和通過在文件名中包含「 ../」序列的目錄遍歷攻擊。 在CUBA中,除了在代碼中檢查之外,還可以在 GUI 中使用驗證器。
以上只是安全編碼原理的幾個簡單例子。 請仔細閱讀該指南,能在多方面改善你的代碼安全性。
保護個人數據安全
由於法律要求,某些個人信息應受到保護。 在歐洲,有通用數據保護條例(GDPR),在美國的醫療應用中,有健康保險攜帶和責任法案(HIPAA)要求等,中國近年來對個人信息的保護也上升到了一個新的高度,不少公司因為泄露個人隱私的問題而被調查。因此,在實施項目時要考慮到這一點。
使用 CUBA 可以設置各種權限,並使用角色和訪問組限制對數據的訪問。 在訪問組中,還可以定義各種約束,以防止未經授權訪問個人數據。
但是訪問權限控制只是確保個人數據安全的一部分。 數據保護標準和行業特定要求還有很多。 在規劃應用程序的體系結構和數據模型之前,需要先了解這些要求。
修改或者禁用默認用戶和角色
當使用 CUBA 框架創建應用程序時,系統會創建兩個默認用戶:admin 和 anonymous。最好在系統上到生產環境之後,客戶開始用之前修改這兩個用戶的默認密碼。可以手動修改或者在 30-….sql 的初始化腳本中添加 SQL 語句修改。
按照 CUBA 文檔的推薦步驟可以很好的配置生產環境需要使用的角色。
如果應用程序需要支持複雜的組織結構,可以考慮在組織機構級別為每個分支機構創建本地管理員,而不是創建多個「超級管理員」。
導出角色至生產環境
在第一次部署之前,通常需要從開發環境或預發佈(staging) 環境將角色和訪問組複製到生產服務器。 在CUBA中,可以使用內置的管理界面來執行此操作,而不必手動從數據庫複製。
導出角色和權限配置可以使用 Administration->Roles 界面內的導出按鈕。下載導出的文件之後,可以在生產環境使用該界面進行導入。訪問組也有類似的按鈕操作。
配置應用程序
生產環境通常與開發環境不同,應用程序配置也會不同。也就是說,需要進行一些其他檢查,以確保應用程序在生產時能夠平穩運行。
日誌配置
確保已針對生產環境正確配置了日誌子系統:日誌級別已設置為所需級別(通常為 INFO),並且在應用程序重啟時不會刪除日誌。 可以參考文檔了解正確的日誌設置和有用的 Logger。
如果使用 Docker,請使用 Docker volumes 將日誌文件存儲在容器外部。
為了進行正確的日誌記錄分析,可以部署特殊的工具來收集、存儲和分析日誌。 比如 ELK 家族或者 Graylog。 建議將日誌記錄軟件安裝到單獨的服務器上,以避免對應用程序造成性能影響。
在群集配置中運行
可以將CUBA應用程序配置為在群集中運行。 如果決定使用此功能,則需要注意你的應用程序架構,否則,可能會有不符合期望的行為出現。 下面幾點針對群集環境需要專門調整的最常用功能:
計劃任務
如果要在應用程序中執行計劃任務,例如每天生成報告或每周發送電子郵件,則可以使用相應的框架內置功能。 但是,請想像一下,如果你是一位同時收到了三封相同營銷電子郵件的客戶。你會覺得開心嗎?如果計劃任務在三個群集節點上都執行,則很可能會發生這種情況。 為避免這種情況,最好使用 CUBA 任務計劃程序,該程序可以創建單例(singleton)任務。
分佈式緩存
緩存是可以提高應用程序性能的東西。有時開發人員會嘗試把幾乎所有的數據都進行緩存,因為現在內存非常便宜。但是,當應用程序使用集群部署在多台服務器上時,將在服務器之間分配緩存,還會做緩存同步。如果同步過程發生在相對較慢的網絡連接上,這可能會增加響應時間。所以在決定添加更多緩存之前(尤其是在集群環境中),需要先進行負載測試並衡量性能。
結論
CUBA 平台簡化了開發過程,所以很可能你會提前完成開發並開始考慮投入生產。可是,無論是否使用 CUBA,部署都不簡單。如果能在開發的早期階段就開始思考部署流程並遵循本文所述的簡單規則,那麼你的應用程序投入生產的過程應該會很順利,所需的工作量很小,並且不會遇到嚴重的問題。