讓程式碼變得優雅簡潔的神器:Java8 Stream流式程式設計

朱季謙發表於2023-04-10

image


本文主要基於實際專案常用的Stream Api流式處理總結。


因筆者主要從事風控反欺詐相關工作,故而此文使用比較熟悉的三要素之一的【手機號】黑名單作程式碼案例說明。


我在專案當中,很早就開始使用Java 8的流特性進行開發了,但是一直都沒有針對這塊進行開發總結。這次就對這一塊程式碼知識做一次全面總結,在總結的過程中去發現自己的不足,同時方便日後開發查詢。


在實際專案當中,若能熟練使用Java8 的Stream流特性進行開發,就比較容易寫出簡潔優雅的程式碼。目前市面上很多開源框架,如Mybatis- Plus、kafka Streams以及Flink流處理等,都有一個相似的地方,即用到Stream流特性,其寫出的程式碼簡潔而易懂,當然,若是在不熟悉流特性的基礎上而貿然去使用Stream開發的話,難免會寫出一手bug。

此文主要適合新手。


一、Stream中間操作

​ Stream的中間操作是指在流鏈當中,可以對資料進行處理操作,包括filter過濾、map對映轉換、flatMap合併、distinct去重、sorted排序等操作。這些操作都會返回一個新的Stream流物件,可以透過鏈式呼叫多箇中間操作進行復雜的資料處理。需要注意的是,中間操作需要具有終止操作才會觸發。

​ 下面按類別講解Stream常見的中間操作。

1.1、filter:過濾出符合條件的元素。

​ filter()方法常用於實現資料過濾,即可以對集合、陣列等資料來源篩選出符合指定條件的元素,並返回一個新的流。

​ 假設有一個黑名單手機號列表,需要篩選出其中所有開頭為“133”的元素,那麼可以透過filter()實現——

//將陣列轉換為一個字串列表
List<String> numbers = Arrays.asList("13378520000","13278520000","13178520000","13358520000");
//透過stream()方法建立一個流,接著使用filter()方法過濾出字首為“133”的元素,最終透過collect() 方法將結果收集到一個新列表中
List<String> filterdNumbers = numbers.stream().filter(s -> s.startsWith("133")).collect(Collectors.toList());
System.out.println(filterdNumbers);


列印結果:[13378520000, 13358520000]

1.2、map:對映轉換元素。

​ map()方法用於對流中的每個元素進行對映操作,將其轉換為另一個元素或者提取其中的資訊,並返回一個新的流。

​ 根據以下兩個案例分別學習map()將元素轉換為另一個元素以及提取元素其中的資訊——

1.2.1、轉換元素

​ 假設有一個手機號字元列表,需要根據前7位來確定手機號歸屬地,那麼就需要獲取所有手機號前7位子字串,可以使用map()方法實現:

List<String> numbers = Arrays.asList("13378520000","13278520000","13178520000","13558520000");
//透過stream()方法建立一個流,使用map()方法將每個字串轉換為擷取前7位的字元,最後使用collect()方法將結果收集到一個新列表中
List<String> filterdNumbers = numbers.stream().map(s -> s.substring(0,7)).collect(Collectors.toList());
System.out.println(filterdNumbers);


列印結果:[1337852, 1327852, 1317852, 1355852]

1.2.2、提取元素資訊

​ 假設有一個使用者物件列表,我們需要提取其中每個物件的手機號,可以使用map()方法實現:

List<People> peopleList = Arrays.asList(
        new People("王二","13378520000"),
        new People("李二","13278520000"),
        new People("張四","13178520000")
);
//透過stream()方法建立一個流,使用map()方法提取每個使用者的手機號,最後使用collect()方法將結果收集到一個新列表中
List<String> tel = peopleList.stream().map(People::getTel).collect(Collectors.toList());
System.out.println(tel);


列印結果:[13378520000, 13278520000, 13178520000]

1.3、flatMap:將多個流合併為一個流。

​ flatMap()方法可以實現多對多的對映,或者將多個列表合併成一個列表操作。


1.3.1、實現多對多的對映

​ 假設有兩組餘額列表A和B,需要將A組每個元素都與B組所有元素依次進行相加,可以使用flatMap實現該多對多的對映——

