August 5, 2020
By: Kevin

多肽项目总结

  1. 布局与样式
    1. Material的关键属性
    2. styled functions
  2. SVG动画
    1. 将html转成hiccup
    2. svg的管线流向图及其他常用动画
    3. 有关svg的实现运动的动画效果
  3. reagent & react
    1. error-boundary
    2. SetInnerHTML
  4. shadow-cljs
    1. 多目标编译
    2. 前端单元测试
    3. 配置文件定义变量, 区分环境
    4. 编译hook, 生成依赖资源
  5. 逻辑实现
    1. 执行调度
    2. core.match简化条件判断
    3. 共享clj/cljs的代码
    4. core.async和javascript的Promise
    5. 优雅的开始/结束一个core.async的loop
    6. core.async的pub-sub
    7. core.async的监听多channel
    8. 使用taoensso.sente做websocket通讯
    9. 新增一个版本步骤
      1. 新增数据库
      2. 默认函数的方法处理
      3. 前后端函数的联调处理
      4. 硬件指令联调
    10. 使用taoensso.timbre来记录log
    11. 语言的基本错误
    12. wirshark的使用
  6. electron
    1. windows上屏蔽menu
    2. 屏蔽快捷键
    3. 关键路径
    4. 补充本系统的目录内容
    5. 数据目录
    6. 安装目录
    7. 开发模式下
      1. 后端
    8. 生产环境
      1. 后端
      2. 前端
    9. electron的版本变化和它的进程模型
    10. render进程的启动参数
      1. 支持node的require
      2. 全屏幕
      3. 是否保留菜单
      4. 禁止自动休眠/熄屏
    11. 指定目录
    12. 启动外部进程
    13. 打印
    14. 在render中通过remote对象结束应用
    15. electron-builder 打包
      1. icons
    16. electron的一些原生UI能力
    17. 应用自动升级(draft)
    18. osx下打包签名(draft)
  7. 安装/卸载界面的定制
  8. re-frame
    1. 使用拦截器->interceptor在事件处理的前/后增加逻辑
    2. reg-sub 很灵活
  9. NPM体系
    1. NPM上的包是非常豪放的, 某些版本的包有bug很正常
    2. package.json 和 package-lock.json
    3. MaterialUI的lab包
    4. 尽可能小范围的引用
  10. Babashka脚本
    1. bb.edn
      1. 配置tasks跨平台/全功能的任务执行
      2. etaion 驱动Electron 前端测试
      3. 使用Babashka而不是clojure做脚本,更快的启动和测试.
  11. react中cljs 晦涩的错误(draft)
  12. 参考资料

布局与样式

系统使用material UI 4.0, 是一套reactjs的组件库, 支持灵活的风格定制.

Material的关键属性

MaterialUI支持(button, icon, tab, button-base)组件上的水波(ripple)效果. 所以我们在首页的色块上, 提供了这种观感.

所以我们可以扩展ButtonBase,来实现带有这个效果的组件.

