前面的主題都專注於擴充加強 Ktor 及實作底層基礎設施功能,最後我們來看在 Multi-Project 架構下,要如何建立一個子專案。那麼要建立什麼子專案呢? 我認為除了功能開發之外,後續的監控及維運也是非常重要的,所以我參考 Spring Boot Actuator 的概念,建立一個專門監控、管理系統的子專案 Ops
(DevOps 的 Operations 縮寫)。
由於我不可能獨自實作像 Spring Boot Actuator 這麼完整的功能,這也不是我的目標,所以我目前只有實作最基本的 health check endpoint。我把重點放在協助小規模的新創開發團隊,在沒有完善的公司制度、人力資源及基礎設施服務的情況下,後端工程師在開發 API 的時候能兼顧資安與團隊合作效率。
通常一個網站除了開發前台功能給外部使用者,公司也需要有內部的後台管理系統,用來建立初始資料、新增網站內容、或是啟用/停用某個功能。然而小規模的新創開發團隊可能沒有時間為「內部使用」的管理系統拆分成獨立的微服務,更沒有時間去做 Web 管理後台,所以在開發維運初期,後端工程師通常使用最簡單的 API Key 驗證方式保護內部 API,再使用 Postman 工具進行操作,此時就會遇到內部 API 權限控管與工作分配的兩難。
如果後端工程師把 API Key 交給他人,委託處理這些日常瑣事,就會有 API Key 外流的風險。反之,如果 API Key 由後端自己保管,那麼工作時就可能常常被雜事打斷,影響工作效率。當然以上問題可以透過嚴謹的制度及網路管理解決部分問題,但如果系統在開發初期就能快速為內部 API 加上 RBAC 機制,就能對公司每位員工做更細緻的權限控管,放心分配工作,也能提早為未來開發 Web 後台管理系統奠下基礎。
對於小公司來說,因為人數少、時間少、資源少,所以容易基於人與人之間的信任而簡化制度流程,防火牆也只能擋掉公司外的人,所以內部 API 權限控管是比較容易忽略的。而且就算有嚴謹的制度流程,從資安的角度來看,OWASP 在 2019 年列出十大 API 安全風險 包含這一項 Broken Function Level Authorization
,所以系統本來就應該有 RBAC 機制保護所有 API,建立最後一道防線。
架構設計上,前後台的使用者帳號、角色權限、登入驗證機制…等應該要分開,所以本專案的 Multi-Project 模組化開發方式非常適合營運初期的小規模開發團隊,把後台拆分為獨立的子專案就好,不必拆分為微服務,增加開發及維運的成本。
Club 前台子專案的使用者從 App 端登入時,系統要透過 http header clientVersion
檢查是否有新版本,甚至要求必須先升級才能繼續使用,所以我在後台子專案開發 App 版本管理 API ,並且交由 App Team 自行去記錄 App 的版本資訊,達到權限控管及分工合作的目標。
※ 實際上 OpsTeam 也可以使用這些 API,所以我把 OpsTeam 的角色也加到 AppTeam 的 PrincipalAuth 物件裡面
fun Routing.opsAppRelease() {
val appReleaseService by inject<AppReleaseService>()
route("${OpsConst.urlRootPath}/app/releases") {
authorize(OpsAuth.AppTeam) {
// 新增 API
post<CreateAppReleaseForm, Unit>(OpsOpenApi.CreateAppRelease) { dto ->
appReleaseService.create(dto)
call.respond(CodeResponseDTO.OK)
}
// 修改 API
put<UpdateAppReleaseForm, Unit>(OpsOpenApi.UpdateAppRelease) { dto ->
appReleaseService.update(dto)
call.respond(CodeResponseDTO.OK)
}
// 查詢 API
dynamicQuery<AppReleaseDTO>(OpsOpenApi.FindAppReleases) { dynamicQuery ->
call.respond(dynamicQuery.queryDB<AppReleaseDTO>())
}
// 驗證版本 API
getWithLocation<CheckAppReleaseLocation, CheckAppReleaseResponse>(OpsOpenApi.CheckAppRelease) { location ->
val result = appReleaseService.check(AppVersion(location.appId, location.verName))
call.respond(DataResponseDTO(CheckAppReleaseResponse(result)))
}
}
}
}
// 實際上 OpsTeam 也可以使用這些 API,所以我把 OpsTeam 的角色也加到 AppTeam 的 PrincipalAuth 物件裡面
val AppTeam = PrincipalAuth.User(
userAuthProviderName, allAuthSchemes, setOf(UserSource),
mapOf(OpsUserType.User.value to setOf(OpsUserRole.OpsTeam.value, OpsUserRole.AppTeam.value))
)
@OptIn(KtorExperimentalLocationsAPI::class)
@io.ktor.locations.Location("/check")
data class CheckAppReleaseLocation(val appId: String, val verName: String) : Location()
data class CheckAppReleaseResponse(val result: ClientVersionCheckResult)
之前寫的文章大多是描述子專案如何在 Multi-Project 架構下,實作某個特定功能。這麼多篇文章看下來,讀者可能會有見樹不見林的感覺,所以我在此以後台 Ops 子專案為例,列出從頭到尾建立一個子專案需要實作那些項目。
先在 settings.gradle.kts 增加 projects:ops
rootProject.name = "fanpoll"
include("app", "infra", "projects:ops", "projects:club")
然後 ops 子專案的 build.gradle.kts 引入 app.subproject-conventions plugin 即可
plugins {
id("app.subproject-conventions")
}
更多細節可參考 [Day 4] 使用 Gradle Multi-Project Builds X Shadow Plugin X Docker Compose 建置、打包、部署
下圖是 ops 子專案的檔案目錄結構,我把子專案實作 Multi-Project 架構下的各個功能的程式碼都放在獨立的 OpsXXX.kt 檔案,一目瞭然。另一方面,目前 ops 子專案的功能相對較少且獨立,再加上 Kotlin 可以把多個類別放在同一個檔案,所以我依功能劃分檔案,集中放置在features package。
話說我們能依自己的想法規劃檔案目錄結構,這都是因為 Ktor 不基於 annotation 及 DI,程式執行流程都是自己控制的函式呼叫。不像 Spring 依賴 @ComponentScan
, @EntityScan
annotation 或是 Play Framework 預設尋找 controllers, models …等資料夾,需要遵循 Web 框架規範的方式編排檔案。
在 Ktor 設定檔 application.conf 加入 ops module,當 Server 啟動時就會依序執行各個 module 對應的 main function
更多細節可參考 [Day 3] 以 Ktor Module 實作模組化開發
ktor {
application {
modules = [
fanpoll.infra.ApplicationKt.main,
fanpoll.ops.OpsProjectKt.opsMain,
fanpoll.club.ClubProjectKt.clubMain
]
}
}
fun Application.opsMain() {
// 初始化 ops 子專案…
}
初始化 ops 專案需要先建立 Project 物件,必須傳入以下物件
List<PrincipalSourceAuthConfig>
List<UserType>
ProjectOpenApi
List<NotificationType>?
底層 infra module 再根據 Project 物件執行相對應的功能
更多細節可參考 [Day 3] 以 Ktor Module 實作模組化開發
fun Application.opsMain() {
logger.info { "load ${OpsConst.projectId} project..." }
val projectManager = get<ProjectManager>()
val projectConfig = ProjectManager.loadConfig<OpsConfig>(OpsConst.projectId)
projectManager.register(
Project(
OpsConst.projectId,
projectConfig.auth.principalSourceAuthConfigs,
OpsUserType.values().map { it.value },
OpsOpenApi.Instance,
OpsNotification.AllTypes
)
)
// 以下省略
}
class Project(
override val id: String,
val principalSourceAuthConfigs: List<PrincipalSourceAuthConfig>,
val userTypes: List<UserType>,
val projectOpenApi: ProjectOpenApi,
val notificationTypes: List<NotificationType>? = null
) : IdentifiableObject<String>()
初始化 ops 子專案包括以下項目
fun Application.opsMain() {
// 以上省略
authentication {
service(OpsAuth.serviceAuthProviderName, projectConfig.auth.getServiceAuthConfigs())
session(
OpsAuth.userAuthProviderName,
UserSessionAuthValidator(projectConfig.auth.getUserAuthConfigs(), get()).configureFunction
)
}
val availableLangs = get<AvailableLangs>()
val responseMessagesProvider = get<ResponseMessagesProvider>()
responseMessagesProvider.merge(
HoconMessagesProvider(availableLangs, "i18n/response/${OpsConst.projectId}", "response_")
)
val i18nNotificationProjectMessages = get<I18nNotificationProjectMessages>()
i18nNotificationProjectMessages.addProvider(
OpsConst.projectId,
I18nNotificationMessagesProvider(
PropertiesMessagesProvider(
availableLangs,
"i18n/notification/${OpsConst.projectId}",
"notification_"
)
)
)
koin {
modules(
module(createdAtStart = true) {
single { projectConfig }
single { OpsUserService() }
single { OpsLoginService(get()) }
}
)
}
// 以下省略
}
每個 Web 框架都有自己定義 route 的方式,例如 Spring MVC 使用 @RequestMapping
annotation,Play Framework 使用自定義的 route 語法撰寫 route 在 conf/routes 設定檔。至於 Ktor 則是讓開發者使用 DSL 語法撰寫 route function,所以我們可以使用巢狀階層的結構來編排 route,而且可以依功能放置在獨立的 XXXRoute.kt 檔案。
fun Application.opsMain() {
// 以上省略
routing {
ops()
}
}
// 集中在 OpsRoutes.kt 檔案裡定義每個功能的 route
fun Routing.ops() {
opsUser()
opsLogin()
opsMonitor()
opsDataReport()
opsAppRelease()
}