List<Integer> listA = Arrays.asList(1, 2, 3);
List<Integer> listB = Arrays.asList(4, 5, 6);
List<Integer> list = listA.stream().flatMap(a -> listB.stream().map(b -> a +b)).collect(Collectors.toList());
System.out.println(list);


列印結果:  [5, 6, 7, 6, 7, 8, 7, 8, 9]	  

1.3.2、將多個列表合併成一個列表

​ 假設有一個包含多個手機號字串列表的列表,現在需要合併所有手機號字串成為一個列表,可以使用flatMap()方法實現:

List<List<String>> listOfLists = Arrays.asList(
        Arrays.asList("13378520000", "13278520000"),
        Arrays.asList("13178520000", "13558520000"),
        Arrays.asList("15138510000", "15228310000")
);
List<String> flatMapList = listOfLists.stream().flatMap(Collection::stream).collect(Collectors.toList());
System.out.println(flatMapList);


列印結果:[13378520000, 13278520000, 13178520000, 13558520000, 15138510000, 15228310000]

1.4、distinct:去除重複的元素。

​ distinct()方法可以用來去除流中的重複元素,生成無重複的列表。

​ 假設有一個包含重複手機號字串的列表,可以使用distinct()去重操作——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "15138510000");
List<String> disNumbers = numbers.stream().distinct().collect(Collectors.toList());
System.out.println(disNumbers);		


列印結果:[13378520000, 15138510000, 13178520000]		

​ 注意一點的是,distinct用於針對流作去重操作時,需要確定流中元素實現了equals()和hashCode()方法,因為這兩個方法是判斷兩個物件是否相等的標準。


1.5、sorted:排序元素。

​ sorted()方法用於對流中的元素進行排序。

​ 假設需要對一組People物件按照年齡排序,下面分別按照升序排序和降序排序——


1.5.1、升序排序

​ 預設情況下,是升序排序——

List<People> peopleList = Arrays.asList(
        new People("王二",20),
        new People("李二",30),
        new People("張四",31)
);
List<People> newpeopleList=peopleList.stream().sorted(Comparator.comparing(People::getAge)).collect(Collectors.toList());
//列印結果
newpeopleList.stream().forEach(System.out::println);


列印結果:
People{name='王二', age=20}
People{name='李二', age=30}
People{name='張四', age=31}

1.5.2、降序排序

透過reversed()方法進行逆序排序,也就是將升序排序進行倒序排序——

List<People> peopleList = Arrays.asList(
        new People("王二",20),
        new People("李二",30),
        new People("張四",31)
);
List<People> newpeopleList = peopleList.stream().sorted(Comparator.comparing(People::getAge).reversed()).collect(Collectors.toList());
//列印結果
newpeopleList.stream().forEach(System.out::println);


列印結果:
People{name='張四', age=31}
People{name='李二', age=30}
People{name='王二', age=20}

1.6、peek:檢視每個元素的資訊,但不修改流中元素的狀態。

​ peek()方法用於檢視流中的元素而不會修改流中元素的狀態,可以在流中的任何階段使用,不會影響到流的操作,也不會終止流的操作。

List<String> telList = Arrays.asList("13378520000","13278520000","13178520000","13558520000");
telList.stream().peek(t -> System.out.println(t))
        .map(t -> t.substring(0,3))
        .peek(t -> System.out.println(t))
        .collect(Collectors.toList());


列印結果:
			13378520000
			133
			13278520000
			132

peek()方法和forEach很類似,都是可以用於遍歷流中的元素,但是,兩者之間存在較大的區別。主要一點是,forEach在流中是一個終止操作,一旦呼叫它,就意味著Stream流已經被處理完成,不能再進行任何操作,例如,無法在forEach之後針對流進行map、filter等操作,但peek方法可以,以上的案例可以看出,在第一次呼叫peek列印一個元素後,該元素還可以接著進行map操作,進行字串的前三位擷取。

這是peek()方法和forEach最大的區別。


1.7、limit 和 skip:擷取流中的部分元素。

​ limit()和skip()都是用於擷取Stream流中部分元素的方法,兩者區別在於,limit()返回一個包含前n個元素的新流,skip()則返回一個丟棄前n個元素後剩餘元素組成的新流。

