蒼穹之邊,浩瀚之摯,眰恦之美;悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》
寫在開頭
隨著業務需求的發展和用戶數量的激增,對于互聯網應用系統或者服務應用程序則提出了新的挑戰,也對從事系統研發的開發者有了更高的要求。作為一名IT從業研發人員,我們都知道的事,良好的用戶體驗是我們和應用系統間快速反饋,一直以來都是我們考量一個系統是否穩定和是否高效的設計目標,但是保證這個目標的關鍵之一,主要在于如何保證系統間的通信穩定和高效。從而映射出,如何正確理解軟件應用系統中關于系統通信的那些事?是我們必須了解和理解的一項關鍵工作,接下來,我們就一起來總結和探討一下。
基本概述
要想理解系統服務間的交流,拿我們人與人的交流來做類比是個不錯的選擇。我們都知道,人與人之間的實現交流的基本元素主要有以下幾個方面:
從而得知,系統服務間的交流的主要表現在以下幾個方面:
組成要素
實現系統間通信主要的三個要素:通信格式,通信協議,通信模型。
根據人與人的交流的構成要素,抽象成計算機系統服務中對應的概念(行之有效的概念往往是簡單且趨同的),系統間通信主要考慮以下三個方面:通信格式,通信協議,通信模型。具體詳情如下:
接下來,我們來詳細解析這些組成要素:
網絡協議
我們用手機連接上網的時候,會用到許多網絡協議。從手機連接 W i F i 開始, 使用的是 8 0 2 . 11 (即 W L A N ) 協議, 通過 W L A N 接入網絡; 手機自動獲取網絡配置,使用的是 D H C P 協議,獲取配置后手機才能正常通信。這時手機已經連入局域網,可以訪問局域網內的設備和資源, 但還不能使用互聯網應用,例如:微信、抖音等。想要訪問互聯網,還需要在手機的上聯網絡設備上實現相關協議, 即在無線路由器上配置 N AT、 P P P O E 等功能, 再通過運營商提供的互聯網線路把局域網接入到互聯網中, 手機就可以上網玩微信、刷抖音了。常見的網絡主要有:
簡單來說,就是手機、無線路由器等設備通過多種網絡協議實現通信。網絡協議就是為了通信各方能夠互相交流而定義的標準或規則, 設備只要遵循相同的網絡協議就能夠實現通信。那網絡協議又是誰規定的呢? ISO 制定了一個國際標準OSI , 其中的 OSI 參考模型常被用于網絡協議的制定。常見的網絡協議:
網絡模型
從計算機網絡層面來說,常見網絡模型主要有OSI 參考模型和TCP/IP 模型兩種,主要表達如下:
OSI 參考模型:
O S I 參考模型將網絡協議提供的服務分成 7 層,并定義每一層的服務內容, 實現每一層服務的是協議, 協議的具體內容是規則。上下層之間通過接口進行交互,同一層之間通過協議進行交互。 O S I 參考模型只對各層的服務做了粗略的界定, 并沒有對協議進行詳細的定義,但是許多協議都對應了 7 個分層的某一層。所以要了解網絡,首先要了解 O S I 參考模型:
TCP/IP 模型:
由于 OSI 參考模型把服務劃得過于瑣碎,先定義參考模型再定義協議,有點理想化。 TCP / IP 模型則正好相反, 通過已有的協議歸納總結出來的模型,成為業界的實際網絡協議標準。TCP / IP 是有由 I E T F 建議、推進其標準化的一種協議, 是 IP 、 TCP 、HTTP 等協議的集合。TCP / IP是為使用互聯網而開發制定的協議族, 所以互聯網的協議就是 TCP / IP。TCP / IP 每層的主要協 議詳情如下:
除此之外,我們還需要知道Linux 網絡I/O 模型和Java JDK中的I/O 模型:
Linux 網絡I/O 模型:
Linux的內核將所的外部設備看作一個文件來操作,對于一個文件的讀寫操作會調用內核提供的系統命令,返回一個文件描述符(fd,File Descriptor);同時,在面對一個Socket的讀寫時也會有相應的套接字描述符(socketfd,Socket File Descriptor),描述符是一個數字,它指向內核中的一個結構體,比如文件路徑,數據區等。Linux 網絡I/O 模型是按照UNIX網絡編程來定義的,主要有:
阻塞I/O模型(Blocking I/O ):
最流行的I/O模型,本書到目前為止的所有例子都使用該模型。默認情形下,所有套接字都是阻塞的。使用UDP而不是TCP為例子的原因在于就UDP而言,數據準備好讀取的概念比較簡單:要么整個數據報已經收到,要么還沒有。對于TCP而言,諸如套接字低水位標記等額外變量開始起作用,道指這個概念復雜。我們把recvfrom函數視為系統調用,因為我們正在區分應用進程和內核。不管如何實現,一般都會從在應用進程空間中國運行切換到在內核空間中運行,一端時間之后再切換回來。 在上圖中,進程調用recvfrom,其系統調用直到數據報到達且被復制到應用進程的緩沖區中或者發送錯誤才返回。最常見的錯誤是系統調用被信號中斷,我們說進程在從調用recvfrom開始到它返回的整段時間內是被阻塞的。recvfrom成功返回后,應用進程開始處理數據報。
非阻塞I/O模型(NoneBlocking I/O):
進程把一個套接字設置成非阻塞是在通知內核:當所有請求的I/O操作非得把本進程投入睡眠才能完成時,不要把本進程投入睡眠,而是返回一個錯誤。前三次調用recvfrom時沒有數據可返回,因此內核轉而立即返回一個EWOULDBLOCK錯誤。第四次調用recvfrom時已有一個數據報準備好,它被復制到應用進程緩沖區,于是recvfrom成功返回。接著處理數據。當一個應用進程像這樣對一個非阻塞描述符循環調用recvfrom時,我們成為輪詢,應用進程持續輪詢內核,以查看某個操作是否就緒。這么做往往耗費大量CPU時間,不過這種模型偶爾也會遇到。
I/O復用模型(IO Multiplexing):
I/O復用,我們就可以調用select或者poll,阻塞在這兩個系統調用中的某一個,而不是阻塞在真正的I/O系統調用上。我們阻塞與select調用,等待數據報套接字變為可讀。當select返回套接字可讀這一條件時,我們調用recvfrom把所可讀數據報復制到應用進程緩沖區。比較上面兩圖,I/O復用并不顯得有什么優勢,事實上由于使用select需要兩個而不是單個系統調用,其優勢在于可以等待多個描述符就緒。
信號驅動I/O復用模型(Signal Driven IO):
可以用信號,讓內核在描述符就緒時發送SIGIO信號通知我們。稱為信號驅動式I/O。我們首先開啟套接字的信號驅動式I/O功能,并通過sigaction系統調用安裝一個信號處理函數。該系統調用將立即返回,我們的進程繼續工作,也就是說它沒有被阻塞。當數據報準備好讀取時,內核就為該進程產生一個SIGIO信號。我們隨后既可以在信號處理函數中調用recvfrom讀取數據報,并通知主循環數據已準備好待處理。也可以立即通知循環,讓它讀取數據報。無論如何處理SIGIO信號,這種模型的優勢在于等待數據報到達期間進程不被阻塞。主循環可以繼續執行,只要等待來自信號處理函數的通知:既可以是數據已準備好被處理,也可以是數據報已準備好被讀取。
異步I/O模型(Asynchronous IO ):
告知內核啟動某個操作,并讓內核在整個操作(包括將數據從內核復制到我們自己的緩沖區)完成后通知我們。這種模型與前一節介紹的信號驅動模型的主要區別在于:信號驅動I/O是由內核通知我們如何啟動一個I/O操作,而異步I/O模型是由內核通知我們I/O操作何時完成。我們調用aio_read函數,給內核傳遞描述符、緩沖區指針。緩沖區大小和文件偏移,并告訴內核當整個操作完成時如何通知我們。該系統調用立即返回,而且在等到I/O完成期間,我們的進程不被阻塞。
Java JDK中的I/O 模型:
在Java語言中,應用程序發起 I/O 調用后,會經歷兩個階段:
其中,阻塞和非阻塞:
而我們常說的同步和異步主要如下:
BIO模型
同步阻塞 IO 模型中,服務器應用程序發起 read 系統調用后,會一直阻塞,直到內核把數據拷貝到用戶空間。完整的架構應該是 客戶端-內核-服務器,客戶端發起IO請求,服務器發起系統調用,內核把IO數據從內核空間拷貝到用戶空間,服務器應用程序才能使用到客戶端發送的數據。一般來說,客戶端、服務端其實都屬于用戶空間,借助內核交流數據。
當用戶進程發起了read系統調用,kernel就開始了IO的第一個階段:準備數據。對于網絡IO來說,很多時候數據在一開始還沒有到達內核(比如說客戶端目前只是建立了連接,還沒有發送數據 或者是 網卡等待接收數據),所以kernel就需要要等待足夠的數據到來。而在服務器進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,然后kernel返回結果,用戶進程才解除阻塞狀態,重新運行起來。
Java中的JDBC也使用到了BIO技術。BIO在客戶端連接數量不高的情況下是沒問題的,但是當面對十萬甚至百萬級連接的時候,無法處理這種高并發情況,因此我們需要一種更高效的 I/O 處理模型來應對。
NIO模型
Java 中的 NIO 于 JDK 1.4 中引入,對應 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解為 Non-blocking,不單純是 New。它支持面向緩沖的,基于通道的 I/O 操作方法。 對于高負載、高并發的(網絡)情況下,應使用 NIO 。
當服務器進程發出read操作時,如果kernel中數據還沒準備好,那么并不會阻塞服務器進程,而是立即返回error,用戶進程判斷結果是error,就知道數據還沒準備好,此時用戶進程可以去干其他的事情。一段時間后用戶進程再次發read,一直輪詢直到kernel中數據準備好,此時用戶發起read操作,產生system call,kernel 馬上將數據拷貝到用戶內存,然后返回,進程就能使用到用戶空間中的數據了。
BIO一個線程只能處理一個IO流事件,想處理下一個必須等到當前IO流事件處理完畢。而NIO其實也只能串行化的處理IO事件,只不過它可以在內核等待數據準備數據時做其他的工作,不像BIO要一直阻塞住。NIO它會一直輪詢操作系統,不斷詢問內核是否準備完畢。但是,NIO這樣又引入了新的問題,如果當某個時間段里沒有任何客戶端IO事件產生時,服務器進程還在不斷輪詢,占用著CPU資源。所以要解決該問題,避免不必要的輪詢,而且當無IO事件時,最好阻塞住(線程阻塞住就會釋放CPU資源了)。所以NIO引入了多路復用機制,可以構建多路復用的、同步非阻塞的IO程序。
AIO模型
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改進版 NIO 2,它是異步 IO 模型。異步 IO 是基于事件和回調機制實現的,也就是進程操作之后會直接返回,不會阻塞在那里,當后臺處理完成,操作系統會通知相應的線程進行后續的操作。用戶進程發起read操作之后,立刻就可以開始去做其它的事。
內核收到一個asynchronous read之后,首先它會立刻返回,所以不會對用戶進程產生任何阻塞。kernel會等待數據準備完成,然后將數據拷貝到用戶內存,當這一切都完成之后,kernel會給用戶進程發送一個signal,告訴它read操作完成了。
IO多路復用模型
Java 中的 NIO ,提供了 Selector(選擇器)這個封裝了操作系統IO多路復用能力的工具,通過Selector.select(),我們可以阻塞等待多個Channel(通道),知道任意一個Channel變得可讀、可寫,如此就能實現單線程管理多個Channels(客戶端)。當所有Socket都空閑時,會把當前線程(選擇器所處線程)阻塞掉,當有一個或多個Socket有I/O事件發生時,線程就從阻塞態醒來,并返回給服務端工作線程所有就緒的socket(文件描述符)。各個操作系統實現方案:
IO多路復用題同非阻塞IO本質一樣,只不過利用了新的select系統調用,由內核來負責本來是服務器進程該做的輪詢操作。看似比非阻塞IO還多了一個系統調用的開銷,不過因為可以支持多路復用IO,即一個進程監聽多個socket,才算提高了效率。進程先是阻塞在select/poll上(進程是因為select/poll/epoll函數調用而阻塞,不是直接被IO阻塞的),再是阻塞在讀寫操作的第二階段上(等待數據從內核空間拷貝到用戶空間)。
IO多路復用的實現原理:利用select、poll、epoll可以同時監聽多個socket的I/O事件的能力,而當有I/O事件產生時會被注冊到Selector中。在所有socket空閑時,會把當前選擇器進程阻塞掉,當有一個或多個流有I/O事件(或者說 一個或多個流有數據到達)時,選擇器進程就從阻塞態中喚醒。通過select或poll輪詢所負責的所有socket(epoll是只輪詢那些真正產生了事件的socket),返回fd文件描述符集合給主線程串行執行事件。
??[特別注意]:
select和poll每次調用時都需要將fd_set(文件描述符集合)從用戶空間拷貝到內核空間中,函數返回時又要拷貝回來(epoll使用mmap,避免了每次wait都要將數組進行拷貝)。
在實際開發過程中,基于消息進行系統間通信,我們一般會有四種方法實現:
基于TCP/IP+BIO實現:
在Java中可基于Socket、ServerSocket來實現TCP/IP+BIO的系統通信。
為了滿足服務端可以同時接受多個請求,最簡單的方法是生成多個Socket。但這樣會產生兩個問題:
為了解決上面的問題,通常采用連接池的方式來維護Socket。一方面能限制Socket的個數;另一方面避免重復創建Socket帶來的性能下降問題。這里有一個問題就是設置合適的相應超時時間。因為連接池中Socket個數是有限的,肯定會造成激烈的競爭和等待。
Server服務端:
//創建對本地端口的監聽
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//向服務器發送字符串信息
out.println("hello");
//阻塞讀取服務端的返回信息
in.readLine();
Client客戶端:
//創建連接
Socket socket = new Socket(目標IP或域名, 目標端口);
//BufferedReader用于讀取服務端返回的數據
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//PrintWriter向服務器寫入流
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//像服務端發送流
out.println("hello");
//阻塞讀取服務端的返回信息
in.readLine();
基于TCP/IP+NIO實現:
Java可以基于Clannel和Selector的相關類來實現TCP/IP+NIO方式的系統間通信。Channel有SocketClannel和ServerSocketChannel兩種:
Server服務端:
SocketChannel channel = SocketChannel.open();
//設置為非阻塞模式
channel.configureBlocking(false);
//對于非阻塞模式,立即返回false,表示連接正在建立中
channel.connect(SocketAdress);
Selector selector = Selector.open();
//向channel注冊selector以及感興趣的連接事件
channel.regester(selector,SelectionKey.OP_CONNECT);
//阻塞至有感興趣的IO事件發生,或到達超時時間
int nKeys = selector.select(超時時間【毫秒計】);
//如果希望一直等待知道有感興趣的事件發生
//int nKeys = selector.select();
//如果希望不阻塞直接返回當前是否有感興趣的事件發生
//int nKeys = selector.selectNow();
//如果有感興趣的事件
SelectionKey sKey = null;
if(nKeys>0){
Set keys = selector.selectedKeys();
for(SelectionKey key:keys){
//對于發生連接的事件
if(key.isConnectable()){
SocketChannel sc = (SocketChannel)key.channel();
sc.configureBlocking(false);
//注冊感興趣的IO讀事件
sKey = sc.register(selector,SelectionKey.OP_READ);
//完成連接的建立
sc.finishConnect();
}
//有流可讀取
else if(key.isReadable()){
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel sc = (SocketChannel) key.channel();
int readBytes = 0;
try{
int ret = 0;
try{
//讀取目前可讀取的值,此步為阻塞操作
while((ret=sc.read(buffer))>0){
readBytes += ret;
}
}
fanally{
buffer.flip();
}
}
finally{
if(buffer!=null){
buffer.clear();
}
}
}
//可寫入流
else if(key.isWritable()){
//取消對OP_WRITE事件的注冊
key.interestOps(key.interestOps() & (!SelectionKey.OP_WRITE));
SocketChannel sc = (SocketChannel) key.channel();
//此步為阻塞操作
int writtenedSize = sc.write(ByteBuffer);
//如未寫入,則繼續注冊感興趣的OP_WRITE事件
if(writtenedSize==0){
key.interestOps(key.interestOps()|SelectionKey.OP_WRITE);
}
}
}
Selector.selectedKeys().clear();
}
//對于要寫入的流,可直接調用channel.write來完成。只有在未寫入成功時才要注冊OP_WRITE事件
int wSize = channel.write(ByteBuffer);
if(wSize == 0){
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}
Server端實體:
ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket serverSocket = ssc.socket();
//綁定要監聽的接口
serverSocket.bind(new InetSocketAdress(port));
ssc.configureBlocking(false);
//注冊感興趣的連接建立事件
ssc.register(selector,SelectionKey.OP_ACCEPT);
基于UDP/IP+BIO實現:
Java對UDP/IP方式的網絡數據傳輸同樣采用Socket機制,只是UDP/IP下的Socket沒有建立連接,因此無法雙向通信。如果需要雙向通信,必須兩端都生成UDP Server。 Java中通過DatagramSocket和DatagramPacket來實現UDP/IP+BIO方式和系統間通信:
DatagramSocket:負責監聽端口和讀寫數據
//如果希望雙向通信,必須啟動一個監聽端口承擔服務器的職責
//如果不能綁定到指定端口,則拋出SocketException
DatagramSocket serverSocket = new DatagramSocket(監聽的端口);
byte[] buffer = new byte[65507];
DatagramPacket receivePacket = new DatagramPacket(buffer,buffer.length);
DatagramSocket socket = new DatagramSocket();
DatagramPacket packet = new DatagramPacket(datas,datas.length,server.length);
//阻塞方式發送packet到指定的服務器和端口
socket.send(packet);
//阻塞并同步讀取流消息,如果讀取的流消息比packet長,則刪除更長的消息
//當連接不上目標地址和端口時,拋出PortUnreachableException
DatagramSocket.setSoTimeout(超時時間--毫秒級);
serverSocket.receive(receivePacket);
基于UDP/IP+NIO實現:
Java中可以通過DatagramClannel和ByteBuffer來實現UDP/IP方式的系統間通信:
//讀取流信息
DatagramChannel receiveChannel = DatagramChannel.open();
receiveChannel.configureBlocking(false);
DatagramSocket socket = receiveChannel.socket();
socket.bind(new InetSocketAddress(rport));
Selector selector = Selector.open();
receiveChannel.register(selector, SelectionKey.OP_REEAD);
//之后即可像TCP/IP+NIO中對selector遍歷一樣的方式進行流信息的讀取
//...
//寫入流信息
DatagramChannel sendChannel = DatagramChannel.open();
sendChannel.configureBlocking(false);
SocketAdress target = new InetSocketAdress("127.0.0.1",sport);
sendChannel.connect(target);
//阻塞寫入流
sendChannel.write(ByteBuffer);
發展歷程
從軟件系統的發展歷程來看,在分布式應用出現之前,市面上幾乎所有的軟件系統都是集中式的,軟件,硬件以及各個組件之間的高度耦合組成了單體架構軟件平臺,即就是所謂的單機系統。
一般來說,大型應用系統通常會被拆分成多個子系統,這些子系統可能會部署在多臺機器上,也有可能只在一臺機器上的多個線程中,這就是我們常說的分布式應用。
從部署形態上來說,以多臺服務器和多個進程部署服務,都是為了實現一個業務需求和程序功能。分布式系統中的網絡通信一般都會采用四層的 TCP 協議或七層的 HTTP 協議,在我的了解中,前者占大多數,這主要得益于 TCP 協議的穩定性和高效性。網絡通信說起來簡單,但實際上是一個非常復雜的過程,這個過程主要包括:對端節點的查找、網絡連接的建立、傳輸數據的編碼解碼以及網絡連接的管理等等,每一項都很復雜。
對于系統間通信來說,我們需要區分集群和分布式兩個標準:
實現方式
在分布式服務誕生以前,主要采用以下幾種方式實現系統間的通信:
在分布式應用時代,業界通常一般兩種方式可以來實現系統間的通信,主要如下:
同時,從各系統間通信的整合方式,可以分為:
RPC服務調用(RPC服務)
RPC是一種通過網絡從遠程計算機程序上請求服務,不需要我們了解底層網絡技術的協議。主要體現在以下幾個方面:
在 RPC 框架里面,我們是怎么支持插件化架構的呢?我們可以將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離,并提供接口的默認實現。在 Java 里面,JDK 有自帶的 SPI(Service Provider Interface)服務發現機制,它可以動態地為某個接口尋找服務實現。使用 SPI 機制需要在 Classpath 下的 META-INF/services 目錄里創建一個以服務接口命名的文件,這個文件里的內容就是這個接口的具體實現類。
但在實際項目中,我們其實很少使用到 JDK 自帶的 SPI 機制,首先它不能按需加載,ServiceLoader 加載某個接口實現類的時候,會遍歷全部獲取,也就是接口的實現類得全部載入并實例化一遍,會造成不必要的浪費。另外就是擴展如果依賴其它的擴展,那就做不到自動注入和裝配,這就很難和其他框架集成,比如擴展里面依賴了一個 Spring Bean,原生的 Java SPI 就不支持。
我們將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離并提供接口的默認實現。這樣的架構相比之前的架構,有很多優勢。首先它的可擴展性很好,實現了開閉原則,用戶可以非常方便地通過插件擴展實現自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精簡,依賴外部包少,這樣可以有效減少開發人員引入 RPC 導致的包版本沖突問題。
一般一個RPC 框架里面都有會涉及兩個模塊:
除此之外,我們還可以在協議模塊中加入壓縮功能,這是因為壓縮過程也是對傳輸的二進制數據進行操作。在實際的網絡傳輸過程中,我們的請求數據包在數據鏈路層可能會因為太大而被拆分成多個數據包進行傳輸,為了減少被拆分的次數,從而導致整個傳輸過程時間太長的問題,我們可以在 RPC 調用的時候這樣操作:在方法調用參數或者返回值的二進制數據大于某個閾值的情況下,我們可以通過壓縮框架進行無損壓縮,然后在另外一端也用同樣的壓縮算法進行解壓,保證數據可還原。
傳輸和協議這兩個模塊是 RPC 里面最基礎的功能,它們使對象可以正確地傳輸到服務提供方。但距離 RPC 的目標——實現像調用本地一樣地調用遠程,還缺少點東西。因為這兩個模塊所提供的都是一些基礎能力,要讓這兩個模塊同時工作的話,我們需要手寫一些黏合的代碼,但這些代碼對我們使用 RPC 的研發人員來說是沒有意義的,而且屬于一個重復的工作,會導致使用過程的體驗非常不友好。
消息隊列(MQ服務)
分布式子系統之間需要通信時,就發送消息。一般通信的兩個要點是:消息處理和消息傳輸。
消息隊列本質上是一種系統間相互協作的通信機制。一般使用消息隊列可以業務解耦,流量削峰,日志收集,事務最終一致性,異步處理等業務場景。在我們實際開發工作中,一般消息隊列的使用需要實現:
當然,在技術選型的時候,我們需要選擇最適合我們的。
版權聲明:本文為博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。
大家好,我是Echa。
最近有不少的粉絲們私信問我有沒有程序員、技術產品、項目經理、UI設計師等崗位的提升工作效率的工具,而且是免費開源不限制的。比如:畫圖工具、數據抓包工具、原型制作工具,資源如何搜索等等。小編也給你們一一回復了,請大家不要著急,更不會辜負粉絲們的要求,給老鐵們安排上。接下來給大家分享20個提升程序員軟技能與效率的必備工具,希望能幫上老鐵們,同時祝福粉絲們在工作上,事事順心,事業一帆風順。如果有哪些粉絲們覺得比較實用的工具推薦,請在下方評論留言,小編后續補充。
創作不易,喜歡的老鐵們加個關注,點個贊,后面會持續更新干貨,速速收藏,謝謝!你們的一個小小舉動就是對小編的認可,更是創作的動力。
官方網址:https://www.diagrams.net/
在線預覽:https://app.diagrams.net/
diagrams - 是一款免費的在線圖表編輯工具, 可以用來編輯工作流, BPM, org charts, UML, ER圖, 網絡拓撲圖等,而且是免費的哦,創建的圖表等
如下圖: