[你必须知道的异步编程]——异步编程模型(APM)

发布时间:2020-07-26 08:47:36 作者:LearningHard
来源:网络 阅读:1311

本专题概要:


一、引言

  在前面的C#基础知识系列中介绍了从C#1.0——C#4.0中一些主要特性,然而.NET 4.5更新,除了提供了一些新的类和一些新的模板外,对于C#语言也做了一定的更新,最重要的就是.NET 4.5(对应于C#5.0)中提供了async和await两个关键字,这两个关键字是我们实现异步编程更加容易了,其实早在.NET 1.0开始微软就对异步编程做了相应的支持——即异步编程模型(APM), 之后在.NET 2.0中又提出了基于事件的异步编程模型(EAP),.NET 4.0中又提出了基于任务的异步编程模型(TAP)。所以为了帮助大家全面理解.NET类库对异步编程的支持,这里我把我学习异步编程的一些体会和理解分享出来,希望对大家在学习的过程中有所帮助。

  在开始讲解APM之前,我想先分享一下Visual Studio 版本、C# 版本和.NET 版本的一个对应关系。之所以在这里分享这个对应关系,是因为在C#基础知识系列的文章发布之后,有些初学者对.NET版本和C#语言特性之间的对应关系有点不清楚,有时候会弄混淆了。并且通过这个对应关系,也可以帮助大家对C#和.NET 类库有个全面的把控,可以帮助大家理清楚C#和.NET 类库中各个知识点,使他们可以对号入坐。具体他们的之间对应关系见下表:

C# 版本

.NET Framework版本

Visual Studio版本

发布日期

特性

C# 1.0

.NET Framework 1.0

Visual Studio .NET 2002

2002.1

委托

事件

APM

C# 1.1

.NET Framework 1.1

Visual Studio .NET 2003

2003.4

C# 2.0

.NET Framework 2.0

Visual Studio 2005(开始命名为Visual Studio)

2005.11

泛型

匿名方法

迭代器

可空类型

C# 3.0

.NET Framework 3.0

.NET Framework 3.5

Visual Studio 2008

2007.11

隐式类型的部变量

对象集合初始化

自动实现属性

匿名类型

扩展方法

查询表达式

Lambda表达式

表达式树

分部类和方法

Linq

C# 4.0

.NET Framework 4.0

Visual Studio 2010

2010.4

动态绑定

命名和可选参数

泛型的协变和逆变

互操作性

C# 5.0

.NET Framework 4.5

Visual Studio 2012

2012.8

异步和等待(async和await)

调用方信息(Caller Information)

二、你知道APM吗?

  APM即异步编程模型的简写(Asynchronous Programming Model),大家在写代码的时候或者查看.NET 的类库的时候肯定会经常看到和使用以BeginXXX和EndXXX类似的方法,其实你在使用这些方法的时候,你就再使用异步编程模型来编写程序。异步编写模型是一种模式,该模式允许用更少的线程去做更多的操作,.NET Framework很多类也实现了该模式,同时我们也可以自定义类来实现该模式,(也就是在自定义的类中实现返回类型为IAsyncResult接口的BeginXXX方法和EndXXX方法)另外委托类型也定义了BeginInvoke和EndInvoke方法,并且我们使用WSDL.exe和SvcUtil.exe工具来生成Web服务的代理类型时,也会生成使用了APM的BeginXxx和EndXxx方法。下面就具体就拿FileStream类的BeginReadEndRead方法来介绍下下异步编程模型的实现。

BeginXxx方法——开始执行异步操作介绍

当需要读取文件中的内容时,我们通常会采用FileStream的同步方法Read来读取,该同步方法的定义为:

// 从文件流中读取字节块并将该数据写入给定的字节数组中
// array代表把读取的字节块写入的缓存区
// offset代表array的字节偏量,将在此处读取字节
// count 代表最多读取的字节数
public override int Read(byte[] array, int offset, int count )

   该同步方法会堵塞执行的线程,当一个WinForm程序需要实现读取一个大文件的内容然后把内容显示在界面时,如果我们调用该方法去读取文件的内容时,此时Read方法会堵塞UI线程,在读取文件内容没有完成之前,用户不能对窗体进行任何的操作,包括关闭应用程序,此时用户看到的该窗体会出现无法响应,这样就给用户带来不好一个用户体验,从用户角度来看是用户体验不好,此时我们自己解决问题的思路肯定是——能不能让读取文件操作在另外一个线程中执行,这样就不会堵塞UI线程,这时候UI线程继续做属于自己的事情,即响应用户的操作。不错,微软也肯定也想到了这个解决方案的,并且在实际操作中也是这么做的,即通过BeginRead方法来实现异步编程,使读取操作不再堵塞UI线程BeginRead方法代表异步执行Read操作,并返回实现IAsyncResult接口的对象,该对象存储着异步操作的信息,下面就看下BeginRead方法的定义,看看与同步Read的方法区别在哪里的.

// 开始异步读操作
// 前面的3个参数和同步方法代表的意思一样,这里就不说了,可以看到这里多出了2个参数
// userCallback代表当异步IO操作完成时,你希望由一个线程池线程执行的方法,该方法必须匹配AsyncCallback委托
// stateObject代表你希望转发给回调方法的一个对象的引用,在回调方法中,可以查询IAsyncResult接口的AsyncState属性来访问该对象
public override IAsyncResult BeginRead(byte[] array, int offset, int numBytes, AsyncCallback userCallback, Object stateObject
)

从上面的代码中可以看出异步方法和同步方法的区别,如果你在使用该异步方法时,不希望异步操作完成后调用任何代码,你可以把userCallback参数设置为null。该异步方法子所以不会堵塞UI线程是因为调用该方法后,该方法会立即把控制权返回给调用线程(如果是UI线程来调用该方法时,即返回给UI线程),然而同步却不是这样,同步方法是等该操作完成之后返回读取的内容之后才返回给调用线程,从而导致在操作完成之前调用线程就一直等待状态。

EndXxx方法——结束异步操作介绍

  前面介绍完了BeginXxx方法,我们看到所有BeginXxx方法返回的都是实现了IAsyncResult接口的一个对象,并不是对应的同步方法所要得到的结果的。此时我们需要调用对应的EndXxx方法来结束异步操作,并向该方法传递IAsyncResult对象,EndXxx方法的返回类型就是和同步方法一样的。例如,FileStreamEndRead方法返回一个Int32来代表从文件流中实际读取的字节数。