int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
System.out.print("取陣列前5個元素:");
Arrays.stream(arr).limit(5).forEach(n -> System.out.print(n + " ")); // 輸出結果為:1 2 3 4 5


System.out.print("跳過前3個元素,取剩餘陣列元素:");
Arrays.stream(arr).skip(3).forEach(n -> System.out.print(n + " ")); // 輸出結果為:4 5 6 7 8 9 10

二、Stream終止操作

​ Stream的終止操作是指執行Stream流鏈中最後一個步驟,到這一步就會結束整個流處理。在Java8中,Stream終止操作包括forEach、toArray、reduce、collect、min、max、count、anyMatch、allMatch、noneMatch、findFirst和findAny等。這些終止操作都有返回值。需要注意一點是,如果沒有執行終止操作的話,Stream流是不會觸發執行的,例如,一個沒有終止操作的peek()方法程式碼是不會執行進而列印——

list.stream().peek(t -> System.out.println("ddd"))

​ 當加上終止操作話,例如加上collect,就會列印出“ddd”——

list.stream().peek(t -> System.out.println("ddd")).collect(Collectors.toList());

下面按類別分別講解各個終止操作的使用。


2.1、forEach:遍歷流中的每個元素。

​ 該forEach前面已經提到,這裡不做過多介紹。


2.2、count:統計流中元素的數量。

​ count可以統計流中元素的數量並返回結果。

​ 假設有一個包含多個手機號字串的列表,需要統計去重後的手機號數量,就可以使用count方法——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "15138510000");
long count = numbers.stream()
        .distinct()//去重
        .count();//統計去重後的手機號
System.out.println(count);


列印結果:3

2.3、reduce:將流中的所有元素歸約成一個結果。

​ reduce()可以將流中的所有元素根據指定規則歸約成一個結果,並將該結果返回。

​ 常用語法格式如下:

Optional<T> result = stream.reduce(BinaryOperator<T> accumulator);

​ 可見,reduce方法會返回一個Optional型別的值,表示歸約後的結果,需要透過get()方法獲取Optional裡的值。

假設有一個包含多個手機號字串的List列表,需要在去重之後,再將列表所有字串拼按照逗號間隔接成一個字串返回,那麼就可以透過reduce來實現——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "15138510000");
Optional result = numbers.stream()
        .distinct() //去重
        .reduce((a ,b) -> a+","+b);//指定規則為,相臨兩個字元透過逗號“,”間隔
System.out.println(result.get());

列印結果:13378520000,15138510000,13178520000

2.4、collect:將流中的元素收集到一個容器中,並返回該容器。

​ collect的作用是將流中的元素收集到一個新的容器中,返回該容器。打個比喻,它就像一個採摘水果的工人,負責將水果一個個採摘下來,然後放進一個籃子裡,最後將籃子交給你。我在前面的案例當中,基本都有用到collect,例如前面2.1的filter過濾用法中的List filterdNumbers = numbers.stream().filter(s -> s.startsWith("133")).collect(Collectors.toList()),就是將過濾出字首為“133”的字串,將這些過濾處理後的元素交給collect這個終止操作。這時collect就像採摘水果的員工,把採摘為字首“133”的“水果”透過toList()方法收集到一個新的List容器當中,然後交給你。最後你就可以得到一個只裝著字首為“133”的元素集合。

​ 在Java8的collect方法中,除裡toList()之外,還提供了例如toSet,toMap等方法滿足不同的場景,根據名字就可以知道,toSet()返回的是一個Set集合,toMap()返回的是一個Map集合。


2.5、min 和 max:找出流中的最小值和最大值。

​ min和max用來查詢流中的最小值和最大值。

​ 假設需要在查詢出使用者列表中年齡最小的使用者,可以按照以下程式碼實現——

List<People> peopleList = Arrays.asList(
        new People("王二",20),
        new People("李二",30),
        new People("張四",31)
);
//查詢年齡最小的使用者,若沒有則返回一個null
People people = peopleList.stream().min(Comparator.comparing(People::getAge)).orElse(null);
System.out.println(people);

列印結果:People{name='王二', age=20}

​ max的用法類似,這裡不做額外說明。

2.6、anyMatch、allMatch 和 noneMatch:判斷流中是否存在滿足指定條件的元素。


