前、後端通用的可視化邏輯編排

来源:https://www.cnblogs.com/idlewater/archive/2023/07/21/logicflow.html
-Advertisement-
Play Games

可視化邏輯編排引擎,包括數據流驅動的邏輯編排原理,業務編排編輯器的實現,頁面控制項聯動,前端業務邏輯與UI層的分離,子編排的復用、自定義迴圈等嵌入式子編排的處理、事務處理等 ...


前一段時間寫過一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整》,得到了很多朋友的關註。
其中的邏輯編排部分過於簡略,不少朋友希望能寫一些關於邏輯編排的內容,本文就詳細講述一下邏輯編排的實現原理。
邏輯編排的目的,是用最少甚至不用代碼來實現軟體的業務邏輯,包括前端業務邏輯跟後端業務邏輯。本文前端代碼基於typescript、react技術棧,後端基於golang。
涵蓋內容:數據流驅動的邏輯編排原理,業務編排編輯器的實現,頁面控制項聯動,前端業務邏輯與UI層的分離,子編排的復用、自定義迴圈等嵌入式子編排的處理、事務處理等
運行快照:
image.png
前端項目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
後端演示尚未提供,代碼地址:https://github.com/codebdy/minions-go
註:為了便於理解,本文使用的代碼做了簡化處理,會跟實際代碼有些細節上的出入。

整體架構

image.png
整個邏輯編排,由以下幾部分組成:

  • 節點物料,用於定義編輯器中的元件,包含在工具箱中的圖標,埠以及屬性面板中的組件schema。
  • 邏輯編排編輯器,顧名思義,可視化編輯器,根據物料提供的元件信息,編輯生成JSON格式的“編排描述數據”。
  • 編排描述數據,用戶操作編輯器的生成物,供解析引擎消費
  • 前端解析引擎,Typescript 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟體的業務邏輯。
  • 後端解析引擎,Golang 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟體的業務邏輯。

邏輯編排實現方式的選擇

邏輯編排,實現方式很多,爭議也很多。
一直以來,小編的思路也很局限。從流程圖層面,以線性的思維去思考,認為邏輯編排的意義並不大。因為,經過這麼多年發展,事實證明代碼才是表達邏輯的最佳方式,沒有之一。用流程圖去表達代碼,最終只能是老闆、客戶的豐滿理想與程式員骨感現實的對決。
直到看到Mybricks項目交互部分的實現方式,才打開了思路。類似unreal藍圖數據流驅動的實現方式,其實大有可為。
這種方式的意義是,跳出迴圈、if等這些底層的代碼細節,以數據流轉的方式思考業務邏輯,從而把業務邏輯抽象為可復用的組件,每個組件對數據進行相應處理或者根據數據執行相應動作,從而達到復用業務邏輯的目的。並且,節點的粒度可大可小,非常靈活。
具體實現方式是,把每個邏輯組件看成一個黑盒,通過入埠流入數據,出埠流出變換後的數據:
image.png
舉個例子,一個節點用來從資料庫查詢客戶列表,會是這樣的形式:
image.png
用戶不需要關註這個元件節點的實現細節,只需要知道每個埠的功能就可以使用。這個元件節點的功能可以做的很簡單,比如一個fetch,只有幾十行代碼。也可以做到很強大,比如類似useSwr,自帶緩存跟狀態管理,可以有幾百甚至幾千行代碼。
我們希望這些元件節點是可以自行定義,方便插入的,並且我們做到了。
出埠跟入埠之間,可以用線連接,表示元件節點之間的調用關係,或者說是數據的流入關係。假如,數據讀取成功,需要顯示在列表中;失敗,提示錯誤消息;查詢時,顯示等待的Spinning,那麼就可以再加三個元件節點,變成:
image.png
如果用流程圖,上面這個編排,會被顯示成如下樣子:
image.png
兩個比較,就會發現,流程圖的思考方式,會把人引入條件細節,其實就是試圖用不擅長代碼的圖形來描述代碼。是純線性的,沒有回調,也就無法實現類似js promise的非同步。
而數據流驅動的邏輯編排,可以把人從細節中解放出來,用模塊化的思考方式去設計業務邏輯,更方便把業務邏輯拆成一個個可復用的單元。
如果以程式員的角度來比喻,流程圖相當於一段代碼腳本,是面向過程的;數據流驅動的邏輯編排像是幾個類交互完成一個功能,更有點面向對象的感覺。
朋友,如果是讓你選,你喜歡哪種方式?歡迎留言討論。
另外還有一種類似stratch的實現方式:
image.png
感覺這種純粹為了可視化而可視化,只適合小孩子做玩具。會寫代碼的人不願意用,太低效了。不會寫代碼的人,需要理解代碼才會用。適合場景是用直觀的方式介紹什麼是代碼邏輯,就是說只適合相對比較低智力水平的編程教學,比如幼兒園、小學等。商業應用,就免了。

數據流驅動的邏輯編排

一個簡單的例子

從現在開始,放下流程圖,忘記strach,我們從業務角度去思考也邏輯,然後設計元件節點去實現相應的邏輯。
選一個簡單又典型的例子:學生成績單。一個成績單包含如下數據:
學生成績單
假如數據已經從資料庫取出來了,第一步處理,統計每個學生的總分數。設計這麼幾個元件節點來配合完成:
image.png
這個編排,輸入成績列表,迴圈輸出每個學生的總成績。為了完成這個編排,設計了四個元件節點:

  • 迴圈,入埠接收一個列表,遍歷列表並迴圈輸出,每一次遍歷往“單次輸出”埠發送一條數據,可以理解為一個學生對象(儘量從對象的角度思考,而不是數據記錄),遍歷結束後往“結束埠”發送迴圈的總數。如果按照上面的列表,“單次輸出埠”會被調用4次,每次輸出一個學生對象{姓名:xxx,語文:xxx,數學:xxx...},“結束”埠只被調用一次,輸出結果是 4.
  • 拆分對象,這個元件節點的出埠是可以動態配置的,它的功能是把一個對象按照屬性值按照名字分發到指定的出埠。本例中,就是把各科成績拆分開來。
  • 收集數組,這個節點也可以叫收集到數組,作用是把串列接收到的數據組合到一個數組裡。他有兩個入埠:input埠,用來接收串列輸入,並緩存到數組;finished埠,表示輸入完成,把緩存到的數據組發送給輸出埠。
  • 加和,把輸入埠傳來的數組進行加和計算,輸出總數。

