万象直播app破解版_欧美国产日韩无遮挡在线一区二区,亚洲国产综合精品中久,强奷白丝女仆在线观看,超碰三级大陆在线

您的位置:首頁 > 軟件問答

網絡連接受限制(阻截彈窗廣告 多個應用增設一鍵關閉)

導讀網絡連接受限制文章列表:1、阻截彈窗廣告 多個應用增設一鍵關閉2、WiFi老是掉線斷網?用上無線AP面板,信號直接飆到滿格3、從VPN切換到指紋瀏覽器對賬號有影響嗎?4、Netty進階篇

網絡連接受限制文章列表:

網絡連接受限制(阻截彈窗廣告 多個應用增設一鍵關閉)

阻截彈窗廣告 多個應用增設一鍵關閉

原標題:阻截彈窗廣告多個應用增設一鍵關閉

9月30日起,《互聯網彈窗信息推送服務管理規定》開始施行。北京青年報記者發現,此前,PC端和手機端的工具類軟件是彈窗廣告“霸屏”的重災區,這些廣告多指向web游戲和小程序游戲,還有的涉及色情和賭博交易。在彈窗廣告背后,早已形成一條產業鏈,最低1分錢就可以發送彈窗廣告。

不過,在新規發布后,多個電腦和手機端的彈窗廣告從“霸屏”變身,添加了關閉、跳過或不再顯示等按鈕,用戶可一鍵關閉。分析認為,彈窗廣告被規范之后,用戶體驗改善,對彈窗廣告行業長期健康發展是有幫助的,用戶獲得知情權和同意權,有了申訴渠道,新規保護了用戶的權益。

遭詬病:工具類軟件是彈窗廣告“霸屏”的重災區

北青報記者了解到,此前一些工具類軟件是彈窗廣告的重災區,這些軟件打著“免費”的旗號誘導用戶下載,但大多用彈窗廣告進行營銷。特別是,這些廣告很難關閉,有的十分隱蔽,顏色與頁面一致;有的“關閉按鈕”形同虛設,點擊后反而會打開廣告頁面;還有的則干脆沒有關閉選項。

有律師表示,“網絡彈窗侵犯了消費者的自主選擇權和知情權、信息安全權。”互聯網彈窗廣告作為一種特殊形式的強制性網絡廣告強制彈出頁面,迫使用戶瀏覽或點擊、關閉彈出式廣告,實質上是侵害網絡消費者自主選擇商品或者服務的表現,侵犯了網絡消費者的自主選擇權。

此前,江蘇省消保委對于30款常用軟件進行體驗,迅雷、小鳥壁紙、暴風影音、騰訊視頻等11款軟件存在網絡彈窗問題,占調查總量的37%。其中360安全瀏覽器、暴風影音及下載時默認勾選的軟件、酷我音樂彈窗彈出頻率高,360安全瀏覽器15分鐘內竟彈出廣告9次。從不同渠道下載的應用軟件中,暴風影音、360安全瀏覽器、360安全衛士、小鳥壁紙存在網絡彈窗內容違法現象。比如,暴風影音安裝時默認勾選軟件攜帶的彈窗內容,存在傳播淫穢色情及低俗信息等問題。從不同渠道下載的應用軟件中,2345看圖王、騰訊視頻、愛奇藝、魯大師彈窗無法“一鍵關閉”,沒有關閉功能按鈕。2345看圖王、迅雷、小鳥壁紙等10款軟件存在無法設置彈出周期或永久不彈出的問題。此外,體驗人員還發現7款應用軟件存在嵌入式廣告。

低成本:互聯網彈窗廣告投放最低1分錢

據北青報記者調查了解,“互聯網彈窗廣告”已經成了一條產業鏈。有不少公司專門做這類生意,從彈窗的設計,到彈窗投放,從電腦端到手機端,都有專門的人參與。

有賣家告訴北青報記者,彈窗投放價格最低至1分錢一條,且能夠根據大數據精準細分到不同人群。“互聯網精準PC彈窗尺寸300×250;互聯網精準WAP彈窗尺寸640×180;10000元(發送100萬條彈窗),約合0.01元/條,投放時間2天,50萬條/天。”該賣家發來的報價顯示,這種“強制彈窗”只要用戶的電腦開機就能收到彈窗廣告,不受網絡環境限制,點擊彈窗就能進入官網,網址和圖片都可以進行轉化。

這些廣告還打著大數據的旗號精準投放,有賣家表示:“人群可以自定義,可以根據地域、性別等內容投放,運營商提供的數據自帶人群搜索習慣,經常搜索哪些網頁,我們會定位為此類客戶進行廣告投放,精準度高、轉化率高。”該賣家稱,一般來說點擊率不低于彈窗數的50%,后臺還有系統可以實時監測數據。

北青報記者經過詢問了解到,目前電腦彈窗廣告大量集中在web游戲行業,手機彈窗廣告則集中在小程序游戲等行業,此外還有一些涉及賭博和色情的小廣告也會在深夜出現。

新體驗:新規實施后部分彈窗有所改觀

9月30日,《互聯網彈窗信息推送服務管理規定》開始施行。其中規定,彈窗推送廣告信息的,應當具有可識別性,顯著標明“廣告”和關閉標志,確保彈窗廣告一鍵關閉;不得以彈窗信息推送方式呈現惡意引流跳轉的第三方鏈接、二維碼等信息,不得通過彈窗信息推送服務誘導用戶點擊,實施流量造假、流量劫持。

新規同時規定,不得設置誘導用戶沉迷、過度消費等違反法律法規或者違背倫理道德的算法模型;不得利用算法實施惡意屏蔽信息、過度推薦等行為;不得利用算法針對未成年人用戶進行畫像,向其推送可能影響其身心健康的信息;保障用戶權益,以服務協議等明確告知用戶彈窗信息推送服務的具體形式、內容頻次、取消渠道等,充分考慮用戶體驗,科學規劃推送頻次,不得對普通用戶和會員用戶進行不合理的差別推送,不得以任何形式干擾或者影響用戶關閉彈窗,彈窗信息應當顯著標明彈窗信息推送服務提供者身份。

“國慶節后,我發現打開電腦后,這個系統工具軟件的彈窗還在,但是可以關閉了。”用戶吳女士表示,自己發現該彈窗廣告界面新增了“不再提醒”的按鈕,且點擊后廣告消除了,也沒有再來騷擾自己。對此,吳女士表示已經很滿意了。

浙江大學國際聯合商學院數字經濟與金融創新研究中心聯席主任、研究員盤和林認為,互聯網彈窗包括信息彈窗和廣告彈窗,針對廣告彈窗,新規要求注明發布者信息,要求有廣告字樣的標注,要求增加投訴渠道。這些都是有利于改善用戶體驗的做法,這些做法對于彈窗廣告都是可以實現的,但也客觀上增加了彈窗廣告的合規成本,所以彈窗廣告未來在互聯網上的份額會有所下降,互聯網廣告將更多和具體的互聯網服務綁定,通過嵌入廣告的方式來進行。當然,彈窗廣告不會消失,規范之后,用戶體驗改善,反而對彈窗廣告行業長期健康發展是有幫助的,用戶獲得知情權和同意權,有了申訴渠道,新規的確保護了用戶的權益。(記者 溫婧)

來源:北京青年報

WiFi老是掉線斷網?用上無線AP面板,信號直接飆到滿格

隨著消費升級,個性化需求增強,智能家居已成為我們提升,生活幸福指數的剛需。

家庭WiFi網絡也不再是,一兩臺手機電腦的連接,還有各種智能家居設備。

家庭網絡新趨勢

New trend of home network

據IDC數據統計,2021全年智能家居設備出貨量預計2.3億臺,同比增長14.6%,預計未來五年中國智能家居設備市場出貨量將以21.4%的復合增長率持續增長,2025年市場出貨量將接近5.4億臺。

一般家庭需要連接WiFi網絡的智能家居設備將近20臺,包括24小時連接的家用攝像頭、智能門鎖、掃地機器人等設備。

這意味著家庭網絡需要承載更多的終端設備接入,需要實現更全面的無線覆蓋、更快的聯網速度和更穩定的無線體驗。

這無疑給家庭網絡提出了嚴峻考驗,尤其是大戶型房子,會面臨各種網絡問題,如樓層越高信號越差、多人在線網絡不穩定、WiFi信號有死角、網絡延遲逼到摔手機...

全屋WiFi無縫漫游

Seamless WiFi roaming in the whole house

如今裝修新房,我們不能像以前那樣用一個路由器就解決家里的所有網絡問題。

畢竟,在可預見的未來,智能家居設備數量會越來越多,家庭網絡的發展會越來越快,如果你不想房子剛裝修沒幾年,WiFi信號就變得特別差,那最好不要用傳統用網的思維方式來解決家庭網絡問題。

所以,家庭WiFi全覆蓋解決方案顯得尤為重要。

近幾年,無線AP全屋覆蓋解決方案漸漸成為家庭組網方案中的大趨勢,無線AP面板作為有線局域網與無線局域網的橋梁,也正是家庭WiFi全覆蓋解決方案中至關重要的一環。