2.6.1、anyMatch

​ anyMatch用於判斷,如果流中至少有一個元素滿足給定條件,那麼返回true,反之返回false,即 true||false為true這類的判斷。

​ 假設在一個手機號字串的List列表當中,判斷是否包含字首為“153”的手機號,就可以使用anyMatch——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "15338510000");
boolean hasNum = numbers.stream().anyMatch(n -> n.startsWith("153"));
System.out.println(hasNum);

列印結果:true

2.6.2、allMatch

​ allMatch用於判斷,流中的所有元素是否都滿足給定條件,滿足返回true,反之false,即true&&false為false這類判斷。

​ 假設在一個手機號字串的List列表當中,判斷手機號是否都滿足字首為“153”的手機號,就可以用allMatch——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "15338510000");
boolean hasNum = numbers.stream().allMatch(n -> n.startsWith("153"));
System.out.println(hasNum);

列印結果:false

2.6.2、noneMatch

​ noneMatch用於判斷,如果流中沒有任何元素滿足給定的條件,返回true,如果流中有任意一個條件滿足給定條件,返回false,類似!true為false的判斷。

​ 假設在一個手機號字串的List列表當中,判斷手機號是否都不滿足字首為“153”的手機號,就可以用noneMatch——

List<String> numbers = Arrays.asList("13378520000", "15138510000","13178520000", "1238510000");
//numbers裡沒有字首為“153”的手機號
boolean hasNum = numbers.stream().noneMatch(n -> n.startsWith("153"));
System.out.println(hasNum);


列印結果:true

​ 這三個方法其實存在一定互相替代性,例如在3.6.1中,滿足!anyMatch表示所有手機號都不為“153”字首,才得到true,這不就是noneMatch,主要看在專案當中如何靈活應用。


2.7、findFirst 和 findAny:返回流中第一個或任意一個元素。


2.7.1、findFirst

​ findFirst用於返回流中第一個元素,如果流為空話,則返回一個空的Optional物件——

​ 假設需要對一批同手機號的黑名單使用者按照時間戳降序排序,然後取出第一個即時間戳為最早的使用者,就可以使用findFirst——

List<People> peopleList = Arrays.asList(
        new People("王二","13178520000","20210409"),
        new People("李二","13178520000","20230401"),
        new People("張四","13178520000","20220509"),
        new People("趙六","13178520000","20220109")
);
/**
 * 先按照時間升序排序,排序後的結果如下:
 *   People{name='王二', tel='13178520000', time='20210409'}
 *   People{name='趙六', tel='13178520000', time='20220109'}
 *   People{name='張四', tel='13178520000', time='20220509'}
 *   People{name='李二', tel='13178520000', time='20230401'}
 *
 *排序後,People{name='王二', tel='13178520000', time='20210409'}成了流中的第一個元素
 */
People people = peopleList.stream().sorted(Comparator.comparing(People::getTime)).findFirst().orElse(null);
System.out.println(people);

列印結果:People{name='王二', tel='13178520000', time='20210409'} 


2.7.2、findAny

​ findAny返回流中的任意一個元素,如果流為空,則透過Optional物件返回一個null。

​ 假設有一個已經存在的黑名單手機號列表blackList,現在有一批新的手機號列表phoneNumber,需要基於blackList列表過濾出phoneNumber存在的黑名單手機號,最後從過濾出來的黑名單手機號當中挑選出來出來任意一個,即可以透過findAny實現——

//blackList是已經存在的黑名單列表
List<String> blackList = Arrays.asList("13378520000", "15138510000");
//新來的手機號列表
List<String> phoneNumber = Arrays.asList("13378520000", "13178520000", "1238510000","15138510000","13299920000");
String blackPhone = phoneNumber.stream()
        //過濾出phoneNumber有包含在blackList的手機號,這類手機號即為黑名單手機號。
        .filter(phone -> blackList.contains(phone))
        //獲取過濾確定為黑名單手機號的任意一個
        .findAny()
        //如果沒有則返回一個null
        .orElse(null);
System.out.println(blackPhone);

列印結果:13378520000

三、並行流