对于访问异步操作的结果,APM提供了四种方式供开发人员选择:

  1. 在调用BeginXxx方法的线程上调用EndXxx方法来得到异步操作的结果,但是这种方式会阻塞调用线程,知道操作完成之后调用线程才继续运行

  2. 查询IAsyncResultAsyncWaitHandle属性,从而得到WaitHandle,然后再调用它的WaitOne方法来使一个线程阻塞并等待操作完成再调用EndXxx方法来获得操作的结果。

  3. 循环查询IAsyncResultIsComplete属性,操作完成后再调用EndXxx方法来获得操作返回的结果。

  4. 使用 AsyncCallback委托来指定操作完成时要调用的方法,在操作完成后调用的方法中调用EndXxx操作来获得异步操作的结果。

  在上面的4种方式中,第4种方式是APM的首选方式,因为此时不会阻塞执行BeginXxx方法的线程,然而其他三种都会阻塞调用线程,相当于效果和使用同步方法是一样,个人感觉根本失去了异步编程的特点,所以其他三种方式可以简单了解下,在实际异步编程中都是使用委托的方式。

  通过上面的介绍,大家应该对异步编程模型有了进一步的了解了吧,要识别某个类是否实现了异步编程模型,只需要看是不是有BeginXxx方法(当然返回类型需要是IAsyncResult)和EndXxx方法。其实异步编程模型这个模式,就是微软利用委托和线程池帮助我们实现的一个模式(该模式利用一个线程池线程去执行一个操作,在FileStream类BeginRead方法中就是执行一个读取文件操作,该线程池线程会立即将控制权返回给调用线程,此时线程池线程在后台进行这个异步操作;异步操作完成之后,通过回调函数来获取异步操作返回的结果。此时就是利用委托的机制。所以说异步编程模式时利用委托和线程池线程搞出来的模式,包括后面的基于事件的异步编程和基于任务的异步编程,还有C# 5中的async和await关键字,都是利用这委托和线程池搞出来的。他们的本质其实都是一样的,只是后面提出来的使异步编程更加简单罢了。)

既然这里讲到了FileStream对象,这里就提出一个关于该类值得注意的地方的:

FileStream对象默认情况下是同步打开操作系统句柄,当我们创建一个FileStream对象没有为其指定FileOptions.Asynchronous参数或者没有显示指定useAsync为true时,Windows 操作系统会以同步的方法执行所有的文件操作,即使此时你还是可以调用BeginRead方法。但是这样对于你的应用程序,操作只是表面上是异步执行的,但FileStream类在内部会用另一个线程模拟异步行为。

同样道理,当创建的FileStream对象指定了FileOptions.Asynchronous参数时,然后我们仍然可以调用Read同步方法,此时在内部,FileStream类会开始一个异步操作,并立即使调用线程进入睡眠状态,知道操作完成才会唤醒,通过这样来模拟同步行为。因此在使用FileStream对象时,需要先决定是同步执行还是异步执行。并显示地指定FileOptions.Asynchronous参数或useAsync参数。

三、你想知道如何使用异步编程模型编写代码吗?

  介绍了这么久的异步编程模型,大家肯定很迫不及待地想使用异步编程模型来改写自己的同步应用程序或者实现一个异步的应用程序。下面就通过一个例子来演示如何使用APM来现异步编程(该程序也实现了一个同步方法,为了让大家更好地体会同步线程和异步线程的区别,本程序的实现是一个控制台程序,大家也可以很好地一直与WinForm应用程序和WPF程序):

#region use APM to download file asynchronously
        private static void DownloadFileAsync(string url)
        {
            try
            {
                // Initialize an HttpWebRequest object
                HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);
                // Create an instance of the RequestState and assign HttpWebRequest instance to its request field.
                RequestState requestState = new RequestState();
                requestState.request = myHttpWebRequest;
                myHttpWebRequest.BeginGetResponse(new AsyncCallback(ResponseCallback), requestState);
            }
            catch (Exception e)
            {
                Console.WriteLine("Error Message is:{0}",e.Message);
            }
        }
        // The following method is called when each asynchronous operation completes.
        private static void ResponseCallback(IAsyncResult callbackresult)
        {
            // Get RequestState object
            RequestState myRequestState = (RequestState)callbackresult.AsyncState;
            HttpWebRequest myHttpRequest = myRequestState.request;
            // End an Asynchronous request to the Internet resource
            myRequestState.response = (HttpWebResponse)myHttpRequest.EndGetResponse(callbackresult);
                                                                                      
            // Get Response Stream from Server
            Stream responseStream = myRequestState.response.GetResponseStream();
            myRequestState.streamResponse = responseStream;
            IAsyncResult asynchronousRead = responseStream.BeginRead(myRequestState.BufferRead, 0, myRequestState.BufferRead.Length, ReadCallBack, myRequestState);      
        }
        // Write bytes to FileStream
        private static void ReadCallBack(IAsyncResult asyncResult)
        {
            try
            {
                // Get RequestState object
                RequestState myRequestState = (RequestState)asyncResult.AsyncState;
                // Get Response Stream from Server
                Stream responserStream = myRequestState.streamResponse;
                //
                int readSize = responserStream.EndRead(asyncResult);
                if (readSize > 0)
                {
                    myRequestState.filestream.Write(myRequestState.BufferRead, 0, readSize);
                    responserStream.BeginRead(myRequestState.BufferRead, 0, myRequestState.BufferRead.Length, ReadCallBack, myRequestState);
                }
                else
                {
                    Console.WriteLine("\nThe Length of the File is: {0}", myRequestState.filestream.Length);
                    Console.WriteLine("DownLoad Completely, Download path is: {0}", myRequestState.savepath);
                    myRequestState.response.Close();
                    myRequestState.filestream.Close();
                }     
            }
            catch (Exception e)
            {
                Console.WriteLine("Error Message is:{0}", e.Message);
            }
        }
        #endregion

