进程间通信 – 剪贴板实现

引子

由于在启动一个进程后,操作系统会给这个进程分配 4GB 的私有地址空间,至于为何有 4GB 这么大,

那得考虑进程的私有地址空间和实际物理内存地址空间之间的映射以及页交换等等细节问题了,这里不予讨论,

从名字就可以知道,既然操作系统给每一个进程分配的是私有地址空间,

自然,这段地址空间也只有这个进程自己才能访问了,不然还称为私有干吗呢?

既然这段私有地址空间只能由进程本身访问,那也就说明别的进程是不能够随意的访问这个进程的地址空间的,

而本篇博文介绍的是进程间的通信,而上面又说任意两个进程之间是并能够互相访问对方的私有地址空间的,

都不能访问了,那还通信个屁啊 ?

自然上面的访问对方进程的私有地址空间是行不通了,那应该还有其他办法的 !!!

解决方法:

如果我在物理内存中划分出一块内存,这一块内存不为任何的进程所私有,但是任何的进程又都可以访问这块内存,

那么 进程 A就可以往这块内存中存放数据 Data,然后 进程 B也是可以访问这块内存的,从而 进程 B就可以访问到数据 Data了,

这样不就实现了 进程 A进程 B之间的通信了 !!!

而上面的这种思路就是剪贴板了。

当然解决进程间通信还有好几种思路,这将会在后续博文中介绍,本篇博文暂只介绍利用剪贴板来实现进程间的通信。

剪贴板定义

剪贴板是由操作系统维护的一块内存区域,这块内存区域不属于任何单独的进程,但是每一个进程又都可以访问这块内存区域,

而实质上当在一个进程中复制数据时,就是将数据放到该内存区域中,

而当在另一个进程中粘贴数据时,则是从该块内存区域中取出数据。

剪贴板操作

其实在剪贴板中也就那么几个 API 在使用,所以在这里的还是本着 API 介绍为主,

不管三七二十一,先列出常用的 API 再说(到后面结合 Demo 的使用即可)。

剪贴板的打开 – OpenClipboard

要想把数据放置到剪贴板中,则必须先打开剪贴板,而这是通过 OpenClipboard成员函数实现:

BOOL OpenClipboard(HWND hWndNewOwner);

第一个参数 hWndNewOwner指向一个与之关联的窗口句柄,即代表是这个窗口打开剪贴板,

如果这个参数设置为 NULL的话,则以当前的任务或者说是进程来打开剪贴板。

如果打开剪贴板成功,则该函数返回非 0值,如果其他程序已经打开了剪贴板,

那么当前这个程序就无法再打开剪贴板了,所以会致使打开剪贴板失败,从而该函数返回 0值。

其实这也好理解,你想啊,剪贴板总共才那么一块内存区域,你 进程 A要往里面写数据,你 进程 B又要往里面写数据,那不乱套去,

解决这个乱套的办法就是,如果我 进程 A正在往剪贴板里面写数据(可以理解为 进程 A打开剪贴板了),那么 进程 B就不能往剪贴板里头写数据了,

既然要让 进程 B不能往剪贴板中写数据了,那我就让 进程 B打开剪贴板失败不就得了。

所以如果某个程序已经打开了剪贴板,那么其他应用程序将不能修改剪贴板,

直到打开了剪贴板的这个程序调用了 CloseClipboard函数,

并且只有在调用了 EmptyClipboard函数之后,打开剪贴板的当前窗口才能拥有剪贴板,

注意是必须要在调用了 EmptyClipboard函数之后才能拥有剪贴板。

剪贴板的清空 - EmptyClipboard

这个函数将清空剪贴板,并释放剪贴板中数据的句柄,然后将剪贴板的所有权分配给当前打开剪贴板的窗口,

因为剪贴板是所有进程都可以访问的,

所以应用程序在使用这个剪贴板时,有可能已经有其他的应用程序把数据放置到了剪贴板上,

因此该进程打开剪贴板之后,就需要调用 EmptyClipboard函数来清空剪贴板,

释放剪贴板中存放的数据的句柄,并将剪贴板的所有权分配给当前的进程,

这样做之后当前打开这个剪贴板的程序就拥有了剪贴板的所有权,因此这个程序就可以往剪贴板上放置数据了。

BOOL EmptyClipboard(void);

剪贴板的关闭 - CloseClipboard

如果某个进程打开了剪贴板,则在这个进程没有调用 CloseClipboard函数关闭剪贴板句柄之前,

其他进程都是无法打开剪贴板的,所以我们每次使用完剪贴板之后都应该关闭剪贴板。

注意,这里的关闭剪贴板并不代表当前打开剪贴板的这个程序失去了对剪贴板的所有权,

只有在别的程序调用了 EmptyClipboard函数之后,当前的这个程序才会失去对剪贴板的所有权,

而那个调用 EmptyClipboard函数的程序才能拥有剪贴板。

BOOL CloseClipboard(void);

数据发送到剪贴板 - SetClipboardData

可以通过 SetClipboardData函数来实现往剪贴板中放置数据,这个函数以指定的剪贴板格式向剪贴板中放置数据。

HANDLE  SetClipboardData(UINT uFormat,  HANDLE hMem );

第一个参数 uFormat用来指定要放到剪贴板上的数据的格式,

比如常见的有 CF_BITMAPCF_TEXTCF_DIB等等(其他格式可以参考 MSDN)。

第二个参数 hMem用来指定具有指定格式的数据的句柄,该参数可以是 NULL

如果该参数为 NULL则表明直到有程序对剪贴板中的数据进行请求时,

该程序(也就是拥有剪贴板所有权的进程)才会将数据复制到剪贴板中,也就是提供指定剪贴板格式的数据,

上面提及的就是延迟提交技术,这个延迟提交技术将会在后面做详细的介绍。

剪贴板中数据格式判断 – IsClipboardFormatAvaliable

BOOL  IsClipboardFormatAvailable( UINT format );

该函数用来判断剪贴板上的数据格式是否为 format指定的格式。

剪贴板中数据接收 - GetClipboardData

HANDLE  GetClipboardData( UINT uFormat );

该函数根据 uFormat指定的格式,返回一个以指定格式存在于剪贴板中的剪贴板对象的句柄。

全局内存分配 – HGLOBAL

剪贴板中的内存从何而来

从上面的介绍中可以知道剪贴板其实就是一块内存,那么这块内存是什么时候分配的呢?

难不成说一开机,操作系统就给剪贴板分配个几 M的内存的吧?

这种方式也太逊色了,你想啊,我的程序要往剪贴板中放置的数据,我事先又不晓得数据长度,

所以,一开机操作系统究竟要给剪贴板分配多少内存呢?很明显,太不动态了,不可取。

要想动态的话,那有一种方案,就是当我的程序要往剪贴板中放置数据的时候来确定要分配给剪贴板的内存的大小,

很明显,既然我都知道要往剪贴板中放置那些数据了,自然我也就知道了这些数据的长度,

那么我就可以以这个数据长度来给剪贴板分配内存了,这是很动态的了吧,所以这种方案是可取的,

但关键是,当我们以前在程序中分配内存的时候,都是使用的标准 C运行库中的 malloc或者是 C++中的 new关键字,

(当然分配内存还有很多其他的函数,比如就有内核中的执行体中就有很多分配内存的函数,这里不讨论),

而使用 malloc或者 new有一个问题,那就是,用这个两个东西来分配的内存空间都是在当前进程的私有地址空间上分配内存,

也就是它们两个东东所分配的内存空间为进程私有地址空间所有,并不为所有进程所共享,

上面提到了,任何进程之间都是不能访问对方的私有地址空间的,你把剪贴板中的内存分配到了你当前进程的私有地址空间上,

而其他进程又不能访问你这个进程的私有地址空间,那怎么能够访问剪贴板呢?

很明显,不能使用 mallocnew关键字来分配内存给剪贴板。

我们应该要使用另外一个特殊一点的函数来分配内存给剪贴板,

这个特殊函数所分配的内存不能够是在进程的私有地址空间上分配,而是要在全局地址空间上分配内存,

这样这个函数所分配的内存才能够被所有的进程所共享,这样,剪贴板中的数据就可以被其他的进程所访问了。

GlobalAlloc 函数

GlobalAlloc函数是从堆上分配指定数目的字节,

与其他的内存管理函数相比,全局内存函数的运行速度会稍微慢一些(等下会解释为什么会慢),