​ 前面的案例主要都是以順序流來講解,接下來,就是講解Stream的並行流。在大資料量處理場景下,使用並行流可以提高某些操作效率,但同樣存在一些需要考慮的問題,並非所有情況下都可以使用。

3.1、什麼是並行流:並行流的概念和原理。

​ 並行流是指透過將資料按照一定的方式劃分成多個片段分別在多個處理器上並行執行,這就意味著,可能處理完成的資料順序與原先排序好的資料情況是不一致的。主要是用在比較大的資料量處理情況,若資料量太少,效率並不比順序流要高,因為底層其實就使用到了多執行緒的技術。

​ 並行流的流程原理如下:

1、輸入資料:並行流的初始資料一般是集合或者陣列,例如Arrays.asList("13378520000", "13178520000", "1238510000","15138510000","13299920000");

2、劃分資料:將初始資料平均分成若干個子集,每個子集可以在不同的執行緒中獨立進行處理,這個過程通常叫“分支”(Forking),預設情況下,Java8並行流使用到了ForkJoinPool框架,會將Arrays.asList("13378520000", "13178520000", "1238510000","15138510000","13299920000")劃分成更小的顆粒進行處理,可能會將該陣列劃分成以下三個子集:

[13378520000, 13178520000]    [1238510000, 13338510000]    [13299920000]

3、處理資料:針對劃分好的子集並行進行相同的操作,例如包括過濾(filter)、對映(map)、去重(distinct)等,這個過程通常叫“計算”(Computing),例如需要過濾為字首包括“133”的字符集合,那麼,各個子集,就會處理得到以下結果:

[13378520000]    [13338510000]    []

4、合併結果:將所有子集處理完成的結果進行彙總,得到最終結果。這個過程通常叫“合併”(Merging),結果就會合並如下:

[13378520000,13338510000]  

5、返回結果:返回最終結果。

通俗而言,就是順序流中,只有一個工人在摘水果,並行流中,是多個工人同時在摘水果。


3.2、建立並行流:透過 parallel() 方法將序列流轉換為並行流。

​ 可以透過parallel()方法將順序流轉換為並行流,操作很簡單,只需要在順序流上呼叫parallel()即可。

List<String> numbers = Arrays.asList("13378360000","13278240000","13178590000","13558120000");
//透過stream().parallel()方法建立一個並行流,使用map()方法將每個字串轉換為擷取前7位的字元,最後使用collect()方法將結果收集到一個新列表中
List<String> filNums = numbers.stream().parallel().map(s -> s.substring(0,7)).collect(Collectors.toList());
System.out.println(filNums);


列印結果:[1337836, 1327824, 1317859, 1355812]

3.3、並行流的注意事項:並行流可能引發的執行緒安全,以及如何避免這些問題。

​ 在使用併發流的過程中,可能會引發以下執行緒安全問題:並行流中的每個子集都在不同執行緒執行,可能會導致對共享狀態的競爭和衝突。

​ 避免執行緒問題的方法如下:避免修改共享狀態,即在處理集合過程當中,避免被其他執行緒修改集合資料,可以使用鎖來保證執行緒安全。

​ 使用無狀態操作:在並行流處理過程儘量使用無狀態操作,例如filter、map之類的,可以儘量避免執行緒安全和同步問題。


四、Optional

4.1、什麼是 Optional:Optional 型別的作用和使用場景。

在實際開發當中,Optional型別通常用於返回可能為空的方法、避免null值的傳遞和簡化複雜的判斷邏輯等場景。呼叫Optional物件的方法,需要透過isPresent()方法判斷值是否存在,如果存在則可以透過get()方法獲取其值,如果不存在則可以透過orElse()方法提供預設值,或者丟擲自定義異常處理。


4.2、如何使用 Optional:如何使用 Optional 型別。

使用Optional型別主要目的是在資料可能為空的情況下,提供一種更安全、更優雅的處理方式。

以下是使用Optional型別的常用方法:


4.2.1、ofNullable()和isPresent()方法

​ 將一個可能為null的物件包裝成Optional型別的物件,然後根據isPresent方法判斷物件是否包含空值——

String str = null;
Optional<String> optStr = Optional.ofNullable(str);
if (optStr.isPresent()){
    System.out.println("Optional物件不為空");
}else {
    System.out.println("Optional物件為空");
}