运行结果为(从运行结果也可以看出,在主线程中调用 DownloadFileAsync(downUrl)方法时,DownloadFileAsync(downUrl)方法中的myHttpWebRequest.BeginGetResponse调用被没有阻塞调用线程(即主线程),而是立即返回到主线程,是主线程后面的代码可以立即执行)

[你必须知道的异步编程]——异步编程模型(APM)

如果我们调用的是同步方法时,此时会堵塞主线程,直到文件的下载操作被完成之后主线程才继续执行后面的代码,下面是下载文件的同步方法:

#region Download File Synchrously
        private static void DownLoadFileSync(string url)
        {
            // Create an instance of the RequestState
            RequestState requestState=new RequestState();
            try
            {
                // Initialize an HttpWebRequest object
                HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);
                // assign HttpWebRequest instance to its request field.
                requestState.request = myHttpWebRequest;
                requestState.response = (HttpWebResponse)myHttpWebRequest.GetResponse();
                requestState.streamResponse = requestState.response.GetResponseStream();
                int readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                while (readSize > 0)
                {
                    requestState.filestream.Write(requestState.BufferRead, 0, readSize);
                    readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                }
                Console.WriteLine("\nThe Length of the File is: {0}", requestState.filestream.Length);
                Console.WriteLine("DownLoad Completely, Download path is: {0}", requestState.savepath);
            }
            catch (Exception e)
            {
                Console.WriteLine("Error Message is:{0}", e.Message);
            }
            finally
            {
                requestState.response.Close();
                requestState.filestream.Close();
            }
        }
        #endregion

使用同步方法下载文件的运行结果为(大家可以对照两个方式的结果就可以明显看出他们的区别了。):

[你必须知道的异步编程]——异步编程模型(APM)

四、使用委托也可以实现异步编程,你知道否?

  在前面的介绍中已经提到委托类型也会定义了BeginInvoke方法和EndInvoke方法,所以委托类型也实现了异步编程模型,所以可以使用委托的BeginInvokeEndInvoke方法来回调同步方法从而实现异步编程。因为调用委托的BeginInvoke方法来执行一个同步方法时,此时会使用线程池线程回调这个同步方法并立即返回到调用线程中,由于耗时操作在另外一个线程上运行,所以执行BeginInvoke方法的主线程就不会被堵塞。但是这里存在的一个问题时,因为同步方法在另外一个线程中执行的,然而我们怎么把同步方法执行的状态反应到UI界面上来呢?因为在GUI应用程序(包括Windows窗体,WPF和Silverlight)中,创建窗口的线程是唯一能够对那个窗口进行更新的线程,所以在执行同步方法的线程就不能对窗口中的控件进行操作,也就不能把方法允许的结果反应到窗体上了。这里有两种解决方案,一种是设置控件的CheckForIllegalCrossThreadCalls 属性为false,设置为false的意思代表允许跨线程调用,(这种方式虽然可以解决该问题,但是不推荐,因为它违背了.NET安全规范);第二种就是使用SynchronizationContext基类,该类记录着线程的同步上下文对象,我们可以通过在GUI线程中调用SynchronizationContext.Current属性来获得GUI线程的同步上下文,然后当线程池线程需要更新窗体时,可以调用保存的SynchronizationContext派生对象的Post方法(Post方法会将回调函数送到GUI线程的队列中,每个线程都有各自的操作队列的,线程的执行都是从这个队列中拿方法去执行),向Post方法传递要由GUI线程调用的方法(该方法的定义要匹配SendOrPostCallback委托的签名),还需要想Post方法传递一个要传给回调方法的参数。

4.1 使用委托实现更好的用户体验——不堵塞UI线程

虽然第一种方案是一种不推荐的方案,但是我觉得有些朋友还是不知道怎么实现的,所以在这部分就用具体的代码来实现下,并且该实现也可以与使用同步上下文对象的方式进行对比,这样大家就可以更加了解如何使用委托来进行异步编程了。下面就具体看实现代码吧:

View Code
 // 定义用来实现异步编程的委托
        private delegate string AsyncMethodCaller(string fileurl);
        public Mainform()
        {
            InitializeComponent();
            txbUrl.Text = "http://download.microsoft.com/download/7/0/3/703455ee-a747-4cc8-bd3e-98a615c3aedb/dotNetFx35setup.exe";
                                                                              
            // 允许跨线程调用
            // 实际开发中不建议这样做的,违背了.NET 安全规范
            CheckForIllegalCrossThreadCalls = false;
        }
        private void btnDownLoad_Click(object sender, EventArgs e)
        {
            rtbState.Text = "Download............";
            if (txbUrl.Text == string.Empty)
            {
                MessageBox.Show("Please input valid download file url");
                return;
            }
            AsyncMethodCaller methodCaller = new AsyncMethodCaller(DownLoadFileSync);
            methodCaller.BeginInvoke(txbUrl.Text.Trim(), GetResult, null);
        }
        // 同步下载文件的方法
        // 该方法会阻塞主线程,使用户无法对界面进行操作
        // 在文件下载完成之前,用户甚至都不能关闭运行的程序。
        private string DownLoadFileSync(string url)
        {
            // Create an instance of the RequestState
            RequestState requestState = new RequestState();
            try
            {
                // Initialize an HttpWebRequest object
                HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);
                // assign HttpWebRequest instance to its request field.
                requestState.request = myHttpWebRequest;
                requestState.response = (HttpWebResponse)myHttpWebRequest.GetResponse();
                requestState.streamResponse = requestState.response.GetResponseStream();
                int readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                                                                                
                while (readSize > 0)
                {
                    requestState.filestream.Write(requestState.BufferRead, 0, readSize);
                    readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                }
                // 执行该方法的线程是线程池线程,该线程不是与创建richTextBox控件的线程不是一个线程
                // 如果不把 CheckForIllegalCrossThreadCalls 设置为false,该程序会出现“不能跨线程访问控件”的异常
                return string.Format("The Length of the File is: {0}", requestState.filestream.Length) + string.Format("\nDownLoad Completely, Download path is: {0}", requestState.savepath);
            }
            catch (Exception e)
            {
                return string.Format("Exception occurs in DownLoadFileSync method, Error Message is:{0}", e.Message);
            }
            finally
            {
                requestState.response.Close();
                requestState.filestream.Close();
            }
        }
        // 异步操作完成时执行的方法
        private void GetResult(IAsyncResult result)
        {
            AsyncMethodCaller caller = (AsyncMethodCaller)((AsyncResult)result).AsyncDelegate;
            // 调用EndInvoke去等待异步调用完成并且获得返回值
            // 如果异步调用尚未完成,则 EndInvoke 会一直阻止调用线程,直到异步调用完成
            string returnstring= caller.EndInvoke(result);
            //sc.Post(ShowState,resultvalue);
            rtbState.Text = returnstring;     
        }

运行的结果为:

[你必须知道的异步编程]——异步编程模型(APM)

4.2 在线程中访问另一个线程创建的控件

这部分将使用同步上下文的方式来实现在线程池线程中如何更新GUI线程中窗体,因为在程序代码部分都有详细的解释,这里就直接贴代码了