但是全局函数支持动态数据交换,同时,其分配的内存也不为任何一个进程所私有,而是由操作系统来管理这块内存,

所以用在给剪贴板分配内存空间是很适合的。

这里有读者可能会问:

为什么我们在自己的应用程序中不使用 GlobalAlloc函数来分配内存,而是要使用 malloc或者 new来实现?

其实,这个也只用稍微想想就知道了,你想啊,使用 malloc或者 new分配的内存是在进程的私有地址空间上分配的,

这片私有地址空间都是归这个进程所拥有,所管理的,自然,在以后对这块内存的读写会快很多的,

而全局内存不属于这个进程,你下次要去访问全局内存的时候,还得通过映射转换,这样肯定是运行效率低下一些了,

简单点就可以这样理解,你使用 malloc或者 new分配的内存和你的进程隔得很近,程序要过去拿数据 - 得,很近吧,

而是用 GlobalAlloc函数分配的内存和你的进程隔得很远,程序要过去拿数据 - 太远了,耗时。

应用程序在调用了 SetClipboardData函数之后,

系统就拥有了 hMem参数所标识的数据对象,该应用程序可以读取这个数据对象,

但是在应用程序调用 CloseClipboard函数之前,它都是不能释放该对象的句柄的,或者锁定这个句柄,

如果 hMem标识一个内存对象,那么这个对象必须是利用 GMEM_MOVEABLE标识调用 GlobalAlloc函数为其分配内存的。

HGLOBAL  WINAPI  GlobalAlloc( UINT  uFlags,   SIZE_T  dwBytes );

第一个参数 uFlags用来指定分配内存的方式。其取值如下列表所示

(但是在剪贴板的使用中,由于要实现动态数据交换,所以必须得使用 GHND或者 GMEM_MOVEABLE):

**值** **描述**
**GHND** 即 **GMEM_MOVEABLE**和 **GMEM_ZEROINIT**的组合。
**GMEM_FIXED** 分配一块固定内存,返回值是一个指针。
**GMEM_MOVEABLE** 分配一块可移动内存。
**GMEM_ZEROINIT** 初始化内存的内容为 0
**GPTR** 即 **GMEM_FIXED**和 **GMEM_ZEROINIT**的组合。

第二个参数 dwBytes用来指定分配的字节数。

GlobalReAlloc 函数

HGLOBAL  WINAPI  GlobalReAlloc(HGLOBAL hMem,  SIZE_T dwBytes,  UINT uFlags);

该函数为再分配函数,即在原有的数据对象 hMem上,为其扩大内存空间。

第一个参数 hMem代表由 GlobalAlloc函数返回的数据对象句柄。

第二个参数dwBytes指定需要重新分配的内存的大小。

第三个参数uFlags指定分配的方式(可以参考 GlobalAlloc函数)。

GlobalSize 函数

SIZE_T  WINAPI  GlobalSize( HGLOBAL  hMem );

该函数用来返回内存块的大小。

第一个参数 hMem代表由 GlobalAlloc函数返回的数据对象句柄。

GlobalLock 函数

LPVOID  WINAPI  GlobalLock( HGLOBAL  hMem );

该函数的作用是对全局内存对象加锁,然后返回该对象内存块第一个字节的指针

第一个参数 hMem代表由 GlobalAlloc函数返回的数据对象句柄。

GlobalUnLock 函数

BOOL  WINAPI  GlobalUnlock( HGLOBAL  hMem );

你通过上面的 GlobalLock函数可以获得这块全局内存的访问权,

加锁的意思就是你已经在使用这块全局内存了,别的程序就不能再使用这块全局内存了,

而如果你一直不解锁,那也不是个事啊,别的程序将会一直都使用不了这块全局内存,

那还叫全局内存干吗啊?所以这个函数就是用来对全局内存对象解锁。

第一个参数 hMem代表由 GlobalAlloc函数返回的数据对象句柄。

GlobalFree 函数

HGLOBAL  WINAPI  GlobalFree( HGLOBAL  hMem );

该函数释放全局内存块。

第一个参数 hMem代表由 GlobalAlloc函数返回的数据对象句柄。

Demo1 – ConsoleClipboard(剪贴板常用手法)

整个项目结构很简单:

image

ConsoleClipboard.h

