Java 響應式編程

---- https://www.jianshu.com/p/893f036071fe ---- 

Exploring reactive programming in Java by Miro Cupak

最近學習RxJava。RxJava 在 GitHub 主頁上的自我介紹:

RxJava is a Java VM implementation of Reactive Extensions: a library for composing asynchronous and event-based programs by using observable sequences.

翻譯:RxJava是Reactive Extensions(響應式擴展)在 Java VM 上的實現,是一個使用可觀測的序列來組成異步的、基於事件的程序的庫。

兩個關鍵詞:Reactive Extensionsasynchronous

RxJava 的核心是響應式編程,所解決的問題是“異步”

所以在學習Rx庫之前,有必要了解下什麼是響應式編程


RX

Reactive Extensions是指什麼。

Rx取自於ReactiveX,ReactiveX是Reactive Extensions的縮寫。RxJava是ReactiveX在Java端的一種實現。所以還有RxJs,Rx.Net,RxSwift等多種實現。

官網對其的介紹如下:

An API for asynchronous programming with observable streams

一個帶有可觀察的流的異步編程的API

還是離不開“異步”,提到異步就不得不提到線程

所以,線程是響應式編程的第一步!


響應式的由來

故事從上古時期說起:

Java 1:

衆所周知,Java在設計之初就是一門支持多線程的語言。在Java1.0版本中,要想開闢一個新線程,需使用Thread:

Thread thread = new Thread(() -> System.out.println("hello world"));
thread.start()

除此之外,它幾乎不能做任何複雜的事情。

你可以說它有異步,但是他的整個異步系統處於“癱瘓”狀態,簡陋至極。當然也沒有“響應”可言,可以叫他“零響應式”。

Java 5:

這個版本中爲我們新增了三個很實用的接口:ExcutorService、Future和Callable。

Callable使Runnable有了返回值;ExecutorService.submit(callable)方法返回一個Future<T>,便可以從Future中獲取異步執行返回的結果。

ExecutorService e = Executors.newSingleThreadExecutor();
Future<String> future = e.submit(() -> "hello world");
future.get();

這樣一來,我們有能力提供一個複雜的“異步”系統,但是“被動”且“低廉”。不管怎麼說,它有了自己的一個異步系統,我們姑且稱之爲“一級響應式”。

Java 7

Java 7開始引入了一種新的Fork/Join線程池(Pool),它可以執行一種特殊的任務:把一個大任務拆成多個小任務並行執行。

同樣的代碼可以如下執行:

ExecutorService e = ForkJoinPool.commonPool();
Future<String> future = e.submit(() -> "hello world");
future.get();

在此支持下,我們可以很好的做到各個線程的同步。我們稱之爲“二級響應式”。

但是饒是如此,使用Future獲得異步執行結果時,要麼調用阻塞方法get(),要麼輪詢看isDone()是否爲true,主線程會被迫等待。

Java 8

從Java 8開始引入了CompletableFuture,它針對Future做了改進,可以傳入回調對象,當異步任務完成或者發生異常時,自動調用回調對象的回調方法

CompletableFuture cf = new CompletableFuture<String>();
//cf.complete("hello");
cf.completeExceptionally(new Exception("error"));
cf.get();

等待、成功、異常,都可以作爲方法傳給CompletableFuture。

CompletableFuture中還內置很多方法,分爲三類;

  1. What kind of task is this;
  2. What kind of an Opreation is it support;
  3. What thread should run for the task.

其中,task可以是runnable、consumer或者function。 3種。

Opretion可以是Chain(鏈接操作)、compose(組合)、combine AND( AND結合)、combine OR(OR結合)。4種。

thread可以是:當前線程、主線程 和 自己定義的某線程。3種。

一番排列組合下來,一共有3 × 4 × 3 = 36 種方法。

高效,漂亮,易使用,非阻塞。可以是“三級響應式”了

後續版本種還加入了延時方法,completeOnTimeOut()和orTime(),已經做到了很好的異步操作。

Java 9

到此爲止這個異步系統還差點什麼。比如當出線“生產者”比“消費者”快的情況下【背壓問題】,不能很好的解決。

所以,Java 9 引入了Flow接口。

