Java IO(2)阻塞式輸入輸出(BIO)

来源:http://www.cnblogs.com/yulinfeng/archive/2017/12/06/7995559.html
-Advertisement-
Play Games

在上文中《Java IO(1)基礎知識——位元組與字元》瞭解到了什麼是位元組和字元,主要是為了對Java IO中有關位元組流和字元流有一個更好的瞭解。 本文所述的輸出輸出指的是Java中傳統的IO,也就是阻塞式輸入輸出(Blocking I/O, BIO),在JDK1.4之後出現了新的輸入輸出API——N ...


  在上文中《Java IO(1)基礎知識——位元組與字元》瞭解到了什麼是位元組和字元,主要是為了對Java IO中有關位元組流和字元流有一個更好的瞭解。

  本文所述的輸出輸出指的是Java中傳統的IO,也就是阻塞式輸入輸出(Blocking I/O, BIO),在JDK1.4之後出現了新的輸入輸出API——NIO(New I/O或Non-blocking I/O),也就是同步非阻塞式輸入輸出,再到後面隨著NIO的發展出現了新的非同步非阻塞式的輸入輸出——AIO。

  本文將對BIO,即阻塞式輸入輸出的位元組流以及字元流做簡要概述。 需要明確對於輸出:InputStream、Reader表示輸入,前者表示位元組流,後者表示字元流;OutStream、Writer表示輸出,前者表示位元組流,後者表示字元流。

位元組流(InputStream、OutputStream)

  對於位元組流的輸入頂層類是InputStram、其輸出對應的頂層類是OutputStream。

輸入流(InputStream)

  站在程式的角度,讀取文件的動作稱為輸入,InputStream是一個抽象類,Java中IO的設計並不僅僅是只有InputStream類,因為存在許多輸入流,例如網路、文件等,這些都能為程式提供數據源,而不同的數據源則通過不同的InputStream子類來接收。

  1.  ByteArrayInputStream——位元組數組。
  2.  StringBufferInputStream——String對象,這個類年代久遠已經被廢除了,想要將String對象轉換為流,推薦使用StringReader。
  3.  FileInputStream——從文件中讀取信息,這個流是比較常用的類,因為通常情況下我們都是對文件進行讀寫操作,所以也會著重討論這個類。
  4.  PipedInputStream——和PipedOutputStream配合使用實現“管道化”的概念。
  5.  FileterInputStream——這個類比較特殊,從名字上看叫做“過濾器輸入流”,它是在輸入流中為“裝飾器”提供基類。

  著重來看FileInputStream類,如何從文件中讀取信息。

  FileInputStream 一共有3個構造方法: 

  1.  InputStream in = new FileInputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞文件路徑字元串,在這個構造函數中會為路徑中的文件創建File對象。
  2.  InputStream in = new FileInputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //傳遞File類型的對象,也就是我們自己為路徑中的文件構造為File文件類型。
  3.  InputStream in = new FileInputStream(new FileDescriptor()); //第三個構造方法傳遞的是“文件描述符”對象,通過文件描述符來定位文件,如果比較瞭解Linux和C的話應該是對“文件描述符”這個概念有所耳聞,在許多C源碼中就時常出現“fd”這個變數,其表示的就是文件描述符,就是用於定位文件。這一個在Java日常的應用開發中不常用,用到它的地方其實就是System.out.println的封裝。暫時可以忽略。

  其實深入到FileInputStream這個對象的源碼可以發現,大部分核心的源碼都是native方法,之所以只用nativa方法是因為本地方法速度快。