這是一種跟代碼完全不同的思考方式,每一個元件節點,就是一小段業務邏輯,也就是所謂的業務邏輯組件化。我們的項目中,只提供給了有限的預定義元件節點,想要更多的節點,可以自行自定義並註入系統,具體設計什麼樣的節點,完全取決於用戶的業務需求跟喜好。作者更希望設計元件的過程是一個創作的過程,或許具備一定的藝術性。
剛剛的例子,審視之。有人可能會換一個方式來實現,比如拆分對象跟收集數據這兩個節點,合併成一個節點:對象轉數組,可能更方便,適應能力也更強:
image.png
對象轉換數組節點,對象屬性與數組索引的對應關係,可以通過屬性面板的配置來完成。
這兩種實現方式,說不清哪種更好,選擇自己喜歡的,或者兩種都提供。

輸入節點、輸出節點

一段圖形化的邏輯編排,通過解析引擎,會被轉換成一段可執行的業務邏輯。這段業務邏輯需要跟外部對接,為了明確對接語義,再添加兩個特殊的節點元件:輸入節點(開始節點),輸出節點(結束節點)。
image.png
輸入節點用於標識邏輯編排的入口,輸入節點可以有一個或者多個,輸入節點用細線圓圈表示。
輸出節點用於標識邏輯編排的出口,輸出節點可以有一個或者多個,輸出節點用粗線圓圈表示。
在後面的引擎部分,會詳細描述輸入跟輸出節點如何跟外部的對接。

編排的復用:子編排

一般低代碼中,提升效率的方式是復用,儘可能復用已有的東西,比如組件、業務邏輯,從而達到降本、增效的目的。
設計元件節點是一種創作,那麼使用元件節點進行業務編排,更是一種基於領域的創作。辛辛苦苦創作的編排,如果能被覆用,應該算是對創作本身的尊重吧。
如果編排能夠像元件節點一樣,被其它邏輯編排所引用,那麼這樣的復用方式無疑是最融洽的。也是最方便的實現方式。
把能夠被其它編排引用的編排稱為子編排,上面計算學生總成績的編排,轉換成子編排,被引入時的形態應該是這樣的:
image.png
子編排元件的輸入埠對應邏輯編排實現的輸入節點,輸出埠對應編排實現的輸出節點。

嵌入式編排節點

前文設計的迴圈組件非常簡單,迴圈直接執行到底,不能被中斷。但是,有的時候,在處理數據的時候,要根據每次遍歷到的數據做判斷,來決定繼續迴圈還是終止迴圈。
就是說,需要一個迴圈節點,能夠自定義它的處理流程。依據這個需求,設計了自定義迴圈元件,這是一種能夠嵌入編排的節點,形式如下:
image.png
這種嵌入式編排節點,跟其它元件節點一樣,事先定義好輸入節點跟輸出節點。只是它不完全是黑盒,其中一部分通過邏輯編排這種白盒方式來實現。
這種場景並不多見,除了迴圈,後端應用中,還有事務元件也需要類似實現方式:
image.png
嵌入式元件跟其它元件節點一樣,可以被其它元件連接,嵌入式節點在整個編排中的表現形式:
image.png

基本概念

為了進一步深入邏輯編排引擎跟編輯器的實現原理,先梳理一些基本的名詞、概念。
邏輯編排,本文特指數據流驅動的邏輯編排,是由圖形表示的一段業務邏輯,由元件節點跟連線組成。
元件節點,簡稱元件、節點、編排元件、編排單元。邏輯編排中具體的業務邏輯處理單元,帶副作用的,可以實現數據轉換、頁面組件操作、資料庫數據存取等功能。一個節點包含零個或多個輸入埠,包含零個或多個輸出埠。在設計其中,以圓角方形表示:
image.png
,分為輸入埠跟輸出埠兩種。是元件節點流入或流出數據的通道(或者介面)。在邏輯單元中,用小圓圈表示。
輸入埠,簡稱入埠、入口。輸入埠位於元件節點的左側。
輸出埠,簡稱出埠、出口。輸出埠位於元件節點的右側。
單入口元件,只有一個入埠的元件節點。
多入口元件,有多個入埠的元件節點。
單出口元件,只有一個出埠的元件節點。
多出口元件,有多個出埠的元件節點。
輸入節點,一種特殊的元件節點,用於描述邏輯編排的起點(開始點)。轉換成子編排後,會對應子編排相應的入埠。
輸出節點,一種特殊的元件節點,用於描述邏輯編排的終點(結束點)。轉換成子編排後,會對應子編排相應的出埠。
嵌入式編排,特殊的元件節點,內部實現由邏輯編排完成。示例:
image.png
子編排,特殊的邏輯編排,該編排可以轉換成元件節點,供其它邏輯編排使用。
連接線,簡稱連線、線。用來連接各個元件節點,表示數據的流動關係。

定義DSL

邏輯編排編輯器生成一份JSON,解析引擎解析這份JSON,把圖形化的業務邏輯轉化成可執行的邏輯,並執行。
編輯器跟解析引擎之間要有份約束協議,用來約定JSON的定義,這個協議就是這裡定義的DSL。在typescript中,用interface、enum等元素來表示。
這些DSL僅僅是用來描述頁面上的圖形元素,通過activityName屬性跟具體的實現代碼邏輯關聯起來。比如一個迴圈節點,它的actvityName是Loop,解析引擎會根據Loop這個名字找到該節點對應的實現類,並實例化為一個可執行對象。後面的解析引擎會詳細展開描述這部分。

