iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 29

[Day 29] 建立子專案來監控管理系統

前面的主題都專注於擴充加強 Ktor 及實作底層基礎設施功能,最後我們來看在 Multi-Project 架構下,要如何建立一個子專案。那麼要建立什麼子專案呢? 我認為除了功能開發之外,後續的監控及維運也是非常重要的,所以我參考 Spring Boot Actuator 的概念,建立一個專門監控、管理系統的子專案 Ops (DevOps 的 Operations 縮寫)。

由於我不可能獨自實作像 Spring Boot Actuator 這麼完整的功能,這也不是我的目標,所以我目前只有實作最基本的 health check endpoint。我把重點放在協助小規模的新創開發團隊,在沒有完善的公司制度、人力資源及基礎設施服務的情況下,後端工程師在開發 API 的時候能兼顧資安與團隊合作效率。

內部 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 架構建立後台管理子專案

架構設計上,前後台的使用者帳號、角色權限、登入驗證機制…等應該要分開,所以本專案的 Multi-Project 模組化開發方式非常適合營運初期的小規模開發團隊,把後台拆分為獨立的子專案就好,不必拆分為微服務,增加開發及維運的成本。

後台使用者角色及功能清單

  • 服務角色
    • Root: 管理 Ops 專案的使用者
    • Monitor: 實作類似 spring-actuator 的監控功能,目前支援 healthCheck,預計未來將提供更多系統狀態的資訊
  • 使用者角色 (有帳號可以登入/登出/變更密碼)
    • Operation Team
      • 填寫訊息文字,並撰寫 QueryDSL 只傳送訊息給符合查詢條件的使用者
      • 撰寫 QueryDSL 查詢 User 資料表,把資料匯出成 Excel 檔案,寄送至指定 email
    • App Team
      • App 版本發佈

App 版本管理功能範例

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)

如何建立 Ops 子專案

之前寫的文章大多是描述子專案如何在 Multi-Project 架構下,實作某個特定功能。這麼多篇文章看下來,讀者可能會有見樹不見林的感覺,所以我在此以後台 Ops 子專案為例,列出從頭到尾建立一個子專案需要實作那些項目。

1. 建立 Gradle SubProject

先在 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 框架規範的方式編排檔案。

2. 建立 Ktor Module

在 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 子專案… 
}

3. 建立 Project 物件,註冊至 ProjectManager

初始化 ops 專案需要先建立 Project 物件,必須傳入以下物件

  • 登入驗證設定值 List<PrincipalSourceAuthConfig>
  • 使用者類型及其角色 List<UserType>
  • OpenAPI 文件 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>()

4. 初始化子專案的各個功能

初始化 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()) }
            }
        )
    }
    // 以下省略
}

5. 初始化子專案 Route

每個 Web 框架都有自己定義 route 的方式,例如 Spring MVC 使用 @RequestMappingannotation,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()
}

上一篇
[Day 28] 實作 Multi-Channel Notifications
下一篇
[Day 30] Ktor Q&A 與 Side Project Roadmap
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言