SimpleSubscriber sub = new SimpleSubscriber();
SubmissionPublisher<String> publisher = new SubmissionPublisher<>();
publisher.subscribe(sub);

publisher.submit("msg");
publisher.close();

這一套接口使用下來,越發接近我們想要的模型了,是“四級響應式

但是它並不完美,因爲一般異步都涉及到網絡請求。

Java 9 到 Java 11

這其中新增了Http2接口,除了支持webSocket外,還對Flow進行了很好的適配,使得網絡的響應更加容易了。至此我們稱之爲“五級響應式”

這些在API的種種變化,使得我們操作異步更加方便優雅,響應式的程度也就越高。

不過這些改變都在JDK的層面上。

Reactive Libraries

在往上走就是Library層面的故事了。我們的RxJava庫,就屬於這個層面。

框架

Library往上走就是框架層面,框架中也有響應的支持。

綜上,最後結論如圖:

 
 
 

什麼是響應式編程(Reactive Programming)

In computing, reactive programming is an asynchronous programming paradigm concerned with data streams and the propagation of change. This means that it becomes possible to express static (e.g. arrays) or dynamic (e.g. event emitters) data streams with ease via the employed programming language(s), and that an inferred dependency within the associated execution model exists, which facilitates the automatic propagation of the change involved with data flow.

-- Wikipedia

以上解釋來自維基百科,在計算機領域,響應式編程是一個專注於數據流和變化傳遞的異步編程範式。這意味着可以使用編程語言很容易地表示靜態(例如數組)或動態(例如事件發射器)數據流,並且在關聯的執行模型中,存在着可推斷的依賴關係,這個關係的存在有利於自動傳播與數據流有關的更改。

拋開大段大段的概念,我們先搞清楚一件事情:什麼是編程範式?

通俗的說:編程是爲了解決問題,而解決問題可以有多種視角和思路,其中具有普適性的模式被歸結爲範式。我們常說的:“面向對象”,“面向過程”都是編程範式。

響應式編程是一種從數據流和變化出發的解決問題的模式。所以要研究響應式編程,一定要牢記已經掌握的OO(面向對象,筆者妄斷大家OO的思想都是很根深蒂固了)來做對比,也一定要拋開OO避免鑽牛角尖

爲什麼是異步?

在展開這個問題前,我們先看一個故事,引自知乎:小故事

摘抄如下:

老張愛喝茶,廢話不說,煮開水。

出場人物:老張,水壺兩把(普通水壺,簡稱水壺;會響的水壺,簡稱響水壺)。

1 老張把水壺放到火上,立等水開。(同步阻塞)
老張覺得自己有點傻

2 老張把水壺放到火上,去客廳看電視,時不時去廚房看看水開沒有。(同步非阻塞)
老張還是覺得自己有點傻,於是變高端了,買了把會響笛的那種水壺。水開之後,能大聲發出嘀~~~~的噪音。

3 老張把響水壺放到火上,立等水開。(異步阻塞)
老張覺得這樣傻等意義不大

4 老張把響水壺放到火上,去客廳看電視,水壺響之前不再去看它了,響了再去拿壺。(異步非阻塞)
老張覺得自己聰明瞭。

所謂同步異步,只是對於水壺而言。普通水壺,同步;響水壺,異步。雖然都能幹活,但響水壺可以在自己完工之後,提示老張水開了。這是普通水壺所不能及的。同步只能讓調用者去輪詢自己(情況2中),造成老張效率的低下。

所謂阻塞非阻塞,僅僅對於老張而言。立等的老張,阻塞;看電視的老張,非阻塞。情況1和情況3中老張就是阻塞的,媳婦喊他都不知道。雖然3中響水壺是異步的,可對於立等的老張沒有太大的意義。所以一般異步是配合非阻塞使用的,這樣才能發揮異步的效用。

上面這個小故事還是有點問題,但基本可以說明問題了。

響應,一定是對一個事件、一個信號(諸如此類的描述)產生了反應。響水壺的響應是什麼呢?水溫達到一定程度,水壺的反應是會響。水壺響了,聲音傳遞給老張,老張的反應是去關水壺。

再看普通水壺,水溫達到一定程度,水壺沒有反應,水的反應是冒氣泡,冒水霧。只是這個信號不太容易傳遞,要跑過來看,所以老張只能以輪訓的方式來辦事情,沒法跑到一邊等通知。

