摘要
計算機基礎的同學估計對管道這個詞都不陌生了,尤其是在Linux系統當中,管道操作符已經被廣泛的使用,並給我們的變成帶來了極大的便利。前端領域比較註明的腳手架“gulp”也是以其管道操作著稱。
今天我們就來一步步抽絲剝繭,看看在前端領域的“管道資料流”要如何設計。
一、前言
有計算機基礎的同學估計對管道這個詞都不陌生了,尤其是在Linux系統當中,管道操作符已經被廣泛的使用,並給我們的變成帶來了極大的便利。管道操作通常分為單向管道和雙向管道,當資料從上一節管道流向下一節管道時,我們的資料將會被這節管道進行一定的加工處理,處理完畢後送往下一節管道,依次類推,這樣就可以對一些原始的資料在不斷的管道流動中進行不斷的加工,最後得到我們想要的目標資料。
在我們日常程式設計開發過程中,也可以嘗試使用管道資料的概念,對我們的程式架構進行一定的優化,讓我們程式的資料流動更加清晰明瞭,並可以讓我們像是流水線一樣,每個管道專門負責各自的工作對資料來源進行一次粗加工,達到職責分明與程式解耦的目的。
二、程式設計
現在我們使用Typescript實現一個基礎的管道類的設計,我們今天使用的管道是單向管道。
2.1 Pipe-轉接頭
顧名思義,轉接頭就是需要將不同的多節管道連線在一起成為一整條管道的連線口,通過這個連線頭,我們可以控制資料的流向,讓資料流向他真正該去的的地方。
首先,我們來設計一下我們的轉接頭的型別結構:
type Pipeline<T = any> = {
/**
* 將多節管道連結起來
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
};
上述程式碼描述了一個支援管道資料的類需要有怎樣的一個轉接頭,在程式設計中,我們的轉接頭其實就是一個函式,用於將多節管道相互連結。
從上面的程式碼大家可以看出,為了程式的高複用,我們選擇對管道中傳輸的資料型別進行泛型化,這樣,我們再具體實現某一個程式時,便可更加靈活的使用其中型別,例如:
// 時間型別的管道
type DatePipeline = Pipeline<Date>
// 陣列型別的管道
type ArrayPipeLine = Pipeline<string[]>
// 自定義資料型別的管道
type CustomPipeLine = Pipeline<{name: string, age: number}>
除此之外,我們這個函式的傳入引數和返回值也是有講究的,從上面的程式碼可以看出,我們接收一個管道型別的資料,又返回一個管道型別的資料。其中,引數中傳入的便是下一節管道,這樣,我們就把兩節管道連線到了一起。之所以要返回一個管道型別的資料,是為了讓我們使用時可以鏈式呼叫,更符合管道資料的設計理念,如:
const app = new AppWithPipleline();
app.pipe(new WorkerPipeline1())
.pipe(new WorkerPipeline2())
.pipe(new WorkerPipeline3())
.pipe(new WorkerPipeline4())
也就是說,我們返回的,其實也是下一節管道的引用。
2.2 Push-水泵
有了轉接頭之後,我們還需要一個“水泵”將我們的資料來源源不斷地推送到不同的管道,最終到達目標點。
type Pipeline<T = any> = {
/**
* 實現該方法可以將資料通過管道一層層傳遞下去
* @param data
*/
push(data: T[]): Promise<void>;
/**
* 將多節管道連結起來
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
};
為了適應更多場景,我們設計這個水泵接受一個T[]型別的陣列,在第一節管道當中,當我們拿到了初始的資料來源時,我們就可以利用這個水泵(方法)將資料推送出去,讓後面的每一個加工車間處理資料。
2.3 resolveData - 加工車間
當我們的資料被推送到某一節管道時,會有一個加工車間對推送過來的資料根據各自不同的工序進行粗加工。
注意:我們每一個加工車間應該儘可能保證職責分離,每個加工車間負責一部分的工作,對資料進行一次粗加工,而不是把所有的工作都放到一個加工車間當中,否則就失去了管道資料的意義。
type Pipeline<T = any> = {
/**
* 實現該方法可以將資料通過管道一層層傳遞下去
* @param data
*/
push(data: T[]): Promise<void>;
/**
* 將多節管道連結起來
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
/**
* 用於接受從上一節管道傳遞下來的資料,可進行加工後傳遞到下一節管道
* @param data
*/
resolveData(data: T[]): T[] | Promise<T[]>;
};
加工車間依舊是接收一個T[]型別的資料陣列,拿到這個資料後,按照各自的工序對資料進行加工處理,加工好之後,重新放回流水線的傳送帶上(返回值),送往下一節管道的加工車間繼續加工。
三、具體實現
上面我們只是定義了一個管道應該有的最基本的行為,只有具備以上行為能力的類我們才認為它是一節合格的管道。那麼,接下來,我們就來看看一個管道類需要如何實現。
3.1 基礎管道模型類
class BaseApp<P = any> implements Pipeline<P> {
constructor(data?: P[]) {
data && this.push(data);
}
/**
* 僅內部使用,下一節管道的引用
*/
protected next: Pipeline<P> | undefined;
/**
* 接受到資料後,使用 resolveData 處理獲得新書局後,將新資料推送到下一節管道
* @param data
*/
async push(data: P[]): Promise<void> {
data = await this.resolveData(data);
this.next?.push(data);
}
/**
* 連結管道
* 讓 pipe 的返回值始終是下一節管道的引用,這樣就可以鏈式呼叫
* @param _next
* @returns
*/
pipe(_next: Pipeline<P>): Pipeline<P> {
this.next = _next;
return _next;
}
/**
* 資料處理,返回最新的資料物件
* @param data
* @returns
*/
resolveData(data: P[]): P[] | Promise<P[]> {
return data;
}
}
我們定義了一個實現了Pipleline介面的基礎類,用來描述所有管道的樣子,我們所有的管道都需要繼承到這個基礎類。
在建構函式中,我們接受一個可選參,這個引數代表我們的初始資料來源,只有第一節管道需要傳入這個引數為整個管道注入初始資料,我們拿到這個初始資料後,會使用水泵(push)將這個資料推送出去。
3.2 管道統一資料物件
通常在程式實現時,我們會定義一個統一的資料物件作為管道中流動的資料,這樣更好維護與管理。
type PipeLineData = {
datasource: {
userInfo: {
firstName: string;
lastName: string;
age: number,
}
}
}
3.3 第一節管道
由於第一節管道之前沒有任何管道了,我們想要讓資料流動起來,就需要在第一節管道處使用水泵給予資料一個初始動能,讓他可以流動起來,因此,第一節管道的實現會與其他管道略有不同。
export class PipelineWorker1 extends BaseApp<PipeLineData> {
constructor(data: T[]) {
super(data);
}
}
第一節管道主要的功能就是接受原始資料來源,並使用水泵將資料傳送出去,所以實現起來比較簡單,只需要繼承我們的基類BaseApp,並將初始資料來源提交給基類,基類再用水泵將資料推送出去即可。
3.4 其他管道
其他管道每個管道都會有一個資料處理車間,用來處理流向當前管道的資料,因此我們還需要重寫基類的resolveData方法。
export class PipelineWorker2 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在這裡我們可以對資料進行一些特定的處理
// 注意我們儘可能在傳入的 data 上進行操作,保持引用
data.forEach(item => {
item.userInfo.name = `${item.userInfo.firstName} · ${item.userInfo.lastName}`
});
// 最後,我們再呼叫基類的 resolveData 方法,把處理好的資料傳進去,
// 這樣就完成了一道工序的加工了
return super.resolveData(data);
}
}
export class PipelineWorker3 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在這裡我們可以對資料進行一些特定的處理
// 注意我們儘可能在傳入的 data 上進行操作,保持引用
data.forEach(item => {
item.userInfo.age += 10;
});
// 最後,我們再呼叫基類的 resolveData 方法,把處理好的資料傳進去,
// 這樣就完成了一道工序的加工了
return super.resolveData(data);
}
}
export class Output extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在這裡我們可以對資料進行一些特定的處理
// 注意我們儘可能在傳入的 data 上進行操作,保持引用
console.log(data);
// 最後,我們再呼叫基類的 resolveData 方法,把處理好的資料傳進去,
// 這樣就完成了一道工序的加工了
return super.resolveData(data);
}
}
// 我們還可以利用管道組裝靈活的特性開發出各種各樣的外掛,可隨時插拔
export class Plugin1 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在這裡我們可以對資料進行一些特定的處理
// 注意我們儘可能在傳入的 data 上進行操作,保持引用
console.log("這是一個外掛");
// 最後,我們再呼叫基類的 resolveData 方法,把處理好的資料傳進去,
// 這樣就完成了一道工序的加工了
return super.resolveData(data);
}
}
3.5 組裝管道
上面我們已經將每一節管道都準備好了,現在要把他們組裝起來,投入使用了。
const datasource = {
userInfo: {
firstName: "kiner",
lastName: "tang",
age: 18
}
};
const app = new PipelineWorker1(datasource);
// 管道可以隨意組合
app.pipe(new Output())
.pipe(new PipelineWorker2())
.pipe(new Output())
.pipe(new PipelineWorker3())
.pipe(new Output())
.pipe(new Plugin1());
四、結語
至此,我們就已經完成了一個管道架構的設計了。是不是覺得,使用了管道資料之後,我們的整個程式程式碼的資料流向更加清晰,每個模組之前的分工更加分明,模組與模組之前的專案配合更加靈活了呢?
使用管道設計,還能讓我們可以額外擴充一個外掛庫,使用者可以隨意定製符合各個業務場景的外掛,讓我們的程式的擴充套件性變得極強。