無線AP面板

Wireless ap panel

相比于傳統路由器,無線AP面板能為家庭提供更好的網絡環境和上網體驗。

能夠一次性解決信號盲區、網絡卡頓、網絡掉線等問題,走到家里任何一個地方WiFi都是滿格信號。

加上無線AP面板有著發射功率低、電磁輻射低等優勢,用戶使用起來會更有安全感。

戶型與環境也不再是WiFi信號受限制的理由,不管是一室一廳、兩室一廳,還是150-180平米的大戶型,還是200平米以上的大平層和躍層別墅,都只需要選擇相應的無線AP面板數量,就能暢享極速網絡。

一室一廳

One bedroom

一室一廳

適合裝2個無線AP面板:客廳1個,臥室1個

兩室一廳

Two rooms and one halls

兩室一廳

適合裝3個無線AP面板:客廳1個,主臥1個,次臥1個

三室兩廳

Three rooms and two halls

三室兩廳

適合裝4個無線AP面板:客廳1個,主臥1個,次臥1個,次臥1個

四室兩廳

Four bedrooms and two halls

四室兩廳

適合裝5個AP面板:客廳1個,主臥1個,次臥1個,次臥1個,書房1個

如果你家新房正在裝修,或是已裝修完想網絡升級,無線AP全屋覆蓋方案會是最好的選擇!陽臺看直播,廚房刷菜譜,飄窗看電影,床上看球賽都輕輕松松的~

從VPN切換到指紋瀏覽器對賬號有影響嗎?

最近有網友私信咨詢:已經用VPN運營過一段時間的店鋪切換到指紋瀏覽器,會對店鋪有影響嗎?

其實這個問題的答案很簡單,使用同一個地區的IP,一般不會對賬號造成影響!

因為指紋瀏覽器主要是用來解決VPN不能解決的問題,防關聯技術相比VPN已經進化了不少。

圖片來源于飛跨瀏覽器

VPN(Virtual Private Network )是一種虛擬專用網絡,這是一種在用戶網絡上創建一個或多個網絡的技術。VPN網關通過對數據包的加密和數據包目標地址的轉換實現遠程訪問。VPN可通過服務器、硬件、軟件等多種方式實現,簡單來說,就是利用公用網絡架設專用網絡。

作為最早運用虛擬網絡來做賬號關聯的工具,VPN這項技術還是有一些優點在身上的。

VPN是使用公共網絡,它可以訪問的網是不受限制的,因為用戶可以通過選擇來自哪個國家的IP進行連接。總的來說,VPN 就是隱藏了用戶的真實IP、地理位置、個人數據,網站也無法檢測到用戶是否使用了VPN。

但VPN并不是完美的,運用在跨境電商防關聯上,VPN可能存在一些弊端。

圖片來源于網絡

VPN常見的問題就是連接被拒絕,而且拒絕有多種原因,有時候路由和遠程訪問服務運行不正常,或者TCP/IP連接不存在;有時候也可能是因為域名系統(DNS)的問題,導致用戶無法將服務器名稱解析為IP地址。

而且在數據溯源方面,VPN無法同步第三方的數據和操作,比如用戶、訪問的應用程序和原因,如果出現違規或與供應商相關的問題,VPN無法幫助用戶確定問題的根本原因。

另外,企業在創建和部署VPN線路的時候比較繁瑣,后期維護也比較容易出錯。不同廠商的VPN產品和解決方案大部分是不兼容的,混合使用不同廠商的產品還可能會出現技術問題

另一方面,VPN有安全風險。當用戶在接入點之間漫游的時候,使用高級加密技術的解決方案很可能被攻破,數據不夠安全,這應該是跨境賣家的心頭大患。

正是因為上述這些問題的困擾,很多使用VPN技術防關聯的跨境賣家也都著手在尋找新的防關聯工具。據了解,指紋瀏覽器就是能夠解決一些VPN無法解決的問題。

指紋瀏覽器也稱為超級瀏覽器或者跨境瀏覽器,它是一種特殊的瀏覽器,像飛跨瀏覽器,通過有效隔離,交叉生成獨一無二的指紋環境,配置上純凈的IP,同一臺電腦上的不同賬號就相當于多臺獨立的電腦。

VPN遠程登錄連接被拒絕的問題就不存在了,像飛跨瀏覽器使用的是獨享IP,優質專線網絡加速,幾乎不存在卡頓或者掉線的問題

數據方面,指紋瀏覽器有cookies同步功能,它可以生成多組不同的數據來幫助賣家登錄不同的賬號。cookie是互聯網的重要組成部分。沒有它們,網頁的實用性和交互性就會大大降低。它們賦予網站記憶和改進的能力,但同時也給需要多賬號登錄的用戶造成一定的困擾,現在有了指紋瀏覽器,就能幫助跨境賣家解決賬號多開的問題。

指紋瀏覽器操作便捷也是一個很大優勢,相比vpn繁瑣的部署,指紋瀏覽器只需要登錄平臺賬號,綁定一次店鋪信息,永久免登錄,這樣既節省了工作人員登錄操作的時間,而且賬號密碼不會外流,對店鋪數據安全是一個很大的保障。

圖片來源于飛跨瀏覽器

隨著辦公形式的多樣化,團隊協作、異地辦公也是很多用戶的需求,指紋瀏覽器剛好就出現在這個時間節點,而且隨著它功能越來越完善,選擇用指紋瀏覽器替代VPN的跨境賣家也越來越多了。

Netty進階篇:漫談網絡粘包 半包問題 解碼器與長連接 心跳機制實戰

引言

在前面關于《Netty入門篇》的文章中,咱們已經初步對Netty這個著名的網絡框架有了認知,本章的目的則是承接上文,再對Netty中的一些進階知識進行闡述,畢竟前面的內容中,僅闡述了一些Netty的核心組件,想要真正掌握Netty框架,對于它我們應該具備更為全面的認知。

一、Netty中的粘包半包問題

實際上粘包、半包問題,并不僅僅只在Netty中存在,但凡基于TCP協議構建的網絡組件,基本都需要面臨這兩個問題,對于粘包問題,在之前關于《計算機網絡與協議簇-TCP沾包》中也曾講到過:


但當時我寫成了沾包,但實際上專業的術語解釋為:粘包,這里我糾正一下,接著再簡單說清楚粘包和半包的問題:

粘包:這種現象就如同其名,指通信雙方中的一端發送了多個數據包,但在另一端則被讀取成了一個數據包,比如客戶端發送123、ABC兩個數據包,但服務端卻收成的卻是123ABC這一個數據包。造成這個問題的本質原因,在前面TCP的章節中講過,這主要是因為TPC為了優化傳輸效率,將多個小包合并成一個大包發送,同時多個小包之間沒有界限分割造成的。

半包:指通信雙方中的一端發送一個大的數據包,但在另一端被讀取成了多個數據包,例如客戶端向服務端發送了一個數據包:ABCDEFGXYZ,而服務端則讀取成了ABCEFG、XYZ兩個包,這兩個包實際上都是一個數據包中的一部分,這個現象則被稱之為半包問題(產生這種現象的原因在于:接收方的數據接收緩沖區過小導致的)。

上述提到的這兩種網絡通信的問題具體該如何解決,這點咱們放到后面再細說,先來看看Netty中的沾包和半包問題。

1.1、Netty的粘包、半包問題演示

這里也就不多說廢話了,結合《Netty入門篇》的知識,快速搭建出一個服務端、客戶端的通信案例,如下:

// 演示數據粘包問題的服務端public class AdhesivePackageServer { public static void main(String[] args) { EventLoopGroup group = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap(); server.group(group); server.channel(NioServersocketChannel.class); server.childHandler(new ServerInitializer()); server.bind("127.0.0.1",8888); }}// 演示粘包、半包問題的通用初始化器public class ServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); socketChannel.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 數據就緒事件:當收到客戶端數據時會讀取通道內的數據 @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { // 在這里直接輸出通道內的數據信息 System.out.println(ctx.channel()); super.channelReadComplete(ctx); } }); }}// 演示數據粘包問題的客戶端public class AdhesivePackageClient { public static void main(String[] args) { EventLoopGroup worker = new NioEventLoopGroup(); Bootstrap client = new Bootstrap(); try { client.group(worker); client.channel(NioSocketChannel.class); client.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 在通道準備就緒后會觸發的事件 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 向服務端發送十次數據,每次發送一個字節! for (int i = 0; i < 10; i ) { System.out.println("正在向服務端發送第" i "次數據......"); ByteBuf buffer = ctx.alloc().buffer(1); buffer.writeBytes(new byte[]{(byte) i}); ctx.writeAndFlush(buffer); } } }); } }); client.connect("127.0.0.1", 8888).sync(); } catch (Exception e){ e.printStackTrace(); } finally { worker.shutdownGracefully(); } }}復制代碼

這個案例中的代碼也并不難理解,客戶端的代碼中,會向服務端發送十次數據,而服務端僅僅只做了數據讀取的動作而已,接著來看看運行結果:


從運行結果中可明顯觀測到,客戶端發送的十個1Bytes的數據包,在服務端直接被合并成了一個10Bytes的數據包,這顯然就是粘包的現象,接著再來看看半包的問題,代碼如下:

// 演示半包問題的服務端public class HalfPackageServer { public static void main(String[] args) { EventLoopGroup group = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap(); server.group(group); server.channel(NioServerSocketChannel.class); // 調整服務端的接收窗口大小為四字節 server.option(ChannelOption.SO_RCVBUF,4); server.childHandler(new ServerInitializer()); server.bind("127.0.0.1",8888); }}// 演示半包問題的客戶端public class HalfPackageClient { public static void main(String[] args) { EventLoopGroup worker = new NioEventLoopGroup(); Bootstrap client = new Bootstrap(); try { client.group(worker); client.channel(NioSocketChannel.class); client.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 在通道準備就緒后會觸發的事件 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 向服務端發送十次數據,每次發送十個字節! for (int i = 0; i < 10; i ) { ByteBuf buffer = ctx.alloc().buffer(); buffer.writeBytes(new byte[] {'a','b','c','d','e','f','g','x','y','z'}); ctx.writeAndFlush(buffer); } } }); } }); client.connect("127.0.0.1", 8888).sync(); } catch (Exception e){ e.printStackTrace(); } finally { worker.shutdownGracefully(); } }}-復制代碼

上面的代碼中,客戶端向服務端發送了十次數據,每次數據會發送10個字節,而在服務端多加了下述這行代碼:

server.option(ChannelOption.SO_RCVBUF,4);復制代碼

這行代碼的作用是調整服務端的接收窗口大小為四字節,因為默認的接收窗口較大,客戶端需要一次性發送大量數據才能演示出半包現象,這里為了便于演示,因此將接收窗口調小,運行結果如下:


從上述運行結果中,也能夠明顯觀察到半包現象,客戶端發送的十個數據包,每個包中的數據都為10字節,但服務端中,接收到的數據顯然并不符合預期,尤其是第三個數據包,是一個不折不扣的半包現象。

1.2、粘包、半包問題的產生原因

前面簡單聊了一下粘包、半包問題,但這些問題究竟是什么原因導致的呢?對于這點前面并未深入探討,這里來做統一講解,想要弄明白粘包、半包問題的產生原因,這還得說回TCP協議,大家還記得之前說過的TCP-滑動窗口嘛?

1.2.1、TCP協議的滑動窗口

由于TCP是一種可靠性傳輸協議,所以在網絡通信過程中,會采用一問一答的形式,也就是一端發送數據后,必須得到另一端返回ACK響應后,才會繼續發送后續的數據。但這種一問一答的同步方式,顯然會十分影響數據的傳輸效率。

TCP協議為了解決傳輸效率的問題,引入了一種名為滑動窗口的技術,也就是在發送方和接收方上各有一個緩沖區,這個緩沖區被稱為“窗口”,假設發送方的窗口大小為100KB,那么發送端的前100KB數據,無需等待接收端返回ACK,可以一直發送,直到發滿100KB數據為止。

如果發送端在發送前100KB數據時,接收端返回了某個數據包的ACK,那此時發送端的窗口會一直向下滑動,比如最初窗口范圍是0~100KB,收到ACK后會滑動到20~120KB、120~220KB....(實際上窗口的大小、范圍,TCP會根據網絡擁塞程度、ACK響應時間等情況來自動調整)。

同時,除開發送方有窗口外,接收方也會有一個窗口,接收方只會讀取窗口范圍之內的數據,如果超出窗口范圍的數據并不會讀取,這也就意味著不會對窗口之外的數據包返回ACK,所以發送方在未收到ACK時,對應的窗口會停止向后滑動,并在一定時間后對未返回ACK的數據進行重發。

對于TCP的滑動窗口,發送方的窗口起到優化傳輸效率的作用,而接收端的窗口起到流量控制的作用。

1.2.2、傳輸層的MSS與鏈路層的MTU

理解了滑動窗口的概念后,接著來說說MSS、MTU這兩個概念,MSS是傳輸層的最大報文長度限制,而MTU則是鏈路層的最大數據包大小限制,一般MTU會限制MSS,比如MTU=1500,那么MSS最大只能為1500減去報文頭長度,以TCP協議為例,MSS最大為1500-40=1460。

為什么需要這個限制呢?這是由于網絡設備硬件導致的,比如任意類型的網卡,不可能讓一個數據包無限增長,因為網卡會有帶寬限制,比如一次性傳輸一個1GB的數據包,如果不限制大小直接發送,這會導致網絡出現堵塞,并且超出網絡硬件設備單次傳輸的最大限制。

所以當一個數據包,超出MSS大小時,TCP協議會自動切割這個數據包,將該數據包拆分成一個個的小包,然后分批次進行傳輸,從而實現大文件的傳輸。

1.2.3、TCP協議的Nagle算法

基于MSS最大報文限制,可以實現大文件的切割并分批發送,但在網絡通信中,還有另一種特殊情況,即是極小的數據包傳輸,因為TCP的報文頭默認會有40個字節,如果數據只有1字節,那加上報文頭依舊會產生一個41字節的數據包。

如果這種體積較小的數據包在傳輸中經常出現,這定然會導致網絡資源的浪費,畢竟數據包中只有1字節是數據,另外40個字節是報文頭,如果出現1W個這樣的數據包,也就意味著會產生400MB的報文頭,但實際數據只占10MB,這顯然是不妥當的。

正是由于上述原因,因此TCP協議中引入了一種名為Nagle的算法,如若連續幾次發送的數據都很小,TCP會根據算法把多個數據合并成一個包發出,從而優化網絡傳輸的效率,并且減少對資源的占用。

1.2.4、應用層的接收緩沖區和發送緩沖區

對于操作系統的IO函數而言,網絡數據不管是發送也好,還是接收也罷,并不會采用“復制”的方式工作,比如現在想要傳輸一個10MB的數據,不可能直接將這個數據一次性拷貝到緩沖區內,而是一個一個字節進行傳輸,舉個例子:

假設現在要發送ABCDEFGXYZ....這組數據,IO函數會挨個將每個字節放到發送緩沖區中,會呈現A、B、C、D、E、F....這個順序挨個寫入,而接收方依舊如此,讀取數據時也會一個個字節讀取,以A、B、C、D、E、F....這個順序讀取一個數據包中的數據(實際情況會復雜一些,可能會按一定單位操作數據,而并不是以單個字節作為單位)。

而應用程序為了發送/接收數據,通常都需要具備兩個緩沖區,即所說的接收緩沖區和發送緩沖區,一個用來暫存要發送的數據,另一個則用來暫存接收到的數據,同時這兩個緩沖區的大小,可自行調整其大小(Netty默認的接收/發送緩沖區大小為1024KB)。

1.2.5、粘包、半包問題的產生原因

理解了上述幾個概念后,接著再來看看粘包和半包就容易很多了,粘包和半包問題,可能會由多方面因素導致,如下:

粘包:發送12345、ABCDE兩個數據包,被接收成12345ABCDE一個數據包,多個包粘在一起。 應用層:接收方的接收緩沖區太大,導致讀取多個數據包一起輸出。 TCP滑動窗口:接收方窗口較大,導致發送方發出多個數據包,處理不及時造成粘包。 Nagle算法:由于發送方的數據包體積過小,導致多個數據包合并成一個包發送。

半包:發送12345ABCDE一個數據包,被接收成12345、ABCDE兩個數據包,一個包拆成多個。 應用層:接收方緩沖區太小,無法存方發送方的單個數據包,因此拆開讀取。 滑動窗口:接收方的窗口太小,無法一次性放下完整數據包,只能讀取其中一部分。 MSS限制:發送方的數據包超過MSS限制,被拆分為多個數據包發送。

上述即是出現粘包、半包問題的根本原因,更多的是由于TCP協議造成的,所以想要解決這兩個問題,就得自己重寫底層的TCP協議,這對于咱們而言并不現實,畢竟TCP/IP協議棧,基本涵蓋各式各樣的網絡設備,想要從根源上解決粘包、半包問題,重寫協議后還得替換掉所有網絡設備內部的TCP實現,目前世界上沒有任何一個組織、企業、個人具備這樣的影響力。

1.3、粘包、半包問題的解決方案

既然無法在底層從根源上解決問題,那此時可以換個思路,也就是從應用層出發,粘包、半包問題都是由于數據包與包之間,沒有邊界分割導致的,那想要解決這樣的問題,發送方可以在每個數據包的尾部,自己拼接一個特殊分隔符,接收方讀取到數據時,再根據對應的分隔符讀取數據即可。

對于其他的一些網絡編程的技術棧,咱們不做過多延伸,重點來聊一聊Netty中的粘包、半包問題該如何解決呢?其實這也并不需要自己動手解決,因為Netty內部早已內置了相關實現,畢竟我們能想到的問題,框架的設計者也早已料到,接著一起來看看Netty的解決方案吧。

1.3.1、使用短連接解決粘包問題