#ifndef CONSOLE_CLIP_BOARD_H
#define CONSOLE_CLIP_BOARD_H

#include <Windows.h>
#include <iostream>

using namespace std;

const char * pStrData = "Zachary";

void SetClipBoardData();

void GetClipBoardData();


#endif
ConsoleClipboard.cpp
#include "ConsoleClipboard.h"

int main(int argc, char * argv)
{
SetClipBoardData();
GetClipBoardData();

system("pause");
}

void SetClipBoardData()
{
//将 OpenClipboard 函数的参数指定为 NULL,表明为当前进程打开剪贴板
if(OpenClipboard(NULL))
{
char * pDataBuf;

//全局内存对象
HGLOBAL hGlobalClip;

//给全局内存对象分配全局内存
hGlobalClip = GlobalAlloc(GHND, strlen(pStrData) + 1);
//通过给全局内存对象加锁获得对全局内存块的引用
pDataBuf = (char *)GlobalLock(hGlobalClip);
strcpy(pDataBuf, pStrData);
//使用完全局内存块后需要对全局内存块解锁
GlobalUnlock(hGlobalClip);

//清空剪贴板
EmptyClipboard();
//设置剪贴板数据,这里直接将数据放到了剪贴板中,而没有使用延迟提交技术
SetClipboardData(CF_TEXT, hGlobalClip);
//关闭剪贴板
CloseClipboard();

cout<<"设置剪贴板为:    "<<pStrData<<endl<<endl;
}
}

void GetClipBoardData()
{
if(OpenClipboard(NULL))
{
//判断剪贴板中的数据格式是否为 CF_TEXT
if(IsClipboardFormatAvailable(CF_TEXT))
{
char *            pDataBuf;
HGLOBAL            hGlobalClip;

//从剪贴板中获取格式为 CF_TEXT 的数据
hGlobalClip = GetClipboardData(CF_TEXT);
pDataBuf = (char *)GlobalLock(hGlobalClip);
GlobalUnlock(hGlobalClip);

cout<<"从剪贴板中获取到数据:    "<<pDataBuf<<endl<<endl;
}
CloseClipboard();
}
}

效果展示:

程序运行效果:

image

打开记事本进行粘贴操作:

image

延迟提交技术

什么是延迟提交技术?

当把数据放入剪贴板中时,一般来说要制作一份数据的副本,

也就是要分配全局内存,然后将数据再复制一份,然后再将包含这份副本的内存块句柄传递给剪贴板,

对于小数据量来说,这个没什么,但是对于大数据量的话,就有问题了,

你一使用剪贴板,就往里面复制个什么几百 MB的数据,

那这个数据在剪贴板中的数据被其他数据取代之前都是存放在内存中的啊,

这个方法也太龌龊了,你想啊,要是我就复制了一个 500MB的数据,然后我一直不再复制其他的东西,

那么这个 500MB的数据就会一直驻留在内存中,咦 . . . 太可怕了 !!!太浪费内存的使用效率了 !!!

为了解决上面这个问题,就需要通过使用延迟提交技术来避免内存的浪费,

当使用延迟提交技术时,实际上,直到另一个程序需要数据时,程序才会提供这份数据,

也就是,其实我一开始 程序 A并不往剪贴板中存放真实的数据,

而只是告诉剪贴板,我往里面放了数据(其实数据还没有放进去),

而后,如果有其他的 程序 B访问了剪贴板中的数据,也就是执行了“粘贴”操作,

那么此时操作系统就会去检查数据是不是真正的存放在了剪贴板中,

如果剪贴板中存放了数据,那么直接把数据送出去就可以了(这就没有使用延迟提交技术了),

而如果剪贴板中没有数据,那么 Windows就会给上次往剪贴板中存放数据(尽管没有存放实际的数据)的程序,

也就是程序 A发送消息,

而后,我们的 程序 A就可以再次调用 SetClipboardData来将真实的数据放入到剪贴板中了,这样就是延迟提交技术了。

要实现延迟提交技术,则在 程序A中不应该将数据句柄传送给 Windows

而是在 SetClipboardData调用中使用 NULL

然后当另外一个 程序B调用 GetClipboardData函数时,

Windows就会检查这种格式的数据在剪贴板中的句柄是否为 NULL

