去年感恩節的時候,趁著 Amazon 國際運費免運的優惠,買了一台智慧家庭的 hub。本來研究了一段時間是想買 Samsung SmartThings Hub 的,不過因為搞不懂版本的差異以及剛好又沒貨(那時正好因為 COVID-19 國際貨運運量低迷,可能也有關係吧),所以最後在搜尋了一些國外的評論後,改買了 Hubitat Elevation。

圖片節錄自 Hubitat 官網 [1]
Software entities (class, modules, functions, etc.) should be open for extension, but closed for modification. Junior programmers create simple solutions to simple problems. Senior programmers create complex solutions to complex problems. Great programmers find simple solutions to complex problems. 註1:本部落格的範例程式碼在 2015 年以前的文章中,大多是以全型空白做縮排。如需服用,請自行用文字編輯器的取代功能把全型空白取代成半型空白。
- Bertrand Meyer
- Charles Connell
註2:本部落格的內容授權請參閱部落格底部的授權宣告。
去年感恩節的時候,趁著 Amazon 國際運費免運的優惠,買了一台智慧家庭的 hub。本來研究了一段時間是想買 Samsung SmartThings Hub 的,不過因為搞不懂版本的差異以及剛好又沒貨(那時正好因為 COVID-19 國際貨運運量低迷,可能也有關係吧),所以最後在搜尋了一些國外的評論後,改買了 Hubitat Elevation。

