June 11, 2025
By: Kevin

某系统的二十关键设计决定

  1. AI开发🐙
  2. Kit框架👶
  3. Integrant 的灵活配置⚙️
  4. HugSQL 管理所有 SQL🐼
  5. HanLP 自动生成姓名拼音 🇨🇳
  6. 使用React的非受控组件 📟
  7. Malli Schema 驱动的自然语言生成, 组件的生成 🎼
  8. Oracle 开发模式回退到 SQLite 👼🏻
  9. 本地开发使用 attac的 SQLite 模拟 Oracle 实例🏗️
  10. 测试使用sqlite内存数据库🧿
  11. 自定义注销时强制失效 Cookie🍪
  12. 构建脚本集成 CLJS 编译及版本信息 🏠
  13. 测试夹具自动启动系统 🚗
  14. 多前端应用构建 🏠
  15. Timbre 日志统一记录 🪵
  16. CLJC Malli Spec 共享 💫
  17. 路由按功能分组🚀
  18. 版本文件写入 Git 信息🔀
  19. 文档提示已知测试失败💼
  20. 一条指令统一打包前后端🚛

本文总结了项目中值得借鉴的二十个设计点,并从源代码中选取部分片段加以说明。

AI开发🐙

Codex + jules.

使用Agents.md告知AI足够信息, 让它理解项目, 使用工具, 验证自己的工作成果.

./AGENTS.md
./resources/migrations/AGENTS.md
./resources/sql/AGENTS.md
./src/clj/hc/hospital/AGENTS.md
./src/cljs/hc/hospital/AGENTS.md
./test/clj/hc/hospital/AGENTS.md

Kit框架👶

  1. 修改deps.edn, 依赖自动载入, 项目不需要重启
  2. 比Mount更棒的状态管理
  3. 全clojure技术栈

Integrant 的灵活配置⚙️

系统使用 Integrant 管理各组件,并通过 #profile#env 实现按环境切换。下列代码节选自 resources/system.edn