對於兩個水壺而言,燒水都是阻塞的,水沒燒完就幹不了其他的事情(比如說拿來砸胡桃???)

ok,回到我們的問題:爲什麼是異步?

迴歸到本質回答這個問題:響應式編程,本質上是對數據流或某種變化所作出的反應,但是這個變化什麼時候發生是未知的,所以他是一種基於異步、回調的方式在處理問題。

怪圈:似乎絕大多數博客說着說着就開始講解RxAndroid

正如副標題,在網上搜索到的絕大多數的博客都會說着說着就在教你如何使用RxAndroid。各位,請記住以下幾點:

  • RxAndroid(或RxJava)是很優秀的響應式編程框架。

  • 你並非一定需要使用RxAndroid。

  • RxAndroid並不像那些博客裏面說的那樣會讓你的代碼變得更可讀。

這裏我直接進入第三點。取用扔物線推薦RxJava中的例子:如下這段代碼:

Observable.from(folders)
.flatMap((Func1) (folder) -> { Observable.from(file.listFiles()) }) 
.filter((Func1) (file) -> { file.getName().endsWith(".png") }) 
.map((Func1) (file) -> { getBitmapFromFile(file) })
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) 
.subscribe((Action1) (bitmap) -> { imageCollectorView.addImage(bitmap) });

就這段代碼,是否需要從上到下仔細閱讀一遍之後才能纔會知道他的意圖?

甚至,爲了精讀代碼,他可能是這樣:

Observable.from(folders)
    .flatMap(new Func1<File, Observable<File>>() {
        @Override
        public Observable<File> call(File file) {
            return Observable.from(file.listFiles());
        }
    })
    .filter(new Func1<File, Boolean>() {
        @Override
        public Boolean call(File file) {
            return file.getName().endsWith(".png");
        }
    })
    .map(new Func1<File, Bitmap>() {
        @Override
        public Bitmap call(File file) {
            return getBitmapFromFile(file);
        }
    })
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new Action1<Bitmap>() {
        @Override
        public void call(Bitmap bitmap) {
            imageCollectorView.addImage(bitmap);
        }
    });

ok,請允許我再問一個問題,如此簡介的代碼,您打算單獨用一個類來放嗎?

如果對於任何一個處理類似業務邏輯的rx代碼段都使用類來放,可能類數量會爆炸,而且這些類的命名看起來會很奇葩。若不這樣,您的業務實現類中將充斥諸如此類不精讀不敢確定語義、容易被誤修改、不容易測試的代碼。面對這樣的代碼的時候只會是如履薄冰戰戰兢兢

我是在反對使用RxAndroid嗎?

No,我只是反對濫用Rx,我贊成對某些高度抽象的異步行爲使用Rx構建具有語義性的框架代碼,例如:編寫MVVM分層框架。反對對任何業務細節都去做“一切皆流”的無腦工作。畢竟:業務是需要逐漸迭代發展的,對於有測試代碼支撐的、同時有較強語義性的類,我們泛讀代碼就可以“聞絃歌而知雅意”,對於需要重構何處代碼,修改何處邏輯心中有數,而不必將“流”再反轉回“實際的相互關係”,再打亂,修改,再組織成流,再噁心下一次迭代,而且,最關鍵的是“你可能要從很多的流中找出這一個流”。


--- https://segmentfault.com/a/1190000017548728 --- 

 

摘要:響應式宣言如何解讀,Java中如何進行響應式編程,Reactor Streams又該如何使用?從響應式理解,到Reactor項目示例,再到Spring Webflux框架解讀,本文帶你進入Java響應式編程。

本文圍繞以下三部分進行介紹:
1.Reactive
2.Project Reactor
3.Spring Webflux

一.Reactive

1.Reactive Manifesto
下圖是Reactive Manifesto官方網站上的介紹,這篇文章非常短但也非常精悍,非常值得大家去認真閱讀。

響應式宣言是一份構建現代雲擴展架構的處方。這個框架主要使用消息驅動的方法來構建系統,在形式上可以達到彈性和韌性,最後可以產生響應性的價值。所謂彈性和韌性,通俗來說就像是橡皮筋,彈性是指橡皮筋可以拉長,而韌性指在拉長後可以縮回原樣。這裏爲大家一一解讀其中的關鍵詞:

1)響應性:快速/一致的響應時間。假設在有500個併發操作時,響應時間爲1s,那麼併發操作增長至5萬時,響應時間也應控制在1s左右。快速一致的響應時間才能給予用戶信心,是系統設計的追求。

2)韌性:複製/遏制/隔絕/委託。當某個模塊出現問題時,需要將這個問題控制在一定範圍內,這便需要使用隔絕的技術,避免連鎖性問題的發生。或是將出現故障部分的任務委託給其他模塊。韌性主要是系統對錯誤的容忍。

3)彈性:無競爭點或中心瓶頸/分片/擴展。如果沒有狀態的話,就進行水平擴展,如果存在狀態,就使用分片技術,將數據分至不同的機器上。

4)消息驅動:異步/松耦合/隔絕/地址透明/錯誤作爲消息/背壓/無阻塞。消息驅動是實現上述三項的技術支撐。其中,地址透明有很多方法。例如DNS提供的一串人類能讀懂的地址,而不是IP,這是一種不依賴於實現,而依賴於聲明的設計。再例如k8s每個service後會有多個Pod,依賴一個虛擬的服務而不是某一個真實的實例,從何實現調用1 個或調用n個服務實例對於對調用方無感知,這是爲分片或擴展做了準備。錯誤作爲消息,這在Java中是不太常見的,Java中通常將錯誤直接作爲異常拋出,而在響應式中,錯誤也是一種消息,和普通消息地位一致,這和JavaScript中的Promise類似。背壓是指當上遊向下遊推送數據時,可能下游承受能力不足導致問題,一個經典的比喻是就像用消防水龍頭解渴。因此下游需要向上遊聲明每次只能接受大約多少量的數據,當接受完畢再次向上遊申請數據傳輸。這便轉換成是下游向上遊申請數據,而不是上游向下遊推送數據。無阻塞是通過no-blocking IO提供更高的多線程切換效率。

 

2.Reactive Programming
響應式編程是一種聲明式編程範型。下圖中左側顯示了一個命令式編程,相信大家都比較熟悉。先聲明兩個變量,然後進行賦值,讓兩個變量相加,得到相加的結果。但接着當修改了最早聲明的兩個變量的值後,sum的值不會因此產生變化。而在Java 9 Flow中,按相同的思路實現上述處理流程,當初始變量的值變化,最後結果的值也同步發生變化,這就是響應式編程。這相當於聲明瞭一個公式,輸出值會隨着輸入值而同步變化。

響應式編程也是一種非阻塞的異步編程。下圖是用reactor.ipc.netty實現的TCP通信。常見的server中會用循環發數據後,在循環外取出,但在下圖的實現中沒有,因爲這不是使用阻塞模型實現,是基於非阻塞的異步編程實現。

 

響應式編程是一種數據流編程,關注於數據流而不是控制流。下圖中,首先當頁面出現點擊操作時產生一個click stream,然後頁面會將250ms內的clickStream緩存,如此實現了一個歸組過程。然後再進行map操作,得到每個list的長度,篩選出長度大於2的,這便可以得出多次點擊操作的流。這種方法應用非常廣泛,例如可以篩選出雙擊操作。由此可見,這種編程方式是一種數據流編程,而不是if else的控制流編程。

 

之前有提及消息驅動,那麼消息驅動(Message-driven)和事件驅動(Event-driven)有什麼區別呢。

1)消息驅動有確定的目標,一定會有消息的接受者,而事件驅動是一件事情希望被觀察到,觀察者是誰無關緊要。消息驅動系統關注消息的接受者,事件驅動系統關注事件源。

2)在一個使用響應式編程實現的響應式系統中,消息擅長於通訊,事件擅長於反應事實。

3.Reactive Streams
Reactive Streams提供了一套非阻塞背壓的異步流處理標準,主要應用在JVM、JavaScript和網絡協議工作中。通俗來說,它定義了一套響應式編程的標準。在Java中,有4個Reactive Streams API,如下圖所示:

 

這個API中定義了Publisher,即事件的發生源,它只有一個subscribe方法。其中的Subscriber就是訂閱消息的對象。

 