如果为 NULL,则 Windows会给程序A发送一个消息,从而请求到数据的实际句柄,

这个数据的实际句柄是 程序A在响应消息的处理函数中重新调用 SetClipboardData来提供的。

延迟提交技术中涉及的三个消息:

下面提及的 程序 A 代表剪贴板当前拥有者,也就是 程序 A 负责往剪贴板中写入数据,

而 程序 B 则代表从剪贴板中读取出数据,其没有对剪贴板的所有权。

WM_RENDERFORMAT

程序B调用 GetClipboardData时,Windows将会给 程序A的窗口过程发送这个消息,

其中 wParam参数的值是所要求的格式。

在处理这个消息时,程序 A就不再需要打开或者清空剪贴板了,

也就是不需要再次调用 OpenClipboardEmptyClipboard函数了,

为什么不需要再次调用这两个函数?

这是因为,我们一开始的时候已经调用了这两个函数(如果一开始没有调用的话,窗口根本就不会接受到这个消息),

而此举已经告诉操作系统剪贴板已经归我所有了,而且里面的数据已经被清空了,

剪贴板所有权都归我了,那还去打开个鬼啊,不是浪费嘛?

在处理这个消息时,应该为 wParam所指定的格式创建一个全局内存块,

然后再把数据传递到这个全局内存块中,并要正确的格式和数据句柄再一次调用 SetClipboardData函数。

也就是需要将数据真实的复制到剪贴板中了。

WM_RENDERALLFORAMTS

如果 程序A在它自己仍然是剪贴板所有者的时候就要终止运行,

并且剪贴板上仍然包含着该 程序ASetClipboardData 所设置的 NULL数据句柄(延迟提交技术),

也就是 程序 A当前还是剪贴板的所有者,但是用户又单击了关闭窗口,

而剪贴板中还没有真实的数据存在(因为使用了延迟提交技术),

即数据还没有被提交给剪贴板,程序 A就要死了,则此时 程 序A的窗口过程将接收到这个消息,

这个消息的一般处理为打开剪贴板,并且清空剪贴板,然后把数据加载到内存中,

并为每种格式调用 SetClipboardData,然后再关闭剪贴板即可。

WM_DESTROYCLIPBOARD

当在 程序B中调用 EmptyClipboard时,Windows将会给 程序A的窗口过程发送这个消息。

即通知 程序A其已不再是剪贴板的拥有者了。

Demo2 – MFCClipboard(延迟提交技术的使用)

整个项目结构很简单:

image

主界面:

image

添加 3 个消息处理:

image

消息映射函数声明:
protected:
HICON m_hIcon;

// 生成的消息映射函数
virtual BOOL OnInitDialog();
afx_msg void OnPaint();
afx_msg HCURSOR OnQueryDragIcon();
DECLARE_MESSAGE_MAP()
public:
afx_msg void OnDestroyClipboard();
afx_msg void OnRenderAllFormats();
afx_msg void OnRenderFormat(UINT nFormat);
afx_msg void OnBnClickedBtnWrite();
afx_msg void OnBnClickedBtnRead();
CString m_CStrWrite;
CString m_CStrRead;
};

消息映射实现:

void CMFCClipboardDlg::DoDataExchange(CDataExchange* pDX)
{
CDialogEx::DoDataExchange(pDX);
DDX_Text(pDX, IDC_EDIT_WRITE, m_CStrWrite);
DDX_Text(pDX, IDC_EDIT_READ, m_CStrRead);
}

BEGIN_MESSAGE_MAP(CMFCClipboardDlg, CDialogEx)
ON_WM_PAINT()
ON_WM_QUERYDRAGICON()
ON_WM_DESTROYCLIPBOARD()
ON_WM_RENDERALLFORMATS()
ON_WM_RENDERFORMAT()
ON_BN_CLICKED(ID_BTN_WRITE, &CMFCClipboardDlg::OnBnClickedBtnWrite)
ON_BN_CLICKED(ID_BTN_READ, &CMFCClipboardDlg::OnBnClickedBtnRead)
END_MESSAGE_MAP()

消息映射函数实现