節點類型

元件節點類型叫NodeType,用來區分不同類型的節點,在TypeScript中是一個枚舉類型。

export enum NodeType {
  //開始節點
  Start = 'Start',
  //結束節點
  End = 'End',
  //普通節點
  Activity = 'Activity',
  //子編排,對其它編排的引用
  LogicFlowActivity = "LogicFlowActivity",
  //嵌入式節點,比如自定義邏輯編排
  EmbeddedFlow = "EmbeddedFlow"
}

export interface IPortDefine {
  //唯一標識
  id: string;
  //埠名詞
  name: string;
  //顯示文本
  label?: string;
}

元件節點

//一段邏輯編排數據
export interface ILogicFlowMetas {
  //所有節點
  nodes: INodeDefine<unknown>[];
  //所有連線
  lines: ILineDefine[];
}
export interface INodeDefine<ConfigMeta = unknown> {
  //唯一標識
  id: string;
  //節點名稱,一般用於開始結束、節點,轉換後對應子編排的埠
  name?: string;
  //節點類型
  type: NodeType;
  //活動名稱,解析引擎用,通過該名稱,查找構造節點的具體運行實現
  activityName: string;
  //顯示文本
  label?: string;
  //節點配置
  config?: ConfigMeta;
  //輸入埠
  inPorts?: IPortDefine[];
  //輸出埠
  outPorts?: IPortDefine[];
  //父節點,嵌入子編排用
  parentId?: string;
  // 子節點,嵌入編排用
  children?: ILogicFlowMetas
}

連接線

//連線接頭
export interface IPortRefDefine {
  //節點Id
  nodeId: string;
  //埠Id
  portId?: string;
}

//連線定義
export interface ILineDefine {
  //唯一標識
  id: string;
  //起點
  source: IPortRefDefine;
  //終點
  target: IPortRefDefine;
}

邏輯編排

//這個代碼上面出現過,為了使extends更直觀,再出現一次
//一段邏輯編排數據
export interface ILogicFlowMetas {
  //所有節點
  nodes: INodeDefine<unknown>[];
  //所有連線
  lines: ILineDefine[];
}
//邏輯編排
export interface ILogicFlowDefine extends ILogicFlowMetas {
  //唯一標識
  id: string;
  //名稱
  name?: string;
  //顯示文本
  label?: string;
}

解析引擎的實現

解析引擎有兩份實現:Typescript實現跟Golang實現。這裡介紹基於原理,以Typescript實現為準,後面單獨章節介紹Golang的實現方式。也有朋友根據這個dsl實現了C#版自用,歡迎朋友們實現不同的語言版本並開源。
DSL只是描述了節點跟節點之間的連接關係,業務邏輯的實現,一點都沒有涉及。需要為每個元件節點製作一個單獨的處理類,才能正常解析運行。比如上文中的迴圈節點,它的DSL應該是這樣的:

{
  "id": "id-1",
  "type": "Activity",
  "activityName": "Loop",
  "label": "迴圈",
  "inPorts": [
    {
      "id":"port-id-1",
      "name":"input",
      "label":""
    }
  ],
  "outPorts": [
    {
      "id":"port-id-2",
      "name":"output",
      "label":"單次輸出"
    },
    {
      "id":"port-id-3",
      "name":"finished",
      "label":"結束"
    }
  ]
}

開發人員製作一個處理類LoopActivity用來處理迴圈節點的業務邏輯,並將這個類註冊入解析引擎,key為loop。這個類,我們叫做活動(Activity)。解析引擎,根據activityName查找類,並創建實例。LoopActivity的類實現應該是這樣:

export interface IActivity{
  inputHandler (inputValue?: unknown, portName:string);
}
export class LoopActivity implements IActivity{
  constructor(protected meta: INodeDefine<ILoopConfig>) {}
  //輸入處理
  inputHandler (inputValue?: unknown, portName:string){
    if(portName !== "input"){
      console.error("輸入埠名稱不正確")
      return      
    }
    let count = 0
    if (!_.isArray(inputValue)) {
      console.error("迴圈的輸入值不是數組")
    } else {
      for (const one of inputValue) {
        this.output(one)
        count++
      }
    }
    //輸出迴圈次數
    this.next(count, "finished")
  }
  //單次輸出
  output(value: unknown){
    this.next(value, "output")
  }
  
  next(value:unknown, portName:string){
     //把數據輸出到指定埠,這裡需要解析器註入代碼
  }
}

解析引擎根據DSL,調用inputHanlder,把控制權交給LoopActivity的對象,LoopActivity處理完成後把數據通過next方法傳遞出去。它只需要關註自身的業務邏輯就可以了。
這裡難點是,引擎如何讓所有類似LoopActivity類的對象聯動起來。這個實現是邏輯編排的核心,雖然實現代碼只有幾百行,但是很繞,需要靜下心來好好研讀接下來的部分。

編排引擎的設計

編排引擎類圖

類圖1
LogicFlow類,代表一個完整的邏輯編排。它解析一張邏輯編排圖,並執行該圖所代表的邏輯。
IActivity介面,一個元件節點的執行邏輯。不同的邏輯節點,實現不同的Activity類,這類都實現IActivity介面。比如迴圈元件,可以實現為

export class LoopActivity implements IActivity{
    id: string
    config: LoopActivityConfig
}

LogicFlow類解析邏輯編排圖時,根據解析到的元件節點,創建相應的IActivity實例,比如解析到Loop節點的時候,就創建LoopActivity實例。
LogicFlow還有一個功能,就是根據連線,給構建的IActivity實例建立連接關係,讓數據能在不同的IActivity實例之間流轉。先明白引擎中的數據流,是理解上述類圖的前提。

解析引擎中的Jointer