列印結果:Optional物件為空

4.2.2、get()方法

​ 獲取Optional物件中的值,如果物件為空則丟擲NoSuchElementException異常——

String str = null;
Optional<String> optStr = Optional.ofNullable(str);
if (optStr.isPresent()){
    System.out.println("Optional物件不為空");
}else {
    System.out.println("Optional物件為空");
    optStr.get();
}


控制檯列印結果:
Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.util.Optional.get(Optional.java:135)
	at com.zhu.fte.biz.test.StreamTest.main(StreamTest.java:144)
Optional物件為空

4.2.4、orElse()方法

​ 獲取Optional物件中的值,如果物件為空則返回指定的預設值——

String str = null;
Optional<String> optStr = Optional.ofNullable(str);
if (optStr.isPresent()){
    System.out.println("Optional物件不為空");
}else {
    System.out.println("Optional物件為空,返回預設值:" + optStr.orElse("null"));
}


列印結果:Optional物件為空,返回預設值:null

​ 當然,如果不為空的話,則能正常獲取物件中的值——

String str = "測試";
Optional<String> optStr = Optional.ofNullable(str);
if (optStr.isPresent()){
    System.out.println("Optional物件不為空,返回值:" + optStr.orElse("null"));
}else {
    System.out.println("Optional物件為空,返回預設值:" + optStr.orElse("null"));
}

列印結果:Optional物件不為空,返回值:測試

那麼,問題來了,它是否能判斷“ ”這類空格的字串呢,我實驗了一下,

String str = "     ";
Optional<String> optStr = Optional.ofNullable(str);
if (optStr.isPresent()){
    System.out.println("Optional物件不為空,返回值:" + optStr.orElse("null"));
}else {
    System.out.println("Optional物件為空,返回預設值:" + optStr.orElse("null"));
}


列印結果:Optional物件不為空,返回值:

可見,這類空字串,在orElse判斷當中,跟StringUtils.isEmpty()類似,都是把它當成非空字串,但是StringUtils.isBlank()則判斷為空字串。

4.2.5、orElseGet()方法

orElseGet()和orElse()類似,都可以提供一個預設值。兩者區別在於,orElse方法在每次呼叫時都會建立預設值,而orElseGet只在需要時才會建立預設值。

4.3、Optional 和 null 的區別: Optional 型別與 null 值的異同。

兩者都可以表示缺失值的情況,兩者主要區別為:Optional型別是一種包裝器物件,可以將一個可能為空的物件包裝成一個Optional物件。這個物件可以透過呼叫ofNullable()of()或其他方法來建立。而null值則只是一個空引用,沒有任何實際的值。

Optional型別還可以避免出現NullPointerException異常,具體程式碼案例如下:

String str = null;
//錯誤示範:直接呼叫str.length()方法會觸發NullPointerException
//int length = str.length()

//透過Optional型別避免NullPointerException
Optional<String> optionalStr = Optional.ofNullable(str);
if (optionalStr.isPresent()){//判斷Optional物件是否都包含非空值
    int length = optionalStr.get().length();
    System.out.println("字串長度為:" + length);
}else {
    System.out.println("字串為空!");
}

//使用map()方法對Optional物件進行轉換時,確保返回對結果不為null
Optional<Integer> optionalLength = optionalStr.map(s -> s.length());
System.out.println("字串長度為:" + optionalLength.orElse(-1)); // 使用orElse()方法提供預設值

五、擴充套件流處理

除裡以上常用的流處理之外,Java8還新增了一些專門用來處理基本型別的流,例如IntStream、LongStream、DoubleStream等,其對應的Api介面基本與前面案例相似,讀者可以自行研究。

最後,需要注意一點是,在流處理過程當中,儘量使用原始型別資料,避免裝箱操作,因為裝箱過程會有效能開銷、記憶體佔用等問題,例如,當原始資料int型別被裝箱成Integer包裝型別時,這個過程會涉及到物件的建立、初始化、垃圾回收等過程,需要額外的效能開銷。

以上,就是關於Java8流處理相關知識的總結,筆者水平有限,若存在有誤的地方,還需幫忙指正。

最後,碼字不易,歡迎關注、點贊、收藏,謝謝!

相關文章