1 File file = new File("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 InputStream in = new FileInputStream(file);
3 byte[] b = new byte[64];
4 in.read(b);
5 System.out.println(new String(b));

  這段代碼是讀取本地文件獲取文件中的信息,其中read方法關鍵,FileInputStream中一共有3個read重載方法:

  1.  public int read() //返回讀取的位元組,FileInputStream是按照位元組流的方式讀取,使用該方法將一次讀取一個位元組並返回該位元組。該方法中會調用private native int read0()本地方法。
  2.  public int read(byte b[]) //將讀取的位元組全部放到位元組數組b中,這個位元組數組b是我們提前定義好的,用於存放讀取文件的位元組表示,返回一共讀取的字(1個字母表示1個字,1中文通常則是3個字)。該方法會調用private native int readBytes(byte b[], int off, int len)本地方法。
  3. read(byte b[], int off, int len) //讀取數據的開始處以及待存放位元組數組的長度,基本同上,返回一共讀取的字元(1個字母表示1個字元,1中文通常占用3個位元組也就是3個字元)。該方法會調用private native int readBytes(byte b[], int off, int len)本地方法。

  這基本上就構成了通過FileInputStream位元組流讀取文件的API,到了這裡應該會有一個疑問,那就是讀取出來的位元組放到我們定義的位元組數組中,而這個數組有需要在初始化時給定大小,那此時是如何知道待讀取的文件大小呢?上面定義的64個位元組大小的數組,如果待讀取的文件有128位元組甚至更大呢?就好像上面的例子,如果之定義1個位元組大小,那麼最後只會輸出文件中的第1個位元組。但如果定義64個位元組大小的位元組數組,那又顯得比較浪費。

輸出流(OutputStream)

  同樣是站在程式的角度,寫入文件的操作稱為輸出。和InputStream類比,它也有許多實現類,在這裡不再一一舉出,著重來看FileOutputStream輸出到本地文件的類。如果文件不存在則創建。

1 OutputStream out = new FileOutputStream("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 String str = "this is data";
3 out.write(str.getBytes());    // 由於是以位元組流的方式輸出,自然也是需要將輸出的內容轉換為位元組。

  FileOutputStream類的構造方法一共有5個:主要是分為“文件地址”、“是否以追加方式寫入”、“文件描述符”。 

  1.  OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞文件路徑字元串,在構造方法中會將其構造為一個File對象,如果文件不存在則會新建文件,預設將覆蓋文件的內容進行寫入。因為它實際上是調用FileInputStream(File, boolean)構造方法。
  2.  OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)) //傳遞File對象,預設將覆蓋文件的內容進行寫入。實際還是調用FileInputStream(File, boolean)。
  3.  OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”, true); //第一個參數如第一點所述,第二個參數則表示以追加的方式寫入。
  4.  OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””), true) //向上參考 
  5.  OutputStream out = new FileOutputStream (new FileDescriptor()); //第三個構造方法傳遞的是“文件描述符”對象,不需要過多的關註這個構造方法,因為實在能用的地方不多。

  對於文件輸出的核心API是write方法,對應文件輸入的read方法。既然read能單個讀取,那麼write也有單個寫入,其重載方法一共有3個。 

  1.  public void write(int b); //寫入單個位元組,該方法會調用private native write(b, append)這個方法是私有且本地的,至於第二個append的參數則是表示是否追加寫入文件,這裡的參數是在構造方法中定義的,預設不追加寫入而是以覆蓋的方式寫入。
  2.  public void write(byte b[]); //寫入位元組,這裡傳遞轉換後的位元組數組,通常我們是需要寫入一個字元串,而這裡調用String.valueOf將其轉換為字元數組。此方法會調用private native void writeBytes(byte b[], int off, int len, boolean append),和寫入的類似,第二個參數表示位元組數組從哪個地方開始寫入,len表示寫入多少,最後一個還是表示是否是追加寫入。
  3.  public void write(byte b[], int off, int len); //分析見上 這是對OutputStream的其中一個實現類做的簡要講述,API也較為簡單,類比很好掌握。

字元流(Reader、Writer)