在解析引擎中,數據按照以下路徑流動:
連接器間的數據流轉
有三個節點:節點A、節點B、節點C。數據從節點A的“a-in-1”埠流入,通過一些處理後,從節點A的“a-out-1”埠流出。在“a-out-1”埠,把數據分發到節點B的“b-in-1”埠跟節點C的“c-in-1”埠。在B、C節點以後,繼續重覆類似的流動。
埠“a-out-1”要把數據分發到埠“b-in-1”和埠“c-in-1”,那麼埠“a-out-1”要保存埠“b-in-1”和埠“c-in-1”的引用。就是說在解析引擎中,埠要建模為一個類,埠“a-out-1”是這個類的對象。要想分發數據,埠類跟自身是一個聚合關係。這種關係,讓解析引擎中的埠看起來像連接器,故取名Jointer。一個Joniter實例,對應一個元件節點的埠。
在邏輯編排圖中,一個埠,可以連接多個其它埠。所以,一個Jointer也可以連接多個其它Jointer。
連接器的包含關係
註意,這是實例的關係,如果對應到類圖,就是這樣的關係:
類圖2
Jointer通過調用push方法把數據傳遞給其他Jointer實例。
connect方法用於給兩個Joiner構建連接關係。
用TypeScript實現的話,代碼是這樣的:

//數據推送介面
export type InputHandler = (inputValue: unknown, context?:unknown) => void;
export interface IJointer {
  name: string;
  //接收上一級Jointer推送來的數據
  push: InputHandler;
  //添加下游Jointer
  connect: (jointerInput: InputHandler) => void;
}

export class Jointer implements IJointer {
  //下游Jonter的數據接收函數
  private outlets: IJointer[] = []

  constructor(public id: string, public name: string) {
  }

  //接收上游數據,並分發到下游
  push: InputHandler = (inputValue?: unknown, context?:unknown) => {
    for (const jointer of this.outlets) {
      //推送數據
      jointer.push(inputValue, context)
    }
  }

  //添加下游Joninter
  connect = (jointer: IJointer) => {
    //往數組加數據,跟上面的push不一樣
    this.outlets.push(jointer)
  }

  //刪除下游Jointer
  disconnect = (jointer: InputHandler) => {
    this.outlets.splice(this.outlets.indexOf(jointer), 1)
  }
}

在TypeScript跟Golang中,函數是一等公民。但是在類圖裡面,這個獨立的一等公民是不好表述的。所以,上面的代碼只是對類圖的簡單翻譯。在實現時,Jointer的outlets可以不存IJointer的實例,只存Jointer的push方法,這樣的實現更靈活,並且更容易把一個邏輯編排轉成一個元件節點,優化後的代碼:


//數據推送介面
export type InputHandler = (inputValue: unknown, context?:unknown) => void;

export interface IJointer {
  //當key使用,不參與業務邏輯
  id: string;
  name: string;
  //接收上一級Jointer推送來的數據
  push: InputHandler;
  //添加下游Jointer
  connect: (jointerInput: InputHandler) => void;
}

export class Jointer implements IJointer {
  //下游Jonter的數據接收函數
  private outlets: InputHandler[] = []

  constructor(public id: string, public name: string) {
  }

  //接收上游數據,並分發到下游
  push: InputHandler = (inputValue?: unknown, context?:unknown) => {
    for (const jointerInput of this.outlets) {
      jointerInput(inputValue, context)
    }
  }

  //添加下游Joninter
  connect = (inputHandler: InputHandler) => {
    this.outlets.push(inputHandler)
  }

  //刪除下游Jointer
  disconnect = (jointer: InputHandler) => {
    this.outlets.splice(this.outlets.indexOf(jointer), 1)
  }
}

記住這裡的優化:Jointer的下游已經不是Jointer了,是Jointer的push方法,也可以是獨立的其它方法,只要參數跟返回值跟Jointer的push方法一樣就行,都是InputHandler類型。這個優化,可以讓把Activer的某個處理函數設置為入Jointer的下游,後面會有進一步介紹。

Activity與Jointer的關係

一個元件節點包含多個(或零個)入埠和多個(或零個)出埠。那麼意味著一個IActivity實例包含多個Jointer,這些Jointer也按照輸入跟輸出來分組:
類圖3
TypeScript定義的代碼如下:

export interface IActivityJointers {
  //入埠對應的連接器
  inputs: IJointer[];
  //處埠對應的連接器
  outputs: IJointer[];

  //通過埠名獲取出連接器
  getOutput(name: string): IJointer | undefined
  //通過埠名獲取入連接器
  getInput(name: string): IJointer | undefined
}

//活動介面,一個實例對應編排圖一個元件節點,用於實現元件節點的業務邏輯
export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //連接器,跟元件節點的埠異議對應
  jointers: IActivityJointers,
  //元件節點配置,每個Activity的配置都不一樣,故而用泛型
  config?: ConfigMeta;
  //銷毀
  destory(): void;
}

入埠掛接業務邏輯

入埠對應一個Jointer,這個Jointer的連接關係:
image.png
邏輯引擎在解析編排圖元件時,會給每一個元件埠創建一個Jointer實例:

 //構造Jointers
 for (const out of activityMeta.outPorts || []) {
   //出埠對應的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入埠對應的Jointer
   activity.jointers.inputs.push(new Jointer(input.id, input.name))
 }

新創建的Jointer,它的下游是空的,就是說成員變數的outlets數組是空的,並沒有掛接到真實的業務處理。要調用Jointer的connect方法,把Activity的處理函數作為下游連接過去。
最先想到的實現方式是Acitvity有一個inputHandler方法,根據埠名字分發數據到相應處理函數:

export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //連接器,跟元件節點的埠異議對應
  jointers: IActivityJointers,
  //元件節點配置,每個Activity的配置都不一樣,故而用泛型
  config?: ConfigMeta;
  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown):void
  //銷毀
  destory(): void;
}

export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  id: string;
  jointers: IActivityJointers;
  config?: SomeConfigMeta;
  constructor(public meta: INodeDefine<ConfigMeta>) {
    this.id = meta.id
    this.jointers = new ActivityJointers()
    this.config = meta.config;
  }

  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }

  //埠1處理函數
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
  }
  
  destory = () => {
    //銷毀處理
    ...
  }
}

LogicFlow解析編排JSON,碰到SomeActivity對應的元件時,如下處理:

//創建SomeActivity實例
const someNode = new SomeActivity(meta)

 //構造Jointers
 for (const out of activityMeta.outPorts || []) {
   //出埠對應的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入埠對應的Jointer
   const jointer = new Jointer(input.id, input.name)
   activity.jointers.inputs.push(jointer)
   //給入口對應的連接器,掛接輸入處理函數
   jointer.connect(someNode.inputHandler)
 }

業務邏輯掛接到出埠

入口處理函數,處理完數據以後,需要調用出埠連接器的push方法,把數據分發出去:
image.png
具體實現代碼:

export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  jointers: IActivityJointers;
  ...
  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }

  //埠1處理函數
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
    //處理後得到新的值:newInputValue 和新的context:newContext
    //把數據分發到相應出口
    this.jointers.getOutput(somePortName).push(newInputValue, newContext)
  }
  ...
}

入埠跟出埠,連貫起來,一個Activtity內部的流程就跑通了:
image.png

出埠掛接其它元件節點

入埠關聯的是Activity的自身處理函數,出埠關聯的是外部處理函數,這些外部處理函數有能是其它連接器(Jointer)的push方法,也可能來源於其它跟應用對接的部分。
如果是關聯的是其他節點的Jointer,關聯關係是通過邏輯編排圖中的連線定義的。
image.png
解析器先構造完所有的節點,然後遍歷一遍連線,調用連線源Jointer的conect方法,參數是目標Jointer的push,就把關聯關係構建起來了:

    for (const lineMeta of this.flowMeta.lines) {
      //先找起始節點,這個後面會詳細介紹,現在可以先忽略
      let sourceJointer = this.jointers.inputs.find(jointer => jointer.id === lineMeta.source.nodeId)
      if (!sourceJointer && lineMeta.source.portId) {
        sourceJointer = this.activities.find(reaction => reaction.id === lineMeta.source.nodeId)?.jointers?.outputs.find(output => output.id === lineMeta.source.portId)
      }
      if (!sourceJointer) {
        throw new Error("Can find source jointer")
      }

      //先找起終止點,這個後面會詳細介紹,現在可以先忽略
      let targetJointer = this.jointers.outputs.find(jointer => jointer.id === lineMeta.target.nodeId)
      if (!targetJointer && lineMeta.target.portId) {
        targetJointer = this.activities.find(reaction => reaction.id === lineMeta.target.nodeId)?.jointers?.inputs.find(input => input.id === lineMeta.target.portId)
      }

      if (!targetJointer) {
        throw new Error("Can find target jointer")
      }

      //重點關註這裡,把一條連線的首尾相連,構造起連接關係
      sourceJointer.connect(targetJointer.push)
    }

特殊的元件節點:開始節點、結束節點

到目前為止,解析引擎部分,已經能夠成功解析普通的元件併成功連線,但是一個編排的入口跟出口尚未處理,對應的是編排圖的輸入節點(開始節點)跟輸出節點(結束節點)
image.png
這兩個節點,沒有任何業務邏輯,只是輔助把外部輸入,連接到內部的元件;或者把內部的輸出,發送給外部。所以,這兩個節點,只是簡單的Jointer就夠了。
如果把一個邏輯編排看作一個元件節點:
image.png
輸入元件節點對應的是輸入埠,輸出元件節點對應的是輸出埠。既然邏輯編排也有自己埠,那麼LogicFlow也要聚合ActivityJointers:
類圖4
引擎解析的時候,要根據開始元件節點跟結束元件節點,構建LogicFlow的Jointer:

export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
        case NodeType.Start:
          //start只有一個埠,可能會變成其它流程的埠,所以name謹慎處理
          this.jointers.inputs.push(new Jointer(activityMeta.id, activityMeta.name || "input"));
          break;
        case NodeType.End:
          //end 只有一個埠,可能會變成其它流程的埠,所以name謹慎處理
          this.jointers.outputs.push(new Jointer(activityMeta.id, activityMeta.name || "output"));
          break;
      }
      ...
    }
  }
}

經過這樣的處理,一個邏輯編排就可以變成一個元件節點,被其他邏輯編排所引用,具體實現細節,本文後面再展開敘述。

根據元件節點創建Activity實例

在邏輯編排圖中,一種類型的元件節點,在解析引擎中會對應一個實現了IActivity介面的類。比如,迴圈節點,對應LoopActivity;條件節點,對應ConditionActivity;調試節點,對應DebugActivity;拆分對象節點,對應SplitObjectActivity。
這些Activity要跟具體的元件節點建立一一對應關係,在DSL中以activityName作為關聯樞紐。這樣解析引擎根據activityName查找相應的Activity類,並創建實例。

工廠方法

如何找到並創建節點單元對應的Activity實例呢?最簡單的實現方法,是給每個Activity類實現一個工廠方法,建立一個activityName跟工廠方法的映射map,解析引擎根據這個map實例化相應的Activity。簡易代碼:

//工廠方法的類型定義
export type ActivityFactory = (meta:ILogiFlowDefine)=>IActivity

//activityName跟工廠方法的映射map
export const activitiesMap:{[activityName:string]:ActivityFactory} = {}

export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  inputHandler=(portName:string, inputValue:unknown, context:unknown)=>{
    if(portName === "input"){
      //邏輯處理
      ...
    }
  }
  ...
}

//LoopActivity的工廠方法
export const LoopActivityFactory:ActivityFactory = (meta:ILogiFlowDefine)=>{
  return new LoopActivity(meta)
}