public partial class MainForm : Form
    {
        // 定义用来实现异步编程的委托
        private delegate string AsyncMethodCaller(string fileurl);
          // 定义显示状态的委托
        private delegate void ShowStateDelegate(string value);
        private ShowStateDelegate showStateCallback;
        SynchronizationContext sc;
        public MainForm()
        {
            InitializeComponent();
            txbUrl.Text = "http://download.microsoft.com/download/7/0/3/703455ee-a747-4cc8-bd3e-98a615c3aedb/dotNetFx35setup.exe";
            showStateCallback = new ShowStateDelegate(ShowState);
        }
        private void btnDownLoad_Click(object sender, EventArgs e)
        {
            rtbState.Text = "Download............";
            btnDownLoad.Enabled = false;
            if (txbUrl.Text == string.Empty)
            {
                MessageBox.Show("Please input valid download file url");
                return;
            }
            AsyncMethodCaller methodCaller = new AsyncMethodCaller(DownLoadFileSync);
            methodCaller.BeginInvoke(txbUrl.Text.Trim(), GetResult, null);
            // 捕捉调用线程的同步上下文派生对象
            sc = SynchronizationContext.Current;
        }
        // 同步下载文件的方法
        // 该方法会阻塞主线程,使用户无法对界面进行操作
        // 在文件下载完成之前,用户甚至都不能关闭运行的程序。
        private string DownLoadFileSync(string url)
        {
            // Create an instance of the RequestState
            RequestState requestState = new RequestState();
            try
            {
                // Initialize an HttpWebRequest object
                HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);
                // assign HttpWebRequest instance to its request field.
                requestState.request = myHttpWebRequest;
                requestState.response = (HttpWebResponse)myHttpWebRequest.GetResponse();
                requestState.streamResponse = requestState.response.GetResponseStream();
                int readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                while (readSize > 0)
                {
                    requestState.filestream.Write(requestState.BufferRead, 0, readSize);
                    readSize = requestState.streamResponse.Read(requestState.BufferRead, 0, requestState.BufferRead.Length);
                }
                // 执行该方法的线程是线程池线程,该线程不是与创建richTextBox控件的线程不是一个线程
                // 如果不把 CheckForIllegalCrossThreadCalls 设置为false,该程序会出现“不能跨线程访问控件”的异常
                return string.Format("The Length of the File is: {0}", requestState.filestream.Length) + string.Format("\nDownLoad Completely, Download path is: {0}", requestState.savepath);
            }
            catch (Exception e)
            {
                return string.Format("Exception occurs in DownLoadFileSync method, Error Message is:{0}", e.Message);
            }
            finally
            {
                requestState.response.Close();
                requestState.filestream.Close();
            }
        }
        // 异步操作完成时执行的方法
        private void GetResult(IAsyncResult result)
        {
            AsyncMethodCaller caller = (AsyncMethodCaller)((AsyncResult)result).AsyncDelegate;
            // 调用EndInvoke去等待异步调用完成并且获得返回值
            // 如果异步调用尚未完成,则 EndInvoke 会一直阻止调用线程,直到异步调用完成
            string returnstring = caller.EndInvoke(result);
            // 通过获得GUI线程的同步上下文的派生对象,
            // 然后调用Post方法来使更新GUI操作方法由GUI 线程去执行
            sc.Post(ShowState,returnstring);   
        }
        // 显示结果到richTextBox
        // 因为该方法是由GUI线程执行的,所以当然就可以访问窗体控件了
        private void ShowState(object result)
        {
            rtbState.Text = result.ToString();
            btnDownLoad.Enabled = true;
        }
    }

程序的运行结果和前面使用第一方案的结果是一样的,这里就不重复贴图了,上面所有的实现都是部分代码,你可以在文章的最后下载本专题的所有源码。

五、小结

  到这里本专题关于异步编程模型的介绍就结束了,异步编程模型(APM)虽然是.NET 1.0中提出来的一个模式,相对于现在来说是旧了点,并且微软现在官方也表明在最新的代码中不推荐使用该模型来实现异步的应用程序,而是推荐使用基于任务的异步编程模型来实现异步的应用程序,但是我个人认为,正是因为它是.NET 1.0中提出的来,并且现在来看确实有些旧了, 所以我们才更应该好好研究下它,因为后面提出的EAP和TAP微软做了更多的封装,是我们对异步编程的本质都不清楚的(其实它们的本质都是使用线程池和委托机制的,具体可以查看前面的相关部分),并且系统学习下异步编程,也可以让我们对新的异步编程模型的所带来的好处有更可直观的认识。在后面的一专题我将带大家全面认识下基于事件的异步编程模型(EAP)。


附件:http://down.51cto.com/data/2362807
推荐阅读:
  1. CentOS 7, apm+xcache, rpm包, php module
  2. 详解APM数据采样与端到端

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

apm 异步编程

上一篇:Project network redundant , Vmware virtualization, Dell VRTX P2V - Part 2 (VRTX Network)

下一篇:使用harbor搭建docker私有仓库

相关阅读

您好,登录后才能下订单哦!

密码登录
登录注册
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》