[:> ButtonBase {:class "db w-100 h-100"
                :focus-ripple true
                :id (str "login" (= :logged-in login-state))
                :on-click (fn []
                            (if (and (= :logged-in login-state)
                                     (#{:running :breaked} @(rf/subscribe [:program-status])))
                              (utils/show-error "Program is running. Please abort the program first.")
                              (rf/dispatch [:effects.login/update-state fx])))}
 [:> Box ...]]

styled functions

重点是其中的@material-ui/system提供的style functions, 可以轻松定制自己的风格化组件.

基于自适应的考虑, 我们在系统中自定义了BoxRpx这个组件, 用来处理这一情况:

(defn transform [value]
  (if (number? value)
    (if (<= value 1)
      (str (* 100 value) "%")
      (px-to-vw value))
    value))

;; padding 举例
(def padding (style #js {:prop "paddingRpx"
                         :cssProperty "padding"
                         :transform transform}))

(def spacing (compose padding
                      padding-x
                      padding-y
                      padding-top
                      padding-bottom
                      padding-left
                      padding-right
                      margin
                      margin-x
                      margin-y
                      margin-top
                      margin-bottom
                      margin-left
                      margin-right))

(def sizing (compose width min-width max-width height min-height max-height))

(def typography (compose font-size
                         line-height
                         letter-spacing))

(def style-function (compose responsive/sizing
                             responsive/spacing
                             responsive/typography))

(def BoxRpx ((styled Box) style-function #js {:name "RpxMuiBox"}))

这样,我们就可以在系统中使用我们带有自定义props的组件了

[:> Box {:padding-rpx 32
              :display "flex"
              :flex-direction "column"
              :justify-content "space-between"
              :align-items "center"
              :height 1}
      [:> Box {:flex-grow 1
               :width-rpx 100
               :height-rpx 100
               :display "flex"
               :justify-content "center"
               :align-items "center"}
       [:img {:src icon
              :class "flex-none"}]]
      [:span {:class "b w-100 tl color-white font-size-body"} text]]

SVG动画

将html转成hiccup

svg源码的html语句,通过Calva语句转成hiccup,在clojure语法中可以使用. 注意:需要将转成的一部分内容属性进行修改,部分内容如下

[[":xmlns:xlink" ":xmlnsXlink"]
 [":gradienttransform" ":gradientTransform"]
 [":filterunits" ":filterUnits"]
 [":xlink:href" ":xlinkHref"]
 [":lineargradient" ":linearGradient"]
 [":fegaussianblur" ":feGaussianBlur"]
 [":stddeviation" ":stdDeviation"]
 [":feoffset" ":feOffset"]
 [":fecomposite" ":feComposite"]
 [":fecolormatrix" ":feColorMatrix"]
 [":radialgradient" "radialGradient"]]

svg的管线流向图及其他常用动画

管线流向图使用的属性:stroke-dashoffset

  • stroke-dashoffset 属性: 这个属性在SVG中定义了虚线模式开始的位置. 通过改变这个值, 可以使得看起来虚线在路径上移动.
  • 动画过程:
    • from { stroke-dashoffset: 1000; }: 动画开始时, stroke-dashoffset被设置为1000. 这意味着虚线模式的开始位置被推迟了1000个单位, 使得虚线看起来在特定位置开始.
    • to { stroke-dashoffset: 0; }: 动画结束时, stroke-dashoffset变为0. 这意味着虚线模式回到了其初始位置. 可以通过更改path的中xy的值来更改水流效果的流向问题.

降低CPU负载, 灵活展现管线图的液体流向. 动画内容可以参考下面嵌入的网页.

有关svg的实现运动的动画效果

思路:使用定时器,将图片进行切换,实现svg某一部分内容高亮旋转效果

;; 三张svg组件的传入
(defn mixing-tank-svg "配液罐单张svg, `idx` 是三张svg的index"
  [idx svg]
  (get svg (mod idx 3)))

;; 页面初始化的时候开启定时器,根据某一个状态进行判断render中是否开启某一高亮旋转的组件svg

(defn mixing-tank
  "配液罐&激活罐"
  [component s1s2s3]
  (let [*s1s2s3? (rf/subscribe [:db/motors])
        *stop?   (r/atom false)
        *idx (r/atom 0)]

    (r/create-class
     {:component-did-mount   #(async/go-loop []
                                (async/<! (async/timeout 100))
                                (swap! *idx inc)
                                (when-not @*stop?
                                  (recur)))
      :reagent-render         (fn []
                                (let [open?  (= :close (get @*s1s2s3? :S1 :close))
                                      opens2?  (= :close (get @*s1s2s3? :S2 :close))
                                      opens3?  (= :close (get @*s1s2s3? :S3 :close))]
                                  (cond
                                    (= "s1" s1s2s3) [:g
                                                     [mixing-tank-svg (if open?
                                                                        0
                                                                        @*idx) component]]
                                    (= "s2" s1s2s3) [:g
                                                     [mixing-tank-svg (if opens2?
                                                                        0
                                                                        @*idx) component]]
                                    (= "s3" s1s2s3) [:g
                                                     [mixing-tank-svg (if opens3?
                                                                        0
                                                                        @*idx) component]]
                                    :else nil)))
      :component-will-unmount #(reset! *stop? true)})))

reagent & react

error-boundary

React 应用如果出现error, 整个应用会挂掉, 这个不是我们期待的行为. 在调试期间的error会crash整个程序, 而且不可恢复, 只能对网页进行全部载入, 相当的不友好.

幸而React16引入了error-boundary组件, 即实现了component-did-catch生命周期函数的组件的子组件的错误出现error的时候会被捕获和处理.

出现错误之后, 把错误修正, 代码会继续hotload, 并不打断开发流程.

error-boundary

(defn catch []
  (defn error-boundary [comp]
    (r/create-class
     {:constructor (fn [this props]
                     (set! (.-state this) #js {:error nil}))
      :component-did-catch (fn [this e info])
      :get-derived-state-from-error (fn [error] #js {:error error})
      :render (fn [this]
                (r/as-element
                 (if-let [error (.. this -state -error)]
                   [:div
                    "Something went wrong."
                    [:button {:on-click #(.setState this #js {:error nil})} "Try again"]]
                   comp)))})))

error-boundary的使用:

[catch ;; 全局ui异常捕获
 [root-component]]

error-boundary是个非常让人兴奋的特性, 很大提高了开发的便利, 全局增加error处理应该是它多种用法中的一个, 其他用法有待继续探索.

SetInnerHTML

dangerouslySetInnerHTML 是 React 中的一个特性, 用于在组件内部直接设置 HTML 内容.

直接操作 HTML 可能导致跨站脚本攻击(XSS)的风险, 因此 React 默认会转义所有从用户获得的内容.

dangerouslySetInnerHTML 提供了一种绕过这种默认行为的方式, 允许开发者直接在组件内插入原始 HTML.

我们使用这个特性在组件中插入svg(支持svg带动画)

[:div {:style {:height 100 :width 100}
          :dangerouslySetInnerHTML {:__html "<svg >...</svg>\r\n"} }]

shadow-cljs

项目使用shadow-cljs进行(clojurescript侧)的依赖管理以及编译和打包.

编译过程中, shadow-cljs会把package.json中的依赖以及shadow-cljs中指定的cljs依赖统一编译处理

多目标编译

shadow-cljs支持多个编译目标, 在electorn开发中, 主进程和页面进程是两个目标(还有一个单元测试的目标).

:builds {:test {...}
         :app {:target :browser
               :compiler-options {:optimizations :simple}
               :output-dir "public/compiled/cljs/"
               :asset-path "/compiled/cljs"
               :build-hooks [(resources.hooks/watch-resource)]
               :modules {:main {:init-fn app.main/main!
                                :depends-on #{:resources}}
                         :resources {:entries [resources.helper]}}
               :closure-defines {day8.re-frame.tracing.trace-enabled? true
                                 re-frame.trace.trace-enabled? true}}
         :electron {:target :node-script
                    :dev {:closure-defines {electron.main/dev? true
                                            common.file-util/dev? true}}
                    :main electron.main/main!
                    :output-to "native/cljs/entry.js"}}

前端单元测试

ut

在shadow-cljs.edn中配置test build

{:test {:target    :browser-test
        :compiler-options {:optimizations :simple}
        :test-dir  "resources/public/js/test"
        :ns-regexp "-test$"
        :devtools  {:http-port          8021
                    :http-root          "resources/public/js/test"}} }

在"resources/public/js/test"目录下创建index.html

 <!DOCTYPE html>
<html><head><title>shadow.test.browser</title><meta charset="utf-8"></head><body><script src="/js/test.js"></script><script>shadow.test.browser.init();</script></body></html>

所有文件名符合正则表达 :ns-regexp "-test$"的文件都会认为是测试文件.

配置文件定义变量, 区分环境

在开发模式下, shadow-cljs.edn中,为main进程中的electron.main命名空间的变量dev? 进行赋值, dev? 这个变量在开发和部署模式下会有不同的值.

shadow-cljs.edn中:

{:builds {:electron {:target :node-script
                     :dev {:closure-defines {electron.main/dev? true}}
                     :main electron.main/main!
                     :output-to "native/cljs/entry.js"}}

main.cljs中:

(goog-define dev? false)

编译hook, 生成依赖资源

比如Less生成css


:build-hooks [(resources.hooks/watch-resource)]

逻辑实现

执行调度

系统使用core.async作为异步执行的调度的控制器.

即通过channle之间的依赖关系控制执行流程. 常见的channel有

  1. 普通channel即buffer为1的channel, (chan).
  2. 定时channel(timeout 1000), 可以指定定时的时间.
  3. 绑定特殊buffer的channel, 比如说互动窗口的channel (chan (async/sliding-buffer 1)).

计时器控制某个步骤的等待时间. 我们对计时器进行了封装.

  • 逻辑上计时器的组织

    • 可以多个并行
    • 可以多个串行
    • 可以被打断, 恢复, 终止
    • 可以组织为一个新的计时单元, 具有同样属性
  • 单一的计时器
    单一的计时器

  • 多个串行的计时器
    多个串行的计时器

  • 多个并行的计时器
    多个并行的计时器

core.async对计时器的控制

  • 单一的计时器
    单一的计时器

  • 多个串行的计时器
    多个串行的计时器

  • 多个并行的计时器
    多个并行的计时器

对于其他的channle, 我们也进行了响应的封装, 除了自身依赖关系之外, 也需要可以响应break和abort.

core.match简化条件判断

函数式编程中pattern是一个模式, 很大程度上能简化条件的复杂度.

下面的代码负责watch观察一个atom的变化情况, 从true->false/false->true的时候发通知.

(match [old-state new-state]
       [true false] (send-emg-off-alert)
       [false true] (send-emg-on-alert)
       :else nil)

注意match的版本非常好的描述了这个变化, 而if的版本需要仔细读才能得到这个意思

(if (and (true? old-state) (false? new-state))
  (send-emg-off-alert)
  (when (and (false? old-state) (true? new-state))
    (send-emg-on-alert)))

又或有多个条件判断, 但是结果有限的情况, match也直观很多.

(match [program-running? select-program?  program-table?]
       [true    false    _] [program-table]
       [true    true     _] [function-table (name @program-name)]
       [false    _    true] [program-table]
       [false    _   false] [function-table (name @program-name)])

共享clj/cljs的代码

cljc后缀的代码供两边使用, #? 区分不同环境

(ns program-util
  (:require
   [clojure.edn :as edn]
   #?(:cljs [common.file-util :refer [read-db-from-disk]])))

(defn get-current-speed-interval [db-file]
  (let [db #?(:cljs (read-db-from-disk db-file)
              :clj (edn/read-string (slurp db-file)))
        speed        (get-in db [:params :speed])
        interval     (get-in db [:params :interval])]
    [(speed-limit speed) (interval-limt interval)]))

core.async和javascript的Promise

(:require
   [cljs.core.async :refer [go]]
   [cljs.core.async.interop :refer-macros [<p!]])
;; 下面有三个JS的promise, 我们通过<p!将之依次串行
(defn print-current-page [pdf-file-name]
  (go
    (let [webcontent ((.. electron -remote -getCurrentWebContents))
          dialog-info (<p! (-> electron                ;; promise 1: 拿到dialog的状态
                               (.-remote)
                               (.-dialog)
                               (.showOpenDialog
                                #js {:properties
                                     #js ["openDirectory"]})))
          canceled? (g/get dialog-info "canceled")
          filePath (g/getValueByKeys dialog-info "filePaths" 0)
          data (<p! (.printToPDF webcontent #js {}))]  ;; promise 2 : 拿到要打印的数据
      (when-not canceled?
        (try
          (prn filePath "..." canceled?)               ;; promise 3: 开始打印
          (<p! (.writeFile fs (path-join filePath  (str pdf-file-name ".pdf")) data
                           (fn [error]
                             (if error
                               (js/console.log "error:" error)
                               (js/console.log "print successful@!!!!!")))))
          (catch js/Error err (js/console.log (ex-cause err))))))))

优雅的开始/结束一个core.async的loop

(defn start-loop-query
  "周期ms查询, 默认值是20"
  [ms]
  (reset! global-query-chan (chan))
  (go-loop []
    (alt!
      (async/timeout ms) (do
                           (query-cmd-and-read-bytes)
                           (recur))
      ;;---------
      @global-query-chan nil)))

(defn stop-loop-query []
  (put! @global-query-chan "stop"))

core.async的pub-sub

一个pub多个sub的情况

;; publisher is just a normal channel
(def publisher (atom (chan)))
(defn renew-publisher []
  (reset! publisher (chan)))
;; publication is a thing we subscribe to
(def publication
  (atom (pub @publisher #(:topic %))))
(defn renew-publication []
  (reset! publication (pub @publisher #(:topic %))))
(defn take-and-put-into-global-chan[publication]
  (let [channel (chan)]
    (sub publication :func-end-with-uuid channel)
    (go-loop []
      (<! channel)
      (>! @global-chan " my heart will go on...")
      (recur))))
(defonce func-finished-chan (chan))
(defonce func-begin-chan    (chan))
(defn sub-func-end [publication]
  (let [end-channel   (chan)]
    (sub publication :func-end-with-uuid   end-channel)
    (go-loop []
      (put! func-finished-chan
            (<! end-channel))
      (recur))))
(defn sub-func-begin [publication]
  (let [begin-channel (chan)]
    (sub publication :func-begin-with-uuid begin-channel)
    (go-loop []
      (put! func-begin-chan
            (<! begin-channel))
      (recur))))
(defn sub-to-all []
  (renew-publisher)
  (renew-publication)
  (sub-func-begin @publication)
  (sub-func-end @publication)
  (take-and-put-into-global-chan @publication))

core.async的监听多channel

(go-loop []
  (let [[v ch] (alts! [func-begin-chan
                       func-finished-chan])]
    (if (= ch func-begin-chan )
      (send-step-begin v)       ;; 来自begin channel
      (send-step-finished v) )) ;; 来自finish channel
  (recur))

使用taoensso.sente做websocket通讯

前后端无缝衔接, 非常愉快的使用体验

websocket前后端通信

taoensso.sente 是一个用于 Clojure(Script) 的 WebSocket 库. 它提供了一种简单, 灵活和可靠的方式来构建基于 WebSocket 的实时应用程序, 如聊天应用, 实时协作工具等.

这部分的逻辑可以详述.

(def chsk-send!   send-fn) ; ChannelSocket's send API fn

(defn send-emg-on-alert []
  (debug "emergency break on")
  (chsk-send! :taoensso.sente/nil-uid
              [:server/emergency-stop-on nil]))
;; 这样,  [:server/emergency-stop-on nil] 函数将会创建一个新的 Ring 处理器, 用于处理 WebSocket 连接.
;; :chsk/recv 选项来设置接收消息的事件处理器.
;; 需要编写消息处理器函数来处理接收到的消息. 这个函数将会接收到两个参数: 通道 ch 和消息 msg.

新增一个版本步骤

新增数据库

  1. 每个版本的function和基础数据内容都是通过src/cljc/common/db中的文件进行配置
  2. db-template中的内容在每次reset-db选择版本的时候都会重新写入db, 此处需要注意是否有其他依赖的数据库进行了同步操作, 比如本系统中的cn_dist
(defn main! []
  (let [db-path (file-path (if dev? "p_server_dev.db" "p_server.db"))]
    (rf/clear-subscription-cache!)
    (run-query "select * from sys_db" db-path  db-rows->edn db-callback)
    (run-query "select * from cn_dict;" db-path rows->edn  build-dict)
    (rf/dispatch [:init-re-frame-pub-sub-delay])))

;; 解决方案: 在选择版本号的时候重新run-query一遍数据库
  1. 以870为例, 写入该版本的所有function,db-template对象中的function即为所有function的的内容
;;functions
;;显示的函数名 {默认的参数名 默认的参数值}
    {"A to RV" {:RV-Volume "100.0"
                :Purge-Time "0.5"}
     "B to AT" {:AT-Volume "100.0"
                :Purge-Time "0.5"}

默认函数的方法处理

  • src/cljc/program_util.cljc文件中是关于函数名映射成方法名的主要文件,cljc文件是前后端可共用文件
  • btn-name->func-name将初始化数据的函数名称映射成方法名称
  • 参数: process-parameters处理函数名称的默认参数
  • 溶液计算: calc-reagent-consumption-inner溶液的消耗计算函数
  • 时间计算: calc-func-time-inner
  • 将处理之后的方法名映射到peptide.clj中的f-mapping方法中

前后端函数的联调处理

  • 主要: taoensso.sente创建的一个websocket进行操作
  • 前端通过调用封装的:ws-event方法向后端发送一个函数, 后端调用该函数, 执行后端方法
  • 后端go-loop-recur方法, 循环监听一个channel是否产生变化, 如果产生变化, 则发送函数, 触发前端调用函数
;;前端向后端发送
(rf/reg-fx :ws-event
           (fn [[msg-id event]]
             (chsk-send! [msg-id event])))
(kf/reg-event-fx :open-pump
                 (fn [db [vs]]
                    :ws-event [:user/open-pump vs]))
;;后端接收
(defmethod -event-msg-handler :user/open-pump
  [{:keys [event]}]
  (let [[_ vs] event]
    (open-pump vs)))
;;后端向前端发送
(defn pumps-chan (chan))
(defn open-pump
  "泵开启"
  [ps]
  (put! pumps-chan [:open ps]))

;;监听
(defonce _pumps-chan
  (go-loop []
    (send-pumps-changes (<! pumps-chan))
    (recur)))
;;发送
(defn send-pumps-changes [[open-or-close vs]]
  (chsk-send! :taoensso.sente/nil-uid
              [:server/send-pumps-changes [open-or-close vs]]))

这样就实现前后联调啦!

硬件指令联调

  • 目前解析消息的字节序
  • 发送是小端字节序
  • 接收是大端字节序

接收plc传入的消息

通过go-loop函数,进行循环取plc传入的数据,此时会造成一个延迟,由于消息接收很频繁所以对系统会造成延迟.

方案:进行消息过滤,这一次与上一次数据进行比较如果无变化则不向前端下发内容

;; 1.定义一下内容对传入的信息进行解析
;; ubyte:表示无符号字节(Unsigned Byte), 它是一个 8 位无符号整数, 范围在 0 到 255 之间.
;; float32-be 表示大端(Big Endian)格式的 32 位浮点数, 即单精度浮点数. 它是一个 32 位的二进制数, 其中最高位表示符号位, 接下来的 8 位表示指数部分, 剩余的 23 位表示尾数部分.
;; 根据客户提供的plc传给上位机的内容,进行字符解析

(g/defcodec frame-synthesis
  [:ubyte :ubyte :ubyte :ubyte :ubyte :ubyte :ubyte :ubyte
   :float32-be
   :float32-be
   :float32-be

   :float32-be
   :float32-be
   :float32-be

   :float32-be
   :float32-be
   :float32-be

   :float32-be
   :float32-be
   :float32-be

   :float32-be
   :float32-be
   :float32-be

   :float32-be
   :float32-be])

plc硬件指令下发

例如:通过某一个方法进行硬件指令下发

  • 硬件指令内容为 "03 02 00 00 00 00"
  • 传入的内容如果使用boolean则需要转成十六进制的字节
  • gio/encode 主要生成16进制的字节数据和频率数据
  • ubyte:表示了要编码的数据的格式,指定了要编码为一个无符号字节
  • unit16-le:表示一个16位无符号整数
(defn inverter1-cmd
  "上位机发给PLC  变频电机1控制
  16#03 02 MB25 MB26 MB27
  启动 := M25.0;
  频率(HZ) MB26 MB27"
  [open? freq]
  (gio/encode [:ubyte :ubyte :ubyte :uint16-le :ubyte :ubyte]
              [0x03   0x02   (flags->byte [open?])  freq]))

以上便可以实现,对传入的消息进行解析+下发硬件指令

使用taoensso.timbre来记录log

前后端都有很愉快的使用体验

语言的基本错误

还是会犯一些基础的语法错误, 嗯嗯...不丢人😂

case语句的判断条件不能是变量. 下面这个你猜结果是啥?

(let [v 2]
  (case 2
    1    "your input is 1"
    v    "your input is 2"
    "not 1 or 2"
    ))
;;=> "not 1 or 2"
;; 补充:如果想在case匹配多个选择可以使用如下内容
(case id
    (1 2 3) "1,2 or 3"
    "default")
;; 如果传入的id为1或2或3则
;; => "1,2 or 3"

thread-macr使用匿名函数的时候不要忘记再外面加层括号.

因为使用但参数的函数的时候, thread-macro允许不加括号, 有的时候造成了误导.

(->> 10
     inc           ;; 单参数函数可以不加括号
     range         ;; 同上
     (reduce +))

以下情况要注意, 都要加括号哟....

(->> 10
     ((partial + 1))
     ((comp range inc))
     (#(filter even? %))
     ((fn [lst] (count lst))))

wirshark的使用

在联调过程中, 或者项目开发的过程中, 需要检查后台接口是否断联

  1. 可以使用nmap内置的ncat

    如: 后台接口为http://localhost:3000

    ncat localhost 3000
    
  2. wireshark抓包工具的使用

    找到以太网链接启动的内容

    过滤接口

    tcp.port== 6688
    

electron

windows上屏蔽menu

设置BrowserWindow对象的MenuBarVisibility

(.setMenuBarVisibility win false)

屏蔽快捷键

electron包下的globalShortcut可以定制快捷键, regiseter函数的返回值是true或者false, 代表是否可以绑定这个快捷键.

(defn disable-hot-keys
  "禁止打开开发者模式, 禁止使用快捷键刷新"
  []
  (.register e/globalShortcut "Control+Shift+I"  (fn [] #js{}))
  (.register e/globalShortcut "Control+Shift+R"  (fn [] #js{})))

关键路径

在桌面应用的设计中, 一般会区分数据目录应用目录, 数据目录用来存储应用使用过程中产生的数据(数据库), 应用安装目录保存应用程序本身.

这个设计可以保证用户在升级/重装应用的时候, 数据可以得到保留.

同样的, 在我们当前的应用中, 对此也进行了区分, 但是事情的复杂性在于细节, 其中的一个细节在于应用的开发时和部署时, 这两个目录是不一样的.

还有一个细节是, 我们的应用是一个多进程应用, 即Electon主进程, Electonr的renderer进程和后台的JVM进程. 这几个进程在不同的场景中, 都要和 数据目录(中的数据库), 以及应用目录(日志)打交道. 而且各个进程中, 两个目录的获得方法也有差异.

最后还有就是Electron中是否启用asar压缩也决定了这两个目录的位置.

开发时

开发时, 数据目录和应用目录都是工程目录的根目录, 保证开发可以以最简单方便的方式进行.

部署时(windows系统)

  • 用户home路径, 获得方式 (.. js/process -env -HOME), 在window对应用户的home目录, 比如 C:\usrs\userX\
  • 应用数据路径 (.. js/process -env -APPDATA), windows下对应用户目录下 C:\usrs\userX\AppData\Roming\AppY\
  • 应用程序exe路径 (.-execPath js/process), windows下对应 "C:\Users\a123\AppData\Local\Programs\psi-workstation\Aurite-workstation.exe"
  • js代码所在的路径 __dirname, 对应入口js的所在路径, 因为renderer进程和main进程各有自己的入口js, 所有两者也有区分
    • main进程, 即使是asar的压缩文件中比如说 C:\Users\a123\AppData\Local\Programs\psi-workstation\resources\app.asar\native\cljs
    • renderer进程, C:\Users\a123\AppData\Local\Programs\psi-workstation\resources\electron.asar\renderer

补充本系统的目录内容

数据目录

C:\usrs\userX\AppData\Roming\psi-workstation

安装目录

C:\usrs\userX\AppData\local\psi-workstation

开发模式下

后端

  1. 后端配置文件 dev-config.edn 因本系统中使用多个版本及版本的不同信息在配置文件中, 添加字段:version区分不通用版本信息内容
  2. 后端读取文件路径及方式 a. 使用java的System.getProperty读取的内容,如:
    System.getProperty("user.dir")
    

    读取的是当前java -jar 文件名.jar所在的路径,如果是后台启动则启动的是后台所在的当前目录,可以通过emcas中的cider-connect-clj :local-host 7000链接本系统的后台,输入

    (clojrue.java.shell/sh "powershell" "pwd")
    

    来查看当前windows环境下后端所执行的目录,是否与getProperty获取的路径一致

  3. System.getPropertyclojure.java.shell/sh命令获取的路径是一致的,开发环境直接使用环境中添加的sqlite命令和vl2svg命令进行更改
  4. 注意:使用sh命令操作powershell的时候需要powershell空格Invoke-Expression 表达式
(sh "powershell" "Invoke-Expression" "'表达式'")

表达式中如果有双引号需要转义,使用三个反斜杠加双引号进行转移

生产环境

后端

  1. 后端配置文件 env/prod/resources/config.edn中的配置文件的内容是打包之后的后台读取的配置文件,可以看到在env/dev/resources同样有config.edn文件,但是由于开发环境中,读取dev-config.edn文件的优先级高于env/dev/resources/config.edn中的内容,所以开发环境中该文件为空对象
  2. System.getPropertyclojure.java.shell/sh命令获取的路径是一致的 本系统中使用shell命令来执行powershell命令,其中vega和sqlite的exe安装包,在安装目录的resources目录下,所以执行win的生成环境时需要判断是否是win环境,添加为./resouces/sqlite3.exe./resources/vega-cli.exe`
  3. 打包的过程中如果需要将系统的内容打包成单独的文件或者exe模块,需要在package.json中添加
 "extraResources": [
      {
        "from": "./target/p-server-0.1.0-SNAPSHOT-standalone.jar",
        "to": "./"
      },
      {
        "from": "./sqlite3.exe",
        "to": "sqlite3.exe"
      },
      {
        "from": "./vega-report.exe",
        "to": "vega-report.exe"
      },
      {
        "from": "./pdfImg.png",
        "to": "pdfImg.png"
      },
      {
        "from": "./p_server_dev.db",
        "to": "p_server.db"
      }
    ]

前端

  1. 所在文件是当前目录,首先确认打包的时候是否是在安装目录下C:\usrs\userX\AppData\local\psi-workstation前端读取安装目录文件需要用到 (path-join js/__dirname "../../../" "temp-demo.pdf")

electron的版本变化和它的进程模型

本项目的开发从20年持续到23年, Electron版本跨度从10.x现在(23年11月)的28.x, 中间有18个大版本.

参考发布时间线

Electron 的发布周期是每 8 周发布一个新的稳定版本, 这是为了跟随 Chromium 的发布周期, Chromium 每 6 周发布一个新版本electronjs.org. 这意味着每年 Electron 会发布大约 11 个新的稳定版本.

需要注意的是, 这个周期是从 2021 年开始的, 之前的发布周期是每 12 周发布一个新的稳定版.

此外, Electron 的版本支持政策是最新的 3 个稳定版本, 但从 2021 年开始, 这个政策扩展到了最新的 4 个稳定版本electronjs.org. 这意味着 Electron 的开发者可以期待每个版本至少被支持 4 个月.

一方面, 如此剧烈的变动说明electron是个非常活跃的项目(值的拥有),另一方面又意味着对于开发者得不断适应API和参数的变化.

Electron的天赋能力是让浏览器中的js运行时可以使用nodejs的完整生态, 这是个超能力,后续的版本很多是对这个能力进行限制.

希望大家尽可能使用preload脚本来限制前端可用的api.

  • contextIsolation 的变化
  • remote模块在14后被完全移除掉
  • nodeIntegration 的变化

render进程的启动参数

支持node的require

Browserwindow的启动参数设置

(ns electron.main
  (:require
   ["electron" :as e]))

(e/BrowserWindow.
 #js {:webPreferences #js {:nodeIntegration true}})

全屏幕

(e/BrowserWindow.
 #js {:webPreferences #js {:fullscreen true
                           :simpleFullscreen true}})

是否保留菜单

(e/BrowserWindow.
 #js {:webPreferences #js {:autoHideMenuBar (if dev? false true)}})
;; 测试环境保留menubar

禁止自动休眠/熄屏

工业系统执行过程中, 是要求不能熄屏和休眠的.

(ns electron.main
  (:require
   ["electron" :as e]))
;; 系统禁止休眠和熄屏
(doto e/powerSaveBlocker
  (.start  "prevent-app-suspension" )
  (.start  "prevent-display-sleep" ))

指定目录

参考下面代码, 注意<p!来串联多个promise

(defonce fs (js/require "fs"))
(defonce electron (js/require "electron"))
(defn print-current-page [pdf-file-name]
  (go
    (let [webcontent ((.. electron -remote -getCurrentWebContents))
          dialog-info (<p! (-> electron
                               (.-remote)
                               (.-dialog)
                               (.showOpenDialog
                                #js {:properties
                                     #js ["openDirectory"]})))
          _ (prn dialog-info)
          _ (def _d dialog-info)
          canceled? (g/get dialog-info "canceled")
          filePath (g/getValueByKeys dialog-info "filePaths" 0)
          data (<p! (.printToPDF webcontent #js {}))
          ]
      (when-not canceled?
        (try
          (prn filePath "..." canceled?)
          (<p! (.writeFile fs (path-join filePath  (str pdf-file-name ".pdf")) data
                           (fn [error]
                             (if error
                               (js/console.log "error:" error)
                               (js/console.log "print successful@!!!!!")))))
          (catch js/Error err (js/console.log (ex-cause err))))))))

启动外部进程

使用child-process的的exec或者spawn函数, 下面启动了根目录下的一个jar包

(ns electron.main
  (:require
   ["child_process" :as cp]
   [taoensso.timbre :as timbre :refer-macros (tracef debugf infof warnf errorf)]))
(.exec cp
       (str "java -jar " "./my-server.jar" )
       nil
       (fn [error, stdout, stderr]
         (when error
           (errorf "启动发生了错误:%s" (.-stack error)))
         (infof "执行结果:%s" stdout)
         (when (.-lenght stderr)
           (errorf "Error:%s" stderr))))

打印

打印整个web内容为pdf, 需要用到文件系统fs模块和electron模块

(defonce fs (js/require "fs"))
(defonce electron (js/require "electron"))
(defn print-current-page [pdf-file-name]

  (let  [webcontent ((.. electron -remote -getCurrentWebContents))]
    (-> (.printToPDF webcontent #js {})
        (.then (fn [data]
                 (.writeFile fs (str "./" pdf-file-name ".pdf") data
                             (fn [error]
                               (if error
                                 (js/console.log error)
                                 (js/console.log "print successful@!!!!!"))))))
        (.catch (fn [error]
                  (js/console.log error))))))

在render中通过remote对象结束应用

(defn close-app []
  ( -> electron
   (.-remote)
   (.getCurrentWindow)
   (.close)))

electron-builder 打包

electron-builder 是electron的一站式打包方案, 功能上包括了高度定制化的增加icon, 打包,自动升级等功能, 当前的使用只能算得上是浅尝辄止.

icons

在根目录下创建build目录, 放入mac的icns后缀的图标, windows平台下的ico或者png后缀图标, linux下的png图标. 打包生成的应用就有图标了.

当前windows已经支持png作为应用图表, 如下配置可以增加icon. 注意icon生成是个打包期间的行为, 目录public就是当前项目根目录下的public目录

    "win": {
      "target": "nsis",
      "icon": "public/icon.png"
    }

electron的一些原生UI能力

结合repl, 可以玩一天

(comment ;; 创建一个无边框的窗口
  (let [board-less-win (e/BrowserWindow. #js { :width 800 :height 100, :frame false })]
    (.show board-less-win)))
(comment
  (.setFullScreen  @main-window true)         ;; 全屏幕
  (.setSimpleFullScreen  @main-window true)   ;; 全屏幕
  (.maximize  @main-window)                   ;; 窗口最大化
  (.minimize @main-window)                    ;; 最小化
  (.restore @main-window)                     ;; 从docker恢复
  (.getContentSize @main-window )             ;; 拿到content大小
  (.isAlwaysOnTop @main-window )              ;; 始终最前
  (.setProgressBar @main-window 0.5)          ;; 设置一个docker icon上的进度条
  (.setBadgeCount e/app 10 ))                 ;; 设置一个角标

应用自动升级(draft)

文档

electron-build方案

osx下打包签名(draft)

安装/卸载界面的定制

NSIS通常用作Windows平台的安装解决方案, 通常与electron-builder或其他打包工具一起使用. electron-builder提供了一个简化的接口来定义NSIS安装程序的行为和外观, 这样开发者无需手动编写复杂的NSIS脚本.

NSIS(Nullsoft Scriptable Install System)是一个开源的工具, 用于创建Windows安装器. 它最初是由Nullsoft公司开发的, 该公司也是Winamp媒体播放器的开发者. NSIS允许开发者构建具有各种定制选项和复杂逻辑的安装程序.

可以在electron-builder的配置文件中(通常位于项目的package.json)指定各种NSIS设置, 以定制安装程序的行为. 这包括定义安装和卸载程序的图标, 是否允许用户更改安装目录, 安装过程中的自定义脚本等.

re-frame

使用拦截器->interceptor在事件处理的前/后增加逻辑

拦截器截获context(re-frame中最大的数据结构, 是cofx的的爹), 可以拿到/修改db, event, cofx.

下面是个针对多有的参数修改事件记录log的例子, 在事件处理之前截获,并且记录修改的值.

(def log-change-params-event
  (rf/->interceptor
   :id      :log-event
   :before  (fn [context]
              (def _context context)
              (let [user (get-in context  [:coeffects :db :login :username])
                    event (get-in context [:coeffects :re-frame.std-interceptors/untrimmed-event])
                    id    (first event)
                    val   (second event)]
                (rf/dispatch
                 [:write-log (str "USER_ACTION: user " user " changed parameter " (name id) " to: " val)]))
              context)))
;;------------------------------------------
(run!
 (fn [e]
   (kf/reg-event-db
    (:id e)
    [log-change-params-event]
    (fn [db [val]]
      (assoc-in db (:path e) val)))
   (rf/reg-sub (:id e)
               (fn [db]
                 (get-in db (:path e)))))
 params-meta)

reg-sub 很灵活

reg-sub的完全体:

(reg-sub
  :query-id
  signal-fn     ;; <-- here
  computation-fn)

注意:

  1. singal-fn <---- 不写明就是 (fn [_ _] re-frame/app-db), 返回db
  2. coputation-fn <---- 我们熟悉的匿名函数, 其实它非常灵活

signal-fn 返回一个vector, 接下来这个vector会传给计算函数

(fn [query-vec dynamic-vec]
  [(subscribe [:a-sub])
   (subscribe [:b-sub])])

计算函数的签名是这样子:

(fn [[a b] query-vec]     ;; 1st argument is a seq of two values
  ....)

其实我们平时用的是一个特殊情况, 等价与下面:

(reg-sub
 :query-id
 (fn [_ _]  re-frame/app-db)   ;; <--- explicit input-fn
 a-computation-fn)             ;; has signature:  (fn [db query-vec]  ... ret-value)

灵活传参, 可以省掉很多不必要的reg-sub.

(rf/reg-sub :uuid-end-time
            (fn [db [_ uuid]]
              (get-in db [:step-uuids :end uuid])))
;;-----------
(rf/subscribe [:uuid-end-time uuid])

NPM体系

NPM上的包是非常豪放的, 某些版本的包有bug很正常

这是具体的包的作者的错, 不是NPM的错, 开发过程中遇到过

  1. shadow-cljs 2.10.19 不能读取目录下的shadow-cljs.edn配置文件
  2. material-ui/lab 4.0.0-alpha.56 的Alert控件不能正常编译
  3. ....

不好怀疑自己, 多多尝试吧, 现阶段前端的包测试不充分是常态.

package.json 和 package-lock.json

根据我的粗浅理解, package.json 只是定了个版本的范围, 即必须大于4.8.3

{"@material-ui/core": "^4.8.3"}

package-lock.json记录了我的实际安装版本, 是4.11.0

    "@material-ui/core": {
      "version": "4.11.0",
      "resolved": "https://registry.npm.taobao.org/@material-ui/core/download/@material-ui/core-4.11.0.tgz",
      "integrity": "sha1-tpsm5FU8nlPyv68QU+IWoK+b4Vo=",
      "requires": {
        "@babel/runtime": "^7.4.4",
        "@material-ui/styles": "^4.10.0",
        "@material-ui/system": "^4.9.14",
        "@material-ui/types": "^5.1.0",
        "@material-ui/utils": "^4.10.2",
        "@types/react-transition-group": "^4.2.0",
        "clsx": "^1.0.4",
        "hoist-non-react-statics": "^3.3.2",
        "popper.js": "1.16.1-lts",
        "prop-types": "^15.7.2",
        "react-is": "^16.8.0",
        "react-transition-group": "^4.4.0"
      }
    },

如果想明确指定某个版本, 可以在package.json中写明版本号, 注意没有^号:

    "@material-ui/lab": "4.0.0-alpha.50",

MaterialUI的lab包

主要分为三个依赖包

  1. core
  2. icons
  3. lab

留意下会发现lab的包都是alpha, 也就都是探索版, MaterialUI中的很多新特性/控件会先放到lab包中, 稳定以后向core迁移. 这就会导致alpha中的很多内容是不稳定的.

尽可能小范围的引用

发现在开发的时候, 要编译七千多文件, 不仅编译慢, 执行也会受拖累, 打包也会大很多.

[:app] Compiling ...
[:app] Build completed. (1677 files, 0 compiled, 0 warnings, 1.32s)

Bisect一下发现某个版本上引入了整个icons包.

["@material-ui/icons" :as icons]

删掉之后

[:app] Compiling ...
[:app] Build completed. (7229 files, 15 compiled, 0 warnings, 8.09s)
[:app] Compiling ...

所以还是要用什么引什么, 不光对于icon包, 所有的包都适用.

["@material-ui/core/IconButton" :default IconButton]
["@material-ui/icons/KeyboardArrowUp" :default KeyboardArrowUp]
["@material-ui/icons/KeyboardArrowDown" :default KeyboardArroDown]

Babashka脚本

bb.edn

配置tasks跨平台/全功能的任务执行

➜  p-server git:(master) ✗ bb tasks
The following tasks are available:

clean-up      kill all running backedn & plc
init-db       reset db to its clean state
delete-shadow delete .shadow-cljs
compile-cljs  compile cljs target app & electron
compile-css   webpack compile css
welcome       欢迎
backend       start running lein run with `log-file-name`
plc           run plc simulator
build-ui      build ui, 1. delete .shadow-cljs 2. update css 3. build app electron 4. build-package
ui-dev        以开发模式启动UI
build-package update package commit & date time
test          执行所有的UI单元测试
psi           switch back to psi-station
cleavage      switch to cleavage-station

etaion 驱动Electron 前端测试

  • 启动/停止 electron
  • 写入org-mode的测试报告
  • 使用js-execute执行js脚本, 拿到re-frame.db/app-db来进行各类判断
  • 使用npre-client控制后台服务

使用Babashka而不是clojure做脚本,更快的启动和测试.

具体的实现内容,可以查看单元测试blog 以下是一个前端的保存点击的例子

;;clojure.test 中的,为了保证每次测试的独立性 :each在每个deftest前都要执行一次传入函数
;; compose-fixtrue 谁现在前先执行谁 先执行 backend 清日志 db electron
(use-fixtures :each (compose-fixtures
                     (compose-fixtures
                      backend-fixture
                      db-fixture)
                     electron-fixture))



(report/deftest-with-rpt Program页面测试
  (screenshot report/*test-name* "初始化页面")
  (api/wait driver 1)
  (select-versin-if-needed driver 870)  (screenshot report/*test-name* "选择进入870")
  (login driver)
  (screenshot report/*test-name* "用户登录")
  (goto-program  driver)


  (report/testing-with-rpt
   "保存一个程序"
   ;;普通的打印日志
   (org-insert-txt report/*test-name* "** 编写并保存program")
   ;; 截图 + 备注
   (screenshot report/*test-name* "进入program界面")
   ;; 等待
   (api/wait-exists driver {:id "A to RV"})
   ;; 双击
   (api/double-click driver {:id "A to RV"})
   ;; 单击
   (api/click driver {:id "more"})
   ;; 等待点击
   (api/click-visible driver {:id "save"})
   (api/wait-visible driver {:id "name"})
   ;; 模拟输入
   (api/fill-human driver {:id "name"} "AtoRV")
   (api/click-visible driver {:id "saveProgram"})

   ;;生成一个列表的assert语句 true/false + 备注
   (report/is-with-rpt (boolean (get-in (get-state driver) [:programs :AtoRV])) (str "保存" "A to RV" "函数成功"))

   (screenshot report/*test-name* "保存程序")
   ;; 按esc键
   (api/fill-active driver keys/escape)

   (goback driver)
   (goto-sync-ctrl driver)

   (api/wait-visible driver {:tag :td :fn/has-text "AtoRV"})
   (api/double-click driver {:tag :td :fn/has-text "AtoRV"})
   ;;开始执行程序之前打开watcher
   (screenshot  report/*test-name* "准备执行程序")
   (api/click-visible driver {:id "START"})
   (api/wait-visible driver {:id "pass"})

   ;; 删除键
   (api/fill-human driver {:id "pass"} (repeat 10 keys/backspace))
   (api/fill-human driver {:id "pass"} "admin")
   (api/fill-active driver keys/tab)
   (api/fill-active driver keys/tab)
   (api/fill-active driver keys/enter)
   (org-insert-txt report/*test-name* "执行function A to RV")

   (let [[func-name every-step] (log-handler "log/trace-rolling.log" 50)]
     (loop []
       (let [str-list (every-step)
             str-log (second (string/split str-list #" - "))
             operat-num (second (string/split str-log #": "))
             ;;FIXME:仅限某一个funtion/program还需更改
             end-flag (= "Ends" (last (string/split str-log #" ")))]

         (org-insert-txt report/*test-name* (str "function执行的步骤" str-list))
         (report/is-with-rpt (boolean operat-num) str-log)
         (api/click-visible driver {:id "Information"})
         (screenshot report/*test-name* "程序执行变化步骤")


         (when-not end-flag
           (recur)))))

   (api/click-visible driver {:id "Reagent Consumption"})
   (screenshot report/*test-name* "溶液内容")





   )

  (report/testing-with-rpt
   "第二个function的内容"
   (org-insert-txt report/*test-name* "43个function点击auto的内容")
   )
  )


react中cljs 晦涩的错误(draft)

参考资料

Tags: re-frame Reagent electron MaterialUI clojurescript