作爲訂閱者,有四個方法。onSubscribe會在每次接收消息時調用,得到的數據都會經過onNext方法。onError方法會在出現問題時調用,Throwable即是出現的錯誤消息。在結束時調用onComplete方法。

 

Subscription接口用來描述每個訂閱的消息。request方法用來向上遊索要指定個數的消息,cancel方法用於取消上游的數據推送,不再接受消息。

 

Processor接口繼承了Subscriber和Publisher,它既是消息的發生者也是消息的訂閱者。這是發生者和訂閱者間的過渡橋樑,負責一些中間轉換的處理。
Reactor Library從開始到現在已經歷經多代。第0代就是java包Observable 接口,也就是觀察者模式。具體的發展見下圖:

 

第四代雖然仍然是RxJava2,但是相比第三代的RxJava2,其中的小版本有了不一樣的改進,出現了新特性。
Reactor Library主要有兩點特性。一是基於回調(callback-based),在事件源附加回調函數,並在事件通過數據流鏈時被調用;二是聲明式編程(Declarative),很多函數處理業務類似,例如map/filter/fold等,這些操作被類庫固化後便可以使用聲明式方法,以在程序中快速便捷使用。在生產者、訂閱者都定義後,聲明式方法便可以用來實現中間處理者。

二.Project Reactor

Project Reactor,實現了完全非阻塞,並且基於網絡HTTP/TCP/UDP等的背壓,即數據傳輸上游爲網絡層協議時,通過遠程調用也可以實現背壓。同時,它還實現了Reactive Streams API和Reactive Extensions,以及支持Java 8 functional API/Completable Future/Stream /Duration等各新特性。下圖所示爲Reactor的一個示例:

 

首先定義了一個words的數組,然後使用flatMap做映射,再將每個詞和s做連接,得出的結果和另一個等長的序列進行一個zipWith操作,最後打印結果。這和Java 8 Stream非常類似,但仍存在一些區別:
1)Stream是pull-based,下游從上游拉數據的過程,它會有中間操作例如map和reduce,和終止操作例如collect等,只有在終止操作時纔會真正的拉取數據。Reactive是push-based,可以先將整個處理數據量構造完成,然後向其中填充數據,在出口處可以取出轉換結果。

2)Stream只能使用一次,因爲它是pull-based操作,拉取一次之後源頭不能更改。但Reactive可以使用多次,因爲push-based操作像是一個數據加工廠,只要填充數據就可以一直產出。

3)Stream#parallel()使用fork-join併發,就是將每一個大任務一直拆分至指定大小顆粒的小任務,每個小任務可以在不同的線程中執行,這種多線程模型符合了它的多核特性。Reactive使用Event loop,用一個單線程不停的做循環,每個循環處理有限的數據直至處理完成。

在上例中,大家可以看到很多Reactive的操作符,例如flatMap/concatWith/zipWith等,這樣的操作符有300多個,這可能是學習這個框架最大的壓力。如何理解如此繁多的操作符,可能一個歸類會有所幫助:

 

1)新序列創建,例如創建數組類序列等;
2)現有序列轉換,將其轉換爲新的序列,例如常見的map操作;
3)從現有的序列取出某些元素;
4)序列過濾;
5)序列異常處理。
6)與時間相關的操作,例如某個序列是由時間觸發器定期發起事件;
7)序列分割;
8)序列拉至同步世界,不是所有的框架都支持異步,再需要和同步操作進行交互時就需要這種處理。
上述300+操作符都有如下所示的彈珠圖(Marble Diagrams),用表意的方式解釋其作用。例如下圖的操作符是指,隨着時間推移,逐個產生了6個元素的序列,黑色豎線表示新元素產生終止。在這個操作符的作用下,下方只取了前三個元素,到第四個元素就不取了。這些彈珠圖大家可以自行了解。

 

三.Spring Webflux

1.Spring Webflux框架
Spring Boot 2.0相較之前的版本,在基於Spring Framework 5的構建添加了新模塊Webflux,將默認的web服務器改爲Netty,支持Reactive應用,並且Webflux默認運行在Netty上。而Spring Framework 5也有了一些變化。Java版本最低依賴Java 8,支持Java 9和Java 10,提供許多支持Reactive的基礎設施,提供面向Netty等運行時環境的適配器,新增Webflux模塊(集成的是Reactor 3.x)。下圖所示爲Webflux的框架:

 

