[你必須知道的異步編程]——異步編程模型(APM)

本專題概要:html

  • 引言編程

  • 你知道APM嗎?c#

  • 你想知道如何使用異步編程模型編寫代碼嗎?windows

  • 使用委託也能夠實現異步編程,你知道否?數組

  • 小結緩存


1、引言安全

  在前面的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 類庫中各個知識點,使他們能夠對號入坐。具體他們的之間對應關係見下表:async

C# 版本ide

.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)

2、你知道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參數。

3、你想知道如何使用異步編程模型編寫代碼嗎?

  介紹了這麼久的異步編程模型,你們確定很火燒眉毛地想使用異步編程模型來改寫本身的同步應用程序或者實現一個異步的應用程序。下面就經過一個例子來演示如何使用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調用被沒有阻塞調用線程(即主線程),而是當即返回到主線程,是主線程後面的代碼能夠當即執行)

若是咱們調用的是同步方法時,此時會堵塞主線程,直到文件的下載操做被完成以後主線程才繼續執行後面的代碼,下面是下載文件的同步方法:

#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

使用同步方法下載文件的運行結果爲(你們能夠對照兩個方式的結果就能夠明顯看出他們的區別了。):

4、使用委託也能夠實現異步編程,你知道否?

  在前面的介紹中已經提到委託類型也會定義了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;     
        }

運行的結果爲:

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;
        }
    }

程序的運行結果和前面使用第一方案的結果是同樣的,這裏就不重複貼圖了,上面全部的實現都是部分代碼,你能夠在文章的最後下載本專題的全部源碼。

5、小結

  到這裏本專題關於異步編程模型的介紹就結束了,異步編程模型(APM)雖然是.NET 1.0中提出來的一個模式,相對於如今來講是舊了點,而且微軟如今官方也代表在最新的代碼中不推薦使用該模型來實現異步的應用程序,而是推薦使用基於任務的異步編程模型來實現異步的應用程序,可是我我的認爲,正是由於它是.NET 1.0中提出的來,而且如今來看確實有些舊了, 因此咱們才更應該好好研究下它,由於後面提出的EAP和TAP微軟作了更多的封裝,是咱們對異步編程的本質都不清楚的(其實它們的本質都是使用線程池和委託機制的,具體能夠查看前面的相關部分),而且系統學習下異步編程,也可讓咱們對新的異步編程模型的所帶來的好處有更可直觀的認識。在後面的一專題我將帶你們全面認識下基於事件的異步編程模型(EAP)。

相關文章
相關標籤/搜索