{:system/env
 #profile {:dev :dev
           :test :test
           :prod :prod}

 :server/http
 {:port #long #or [#env PORT 3000]
  :host #or [#env HTTP_HOST "0.0.0.0"]
  :handler #ig/ref :handler/ring}

上述配置既统一了端口与主机名设置,也确保了在不同环境下加载对应组件。

HugSQL 管理所有 SQL🐼

所有数据库查询均集中在 resources/sql/queries.sql 中,通过 HugSQL 的元数据头定义查询名称和返回类型:

-- 插入患者评估
-- :name insert-patient-assessment! :! :n
INSERT INTO patient_assessments
(patient_id, assessment_data, patient_name_pinyin, patient_name_initial, doctor_signature_b64, created_at, updated_at)
VALUES (:patient_id, :assessment_data, :patient_name_pinyin, :patient_name_initial, :doctor_signature_b64, datetime('now'), datetime('now'));

这种做法使得 SQL 与业务逻辑分离,便于维护。

HanLP 自动生成姓名拼音 🇨🇳

后端在接收患者数据时使用 HanLP 将姓名转为拼音和首字母,方便按拼音检索:

(defn- get-pinyin-parts [name-str]
  (if (str/blank? name-str)
    {:pinyin nil :initial nil}
    (let [pinyin-list (HanLP/convertToPinyinList name-str)
          pinyin-full (str/join "" (map #(.getPinyinWithoutTone %) pinyin-list))
          initials (str/join "" (map #(.getShengmu %) pinyin-list))]
      {:pinyin (str/lower-case pinyin-full)
       :initial (str/lower-case initials)})))

使用React的非受控组件 📟

扫码导入 HIS 数据 医生端可通过扫描二维码自动查询 HIS 系统中的患者信息:

form_components.cljs 将常用表单逻辑封装为可复用组件,简化页面开发:

(defn yes-no-with-description
  [{:keys [label field-name-prefix label-width]}]
  (let [switch-value* (r/atom false)]
    (fn []
      [:> Row {:gutter 8 :wrap false :align "middle"}
       [:> Col {:style {:whiteSpace "nowrap"}} [:span (str label ":")]]
       [:> Col [:> Form.Item {:name (keyword (str field-name-prefix "-switch"))
                             :valuePropName "checked"}
                [:> Switch {:onChange #(reset! switch-value* %)}]]]
       [:> Col {:flex "auto"}
        [:> Form.Item {:name (keyword (str field-name-prefix "-desc"))}
         [:> Input {:disabled (not @switch-value*)}]]]]))

Malli Schema 驱动的自然语言生成, 组件的生成 🎼

前端通过 Malli Schema 自动生成评估摘要文本,便于呈现给医生查看:

(defn render-form-item-from-spec [[field-key field-schema optional? parent-form-path form-instance entry-props]]
  (cond
    is-cond-map
    [:f> render-conditional-map-section]

    (= malli-type :map)
    [render-map-schema-fields ]

    (= malli-type :string)
    [render-text-input]

    (or (= malli-type :int) (= malli-type :double) (= malli-type :float))
    [render-number-input ...]

    (= malli-type :enum)
    (let [enum-count (count (get-malli-children field-schema))]
      (if (> enum-count 3)
        [render-select]
        [render-radio-group]))

    (= malli-type :vector)
    (if (= :enum (get-malli-type (first (get-malli-children field-schema))))
      [render-checkbox-group]
      (do (timbre/warn "Unsupported vector child type for field " field-key) nil))

    (is-date-string-schema? field-schema)
    [render-datepicker]

    (= malli-type :boolean)
    [render-radio-group]

    :else
    (do (timbre/warn (str "No renderer for malli type: " malli-type " of field " field-key " schema: " (pr-str field-schema)))
        [:p (str "Unrecognized type: " malli-type " for " label-text)])))

Oracle 开发模式回退到 SQLite 👼🏻

若 Oracle JDBC URL 指向内存 SQLite,系统会自动载入测试数据,方便本地开发:

(let [conn (conman/connect! pool-spec)]
  (when (and jdbc-url
             (str/includes? jdbc-url "sqlite")
             (str/includes? jdbc-url "memory"))
    (log/info "Initializing SQLite in-memory HIS database")
    (when-let [sql-rsrc (io/resource "sql/his_dev_setup.sql")]
      (let [sql-text (slurp sql-rsrc)
            statements (->> (str/split sql-text #";\s*")
                            (map str/trim)
                            (remove empty?))]
        (jdbc/execute! conn ["ATTACH DATABASE ':memory:' AS his50;"])
        (doseq [stmt statements]
          (jdbc/execute! conn [stmt])))))
  conn)

本地开发使用 attac的 SQLite 模拟 Oracle 实例🏗️

-- :name find-patient-in-pat-register-by-reg-no :? :1
-- :doc 从 his50.VIEW_PAT_REGISTER 按挂号流水号查询患者
SELECT NAME, SEX, DATE_OF_BIRTH, ID_NO
FROM his50.VIEW_PAT_REGISTER
WHERE REGISTER_NO = :register_no

测试使用sqlite内存数据库🧿

resources/system.edn 中为 :db.oracle/connection:test profile 配置了内存 SQLite.使得针对 HIS 交互部分的测试可以在独立的内存数据库中运行:

    :db.oracle/connection #profile {
      ;; ... 其他 profiles ...
      :test {:jdbc-url  "jdbc:sqlite:file:his_mem_db?mode=memory&cache=shared"}
    }

中间件在响应中插入过期的 Set-Cookie 头,确保会话立即失效:

(defn wrap-force-logout-cookie [handler actual-cookie-name base-attrs-for-expired-cookie]
  (fn [request]
    (let [response (handler request)]
      (if (get response ::force-expire-cookie)
        (let [expired-cookie-map-entry {:value ""
                                        :path (:path base-attrs-for-expired-cookie)
                                        :max-age 0
                                        :expires "Thu, 01 Jan 1970 00:00:00 GMT"
                                        :http-only (:http-only base-attrs-for-expired-cookie)
                                        :same-site (:same-site base-attrs-for-expired-cookie)
                                        :secure (:secure base-attrs-for-expired-cookie)}
              header-value-str (str actual-cookie-name "=")
              path-attr (str "; Path=" (:path base-attrs-for-expired-cookie))
              httponly-attr (if (:http-only base-attrs-for-expired-cookie) "; HttpOnly" "")
              samesite-val-name (when-let [ss (:same-site base-attrs-for-expired-cookie)] (name ss))
              samesite-attr (if samesite-val-name (str "; SameSite=" (str/capitalize samesite-val-name)) "")
              secure-attr (if (:secure base-attrs-for-expired-cookie) "; Secure" "")
              expires_attrs_str "; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
              full-expired-cookie-header (str header-value-str path-attr httponly-attr samesite-attr secure-attr expires_attrs_str)
              response-with-marker-removed (dissoc response ::force-expire-cookie)
              response-updated-cookies-map (assoc-in response-with-marker-removed [:cookies actual-cookie-name] expired-cookie-map-entry)
              existing-headers (get response-updated-cookies-map :headers {})
              set-cookie-header-val (get existing-headers "Set-Cookie")
              final-set-cookie-value (cond
                                       (nil? set-cookie-header-val) full-expired-cookie-header
                                       (vector? set-cookie-header-val) (vec (cons full-expired-cookie-header
                                                                                  (remove #(str/starts-with? % (str actual-cookie-name "=")) set-cookie-header-val)))
                                       (string? set-cookie-header-val) (if (str/starts-with? set-cookie-header-val (str actual-cookie-name "="))
                                                                         full-expired-cookie-header
                                                                         [set-cookie-header-val full-expired-cookie-header])
                                       :else full-expired-cookie-header)
              final-headers (assoc existing-headers "Set-Cookie" final-set-cookie-value)]
          (assoc response-updated-cookies-map :headers final-headers))
        response))))

构建脚本集成 CLJS 编译及版本信息 🏠

build.clj 在打包前先编译前端并写入 git 提交号和时间戳:

(defn build-cljs []
  (println "npx shadow-cljs release app patient-app...")
  (let [{:keys [exit], :as s} (shell "npx shadow-cljs release app patient-app")]
    (when-not (zero? exit)
      (throw (ex-info "could not compile cljs" s)))
    (copy-tree "target/classes/cljsbuild/public" "target/classes/public")))

(defn write-version-file []
  (println "Writing version.properties file...")
  (let [props (Properties.)
        commit-hash (try
                      (-> (shell {:out :string} "git rev-parse --short HEAD")
                          :out
                          string/trim)
                      (catch Exception e
                        (println "Error getting git commit hash:" (.getMessage e))
                        "UNKNOWN"))
        timestamp (-> (SimpleDateFormat. "yyyy-MM-dd HH:mm:ss Z")
                      (.format (Date.)))
        version-file-dir "resources/hc/hospital"
        version-file-path (str version-file-dir "/version.properties")]
    (fs/create-dirs version-file-dir)
    (.setProperty props "git.commit.hash" commit-hash)
    (.setProperty props "build.timestamp" timestamp)
    (with-open [fos (FileOutputStream. version-file-path)]
      (.store props fos "Build version information")))

测试夹具自动启动系统 🚗

测试用 system-fixture 在运行前启动 Integrant 系统并执行数据库迁移:

(defn system-fixture
  []
  (fn [f]
    (core/start-app {:opts {:profile :test}})
    (reset (:db.sql/migrations (system-state)))
    (f)
    (core/stop-app)))

多前端应用构建 🏠

shadow-cljs.edn 定义了医生端和患者端两个构建目标,分别输出到不同目录:

:patient-app {:target     :browser
              :output-dir "target/classes/cljsbuild/public/js/patient"
              :asset-path "/js/patient"
              :modules    {:patient-app {:entries [hc.hospital.patient.core]
                                         :init-fn hc.hospital.patient.core/init!}}}

Timbre 日志统一记录 🪵

logging.clj 配置每天滚动的日志文件及控制台输出,统一管理日志:

(defn configure-logging!
  ([] (configure-logging! {}))
  ([opts]
   (timbre/merge-config!
    {:appenders {:println (timbre/println-appender)
                 :daily-file (apply daily-file-appender [opts])}})))

CLJC Malli Spec 共享 💫

assessment_complete_cn_spec.cljc 在 clj 与 cljs 中共享数据规范,支持生成式测试和前端校验:

(def NonEmptyString (m/schema [:string {:min 1}]))
(def OptionalBoolean (m/schema [:maybe :boolean]))

路由按功能分组🚀

system.edn 中同时定义页面路由和多组 API 路由,结构清晰:

:reitit.routes/patient-api {:base-path "/api"
                            :env #ig/ref :system/env
                            :query-fn #ig/ref :db.sql/query-fn
                            :oracle-query-fn #ig/ref :db.oracle/query-fn}

版本文件写入 Git 信息🔀

build.clj 在构建时生成 version.properties,记录提交号和时间戳:

(defn write-version-file []
  (let [commit-hash (-> (shell {:out :string} "git rev-parse --short HEAD") :out string/trim)
        timestamp (-> (SimpleDateFormat. "yyyy-MM-dd HH:mm:ss Z") (.format (Date.)))]
    (fs/create-dirs "resources/hc/hospital")
    (.setProperty props "git.commit.hash" commit-hash)
    (.setProperty props "build.timestamp" timestamp)))

文档提示已知测试失败💼

readme.org 在构建部分前提醒开发者当前存在的测试问题,避免误判:

*重要提示:* 当前 Clojure 测试套件存在一些已知问题,导致测试无法全部通过...

一条指令统一打包前后端🚛

通过 clj -T:build all(或 make uberjar)即可生成单一的 hospital-standalone.jar,运行后在一个端口提供全部服务:

clj -T:build all
Tags: 设计