左側是通常使用的框架,通過Servlet API的規範和Container進行交互,上一層是Spring-Webmvc,再上一層則是經常使用的一些註解。右側爲對應的Webflux層級,只要是支持NIO的Container,例如Tomcat,Jetty,Netty或Undertow都可以實現。在協議層的是HTTP/Reactive Streams。再上一層是Spring-Webflux,爲了保持兼容性,它支持這些常用的註解,同時也有一套新的語法規則Router Functions。下圖顯示了一個調用的實例:

 

 

 

在Client端,首先創建一個WebClient,調用其get方法,寫入URL,接收格式爲APPLICATION_STREAM_JSON的數據,retrieve獲得數據,取得數據後用bodyToFlux將數據轉換爲Car類型的對象,在doOnNext中打印構造好的Car對象,block方法意思是直到回調函數被執行纔可以結束。在Server端,在指定的path中進行get操作,produces和以前不同,這裏是application/stream+json,然後返回Flux範型的Car對象。傳統意義上,如果數據中有一萬條數據,那麼便直接返回一萬條數據,但在這個示例返回的Flux範型中,是不包含數據的,但在數據庫也支持Reactive的情況下,request可以一直往下傳遞,響應式的批量返回。傳統方式這樣的查詢很有可能是一個全表遍歷,這會需要較多資源和時間,甚至影響其他任務的執行。而響應式的方法除了可以避免這種情況,還可以讓用戶在第一時間看到數據而不是等待數據採集完畢,這在架構體驗的完整性上有了很大的提升。application/stream+json也是可以讓前端識別出,這些數據是分批響應式傳遞,而不會等待傳完才顯示。

現在的Java web應用可以使用Servlet棧或Reactive棧。Servlet棧已經有很久的使用歷史了,而現在又增加了更有優勢的Reactive棧,大家可以嘗試實現更好的用戶體驗。

 

2.Reactive編程模型
下圖中是Spring實現的一個向後兼容模型,可以使用annotation來標註Container。這是一個非常清晰、支持非常細節化的模型,也非常利於同事間的交流溝通。

 

下圖是一個Functional編程模型,通過寫函數的方式構造。例如下圖中傳入一個Request,返回Response,通過函數的方法重點關注輸入輸出,不需要區分狀態。然後將這些函數註冊至Route。這個模型和Node.js非常接近,也利於使用。

 

 

3.Spring Data框架
Spring Data框架支持多種數據庫,如下圖所示,最常用的是JPA和JDBC。在實踐中,不同的語言訪問不同的數據庫時,訪問接口是不一樣的,這對編程人員來說是個很大的工作量。

Spring Data便是做了另一層抽象,使你無論使用哪種數據庫,都可以使用同一個接口。具體特性這裏不做詳談。

 

下圖展示了一個Spring Data的使用示例。只需要寫一個方法簽名,然後註解爲Query,這個方法不需要實現,因爲框架後臺已經採用一些技術,直接根據findByFirstnameAndLastname就可以查詢到。這種一致的調用方式無疑提供了巨大的方便。

現在Reactive對Spring Data的支持還是不完整的,只支持了MongoDB/Redis/Cassandra和Couchbase,對JPA/LDAP/Elasticsearch/Neo4j/Solr等還不兼容。但也不是不能使用,例如對JDBC數據庫,將其轉爲同步即可使用,重點在於findAll和async兩個函數,這裏不再展開詳述,具體代碼如下圖所示:

Reactive不支持JDBC最根本的原因是,JDBC不是non-blocking設計。但是現在JavaOne已經在2016年9月宣佈了Non-blocking JDBC API的草案,雖然還未得到Java 10的支持,但可見這已經成爲一種趨勢。

四.總結

Spring MVC框架是一個命令式邏輯,方便編寫和調試。Spring WebFlux也具有衆多優勢,但調試卻不太容易,因爲它經常需要切換線程執行,出現錯誤的棧可能已經銷燬。當然這也是現今Java的編譯工具對WebFlux不太友好,相信以後會改善。下圖中列出了Spring MVC和Spring WebFlux各自的特性及交叉的部分。最後也附上一些參考資料。

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章