對于短連接大家應該都不陌生,HTTP/1.0版本中,默認使用的就是TCP短連接,這是指客戶端在發送一次數據后,就會立馬斷開與服務端的網絡連接,在客戶端斷開連接后,服務端會收到一個-1的狀態碼,而咱們可以用這個作為消息(數據)的邊界,以此區分不同的數據包,如下:

// 演示通過短連接解決粘包問題的服務端public class AdhesivePackageServer { public static void main(String[] args) { EventLoopGroup group = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap(); server.group(group); server.channel(NioServerSocketChannel.class); server.childHandler(new ServerInitializer()); server.bind("127.0.0.1",8888); }}// 演示通過短連接解決粘包問題的客戶端public class Client { public static void main(String[] args) { for (int i = 0; i < 3; i ) { sendData(); } } private static void sendData(){ EventLoopGroup worker = new NioEventLoopGroup(); Bootstrap client = new Bootstrap(); try { client.group(worker); client.channel(NioSocketChannel.class); client.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 在通道準備就緒后會觸發的事件 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 向服務端發送一個20字節的數據包,然后斷開連接 ByteBuf buffer = ctx.alloc().buffer(1); buffer.writeBytes(new byte[] {'0','1','2','3','4', '5','6','7','8','9', 'A','B','C','D','E', 'M','N','X','Y','Z'}); ctx.writeAndFlush(buffer); ctx.channel().close(); } }); } }); client.connect("127.0.0.1", 8888).sync(); } catch (Exception e){ e.printStackTrace(); } finally { worker.shutdownGracefully(); } }}復制代碼

服務端的代碼,依舊用之前演示粘包問題的AdhesivePackageServer,上述只對客戶端的代碼進行了改造,主要是將創建客戶端連接、發送數據的代碼抽象成了一個方法,然后在循環內部調用該方法,運行結果如下:


從運行結果中可以看出,發送的3個數據包,都未出現粘包問題,每個數據包之間都是獨立分割的。但這種方式解決粘包問題,實際上屬于一種“投機取巧”的方案,畢竟每個數據包都采用新的連接發送,在操作系統級別來看,每個數據包都源自于不同的網絡套接字,自然會分開讀取。

但這種方式無法解決半包問題,例如這里咱們將服務端的接收緩沖區調小:

// 演示半包問題的服務端public class HalfPackageServer { public static void main(String[] args) { EventLoopGroup group = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap(); server.group(group); server.channel(NioServerSocketChannel.class); // 調整服務端的接收緩沖區大小為16字節(最小為16,無法設置更小) server.childOption(ChannelOption.RCVBUF_ALLOCATOR, new AdaptiveRecvByteBufAllocator(16,16,16)); server.childHandler(new ServerInitializer()); server.bind("127.0.0.1",8888); }}復制代碼

然后再啟動這個服務端,接著再啟動前面的客戶端,效果如下:


從結果中依舊會發現,多個數據包之間還是發生了半包問題,因為服務端的接收緩沖區一次性最大只能存下16Bytes數據,所以客戶端每次發送20Bytes數據,無法全部存入緩沖區,最終就出現了一個數據包被拆成多個包讀取。

正由于短連接這種方式,無法很好的解決半包問題,所以一般線上除開特殊場景外,否則不會使用短連接這種形式來單獨解決粘包問題,接著看看Netty中提供的一些解決方案。

1.3.2、定長幀解碼器

前面聊到的短連接方式,解決粘包問題的思路屬于投機取巧行為,同時也需要頻繁的建立/斷開連接,這無論是從資源利用率、還是程序執行的效率上來說,都并不妥當,而Netty中提供了一系列解決粘包、半包問題的實現類,即Netty的幀解碼器,先來看看定長幀解碼器,案例如下:

// 通過定長幀解碼器解決粘包、半包問題的演示類public class FixedLengthFrameDecoderDemo { public static void main(String[] args) { // 通過Netty提供的測試通道來代替服務端、客戶端 EmbeddedChannel channel = new EmbeddedChannel( // 添加一個定長幀解碼器(每條數據以8字節為單位拆包) new FixedLengthFrameDecoder(8), new LoggingHandler(LogLevel.DEBUG) ); // 調用三次發送數據的方法(等價于向服務端發送三次數據) sendData(channel,"ABCDEGF",8); sendData(channel,"XYZ",8); sendData(channel,"12345678",8); } private static void sendData(EmbeddedChannel channel, String data, int len){ // 獲取發送數據的字節長度 byte[] bytes = data.getBytes(); int dataLength = bytes.length; // 根據固定長度補齊要發送的數據 String alignString = ""; if (dataLength < len){ int alignLength = len - bytes.length; for (int i = 1; i <= alignLength; i ) { alignString = alignString "*"; } } // 拼接上補齊字符,得到最終要發送的消息數據 String msg = data alignString; byte[] msgBytes = msg.getBytes(); // 構建緩沖區,通過channel發送數據 ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8); buffer.writeBytes(msgBytes); channel.writeInbound(buffer); }}復制代碼

注意看上述這個案例,在其中就并未搭建服務端、客戶端了,而是采用EmbeddedChannel對象來測試,這個通道是Netty提供的測試通道,可以基于它來快速搭建測試用例,上述中的:

new EmbeddedChannel( new FixedLengthFrameDecoder(8), new LoggingHandler(LogLevel.DEBUG));復制代碼

這段代碼,就類似于之前在服務端的pipeline添加處理器的過程,等價于下述這段代碼:

socketChannel.pipeline().addLast(new FixedLengthFrameDecoder(8));socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));復制代碼

理解了EmbeddedChannel后,接著先來看看運行結果,如下:


注意看上述結果,在該案例中,服務端會以8Bytes為單位,然后對數據進行分包處理,平均每讀取8Bytes數據,就會將其當作一個數據包。如果客戶端發送的一條數據,長度沒有8個字節,在sendData()方法中則會以*號補齊。比如上圖中,發送了一條XYZ數據,因為長度只有3字節,所以會再拼接五個*號補齊八字節的長度。

這種采用固定長度解析數據的方式,的確能夠有效避免粘包、半包問題的出現,因為每個數據包之間,會以八個字節的長度作為界限,然后分割數據。但這種方式也存在三個致命缺陷:

①只適用于傳輸固定長度范圍內的數據場景,而且客戶端在發送數據前,還需自己根據長度補齊數據。

②如果發送的數據超出固定長度,服務端依舊會按固定長度分包,所以仍然會存在半包問題。

③對于未達到固定長度的數據,還需要額外傳輸補齊的*號字符,會占用不必要的網絡資源。

1.3.3、行幀解碼器

上面說到的定長幀解碼器,由于使用時存在些許限制,使用它來解析數據就并不那么靈活,尤其是針對于一些數據長度可變的場景,顯得就有些許乏力,因此Netty中還提供了行幀解碼器,案例如下:

// 通過行幀解碼器解決粘包、半包問題的演示類public class LineFrameDecoderDemo { public static void main(String[] args) { // 通過Netty提供的測試通道來代替服務端、客戶端 EmbeddedChannel channel = new EmbeddedChannel( // 添加一個行幀解碼器(在超出1024后還未檢測到換行符,就會停止讀取) new LineBasedFrameDecoder(1024), new LoggingHandler(LogLevel.DEBUG) ); // 調用三次發送數據的方法(等價于向服務端發送三次數據) sendData(channel,"ABCDEGF"); sendData(channel,"XYZ"); sendData(channel,"12345678"); } private static void sendData(EmbeddedChannel channel, String data){ // 在要發送的數據結尾,拼接上一個n換行符(rn也可以) String msg = data "n"; // 獲取發送數據的字節長度 byte[] msgBytes = msg.getBytes(); // 構建緩沖區,通過channel發送數據 ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8); buffer.writeBytes(msgBytes); channel.writeInbound(buffer); }}復制代碼

在上述案例中,咱們給服務端添加了一個LineBasedFrameDecoder(1024)行解碼器,其中有個1024的數字,這是啥意思呢?這個是數據的最大長度限制,畢竟在網絡接收過程中,如果一直沒有讀取到換行符,總不能一直接收下去,所以當數據的長度超出該值后,Netty會默認將前面讀到的數據分成一個數據包。

同時在發送數據的sendData()方法中,這回就無需咱們自己補齊數據了,只需在每個要發送的數據末尾,手動拼接上一個n或rn換行符即可,服務端在讀取數據時,會按換行符來作為界限分割,運行結果如下:


從結果中能夠看出,每個數據包都是按客戶端發送的格式做了解析,并未出現粘包、半包現象。

1.3.4、分隔符幀解碼器

上面聊了以換行符作為分隔符的解碼器,但Netty中還提供了自定義分隔符的解碼器,使用這種解碼器,能讓諸位隨心所欲的定義自己的分隔符,案例如下:

public class DelimiterFrameDecoderDemo { public static void main(String[] args) { // 自定義一個分隔符(記得要用ByteBuf對象來包裝) ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1); delimiter.writeByte('*'); // 通過Netty提供的測試通道來代替服務端、客戶端 EmbeddedChannel channel = new EmbeddedChannel( // 添加一個分隔符幀解碼器(傳入自定義的分隔符) new DelimiterBasedFrameDecoder(1024,delimiter), new LoggingHandler(LogLevel.DEBUG) ); // 調用三次發送數據的方法(等價于向服務端發送三次數據) sendData(channel,"ABCDEGF"); sendData(channel,"XYZ"); sendData(channel,"12345678"); } private static void sendData(EmbeddedChannel channel, String data){ // 在要發送的數據結尾,拼接上一個*號(因為前面自定義的分隔符為*號) String msg = data "*"; // 獲取發送數據的字節長度 byte[] msgBytes = msg.getBytes(); // 構建緩沖區,通過channel發送數據 ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8); buffer.writeBytes(msgBytes); channel.writeInbound(buffer); }}復制代碼

這個案例的運行結果與上一個完全相同,不同點則在于換了一個解碼器,換成了:

new DelimiterBasedFrameDecoder(1024,delimiter)復制代碼

而后發送數據的時候,對每個數據的結尾,手動拼接一個*號作為分隔符即可。

相較于原本的定長解碼器,行解碼器、自定義分隔符解碼器顯然更加靈活,因為支持可變長度的數據,但這兩種解碼器,依舊存在些許缺點:

①對于每一個讀取到的字節都需要判斷一下:是否為結尾的分隔符,這會影響整體性能。

②依舊存在最大長度限制,當數據超出最大長度后,會自動將其分包,在數據傳輸量較大的情況下,依舊會導致半包現象出現。

1.3.5、LTC幀解碼器

前面聊過的多個解碼器中,無論是哪個,都多多少少會存在些許不完美,因此Netty最終提供了一款LTC解碼器,這個解碼器也屬于實際Netty開發中,應用最為廣泛的一種,但理解起來略微有些復雜,先來看看它的構造方法:

public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder { public LengthFieldBasedFrameDecoder( int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment, int initialBytesToStrip) { this(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip, true); } // 暫時省略其他參數的構造方法......}復制代碼

從上述構造器中可明顯看出,LTC中存在五個參數,看起來都比較長,接著簡單解釋一下:

maxFrameLength:數據最大長度,允許單個數據包的最大長度,超出長度后會自動分包。

lengthFieldOffset:長度字段偏移量,表示描述數據長度的信息從第幾個字段開始。

lengthFieldLength:長度字段的占位大小,表示數據中的使用了幾個字節描述正文長度。

lengthAdjustment:長度調整數,表示在長度字段的N個字節后才是正文數據的開始。

initialBytesToStrip:頭部剝離字節數,表示先將數據去掉N個字節后,再開始讀取數據。

上述這種方式描述五個參數,大家估計理解起來有些困難,那么下面結合Netty源碼中的注釋,先把這幾個參數徹底搞明白再說,先來看個案例:


比如上述這組數據,對應的參數如下:

lengthFieldOffset = 0lengthFieldLength = 4lengthAdjustment = 0initialBytesToStrip = 0復制代碼

這組參數表示啥意思呢?表示目前這條數據,長度字段從第0個字節開始,使用4個字節來描述數據長度,這時服務端會讀取數據的前4個字節,得到正文數據的長度,從而得知:在第四個字節之后,再往后讀十個字節,是一條完整的數據,最終向后讀取10個字節,最終就會讀到Hi, ZhuZi.這條數據。

但上述這種方式對數據解碼之后,讀取時依舊會顯示長度字段,也就是前四個用來描述長度的字節也會被讀到,因此最終會顯示出10Hi, ZhuZi.這樣的格式,那如果想要去掉前面的長度字段怎么辦呢?這需要用到initialBytesToStrip參數,如下:

lengthFieldOffset = 0lengthFieldLength = 4lengthAdjustment = 0initialBytesToStrip = 4復制代碼


這組參數又是啥意思呢?其實和前面那一組數據沒太大的變化,只是用initialBytesToStrip聲明要剝離掉前4個字節,所以數據經過解碼后,最終會去掉前面描述長度的四個字節,僅顯示Hi, ZhuZi.這十個字節的數據。

上述這種形式,其實就是預設了一個長度字段,服務端、客戶端之間約定使用N個字節來描述數據長度,接著在讀取數據時,讀取指定個字節,得到本次數據的長度,最終能夠正常解碼數據。但這種方式只能滿足最基本的數據傳輸,如果在數據中還需要添加一些正文信息,比如附加數據頭信息、版本號的情況,又該如何處理呢?如下:

lengthFieldOffset = 8lengthFieldLength = 4lengthAdjustment = 0initialBytesToStrip = 0復制代碼


上述這個示例中,假設附加信息占8Bytes,這里就需要用到lengthFieldOffset參數,以此來表示長度字段偏移量是8,這意味著讀取數據時,要從第九個字節開始,往后讀四個字節的數據,才能夠得到描述數據長度的字段,然后解析得到10,最終再往后讀取十個字節的數據,讀到一條完整的數據。

當然,如果只想要讀到正文數據怎么辦?如下:

lengthFieldOffset = 8lengthFieldLength = 4lengthAdjustment = 0initialBytesToStrip = 12復制代碼


依舊只需要通過initialBytesToStrip參數,從頭部剝離掉前12個字節即可,這里的12個字節,由八字節的附加信息、四字節的長度描述組成,去掉這兩部分,自然就得到了正文數據。

OK,再來看另一種情況,假如長度字段在最前面,附加信息在中間,但我只想要讀取正文數據怎么辦呢?

lengthFieldOffset = 0lengthFieldLength = 4lengthAdjustment = 8initialBytesToStrip = 12復制代碼


在這里咱們又用到了lengthAdjustment這個參數,這個參數是長度調整數的意思,上面的示例中賦值為8,即表示從長度字段后開始,跳過8個字節后,才是正文數據的開始。接收方在解碼數據時,首先會從0開始讀取四個字節,得到正文數據的長度為10,接著會根據lengthAdjustment參數,跳過中間8個的字節,最后再往后讀10個字節數據,從而得到最終的正文數據。

OK~,經過上述幾個示例的講解后,相信大家對給出的幾個參數都有所了解,如若覺得有些暈乎,可回頭再多仔細閱讀幾遍,這樣有助于加深對各個參數的印象。但本質上來說,LTC解碼器,就是基于這些參數,來確定一條數據的長度、位置,從而讀取到精確的數據,避免粘包、半包的現象產生,接下來上個Demo理解:

// 通過LTC幀解碼器解決粘包、半包問題的演示類public class LTCDecoderDemo {public static void main(String[] args) { // 通過Netty提供的測試通道來代替服務端、客戶端 EmbeddedChannel channel = new EmbeddedChannel( // 添加一個行幀解碼器(在超出1024后還未檢測到換行符,就會停止讀取) new LengthFieldBasedFrameDecoder(1024,0,4,0,0), new LoggingHandler(LogLevel.DEBUG) ); // 調用三次發送數據的方法(等價于向服務端發送三次數據) sendData(channel,"Hi, ZhuZi.");} private static void sendData(EmbeddedChannel channel, String data){ // 獲取要發送的數據字節以及長度 byte[] dataBytes = data.getBytes(); int dataLength = dataBytes.length; // 先將數據長度寫入到緩沖區、再將正文數據寫入到緩沖區 ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(); buffer.writeInt(dataLength); buffer.writeBytes(dataBytes); // 發送最終組裝好的數據 channel.writeInbound(buffer); }}復制代碼

上述案例中創建了一個LTC解碼器,對應的參數值為1024,0,4,0,0,這分別對應前面的五個參數,如下:

maxFrameLength = 1024lengthFieldOffset = 0lengthFieldLength = 4lengthAdjustment = 0initialBytesToStrip = 0復制代碼

這組值意思為:數據的第0~4個字節是長度字段,用來描述正文數據的長度,運行結果如下:


效果十分明顯,既沒有產生粘包、半包問題,而且無需逐個字節判斷是否為分割符,這對比之前的幾種解碼器而言,這種方式的效率顯然好上特別特別多。當然,上述結果中,如果想要去掉前面的四個.,就只需要將initialBytesToStrip = 4即可,從頭部剝離掉四個字節再讀取。

1.3.6、粘包、半包解決方案小結

前面介紹了短連接、定長解碼器、行解碼器、分隔符解碼器以及LTC解碼器這五種方案,其中咱們需要牢記的是最后一種,因為其他的方案多少存在一些性能問題,而通過LTC解碼器這種方式處理粘包、半包問題的效率最好,因為無需逐個字節判斷消息邊界。

但實際Netty開發中,如果其他解碼器更符合業務需求,也不必死死追求使用LTC解碼器,畢竟技術為業務提供服務,適合自己業務的,才是最好的!

二、Netty的長連接與心跳機制

對于長連接、短連接,這個概念在前面稍有提及,所謂的短連接就是每次讀寫數據完成后,立馬斷開客戶端與服務端的網絡連接。而長連接則是相反的意思,一次數據交互完成后,服務端和客戶端之間繼續保持連接,當后續需再次收/發數據時,可直接復用原有的網絡連接。

長連接這種模式,在并發較高的情況下能夠帶來額外的性能收益,因為Netty服務端、客戶端綁定IP端口,搭建Channel通道的過程,放到底層實際上就是TCP三次握手的過程,同理,客戶端、服務端斷開連接的過程,即對應著TCP的四次揮手。

大家都知道,TCP三次握手/四次揮手,這個過程無疑是比較“重量級”的,并發情況下,頻繁創建、銷毀網絡連接,其資源開銷、性能開銷會比較大,所以使用長連接的方案,能夠有效減少創建和銷毀網絡連接的動作。

那如何讓Netty開啟長連接支持呢?這需要涉及到之前用過的ChannelOption這個類,接著來詳細講講它。

2.1、Netty調整網絡參數(ChannelOption)

ChannelOption是Netty提供的參數調整類,該類中提供了很多常量,分別對應著底層TCP、UDP、計算機網絡的一些參數,在創建服務端、客戶端時,我們可以通過ChannelOption類來調整網絡參數,以此滿足不同的業務需求,該類中提供的常量列表如下:

ALLOCATOR:ByteBuf緩沖區的分配器,默認值為ByteBufAllocator.DEFAULT。

RCVBUF_ALLOCATOR:通道接收數據的ByteBuf分配器,默認為AdaptiveRecvByteBufAllocator.DEFAULT。

MESSAGE_SIZE_ESTIMATOR:消息大小估算器,默認為DefaultMessageSizeEstimator.DEFAULT。

CONNECT_TIMEOUT_MILLIS:設置客戶端的連接超時時間,默認為3000ms,超出會斷開連接。

MAX_MESSAGES_PER_READ:一次Loop最大讀取的消息數。 ServerChannel/NioChannel默認16,其他類型的Channel默認為1。

WRITE_SPIN_COUNT:一次Loop最大寫入的消息數,默認為16。 一個數據16次還未寫完,需要提交一個新的任務給EventLoop,防止數據量較大的場景阻塞系統。

WRITE_BUFFER_HIGH_WATER_MARK:寫高水位標記,默認為64K,超出時Channel.isWritable()返回Flase。

WRITE_BUFFER_LOW_WATER_MARK:寫低水位標記,默認為32K,超出高水位又下降到低水位時,isWritable()返回True。

WRITE_BUFFER_WATER_MARK:寫水位標記,如果寫的數據量也超出該值,依舊返回Flase。

ALLOW_HALF_CLOSURE:一個遠程連接關閉時,是否半關本地連接,默認為Flase。 Flase表示自動關閉本地連接,為True會觸發入站處理器的userEventTriggered()方法。

AUTO_READ:自動讀取機制,默認為True,通道上有數據時,自動調用channel.read()讀取數據。

AUTO_CLOSE:自動關閉機制,默認為Flase,發生錯誤時不會斷開與某個通道的連接。

SO_BROADCAST:設置廣播機制,默認為Flase,為True時會開啟Socket的廣播消息。

SO_KEEPALIVE:開啟長連接機制,一次數據交互完后不會立馬斷開連接。

SO_SNDBUF:發送緩沖區,用于保存要發送的數據,未收到接收數據的ACK之前,數據會存在這里。

SO_RCVBUF:接受緩沖區,用戶保存要接受的數據。

SO_REUSEADDR:是否復用IP地址與端口號,開啟后可重復綁定同一個地址。

SO_LINGER:設置延遲關閉,默認為-1。 -1:表示禁用該功能,當調用close()方法后會立即返回,底層會先處理完數據。 0:表示禁用該功能,調用后立即返回,底層會直接放棄正在處理的數據。 大于0的正整數:關閉時等待n秒,或數據處理完成才正式關閉。

SO_BACKLOG:指定服務端的連接隊列長度,當連接數達到該值時,會拒絕新的連接請求。

SO_TIMEOUT:設置接受數據時等待的超時時間,默認為0,表示無限等待。

IP_TOS:

IP_MULTICAST_ADDR:設置IP頭的Type-of-Service字段,描述IP包的優先級和QoS選項。

IP_MULTICAST_IF:對應IP參數IP_MULTICAST_IF,設置對應地址的網卡為多播模式。

IP_MULTICAST_TTL:對應IP參數IP_MULTICAST_IF2,同上但支持IPv6。

IP_MULTICAST_LOOP_DISABLED:對應IP參數IP_MULTICAST_LOOP,設置本地回環地址的多播模式。

TCP_NODELAY:開啟TCP的Nagle算法,會將多個小包合并成一個大包發送。

DATAGRAM_CHANNEL_ACTIVE_ON_REGISTRATION:DatagramChannel注冊的EventLoop即表示已激活。

SINGLE_EVENTEXECUTOR_PER_GROUP:Pipeline是否由單線程執行,默認為True,所有處理器由一條線程執行,無需經過線程上下文切換。

上面列出了ChannelOption類中提供的參數,其中涵蓋了網絡通用的參數、TCP協議、UDP協議以及IP協議的參數,其他的咱們無需過多關心,這里重點注意TCP協議的兩個參數:

TCP_NODELAY:開啟TCP的Nagle算法,會將多個小包合并成一個大包發送。

SO_KEEPALIVE:開啟長連接機制,一次數據交互完后不會立馬斷開連接。

第一個參數就是之前聊到的Nagle算法,而關于現在要聊的長連接,就是SO_KEEPALIVE這個參數,想要讓這些參數生效,需要將其裝載到對應的服務端/客戶端上,Netty中提供了兩個裝載參數的方法:

option():發生在連接初始化階段,也就是程序初始化時,就會裝載該方法配置的參數。

childOption():發生在連接建立之后,這些參數只有等連接建立后才會被裝載。

其實也可以這樣理解,option()方法配置的參數是對全局生效的,而childOption()配置的參數,是針對于連接生效的,而想要開啟長連接配置,只需稍微改造一下服務端/客戶端代碼即可:

// 服務端代碼server.childOption(ChannelOption.SO_KEEPALIVE, true);// 客戶端代碼client.option(ChannelOption.SO_KEEPALIVE, true);復制代碼

通過上述的方式開啟長連接之后,TCP默認每兩小時會發送一次心跳檢測,查看對端是否還存活,如果對端由于網絡故障導致下線,TCP會自動斷開與對方的連接。

2.2、Netty的心跳機制

前面聊到了Netty的長連接,其實本質上并不是Netty提供的長連接實現,而是通過調整參數,借助傳輸層TCP協議提供的長連接機制,從而實現服務端與客戶端的長連接支持。不過TCP雖然提供了長連接支持,但其心跳機制并不夠完善,Why?其實答案很簡單,因為心跳檢測的間隔時間太長了,每隔兩小時才檢測一次!

也許有人會說:兩小時就兩小時,這有什么問題嗎?其實問題有些大,因為兩小時太長了,無法有效檢測到機房斷電、機器重啟、網線拔出、防火墻更新等情況,假設一次心跳結束后,對端就出現了這些故障,依靠TCP自身的心跳頻率,需要等到兩小時之后才能檢測到問題。而這些已經失效的連接應當及時剔除,否則會長時間占用服務端資源,畢竟服務端的可用連接數是有限的。

所以,光依靠TCP的心跳機制,這無法保障咱們的應用穩健性,因此一般開發中間件也好、通信程序也罷、亦或是RPC框架等,都會在應用層再自實現一次心跳機制,而所謂的心跳機制,也并不是特別高大上的東西,實現的思路有兩種:

服務端主動探測:每間隔一定時間后,向所有客戶端發送一個檢測信號,過程如下: 假設目前有三個節點,A為服務端,B、C都為客戶端。 A:你們還活著嗎? B:我還活著! C:.....(假設掛掉了,無響應) A收到了B的響應,但C卻未給出響應,很有可能掛了,A中斷與C的連接。

客戶端主動告知:每間隔一定時間后,客戶端向服務端發送一個心跳包,過程如下: 依舊是上述那三個節點。 B:我還活著,不要開除我! C:....(假設掛掉了,不發送心跳包) A:收到B的心跳包,但未收到C的心跳包,將C的網絡連接斷開。

一般來說,一套健全的心跳機制,都會結合上述兩種方案一起實現,也就是客戶端定時向服務端發送心跳包,當服務端未收到某個客戶端心跳包的情況下,再主動向客戶端發起探測包,這一步主要是做二次確認,防止由于網絡擁塞或其他問題,導致原本客戶端發出的心跳包丟失。

2.2.1、心跳機制的實現思路分析

前面叨叨絮絮說了很多,那么在Netty中該如何實現呢?其實在Netty中提供了一個名為IdleStateHandler的類,它可以對一個通道上的讀、寫、讀/寫操作設置定時器,其中主要提供了三種類型的心跳檢測:

// 當一個Channel(Socket)在指定時間后未觸發讀事件,會觸發這個事件public static final IdleStateEvent READER_IDLE_STATE_EVENT;// 當一個Channel(Socket)在指定時間后未觸發寫事件,會觸發這個事件public static final IdleStateEvent WRITER_IDLE_STATE_EVENT;// 上述讀、寫等待事件的結合體public static final IdleStateEvent ALL_IDLE_STATE_EVENT;復制代碼

在Netty中,當一個已建立連接的通道,超出指定時間后還沒有出現數據交互,對應的Channel就會進入閑置Idle狀態,根據不同的Socket/Channel事件,會進入不同的閑置狀態,而不同的閑置狀態又會觸發不同的閑置事件,也就是上述提到的三種閑置事件,在Netty中用IdleStateEvent事件類來表示。

OK,正是由于Netty提供了IdleStateEvent閑置事件類,所以咱們可以基于它來實現心跳機制,但這里還需要用到《Netty入門篇-入站處理器》中聊到的一個方法:userEventTriggered(),這個鉤子方法,會在通道觸發任意事件后被調用,這也就意味著:只要通道上觸發了事件,都會觸發該方法執行,閑置事件也不例外!

有了IdleState、userEventTriggered()這兩個基礎后,咱們就可基于這兩個玩意兒,去實現一個簡單的心跳機制,最基本的功能實現如下:

客戶端:在閑置一定時間后,能夠主動給服務端發送心跳包。

服務端:能夠主動檢測到未發送數據包的閑置連接,并中斷連接。

2.2.2、帶有心跳機制的客戶端實現

上述這兩點功能實現起來并不難,咱們首先寫一下客戶端的實現,如下:

// 心跳機制的客戶端處理器public class HeartbeatClientHandler extends ChannelInboundHandlerAdapter { // 通用的心跳包數據 private static final ByteBuf HEARTBEAT_DATA = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("I am Alive", CharsetUtil.UTF_8)); @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception { // 如果當前觸發的事件是閑置事件 if (event instanceof IdleStateEvent) { IdleStateEvent idleEvent = (IdleStateEvent) event; // 如果當前通道觸發了寫閑置事件 if (idleEvent.state() == IdleState.WRITER_IDLE){ // 表示當前客戶端有一段時間未向服務端發送數據了, // 為了防止服務端關閉當前連接,手動發送一個心跳包 ctx.channel().writeAndFlush(HEARTBEAT_DATA.duplicate()); System.out.println("成功向服務端發送心跳包...."); } else { super.userEventTriggered(ctx, event); } } } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println("正在與服務端建立連接...."); // 建立連接成功之后,先向服務端發送一條數據 ctx.channel().writeAndFlush("我是會發心跳包的客戶端-A!"); super.channelActive(ctx); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.out.println("服務端主動關閉了連接...."); super.channelInactive(ctx); }}復制代碼

因為要借助userEventTriggered()方法來實現事件監聽,所以咱們需要定義一個類繼承入站處理器,接著在其中做了一個判斷,如果當前觸發了IdleStateEvent閑置事件,這也就意味著目前沒有向服務端發送數據了,因此需要發送一個心跳包,告知服務端自己還活著,接著需要將這個處理器加在客戶端上面,如下:

// 演示心跳機制的客戶端(會發送心跳包)public class ClientA { public static void main(String[] args) { EventLoopGroup worker = new NioEventLoopGroup(); Bootstrap client = new Bootstrap(); try { client.group(worker); client.channel(NioSocketChannel.class); // 打開長連接配置 client.option(ChannelOption.SO_KEEPALIVE, true); // 指定一個自定義的初始化器 client.handler(new ClientInitializer()); client.connect("127.0.0.1", 8888).sync(); } catch (Exception e){ e.printStackTrace(); } }}// 客戶端的初始化器public class ClientInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); // 配置如果3s內未觸發寫事件,就會觸發寫閑置事件 pipeline.addLast("IdleStateHandler", new IdleStateHandler(0,3,0,TimeUnit.SECONDS)); pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8)); pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8)); // 裝載自定義的客戶端心跳處理器 pipeline.addLast("HeartbeatHandler",new HeartbeatClientHandler()); }}復制代碼

客戶端的代碼基本上和之前的案例差異不大,重點看ClientInitializer這個初始化器,里面首先加入了一個IdleStateHandler,參數為0、3、0,單位是秒,這是啥意思呢?點進源碼看看構造函數,如下:

public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) { this(false, readerIdleTime, writerIdleTime, allIdleTime, unit);}復制代碼

沒錯,其實賦值的三個參數,也就分別對應著讀操作的閑置事件、寫操作的閑置事件、讀寫操作的閑置事件,如果賦值為0,表示這些閑置事件不需要關心,在前面的賦值中,第二個參數writerIdleTime被咱們賦值成了3,這表示如果客戶端通道在三秒內,未觸發寫事件,就會觸發寫閑置事件,而后會調用HeartbeatClientHandler.userEventTriggered()方法,從而向服務端發送一個心跳包。

2.2.3、帶有心跳機制的服務端實現

接著再來看看服務端的代碼實現,同樣需要有一個心跳處理器,如下:

// 心跳機制的服務端處理器public class HeartbeatServerHandler extends ChannelInboundHandlerAdapter { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception { // 如果當前觸發的事件是閑置事件 if (event instanceof IdleStateEvent) { IdleStateEvent idleEvent = (IdleStateEvent) event; // 如果對應的Channel通道觸發了讀閑置事件 if (idleEvent.state() == IdleState.READER_IDLE){ // 表示對應的客戶端沒有發送心跳包,則關閉對應的網絡連接 // (心跳包也是一種特殊的數據,會觸發讀事件,有心跳就不會進這步) ctx.channel().close(); System.out.println("關閉了未發送心跳包的連接...."); } else { super.userEventTriggered(ctx, event); } } } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { // 如果收到的是心跳包,則給客戶端做出一個回復 if ("I am Alive".equals(msg)){ ctx.channel().writeAndFlush("I know"); } System.out.println("收到客戶端消息:" msg); super.channelRead(ctx, msg); }}復制代碼

在Server端的心跳處理器中,同樣監聽了閑置事件,但這里監聽的是讀閑置事件,因為一個通道如果長時間沒有觸發讀事件,這表示對應的客戶端已經很長事件沒有發數據了,所以需要關閉對應的客戶端連接。

有小伙伴或許會疑惑:為什么一個客戶端通道長時間未發送數據就需要關閉連接呀?這不是違背了長連接的初衷嗎?答案并非如此,因為前面在咱們的客戶端中,在通道長時間未觸發寫事件的情況下,會主動向服務端發送心跳包,而心跳包也是一種特殊的數據包,依舊會觸發服務端上的讀事件,所以但凡正常發送心跳包的連接,都不會被服務端主動關閉。

OK,接著來看看服務端的實現,其實和前面的客戶端差不多:

// 演示心跳機制的服務端public class Server { public static void main(String[] args) { EventLoopGroup group = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap(); server.group(group); server.channel(NioServerSocketChannel.class); // 在這里開啟了長連接配置,以及配置了自定義的初始化器 server.childOption(ChannelOption.SO_KEEPALIVE, true); server.childHandler(new ServerInitializer()); server.bind("127.0.0.1",8888); }}// 服務端的初始化器public class ServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); // 配置如果5s內未觸發讀事件,就會觸發讀閑置事件 pipeline.addLast("IdleStateHandler", new IdleStateHandler(5,0,0,TimeUnit.SECONDS)); pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8)); pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8)); // 裝載自定義的服務端心跳處理器 pipeline.addLast("HeartbeatHandler",new HeartbeatServerHandler()); }}復制代碼

重點注意看:在服務端配置的是讀閑置事件,如果在5s內未觸發讀事件,就會觸發對應通道的讀閑置事件,但這里是5s,為何不配置成客戶端的3s呢?因為如果兩端的閑置超時時間配置成一樣,就會造成客戶端正在發心跳包、服務端正在關閉連接的這種情況出現,最終導致心跳機制無法正常工作,對于這點大家也可以自行演示。

2.2.4、普通的客戶端實現

最后,為了方便觀看效果,這里咱們再創建一個不會發送心跳包的客戶端B,同樣打開它的長連接選項,然后來對比測試效果,如下:

// 演示心跳機制的客戶端(不會發送心跳包)public class ClientB { public static void main(String[] args) { EventLoopGroup worker = new NioEventLoopGroup(); Bootstrap client = new Bootstrap(); try { client.group(worker); client.channel(NioSocketChannel.class); client.option(ChannelOption.SO_KEEPALIVE, true); client.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8)); pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast(new ChannelInboundHandlerAdapter(){ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 建立連接成功之后,先向服務端發送一條數據 ctx.channel().writeAndFlush("我是不會發心跳包的客戶端-B!"); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.out.println("因為沒發送心跳包,俺被開除啦!"); // 當通道被關閉時,停止前面啟動的線程池 worker.shutdownGracefully(); } }); } }); client.connect("127.0.0.1", 8888).sync(); } catch (Exception e){ e.printStackTrace(); } }}復制代碼

上述這段代碼中,僅構建出了一個最基本的客戶端,其中主要干了兩件事情:

①在連接建立成功之后,先向服務端發送一條數據。

②在連接(通道)被關閉時,輸出一句“俺被開除啦!”的信息,并優雅停止線程池。

除此之外,該客戶端并未裝載自己實現的客戶端心跳處理器,這也就意味著:客戶端B并不會主動給服務端發送心跳包。

2.2.5、Netty心跳機制測試

接著分別啟動服務端、客戶端A、客戶端B,然后查看控制臺的日志,如下:


從上圖的運行結果來看,在三方啟動之后,整體過程如下:

ClientA:先與服務端建立連接,并且在建立連接之后發送一條數據,后續持續發送心跳包。

ClientB:先與服務端建立連接,然后在建立連接成功后發送一條數據,后續不會再發數據。

Server:與ClientA、B保持連接,然后定期檢測閑置連接,關閉未發送心跳包的連接。

在上述這個過程中,由于ClientB建立連接后,未主動向服務端發送心跳包,所以在一段時間之后,服務端主動將ClientB的連接(通道)關閉了,有人會問:明明ClientB還活著呀,這樣做合理嗎?

其實這個問題是合理的,因為這里只是模擬線上環境測試,所以ClientB沒有主動發送數據包,但在線上環境,每個客戶端都會定期向服務端發送心跳包,都會為每個客戶端配置心跳處理器。在都配置了心跳處理器的情況下,如果一個客戶端長時間沒發送心跳包,這意味著這個客戶端十有八九涼涼了,所以自然需要將其關閉,防止這類“廢棄連接”占用服務端資源。

不過上述的心跳機制僅實現了最基礎的版本,還未徹底將其完善,但我這里就不繼續往下實現了,畢竟主干已經搭建好了,剩下的只是一些細枝末節,我這里提幾點完善思路:

①在檢測到某個客戶端未發送心跳包的情況下,服務端應當主動再發起一個探測包,二次確認客戶端是否真的掛了,這樣做的好處在于:能夠有效避免網絡抖動造成的“客戶端假死”現象。

②客戶端、服務端之間交互的數據包,應當采用統一的格式進行封裝,也就是都遵守同一規范包裝數據,例如{msgType:"Heartbeat", msgContent:"...", ...}。

③在客戶端被關閉的情況下,但凡不是因為物理因素,如機房斷電、網線被拔、機器宕機等情況造成的客戶端下線,客戶端都必須具備斷線重連功能。

將上述三條完善后,才能夠被稱為是一套相對健全的心跳檢測機制,所以大家感興趣的情況下,可基于前面給出的源碼接著實現~

又是一年雙11,淘系平臺與微信之間的互通更近一步?

互聯網平臺之間的“互聯互通”進展如何?在今年雙11大促前,阿里系與騰訊系產品之間似乎釋放出了一些“互通”信號,只是在某種程度上,這次的“互通”動作更多是由阿里系產品發出。如何理解這些舉措?我們又可以如何理解目前的流量生態?本文作者發表了他的看法,一起來看一下。

近期阿里與騰訊間的互聯互通“似乎”又進了一步,并且這兩次信號均是由阿里方面釋放。

日前有消息顯示,支付寶已開始支持用戶向微信/QQ好友轉賬,并且無需對方提供相關賬號。據網傳的相關截圖顯示,目前已有部分用戶在支付寶的“轉賬”頁面新增了“轉給微信好友”選項,并提供了三種領取方式,其中包括掃碼領取、驗證轉賬人姓名首字,以及指定用戶領取。

不過可能與大多數朋友理解的“給微信用戶轉賬”不同,支付寶這一“轉給微信好友”功能只是自動生成一個收款人24小時內必須掃碼領取的二維碼,轉賬人不僅需要將這個二維碼轉發給微信/QQ好友、并讓對方掃碼領取,并且收款人識別二維碼后所領取的錢款,最終也還是會進入支付寶賬戶。

不難發現,在這一過程中微信除了通知功能外,幾乎沒有起到任何作用,只能說是一個毫無干系的第三方。對此就有網友指出,“還不如直接在支付寶中添加好友后直接轉賬來的方便”。

同樣“雷聲大雨點小”的,還有日前被曝光的由天貓超市推出的微信小程序。據相關消息顯示,天貓在微信上線了一款名為“天貓超市小鐺家”的小程序,并配有天貓超市的logo與“天貓超市送貨上門”的介紹。

但與外界所認為的天貓超市“微信小程序商城”迥然不同,據一位阿里內部人士回應稱,“這是一個品牌宣傳小程序,接下來可能會涉及優惠券發放,拿到優惠券的用戶依然要到天貓超市現有渠道購物。”簡單來說,就是在微信生態里發“優惠券”,并引流至天貓超市。

事實上,一直以來阿里系電商平臺在微信中獲得流量的難度,都遠大于騰訊的合作伙伴京東與拼多多,并且在微信方面宣布開放外鏈前,微信里的淘寶商品鏈接還需要經過復制淘口令這一極為曲折的方式。

盡管如今微信已然放松了相關限制,但比起獲得了微信API權限、能夠生成有預覽圖文鏈接的京東和拼多多,淘寶的鏈接還有被折疊的可能。顯然騰訊與阿里間想要完全開放,還有很長一段路要走。

更進一步來說,淘系平臺遲遲未在微信里推出小程序電商,或許也有自己的考量。首先對于消費者而言,一旦養成習慣后改變起來顯然更為困難,如果能在小程序里下單購買,可能就沒必要去淘寶或天貓了。對于商家來說也是一樣,畢竟從技術上來講,跟隨用戶遷移到另一平臺并沒有那么難。所以阿里方面可能也不太愿意將自己的流量交給微信,而其與微信保持曖昧關系的本意或許只是為了借助微信來提高旗下平臺的打開率。

而除了無數次被傳出淘系平臺將推商城小程序外,隨著今年雙11的將近,諸多扎根多年的品牌也開始積極地通過“企業微信群”進行雙11大促的預熱。這也表明淘系平臺自身的社交能力還不足以擁有同等的活躍度,而商家也更希望在微信里構建自己的私域流量。并且值得注意的是,淘系平臺的頭部品牌往往很少會在微信開設小程序商城,但無論是不是主觀的選擇,多一個渠道往往就意味著“分流”。

此外不久前傳出的“88VIP接入騰訊視頻會員”一事,同樣也引發了諸多的關注。盡管88VIP客服方面透露,積分兌換騰訊視頻會員即將上線,但這更像是阿里方面采購騰訊視頻的產品、讓用戶兌換,而非與騰訊視頻“合作”。并且對于此事,騰訊視頻方面也直接予以否認。

種種舉動背后,基本都是阿里方面的“單向示好”。其實這也不難理解,畢竟雙方目前的微妙關系中,淘寶/天貓作為對流量更渴求的一方,自然也有意推動雙方的合作。如今在淘系生態中,商品分享的半屏頁面已明顯地標識了“鏈接可打開”的“微信”選項,而在微信的生態中,淘寶/天貓也在不斷提高自己的存在感。

就如阿里方面相關人士此前對媒體透露的那樣,“只要符合業態定位、商業慣例和滿足消費者需要,我們對互聯互通都持開放態度。當然這個過程中,靠一家企業單向、孤立開放都無法實現互聯互通,所以需要相向而行,共同促進平臺經濟的普惠發展”。其言外之意,似乎也將互聯互通的壓力交給了騰訊方面。

不過就像阿里并不希望看到淘系商家在微信建立小程序商城一樣,或許他們同樣也并沒有那么強烈的意愿讓支付寶與微信間的轉賬完全通暢。按淘寶方面相關負責人的說法,“納入微信支付,將以用戶體驗為導向,以安全為底線,分階段、分步驟實現接入多種支付方式”。而這一說法,無疑與此前微信方面開放外鏈幾乎如出一轍。

“安全”二字作為諸多互聯網企業的口頭禪,顯然也是保護自家生態的“擋箭牌”。追溯到早年間,還是阿里方面率先在2008年以安全為由,屏蔽了百度、返利網、美麗說、蘑菇街等平臺。隨后在2013年,阿里又一次以安全為由,關閉了從微信跳轉到淘寶商品、店鋪的通道。而在雙方高筑的圍墻外,如今也依舊還有相關陣營間的“敵對”和“冷戰”,例如時至今日京東也無法使用支付寶付款。

但不可否認的是,在相關監管部門的指導下,雙方的互聯互通只會更加符合用戶的切實利益、而非經過粉飾后的商業手段。

至少在一年一度最受關注的雙11大促到來前,微信所坐擁的龐大流量顯然都是淘系平臺無法忽視的存在。

【本文圖片來自網絡】

原文標題:又是一年雙11,淘系平臺已頻頻向微信示好

作者:三易菌;公眾號:三易生活

原文鏈接:https://mp.weixin.qq.com/s/gPgwczKRtOa8RNT5yN5dBg

本文由 @三易生活 授權發布于人人都是產品經理。未經作者許可,禁止轉載。

題圖來自 Unsplash,基于CC0協議。

該文觀點僅代表作者本人,人人都是產品經理平臺僅提供信息存儲空間服務。

免責聲明:本文由用戶上傳,如有侵權請聯系刪除!