//WM_DESTROYCLIPBOARD 消息处理函数
void CMFCClipboardDlg::OnDestroyClipboard()
{
//当有另外的程序调用 EmptyClipboard 时,
//Windows 将向当前窗口过程发送 WM_DESTROYCLIPBOARD 消息
MessageBox(TEXT("很抱歉 , 您已失去对剪贴板的拥有权 ..."),
TEXT("提示"), MB_ICONINFORMATION);

CDialogEx::OnDestroyClipboard();
}


//WM_RENDERALLFORMATS 消息处理函数
void CMFCClipboardDlg::OnRenderAllFormats()
{
//当剪贴板中的数据句柄为当前程序所拥有,而当前程序又将被退出时,
//Windows 给该程序窗口发送 WM_RENDERALLFORMATS 消息

OpenClipboard();
EmptyClipboard();
CloseClipboard();

CDialogEx::OnRenderAllFormats();
}

//WM_RENDERFORMAT 消息处理函数
void CMFCClipboardDlg::OnRenderFormat(UINT nFormat)
{
//当有另外的程序访问剪贴板时
//Windows 给该程序窗口过程发送 WM_RENDERFORMAT 消息
int                dataNum;
int                dataIndex;
char *            pDataBuf;
HGLOBAL            hGlobalClip;

dataNum = this->m_CStrWrite.GetLength();

hGlobalClip = GlobalAlloc(GHND, dataNum + 1);
pDataBuf = (char *)GlobalLock(hGlobalClip);
for(dataIndex=0;dataIndex<dataNum;dataIndex++)
{
pDataBuf[dataIndex] = this->m_CStrWrite.GetAt(dataIndex);
}
GlobalUnlock(hGlobalClip);

//此时需要将有效数据写入到剪贴板中
SetClipboardData(CF_TEXT, hGlobalClip);

CDialogEx::OnRenderFormat(nFormat);
}


void CMFCClipboardDlg::OnBnClickedBtnWrite()
{
UpdateData();
if(this->m_CStrWrite.GetLength() > 0)
{
if(OpenClipboard())
{
EmptyClipboard();
SetClipboardData(CF_TEXT, NULL);
CloseClipboard();
MessageBox(TEXT("  恭喜您 , 设置剪贴板成功 ..."),
TEXT("提示"), MB_ICONINFORMATION);
}
}
}


void CMFCClipboardDlg::OnBnClickedBtnRead()
{
if(OpenClipboard())
{
//判断剪贴板中的数据格式是否为 CF_TEXT
if(IsClipboardFormatAvailable(CF_TEXT))
{
char *            pDataBuf;
HGLOBAL            hGlobalClip;

//从剪贴板中获取到指定格式的数据
hGlobalClip = GetClipboardData(CF_TEXT);
pDataBuf = (char *)GlobalLock(hGlobalClip);
this->m_CStrRead = pDataBuf;
GlobalUnlock(hGlobalClip);

UpdateData(FALSE);
}
CloseClipboard();
}
}

效果展示:

设置剪贴板中数据:

image

当前程序读取剪贴板中数据:

image

记事本程序读取剪贴板中数据:

image

测试当前进程失去剪贴板所有权:

首先单击当前程序设置好剪贴板中的数据,

然后打开一个记事本文件,在在其中输入一些数据,然后选择这部分数据,按下复制:

image

结束语

对于剪贴板的使用呢,也就是那么几个 API在使用而已,熟悉一下就可以了,

关键是延迟提交技术的使用,同时还有对于全局内存对象的理解还是有点难度的,

不过,我相信我解释的还是比较明白了,大家可以通过我的解释再对照 Demo来理解,

这样理解起来容易快速一些。

上面介绍的是通过剪贴板来实现进程之间的通信,其实这还是有问题的,

因为我们的剪贴板是位于本地机器上,所以,利用剪贴板还是无法实现本地进程与远程进程通信,

当然要想实现本地进程和远程进程的通信,那也还是有办法的,这会在后续博文中引出的。

然后的话,今天圣诞节嘛,祝诸位节日快乐,也不是我崇洋媚外,说个节日快乐还是可以的。

版权所有,迎转载,但转载请注明: 转载自Zachary.XiaoZhen - 梦想的天空
原文链接: https://www.cnblogs.com/BoyXiao/archive/2010/12/25/1916677.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/19275

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月7日 下午8:18
下一篇 2023年2月7日 下午8:19

相关推荐