某系统的二十关键设计决定
- AI开发🐙
- Kit框架👶
- Integrant 的灵活配置⚙️
- HugSQL 管理所有 SQL🐼
- HanLP 自动生成姓名拼音 🇨🇳
- 使用React的非受控组件 📟
- Malli Schema 驱动的自然语言生成, 组件的生成 🎼
- Oracle 开发模式回退到 SQLite 👼🏻
- 本地开发使用 attac的 SQLite 模拟 Oracle 实例🏗️
- 测试使用sqlite内存数据库🧿
- 自定义注销时强制失效 Cookie🍪
- 构建脚本集成 CLJS 编译及版本信息 🏠
- 测试夹具自动启动系统 🚗
- 多前端应用构建 🏠
- Timbre 日志统一记录 🪵
- CLJC Malli Spec 共享 💫
- 路由按功能分组🚀
- 版本文件写入 Git 信息🔀
- 文档提示已知测试失败💼
- 一条指令统一打包前后端🚛
本文总结了项目中值得借鉴的二十个设计点,并从源代码中选取部分片段加以说明。
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框架👶
- 修改deps.edn, 依赖自动载入, 项目不需要重启
- 比Mount更棒的状态管理
- 全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"}
}
自定义注销时强制失效 Cookie🍪
中间件在响应中插入过期的 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