[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()
}

<<:  Day19 弱点扫描软件安装与使用注意事项

>>:  Day 29: 跨平台比较

如果要架设一个HTTPs File Server, 推荐用什麽软件呢?

如果要架设一个HTTPs File Server, 个人使用, 所以不会有certificate, ...

D2 - 先生 帮您带位元运算子

前言 JavaScript 运算子大概可以分为几大类:算数运算子、逻辑运算子、赋值运算子、比较运算子...

如何才能完全删除Mac app以及档案--2022〖必学〗

如何卸载 Mac 软件? 当许多用户购买新的 Macbook/iMac 时,他们会在 Mac 上下载...

Day 2 python简易语法

在开始学习机器学习之前,我们得先准备好环境,我们使用python来当我们的程序语言,稍微介绍一下py...

Composite:组合模式

Composite:组合模式,当需要组合两个类的时候,比如画图:需要画直綫和点,那麽如果分别调用画直...