圖片節錄自 Hubitat 官網 [1]
本來在想要不要找個機會來玩玩 WebFlux,不過看了一下別人做的效能測試,看起來…好像找不太到明顯的動機…。雖然 Webflux 比 MVC 快不少,不過如果是跟 Async HTTP Client 比起來就沒有真的差太多,至少在這些商業邏輯並非真正的 bottleneck 的情況時,從 [1] 的數據看來好像沒什麼幫助….。
最近有機會重新架構一個新的專案,大家討論之後覺得想要試試 Clean Architecture 的架構,就稍微花了點時間學習了一下。不過因為只有粗略地翻過其中的一些章節,因此還只有在比較高層次概觀的理解,對於部份細節的實作還不是相當熟悉。這裡稍微紀錄一下目前為止理解的東西。
最近要跑壓力測試時,拿之前的腳本來改,結果改完後拋出 javax.net.ssl.SSLHandshakeException: Received fatal alert: certificate_required 的錯誤訊息,研究了一段時間,才發現應該是 mTLS 方面的問題…。
在手上已經有 key、certificate 以及 CA certificate 三個 PEM 檔案的情況下,如果是用 curl 的話,指令會長的像這樣:
curl --key key.pem --cert cert.pem --cacert cacert.pem $URL
不過在 JMeter 上,則有兩種作法。一種是如果 JMeter 有打開 GUI 的話,可以從 Options → SSL Manager 來指定。不過開 GUI 一般都只會用在還在寫 script 的階段,寫完正式要跑的時候,通常還是得用 CLI 模式,因此這時需要另外一種方法,也就是自己實際產生 Client Certificate 的 keystore 檔。
要產生 Client Certificate,需要用的工具就是 keytool,指令如下:
sudo openssl pkcs12 -export -name {name} -inkey {keyPath} -in {certPath} -out {keystorePath} -password {password} -noiter -nomaciter其中各個參數的意義如下:
執行後,keystore 就會出現在指定的位置了。
有了 keystore 後,因為 truststore 其實本來也有了(就是 CA certificate 那個 pem 檔),因此接著只需要在啟動 JMeter 時,讓它去吃那兩個檔就好了。其中如果你的 server 的 CA 本來就是公有的 CA,那可以不用自行指定 trustStore。
/bin/jmeter.sh \
-D javax.net.ssl.keyStore={keyStorePath} \
-D javax.net.ssl.keyStorePasswor={keyStorePassword} \
-D javax.net.ssl.trustStore={trustStorePath} \
-D javax.net.ssl.trustStorePassword={trustStorePassword}因為最近需要縮減寫到 Splunk 的 log 量,因此需要先找出從哪裡寫的 log 量最大。要想找到哪邊是寫最多 log 的地方,初步的想法,就是先以 package/class 為單位,看看哪個 package/class 的 log 最多,就會有標的可以知道我們要從哪邊下手會最有效率。
要寫 Splunk query 時,先考慮一下 log 的格式。我們的 log 是單純地用 logback 輸出成 JSON 格式,然後就讓 docker log driver 送去 Splunk,所以 log 的 JSON 內容會放在 line 欄位裡面。因此在 Splunk query 中,package/class 會被放在 line.logger 中。另外如果數字很多的時候,要看出比例的差距也不是很容易,因此可以利用 eventstats 來幫忙計算整體的占比。
index="prod" source="mylog" | eval length = len(_raw)/1048576 | stats sum(length) as package_size by line.logger | eventstats sum(package_size) AS total | eval percent = round(package_size/total*100, 2)."%" | table line.logger, package_size, percent | sort - package_size
結果如下:
| line.logger | package_size | percent |
| com.a.a.a | 37152.74659347534 | 46.89% |
| org.zalando.logbook.Logbook | 28326.010219573975 | 35.75% |
| com.b.b.b | 13741.930490493774 | 17.34% |
| com.c.c.c | 8.518047332763672 | 0.01% |
| com.d.d.d | 6.767726898193359 | 0.01% |
內容稍微打個碼 XD。不過這樣結果就很清楚了,整體來說都是最上面那三個 class 在寫 log,而且其中第一名佔據了將近一半的資料量….。
不過需要注意的是,這個數字並不一定完全能夠代表這些 log 在 Splunk 裡佔據的空間量,因為 len() 指令是在計算字元數(character)的,當使用不同 encoding 時,一個字元換算成 byte 的大小會不一樣。
Parent/Child 的關聯在實務運用上挺方便的,可以讓有重複的內容統一放在 Parent document 上,使得 Child document 只要單純繼承 Parent 就可以一併繼承那些重複的內容。不過其實它相應的限制其實也頗多,而且這個部份在 Vespa 的文件上寫得並沒有非常清楚。
近期連續遇到的狀況就是,在 Search Definition 上定義欄位時不一定會出現錯誤,但結果實際上繼承是無法生效的。舉例來說,Parent document 上如果是 Array<StructA>,其中 StructA 裡面又包含了 StructB 的狀況時,結果是部署時並不會被檢查出錯誤,但實際上 StructB 無法正常地被 Child document 繼承到。
{
"weapons": [{
"type": "sword",
"material": "iron",
"price": 300,
"position": {
"longitude": 0.0,
"latitude": 0.0
}
}, {
"type": "sword",
"material": "mythril",
"price": 9000,
"position": {
"longitude": 0.0,
"latitude": 0.0
}
}]
}以這個例子來說,Search Definition 可能會長這樣:
struct Weapon {
field type type string {}
field material type string {}
field price type double {}
field position type map<string, string> {}
}
field weapons type array<Weapon> {
indexing: summary
struct-field type { indexing: attribute }
struct-field material { indexing: attribute }
struct-field price { indexing: attribute }
struct-field position.key { indexing: attribute }
struct-field position.value { indexing: attribute }
}在這種狀況下,deployment 的檢查都會通過,但實際上 position 這個欄位是無法正常被繼承的。真的進行 Child document 的搜尋時,會發現 position 這個欄位一直都不會出現,但除了 position 以外的其他幾個欄位則都會正常出現。同時如果對 Parent document 呼叫 Document API,還是會看到包含 position 在內的所有欄位都正常地有值。
除了這個例子以外,另外嘗試在 Parent document 有非巢狀但格式有點複雜的欄位型態 map<string, array<string>> 時,結果則是在驗證階段直接拋出錯誤訊息:
For search 'childDocument', import field 'importedField': Field 'mapWithStringArray' via reference field 'weaponReference': Is not an attribute field. Only attribute fields supported
這個部份 Vespa 已經更新了文件 [3],稍微更詳細一點地說明了可支援的型態。可以參考 Vespa 的文件 [1-2]。
踩了幾次地雷以後,簡要來說,就是不要想在 Parent document 上擺什麼複雜的結構,除非那個欄位沒有要被 Child document 繼承,否則還是都用基本型別,最複雜大概就只到 array<struct> 或者 map<string, string> 這種就好了…..。
因為 Parent document 是 global document,意味著它會被放在所有 content node 的記憶體中,因此它沒有辦法使用需要用到 disk 的 index。更確切地說,是 Child document 在繼承 Parent document 的欄位時,它只能繼承到被定義為 attribute 的欄位而已,index 的欄位無法被繼承。而這個限制也同時導致了繼承下來的欄位沒辦法做 partial match,只能夠作為 filter 來使用而已。
紀錄一下最近發現的有點意外的 Reactor 反應。
Mono.justOrEmpty(getXXX())
.switchIfEmpty(fallbackXXX())
...本來預期的行為是,getXXX() 會回覆一個 Optional 物件,當回覆的 Optional 為 empty 時,才會觸發 fallbackXXX() 去呼叫外部服務。但結果在測試時發現 fallbackXXX() 每次都必然會被執行,雖然最後結果會是對的,如果 getXXX() 有回覆時,走到後面的 stream 內容會是 getXXX() 回覆的東西,但這樣就等於每次執行時都會呼叫外部服務了,而且呼叫後拿到的結果還不一定會用到。
查了好一段時間之後,發現問題好像是出在 Java 本身的 method evaluation 行為上,然後解決方法是必須把 fallbackXXX() 用 Mono.defer() 包裝起來,以達成讓 fallbackXXX() 推遲被執行的目的。
Mono.justOrEmpty(getXXX())
.switchIfEmpty( Mono.defer(fallbackXXX()) )
...雖然覺得這個步驟蠻沒意義的,不過總之我又久違地在 Windows 上開了個 Ubuntu 20.04 的 container。
在安裝 sbt 之前,需要先加入 sbt 的 repository 到 apt 的來源。
echo "deb https://dl.bintray.com/sbt/debian /" | sudo tee -a /etc/apt/sources.list.d/sbt.list curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | sudo apt-key add sudo apt-get update
接著就是安裝 Java 與 sbt。
sudo apt-get install -y default-jre sbt
以我目前這個時間點來說,會裝出來的版本如下:
Java: 11.0.7 sbt: 1.3.13
執行以下的初始化指令,讓 sbt 產生一個 http4s 的範例小專案。
sbt new http4s/http4s.g8 -b 0.21
這個指令其實本來在 http4s 的 Quick Start 裡是還有指定 sbt 版本的 -sbt-version 1.3.12,不過這裡就把它拿掉,讓它直接用剛裝的版本了。產生過程會問一些問題,這裡基本上我全都用預設值了。
接著就可以來看看產生出來的檔案有哪些內容了:
build.sbt
organization := "com.example" name := "scala-test" version := "0.0.1-SNAPSHOT" scalaVersion := "2.12.4" val Http4sVersion = "0.16.6a" val Specs2Version = "4.0.2" val LogbackVersion = "1.2.3" libraryDependencies ++= Seq( "org.http4s" %% "http4s-blaze-server" % Http4sVersion, "org.http4s" %% "http4s-circe" % Http4sVersion, "org.http4s" %% "http4s-dsl" % Http4sVersion, "org.specs2" %% "specs2-core" % Specs2Version % "test", "ch.qos.logback" % "logback-classic" % LogbackVersion )
HelloWorld.scala
package com.example.scalatest
import io.circe._
import org.http4s._
import org.http4s.circe._
import org.http4s.server._
import org.http4s.dsl._
object HelloWorld {
val service = HttpService {
case GET -> Root / "hello" / name =>
Ok(Json.obj("message" -> Json.fromString(s"Hello, ${name}")))
}
}Server.scala
package com.example.scalatest
import scala.util.Properties.envOrNone
import org.http4s.server.blaze.BlazeBuilder
import org.http4s.util.ProcessApp
import scalaz.concurrent.Task
import scalaz.stream.Process
object Server extends ProcessApp {
val port: Int = envOrNone("HTTP_PORT").fold(8080)(_.toInt)
def process(args: List[String]): Process[Task, Nothing] = BlazeBuilder.bindHttp(port)
.mountService(HelloWorld.service, "/")
.serve
}
這樣就是個最簡單能跑的 Scala + http4s 的小專案了,可以透過 sbt run 啟動伺服器。執行後可以看到像是這樣的啟動訊息:
[info] Loading settings from plugins.sbt ... [info] Loading project definition from G:\java\intellij\scala-http4s-test\project [info] Loading settings from build.sbt ... [info] Set current project to scala-test (in build file:/G:/java/intellij/scala-http4s-test/) [info] Running com.example.scalatest.Server [pool-6-thread-4] INFO o.h.b.c.n.NIO1SocketServerGroup - Service bound to address /127.0.0.1:8080 [pool-6-thread-4] INFO o.h.s.b.BlazeBuilder - http4s v0.16.6a on blaze v0.12.11 started at http://127.0.0.1:8080/
啟動完成後,就可以用瀏覽器連上 http://127.0.0.1:8080/ 了,例如範例的 REST API 網址會是 http://127.0.0.1:8080/hello/michael,因為輸入的 {name} 是 michael,所以就會獲得 {"message":"Hello, michael"} 的 JSON 回覆。