輸入流(Reader)

  對於字元流的文件讀取方式可以不用像位元組流那樣,讀取出來是一個位元組,想要輸出顯示這個位元組則需要將這個位元組轉換為字元。字元流讀取出來的文件則直接就是字元,不需要再重新轉化。Reader和InputStream類似,也是一個抽象類,它也有不少的實現,其主要實現如下。

  1.  CharArrayReader
  2.  StringReader
  3.  InputStreamReader——這個類略有不同,這個類是位元組流和字元流之間的橋梁,它能將位元組流轉換為字元流,相對比於“FileInputStream”,位元組流的本地文件讀取實際上是InputStreamReader的子類——FileReader
  4.  PipedReader
  5.  FilterReader

  對比字元流的FileInputStream類,此處使用FileReader。和FileInputStream類似它同樣有3個構造方法:

  1.  Reader reader = new FileReader(/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接傳遞文件路徑字元串,在這個構造函數中會為路徑中的文件創建File對象。 
  2.  Reader reader = new FileReader(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //傳遞File類型的對象,也就是我們自己為路徑中的文件構造為File文件類型。 
  3.  Reader reader = new FileReader(new FileDescriptor()); //第三個構造方法傳遞的是“文件描述符”對象,通過文件描述符來定位文件,如果比較瞭解Linux和C的話應該是對“文件描述符”這個概念有所耳聞,在許多C源碼中就時常出現“fd”這個變數,其表示的就是文件描述符,就是用於定位文件,暫時對它可以忽略。

  可以看到它的API操作幾乎和FileInputStream如出一轍,唯一不同的是,它定義的是字元數組而不是位元組數組。

1 Reader reader = new FileReader("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 char[] c = new char[64];
3 reader.read(c);
4 System.out.println(String.valueOf(c));

  同位元組輸入流FileInputStream類似,它的讀取API也是read,並且它也有3個重載方法。如果還能記得FileInputStream的3個read重載方法,那麼這裡也不難猜出FileReader的3個read重載方法分別是:讀取一個字元;讀取所有字元;讀取範圍內的字元。實際上進入FileReader類後可以發現在FileReader類中並沒有read方法,因為它繼承自InputStreamReader,最後發現實際上FileReader#read調用的是父類InputputStreamReader#read方法,而且和位元組流的read使用native本地方法略有不同,InputputStreamReader並沒有採用native方法,而是使用了一個叫做StreamDecoder類,這個類源於sun包,並沒有源代碼,不過還是可以帶著好奇心來一看反編譯後的結果。  

//InputputStreamReader#read
public int read(char cbuf[], int offset, int length) throws IOException {
    return sd.read(cbuf, offset, length);    //調用的StreamDecoder#read方法
}

   對於使用FileReader#read方法調用的則是它的父類InputStreamReader#read,其實我認為可以這麼理解:基於字元流的輸入輸出實際上是我們人為對它進行了轉換,數據在網路中的傳輸實際還是以二進位流的方式,或者說是位元組的方式,為了我們方便閱讀,在傳輸到達時人為地將其轉換為了字元的形式。所以即使這裡是使用的FileReader以字元流的方式輸入,但實際上它使用了位元組-字元之間的橋梁——InputStreamReader類。也就是說StreamDecoder類很就是位元組-字元轉換的核心類。關於StreamDecoder類確實涉及比較複雜,Reader字元流本身也比位元組流要複雜不少。這個地方的源碼暫時還未深入瞭解。

輸出流(Writer)

  和位元組輸出流以及字元輸入流之間的對比Writer也有很多實現類,我們找到有關本地文件寫入的類——FileWriter,同樣發現它繼承自OutputStreamWriter,這個類是Writer的位元組子類和InputStreamReader類似是位元組流和字元流轉換的橋梁。

  有了上面的例子,這裡不再逐個敘述它的構造方法以及write重載方法,有一個需要關註的地方就是它的flush方法。

1 Writer writer = new FileWriter("/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json");
2 String str = "hello";
3 writer.write(str);
4 writer.flush();

  上面的代碼中如果不調用flush方法,字元串將不會寫入到文件中。這是因為在寫文件時,Java會將數據先存入緩存區,緩存區滿後再一次寫入到文件中,在這裡“hello”並沒有占滿緩存,故需要在調用write方法後再調用flush方法防止在緩存區中的數據沒有及時寫入文件。

  不過這裡有一個令我比較疑惑的是,在使用位元組流輸出只含1個字元到文件時,並沒有使用flush也會將數據寫到文件;而在字元流中則像上面的那種情況如果不使用flush則數據不會寫入文件。答案確實是使用位元組流輸出數據到文件時,不需要使用flush,因為調用FileInputStream並沒有重寫flush方法,而是直接調用了父類OutputStream的falush方法,而OutputStream#flush方法里什麼都沒有,就是一個空方法;而使用FileWriter中雖然也並未實現flush方法,但在其父類OutputStreamWriter卻實現了Writer的flush方法,因為在Writer類中flush方法是一個抽象方法必須實現。這裡實際又會有一個疑問,為什麼字元流不需要緩存,而位元組流需要呢?其實就是因為對於位元組流來說,是直接操作文件流,可以理解為“端到端”,而對於字元流來說中間多了一次轉換為字元在“端到端”的中間利用了緩存(記憶體)將字元存放在了緩存中。所以在實際開發中利用位元組流的方式輸入輸出相對更多。

小結

  上面說了這麼多,看似並沒有多少乾貨,大多是關於這幾個流的使用方法,如果仔細看下來會發現最大的乾貨在於最後的flush疑問。這實際上能揭開關於“位元組流”和“字元流”之間的區別。 在重覆一次,儘管位元組流中有flush方法,但是flush在位元組流FileOutputStream並沒用,JDK源碼能說明一切,因為FileOutputStream調用的flush方法根本就是一個空實現。然而在字元流中那就可得註意了,在FileReader調用了write方法後記住調用flush方法,清空緩存寫入文件。 這個問題基本就能解釋位元組流和字元流之間的區別了,位元組流直接操作文件,字元流雖然最後的呈現以及寫入是字元,但其最終還是以位元組在傳輸,位元組到字元的轉換是在記憶體中完成的,這也就是字元流用到了緩存的原因。其實想想就可以知道,對於兩者哪個更好,位元組流更常用,因為它直接操作文件讀取寫入位元組並且不限於文本,可以是音樂、圖片、視頻,而字元流主要是針對純文本文件,況且它還要轉換一次,效率恐怕就沒有位元組來得那麼快了,故一般就是直接使用位元組流——InputStream和OutputStream操作文件。

什麼是(同步)阻塞式輸入輸出(Blocking I/O)

  這一部分的內容將解釋本文的另一主題——阻塞式輸出輸出。

  首先需要瞭解何為“阻塞”。如果對顯示鎖Lock有所瞭解的話,應該是會知道它的兩個方法一個是阻塞式獲取鎖——lock,直到成功地獲取所後才返回;另一個是非阻塞式獲取鎖——tryLock,它首先嘗試獲取鎖,成功獲取所則成功返回,未能獲取鎖也會立即返回,並不會一直等在這裡獲取鎖。相對於阻塞式的IO也是類似,阻塞式IO也會一直等待數據的讀取和寫入直到完成;而對應的非阻塞式IO則不會這樣做,它會立即返回,不管是完成或未完成。

  再舉個例子,在現實生活中你去買煙,老闆說等下我去倉庫里拿,你就一直在那裡等老闆從倉庫里拿煙,這個時候你啥也不做就乾等著,這就是阻塞;對於非阻塞,你還是在買煙,你還是在等老闆給你拿煙,不過此時你可以玩玩手機,時不時問下老闆好了沒有。

  上面的例子都是在“同步”條件下的阻塞與非阻塞。當然還有非同步阻塞與非阻塞,這裡暫不涉及非同步相關,所以本文所述阻塞與非阻塞均是在同步狀態下。

  在此有必要瞭解什麼是同步,通俗地說就是你進行下一步動作需要依賴上一步的執行結果。有時在我們的應用程式中,讀取文件並不是下一步所必需的,也就是說這是兩個不相干的邏輯,此時如果採用同步的手段去讀取文件,讀完過後再做另外的邏輯顯然這個時間就被浪費了,通常情況下採取的措施是——偽非同步,單獨創建一個線程執行讀取文件的操作,代碼形如以下所示:

 1 new Thread(new Runnable() {
 2     @Override
 3     public void run() {
 4         readFile();
 5     }
 6 }).start();
 7 doSomething();
 8 //lamda表達式則更加簡單:
 9 //new Thread(() -> readFile()).start();
10 //doSomething();

  脫離場景談同步阻塞式的傳統IO顯得很無力也不好理解,下麵將結合Socket網路編程再次試著進一步理解“同步阻塞式IO”。

  以Java中使用UDP進行數據通信為例,伺服器端在創建一個socket後會調用其receive等待客戶端數據的到來,而DatagramSocket#receive就是阻塞地等待客戶端數據,如果數據一直不來,它將會一直“卡”在這個方法的調用處,也就是程式此時被阻塞掛起,程式無法繼續執行。

1 //同步阻塞式,伺服器端接收數據
2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
3 socket.receive(request);
4 processData(new String(request.getData()));

  試想以上代碼,客戶端發來的第1條、第2條……這些數據並無直接聯繫,它們只需要交給伺服器端處理即可,但此時伺服器端是同步阻塞式的獲取數據併進行處理,在第1條數據未處理完時,第2條數據就必須等待,通常地做法就是上面提到的採用偽非同步的方式對接收到的數據進行處理。

 1 //(偽)非同步阻塞式,伺服器端接收數據
 2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
 3 socket.receive(request);
 4 new Thread(() -> {        //lamda表達式
 5     try {
 6         processData(new String(request.getData()));
 7     } catch (InterruptedException e) {
 8         e.printStackTrace();
 9     }
10 }).start();

  上面代碼服務端接收到數據後將新開啟一個線程對數據進行處理(更好地方式是利用線程池來管理線程),儘管採用了“偽非同步”的方式處理數據,但實際上這是針對的是客戶端發送數據多,發送數據快時所做的改進措施,但如果客戶端發送的數據少,發送數據慢,實際上上面的修改並無多大意義,因為此時的癥結不在於對伺服器端對數據接收與處理的快慢,而在於伺服器端將會一直阻塞獲取數據使得伺服器端程式被掛起。所以問題還是回到了“阻塞”式IO上來,想要解決這個問題就需要使用到“非阻塞”式IO,這也是下節所講內容。

 

 

這是一個能給程式員加buff的公眾號 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 裝飾者模式: 動態地將責任附加到對象上。想要擴展功能,裝飾者提供有別於繼承的另一種選擇。 舉例: 不知道大家學校的食堂是什麼點餐制度(或者大家就直接想成吃火鍋,我們要火鍋料 + 配菜),我們學校的點餐是:主食大米 + 你想要吃的菜(每個菜都裝在小碗中)。現在問題來了,我點的是大米(0.8元) + 紅 ...
  • Observer Pattern(觀察者模式)定義: 在對象之間定義一對多的依賴,這樣一來,當一個對象改變狀態,依賴它的對象都會收到通知,並自動更新。 乾說定義肯定沒有舉例理解的透徹。想到Observer Pattern(觀察者模式)就來舉個生活中的例子來幫助我們更好消化和理解其具體含義。 舉例: ...
  • 看到這個問題,作為IT行業中的從業者,我的腦海中無數個有關產品經理的段子在策馬奔騰呼嘯而來,然鵝,基本都是黑產品的梗,那麼我們來分析一下為什麼想“打”產品經理呢?如果你是設計師、程式員、測試、美工、運營等需要和產品合作的任意一員。 ...
  • Struts 2 入門: 一:Struts 2執行流程: 1 客戶端發送請求; 2這個請求經過一系列的過濾器(Filter)(這些過濾器中有一個叫做ActionContextCleanUp的可選過濾器,這個過濾器對於Struts2和其他框架的集成很有幫助,例如:SiteMeshPlugin) 3接著 ...
  • 消息的一種開發模式,也是一種設計模式(發佈-訂閱) 中心窗體發消息(不知道消息的接受者),在中心窗體的事件中綁定的方法的對象接受消息(接受者做進一步處理)。 1.WinFrom(發佈-訂閱) 2.Windows(事件 Event) 3.Android(廣播 Broadcast) 4.Java(觀察者 ...
  • 概念 關註點分離(Separation of concerns,SOC)是對只與“特定概念、目標”(關註點)相關聯的軟體組成部分進行“標識、封裝和操縱”的能力,即標識、封裝和操縱關註點的能力。 概念 關註點分離(Separation of concerns,SOC)是對只與“特定概念、目標”(關註點 ...
  • 退出程式不用exit() #01代碼 1 #__author: _nbloser 2 #date: 2017/12/5 3 4 5 shaoguan = ['仁化', '始興', '樂昌', '南雄'] 6 jiangmeng = ['開平', '蓬江', '台山', '鶴山', '恩平'] 7 g ...
  • 21342134123 ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...