//把工廠方法註冊進map,跟迴圈節點的activityName對應好
activitiesMap["loop"] = LoopActivityFactory

//LogicFlow的解析代碼
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //查找元件節點對應的ActivityFactory
          const activityFactory = activitiesMap[activityMeta.activityName]
          if(activityFactory){
            //創建Activity實例
            this.activities.push(activityFactory(activityMeta))
          }else{
            //提示錯誤
          }
          break;
      }
      ...
    }
  }
}

引入反射

正常情況下,上面的實現方法,已經夠用了。但是,作為一款開放軟體,會有大量的自定義Activity的需求。上面的實現方式,會讓Activity的實現代碼略顯繁瑣,並且所有的輸入埠都要通過switch判斷轉發到相應處理函數。
我們希望把這部分工作推到框架層做,讓具體Activity的實現更簡單。所以,引入了Typescipt的反射機制:註解。通過註解自動註冊Activity類,通過註解直接關聯埠與相應的處理函數,省去switch代碼。
代碼經過改造以後,就變成這樣:

//通過註解註冊LoopActivity類
@Activity("loop")
export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  //通過註解把input埠跟該處理函數關聯
  @Input("input")
  inputHandler=(inputValue:unknown, context:unknown)=>{
    //邏輯處理
    ...
  }
  ...
}


//LogicFlow的解析代碼
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //根據反射拿到Activity的構造函數
          const activityContructor = ...//此處是反射代碼
          if(activityContructor){
            //創建Activity實例
            this.activities.push(activityContructor(activityMeta))
          }else{
            //提示錯誤
          }
          break;
      }
      ...
    }
  }
}

LogicFlow是框架層代碼,用戶不需要關心具體的實現細節。LoopActivity的代碼實現,明顯簡潔了不少。
Input註解接受一個參數作為埠名稱,參數預設值是input。
還有一種節點,它的輸入埠是不固定的,可以動態增加或者刪除。比如:
image.png
合併節點就是動態入口的節點,它的功能是接收入口傳來的數據,等所有數據到齊以後,合併成一個對象轉發到輸出埠。這個節點,有非同步等待的功能。
為了處理這種節點,我們引入新的註解DynamicInput。實際項目中合併節點Activity的完整實現:


import {
  AbstractActivity,
  Activity,
  DynamicInput
} from '@rxdrag/minions-runtime';
import { INodeDefine } from '@rxdrag/minions-schema';

@Activity(MergeActivity.NAME)
export class MergeActivity extends AbstractActivity<unknown> {
  public static NAME = 'system.merge';
  private noPassInputs: string[] = [];
  private values: { [key: string]: unknown } = {};

  constructor(meta: INodeDefine<unknown>) {
    super(meta);
    this.resetNoPassInputs();
  }

  @DynamicInput
  inputHandler = (inputName: string, inputValue: unknown) => {
    this.values[inputName] = inputValue;、
    //刪掉已經收到數據的埠名
    this.noPassInputs = this.noPassInputs.filter(name=>name !== inputName)
    if (this.noPassInputs.length === 0) {
      //next方法,把數據轉發到指定出口,第二個參數是埠名,預設值input
      this.next(this.values);
      this.resetNoPassInputs();
    }
  };

  resetNoPassInputs(){
    for (const input of this.meta.inPorts || []) {
      this.noPassInputs.push(input.name);
    }
  }
}

註解DynamicInput不需要綁定固定的埠,所以就不需要輸入埠的名稱。

子編排的解析

子編排就是一段完整的邏輯編排,跟普通的邏輯編排沒有任何區別。只是它需要被其它編排引入,這個引入是通過附加一個Activity實現的。

export interface ISubLogicFLowConfig {
  logicFlowId?: string
}

export interface ISubMetasContext{
  subMetas:ILogicFlowDefine[]
}

@Activity(SubLogicFlowActivity.NAME)
export class SubLogicFlowActivity implements IActivity {
  public static NAME = "system-react.subLogicFlow"
  id: string;
  jointers: IActivityJointers;
  config?: ISubLogicFLowConfig;
  logicFlow?: LogicFlow;

  //context可以從引擎外部註入的,此處不必糾結它是怎麼來的這個細節
  constructor(meta: INodeDefine<ISubLogicFLowConfig>, context: ISubMetasContext) {
    this.id = meta.id
    //通過配置中的LogicFlowId,查找子編排對應的JSON數據
    const defineMeta = context?.subMetas?.find(subMeta => subMeta.id === meta.config?.logicFlowId)
    if (defineMeta) {
      //解析邏輯編排,new LogicFlow 就是解析一段邏輯編排,也可以在別處被調用
      this.logicFlow = new LogicFlow(defineMeta, context)
      //把解析後的連接器對應到本Activity
      this.jointers = this.logicFlow.jointers
    } else {
      throw new Error("No meta on sub logicflow")
    }
  }
  
  destory(): void {
    this.logicFlow?.destory();
    this.logicFlow = undefined;
  }
}

因為不需要把埠綁定到相應的處理函數,故該Activity並沒有使用Input相關註解。

嵌入式編排的解析

邏輯編排中,最複雜的部分,就是嵌入式編排的解析,希望小編能解釋清楚。
再看一遍嵌入式編排的表現形式:
image.png
這是自定義迴圈節點。雖然它埠直接跟內部的編排節點相連,但是實際上這種情況是無法直接調用new LogicFlow 來解析內部邏輯編排的,需要進行轉換。引擎解析的時候,把會把上面的子編排重組成如下形式:
image.png
首先,給子編排添加輸入節點,名稱跟ID分別對應自定義迴圈的入埠名稱跟ID;添加輸出節點,名稱跟ID分別對應自定義迴圈的出埠名稱跟ID。
然後,把一個圖中的紅色數字標註的連線,替換成第二個圖中藍色數字標註的連線。
容器節點的埠,並不會跟轉換後的輸入節點或者輸出節點直接連接,而是在實現中根據業務邏輯適時調用,故用粗虛線表示。
自定義迴圈具體實現代碼:

import { AbstractActivity, Activity, Input, LogicFlow } from "@rxdrag/minions-runtime";
import { INodeDefine } from "@rxdrag/minions-schema";
import _ from "lodash"

export interface IcustomizedLoopConifg {
  fromInput?: boolean,
  times?: number
}

@Activity(CustomizedLoop.NAME)
export class CustomizedLoop extends AbstractActivity<IcustomizedLoopConifg> {
  public static NAME = "system.customizedLoop"
  public static PORT_INPUT = "input"
  public static PORT_OUTPUT = "output"
  public static PORT_FINISHED = "finished"
  
  finished = false

  logicFlow?: LogicFlow;

  constructor(meta: INodeDefine<IcustomizedLoopConifg>) {
    super(meta)
    if (meta.children) {
      //通過portId關聯子流程的開始跟結束節點,埠號對應節點號
      //此處的children是被引擎轉換過處理的
      this.logicFlow = new LogicFlow({ ...meta.children, id: meta.id }, undefined)

      //把子編排的出口,掛接到本地處理函數
      const outputPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_OUTPUT
      )
      if(outputPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(outputPortMeta?.name)?.connect(
          this.oneOutputHandler
        )
      }else{
        console.error("No output port in CustomizedLoop")
      }

      const finishedPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_FINISHED
      )
      if(finishedPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(finishedPortMeta?.id)?.connect(
          this.finisedHandler
        )
      }else{
        console.error("No finished port in CustomizedLoop")
      }
      
    } else {
      throw new Error("No implement on CustomizedLoop meta")
    }
  }

  @Input()
  inputHandler = (inputValue?: unknown, context?:unknown) => {
    let count = 0
    if (this.meta.config?.fromInput) {
      if (!_.isArray(inputValue)) {
        console.error("Loop input is not array")
      } else {
        for (const one of inputValue) {
          //轉發輸入到子編排
          this.getInput()?.push(one, context)
          count++
          //如果子編排調用了結束
          if(this.finished){
            break
          }
        }
      }
    } else if (_.isNumber(this.meta.config?.times)) {
      for (let i = 0; i < (this.meta.config?.times || 0); i++) {
        //轉發輸入到子編排
        this.getInput()?.push(, context)
        count++
        //如果子編排調用了結束
        if(this.finished){
          break
        }
      }
    }
    //如果子編排中還沒有被調用過finished
    if(!this.finished){
      this.next(count, CustomizedLoop.PORT_FINISHED, context)
    }
  }

  getInput(){
    return this.logicFlow?.jointers?.getInput(CustomizedLoop.PORT_INPUT)
  }

  oneOutputHandler = (value: unknown, context?:unknown)=>{
    //輸出到響應埠
    this.output(value, context)
  }

  finisedHandler = (value: unknown, context?:unknown)=>{
    //標識已調用過finished
    this.finished = true
    //輸出到響應埠
    this.next(value, CustomizedLoop.PORT_FINISHED, context)
  }

  output = (value: unknown, context?:unknown) => {
    this.next(value, CustomizedLoop.PORT_OUTPUT, context)
  }
}

基礎的邏輯編排引擎,基本全部介紹完了,清楚了節點之間的編排機制,是時候定義節點的連線規則了。

節點的連線規則

一個節點,是一個對象。有狀態,有副作用。有狀態的對象沒有約束的互連,是非常危險的行為。
這種情況會面臨一個誘惑,或者說用戶自己也分不清楚。就是把節點當成無狀態對象使用,或者直接認為節點就是無狀態的,不加限制的把連線連到某個節點的入口上。
比如上面計算學生總分例子,可能會被糊塗的用戶連成這樣:
image.png
這種連接方式,直接造成收集數組節點無法正常工作。
邏輯編排之所以直觀,在於它把每一個個數據流通的路徑都展示出來了。在一個通路上的一個節點,最好只完成一個該通路的功能。另一個通路如果想完成同樣的功能,最好再新建一個對象:
image.png
這樣兩個收集數組節點,就互不幹擾了。
要實現這樣的約束,只需要加一個連線規則:同一個入埠,只能連一條線
有了這條規則,節點對象狀態帶來的不利影響,基本消除了。
在這樣的規則下,收集數組節點的入口不能連接多條連線,只需要把它重新設計成如下形式:
image.png
一個出埠,可以往外連接多條連線,用於表示並行執行。另一條規則就是:同一個出埠,可以有多條連線
數據是從左往右流動,所以再加上最後一條規則:入埠在節點左側,出埠在節點右側
所有的連線規則完成了,蠻簡單的,編輯器層面可以直接做約束,防止用戶輸錯。

編輯器的實現

編輯器佈局

image.png
整個編輯器分為圖中標註的四個區域。

  • ① 工具欄,編輯器常規操作,比如撤銷、重做、刪除等。
  • ② 工具箱(物料箱),存放可以被拖放的元件物料,這些物料是可以從外部註入到編輯器的。
  • ③ 畫布區,繪製邏輯編排圖的畫布。每個節點都有自己的坐標,要基於這個對DSL進行擴展,給節點附加坐標信息。畫布基於阿裡antv X6實現。
  • ④ 屬性面板,編輯元件節點的配置信息。物料是從編輯器外部註入的,物料對應節點的配置是變化的,所以屬性面板內的組件也是變化的,使用RxDrag的低代碼渲染引擎來實現,外部註入的物料要寫到相應的Schema信息。低代碼Schema相關內容,請參考另一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整

擴展DSL

前面定義的DSL用在邏輯編排解析引擎里,足夠了。但是,在畫布上展示,還缺少節點位置跟尺寸信息。設計器畫布是基於X6實現的,要添加X6需要的信息,來擴展DSL:

export interface IX6NodeDefine {
  /** 節點x坐標 */
  x: number;
  /** 節點y坐標  */
  y: number;
  /** 節點寬度 */
  width: number;
  /** 節點高度 */
  height: number;
}
// 擴展後節點
export interface IActivityNode extends INodeDefine {
  x6Node?: IX6NodeDefine
}

這些信息,足以在畫布上展示一個完整的邏輯編排圖了。

元件物料定義

工具箱區域②跟畫布區域③顯示節點時,使用了共同的元素:元件圖標,元件標題,圖標顏色,這些可以放在物料的定義里。
物料還需要:元件對應的Acitvity名字,屬性面板④ 的配置Schema。具體定義:

import { NodeType, IPortDefine } from "./dsl";

//埠定義
export interface IPorts {
  //入埠
  inPorts?: IPortDefine[];
  //出埠
  outPorts?: IPortDefine[];
}

//元件節點的物料定義
export interface IActivityMaterial<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //標題
  label: string;
  //節點類型,NodeType在DLS中定義,這裡根據activityType決定畫上的圖形樣式
  activityType: NodeType;
  //圖標代碼,react的話,相當於React.ReactNode
  icon?: ComponentNode;
  //圖標顏色
  color?: string;
  //屬性面板配置,可以適配不同的低代碼Schema,使用RxDrag的話,這可以是INodeSchema類型
  schema?: NodeSchema;
  //預設埠,元件節點的埠設置的預設值,大部分節點埠跟預設值是一樣的,
  //部分動態配置埠,會根據配置有所變化
  defaultPorts?: IPorts;
  //畫布中元件節點顯示的子標題 
  subTitle?: (config?: Config, context?: MaterialContext) => string | undefined;
  //對應解析引擎里的Activity名稱,根據這個名字實例化相應的節點業務邏輯對象
  activityName: string;
}

//物料分類,用於在工具欄上,以手風琴風格分組物料
export interface ActivityMaterialCategory<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //分類名
  name: string;
  //分類包含的物料
  materials: IActivityMaterial<ComponentNode, NodeSchema, Config, MaterialContext>[];
}

只要符合這個定義的物料,都是可以被註入設計器的。
在前面定義DSL的時候, INodeDefine 也有一個一樣的屬性是 activityName。沒錯,這兩個activityName指代的對象是一樣的。畫布渲染dsl的時候,會根據activityName查找相應的物料,根據物料攜帶的信息展示,入圖標、顏色、屬性配置組件等。
在做前端物料跟元件的時候,為了重構方便,會把activityName以存在Activity的static變數里,物料定義直接引用,埠名稱也是類似的處理。看一個最簡單的節點,Debug節點的代碼。
Activity代碼:

import { Activity, Input, AbstractActivity } from "@rxdrag/minions-runtime"
import { INodeDefine } from "@rxdrag/minions-schema"

//調試節點配置
export interface IDebugConfig {
  //提示信息
  tip?: string,
  //是否已關閉
  closed?: boolean
}

@Activity(DebugActivity.NAME)
export class DebugActivity extends AbstractActivity<IDebugConfig> {
  //對應INodeDeifne 跟IActivityMaterial的 activityName
  public static NAME = "system.debug"

  constructor(meta: INodeDefine<IDebugConfig>) {
    super(meta)
  }

  //入口處理函數
  @Input()
  inputHandler(inputValue: unknown): void {
    if (!this.config?.closed) {
      console.log(`

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 我們智能客服知識庫機器人已經開發完成,後端資料庫是使用的qdrant向量資料庫,但是該資料庫並沒有導出備份功能,所以我按簡單的純前端實現知識庫導出excel數據 使用第三方庫(如SheetJS) SheetJS是一個流行的JavaScript庫,可幫助處理Excel文件。您可以使用SheetJS來將 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 背景 愉快的雙休周末剛過完,早上來忽然被運營通知線上業務掛了,用戶無法下單。卧槽,趕緊進入debug模式,一查原來是服務端返回的數據有問題,趕緊問了服務端,大佬回覆說是業務部門配置套餐錯誤。好在主責不在我們,不過趕緊寫了復盤文檔,主動找自 ...
  • 博客推行版本更新,成果積累制度,已經寫過的博客還會再次更新,不斷地琢磨,高質量高數量都是要追求的,工匠精神是學習必不可少的精神。因此,大家有何建議歡迎在評論區踴躍發言,你們的支持是我最大的動力,你們敢投,我就敢肝 ...
  • 1.添加函數修改img的屬性; /** * * @param {*} idName 傳入的id,獲取改img的dom,添加相應的數學 */ export const proxyImg = (idName) => { const img = document.getElementById(idName ...
  • 打開前端項目中的 package.json,會發現眾多工具已經占據了我們開發日常的各個角落,它們的存在於我們的開發而言是不可或缺的。有沒有想過這些工具的功能是如何實現的呢?沒錯,抽象語法樹 (Abstract Syntax Tree) 就是上述工具的基石。 ...
  • TCP/IP是一種分層模型,它將通信協議分解為五個層次,每個層次都有特定的功能和任務。以下是TCP/IP五層的處理流程: ...
  • 採用依賴倒置原則後的分層架構和六邊形架構,實際上都符合整潔架構設計理念。但是六邊形架構中使用埠與適配器,讓應用程式能夠以一致的方式被用戶、程式、自動化測試、批處理腳本所驅動,同時能夠讓應用程式邊界更加清晰,從而能更好地防止領域層和應用層邏輯泄露到外層。 ...
  • 本文面向受眾可以是運營、可以是產品、也可以是研發、測試人員,作者希望通過如下思路(知歷史->清家底->明目標->定戰略->做戰術->促成長)幫助大家能夠瞭解電商大促系統的高可用保障,減少哪些高深莫測的黑話和高大尚的論調,而是希望有個體系化的知識讓讀者有所得。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...