[NodeJS]Node.js 打造實時多人遊戲框架
【編者按】Node.js的大紅大紫也造就了一大批新應用、新工具的誕生。比如基於Node.js的開發框架、開源軟體等等。本文轉自阿里巴巴使用者體驗部有一點部落格,作者詳細描述了使用Node.js、Node-Webkitk開發的實時多人遊戲框架Spaceroom過程。
在 Node.js 如火如荼發展的今天,我們已經可以用它來做各種各樣的事情。前段時間UP主參加了極客鬆活動,在這次活動中我們意在做出一款讓“低頭族”能夠更多交流的遊戲,核心功能便是 Lan Party 概念的實時多人互動。極客鬆比賽只有短得可憐的36個小時,要求一切都敏捷迅速。在這樣的前提下初期的準備顯得有些“水到渠成”。跨平臺應用的 solution 我們選擇了node-webkit,它足夠簡單且符合我們的要求。
按照需求,我們的開發可以按照模組分開進行。本文具體講述了開發 Spaceroom(我們的實時多人遊戲框架)的過程,包括一系列的探索與嘗試,以及對 Node.js、WebKit 平臺本身的一些限制的解決,和解決方案的提出。
Getting started
Spaceroom 一瞥
在最開始,Spaceroom 的設計肯定是需求驅動的。我們希望這個框架可以提供以下的基礎功能:
- 能夠以 房間(或者說頻道) 為單位,區分一組使用者
- 能夠接收收集組內使用者發來的指令
- 在各個客戶端之間對時,能夠按照規定的
interval
精確廣播遊戲資料 - 能夠儘量消除由網路延遲帶來的影響
當然,在 coding 的後期,我們為 Spaceroom 提供了更多的功能,包括暫停遊戲、在各個客戶端之間生成一致的隨機數等(當然根據需求這些都可以在遊戲邏輯框架裡自己實現,並非一定需要用到 Spaceroom 這個更多在通訊層面上工作的框架)。
APIs
Spaceroom 分為前後端兩個部分。伺服器端所需要做的工作包括維護房間列表,提供建立房間、加入房間的功能。我們的客戶端 APIs 看起來像這樣:
spaceroom.connect(address, callback)
– 連線伺服器spaceroom.createRoom(callback)
– 建立一個房間spaceroom.joinRoom(roomId)
– 加入一個房間spaceroom.on(event, callback)
– 監聽事件- ……
客戶端連線到伺服器後,會收到各種各樣的事件。例如一個在一間房間中的使用者,可能收到新玩家加入的事件,或者遊戲開始的事件。我們給客戶端賦予了“生命週期”,他在任何時候都會處於以下狀態的一種:
你可以通過 spaceroom.state
獲取客戶端的當前狀態。
使用伺服器端的框架相對來說簡單很多,如果使用預設的配置檔案,那麼直接執行伺服器端框架就可以了。我們有一個基本的需求:伺服器程式碼 可以直接執行在客戶端中,而不需要一個單獨的伺服器。玩過 PS 或者 PSP 的玩家應該清楚我在說什麼。當然,可以跑在專門的伺服器裡,自然也是極好的。
邏輯程式碼的實現這裡簡略了。初代的 Spaceroom 完成了一個 Socket 伺服器的功能,它維護房間列表,包括房間的狀態,以及每一個房間對應的遊戲時通訊(指令收集,bucket 廣播等)。具體實現可以參看原始碼。
同步演算法
那麼,要怎麼才能使得各個客戶端之間顯示的東西都是實時一致的呢?
這個東西聽起來很有意思。仔細想想,我們需要伺服器幫我們傳遞什麼東西?自然就會想到是什麼可能造成各個客戶端之間邏輯的不一致:使用者指令。既然處理遊戲邏輯的程式碼都是相同的,那麼給定同樣的條件,程式碼的執行結果也是相同的。唯一不同的就是在遊戲過程當中,接收到的各種玩家指令。理所當然的,我們需要一種方式來同步這些指令。如果所有的客戶端都能拿到同樣的指令,那麼所有的客戶端從理論上講就能有一樣的執行結果了。
網路遊戲的同步演算法千奇百怪,適用的場景也各不相同。Spaceroom 採用的同步演算法類似於幀鎖定的概念。我們把時間軸分成了一個一個的區間,每一個區間稱為一個 bucket。Bucket 是用來裝載指令的,由伺服器端維護。在每一個 bucket 時間段的末尾,伺服器把 bucket 廣播給所有客戶端,客戶端拿到 bucket 之後從中取出指令,驗證之後執行。
為了降低網路延遲造成的影響,伺服器接到的來自客戶端的指令每一個都會按照一定的演算法投遞到對應的 bucket 中,具體按照以下步驟:
- 設 order_start 為指令攜帶的指令發生時間, t 為 order_start 所在 bucket 的起始時間
- 如果 t + delay_time <= 當前正在收集指令的 bucket 的起始時間,將指令投遞到 當前正在收集指令的 bucket 中,否則繼續 step 3
- 將指令投遞到 t + delay_time 對應的 bucket 中
其中 delay_time 為約定的伺服器延遲時間,可以取為客戶端之間的平均延遲,Spaceroom 裡預設取值80,以及 bucket 長度預設取值48. 在每個 bucket 時間段的末尾,伺服器將此 bucket 廣播給所有客戶端,並開始接收下一個 bucket 的指令。客戶端根據收到的 bucket 間隔,在邏輯中自動進行對時,將時間誤差控制在一個可以接受的範圍內。
這個意思是,正常情況下,客戶端每隔 48ms 會收到從伺服器端發來的一個 bucket,當到達需要處理這個 bucket 的時間時,客戶端會進行相應處理。假設客戶端 FPS=60,每隔 3幀 左右的時間,會收到一次 bucket,根據這個 bucket 來更新邏輯。如果因為網路波動,超出時間後還沒有收到 bucket,客戶端暫停遊戲邏輯並等待。在一個 bucket 之內的時間,邏輯的更新可以使用 lerp 的方法。
在 delay_time = 80, bucket_size = 48 的情況下,任一指令最少會被延遲 96ms 執行。更改這兩個引數,例如在 delay_time = 60, bucket_size = 32 的情況下,任一指令最少會被延遲 64ms 執行。
計時器引發的血案
整個看下來,我們的框架在執行的時候需要有一個精確的計時器。在固定的 interval
下執行 bucket 的廣播。理所當然地,我們首先想到了使用setInterval()
,然而下一秒我們就意識到這個想法有多麼的不靠譜:調皮的setInterval()
似乎有非常嚴重的誤差。而且要命的是,每一次的誤差都會累計起來,造成越來越嚴重的後果。
於是我們馬上又想到了使用 setTimeout()
,通過動態地修正下一次到時的時間來讓我們的邏輯大致穩定在規定的 interval
左右。例如此次setTimeout()
比預期少了5ms,
那麼我們下一次就讓他提前5ms. 不過測試結果不盡人意,而且這怎麼看都不夠優雅。
所以我們又要換一個思路。是否可以讓 setTimeout()
儘可能快地到期,然後我們檢查當前的時間是否到達目標時間。例如在我們的迴圈中,使用setTimeout(callback, 1)
來不停地檢查時間,這看起來像是一個不錯的主意。
令人失望的計時器
我們立即寫了一段程式碼來測試我們的想法,結果令人失望。在目前最新的node.js 穩定版(v0.10.32)以及 Windows 平臺下,執行這樣一段程式碼:- var sum = 0, count = 0;
- function test() {
- var now = Date.now();
- setTimeout(function () {
- var diff = Date.now() - now;
- sum += diff;
- count++;
- test();
- });
- }
- test();
- > sum / count
- 15.624555160142348
什麼?!!我要 1ms 的間隔時間,你卻告訴我實際的平均間隔為 15.625ms!這個畫面簡直是太美。我們在 mac 上做同樣的測試,得到的結果是 1.4ms。於是我們心生疑惑:這到底是什麼鬼?如果我是一個果粉,我可能就要得出 Windows 太垃圾然後放棄 Windows 的結論了,不過好在我是一名嚴謹的前端工程師,於是我開始繼續思索起這個數字來。
等等,這個數字為什麼那麼眼熟?15.625ms 這個數字會不會太像 Windows 下的最大計時器間隔了?立即下載了一個 ClockRes 進行測試,控制檯一跑果然得到了如下結果:
- Maximum timer interval: 15.625 ms
- Minimum timer interval: 0.500 ms
- Current timer interval: 1.001 ms
The actual delay depends on external factors like OS timer granularity and system load.
然而測試結果顯示,這個實際延遲是最大計時器間隔(注意此時系統的當前計時器間隔只有 1.001ms),無論如何讓人無法接受,強大的好奇心驅使我們翻翻看 node.js 的原始碼來一窺究竟。
Node.js 中的 BUG
相信大部分你我都對 Node.js 的 even loop 機制有一定的瞭解,檢視 timer 實現的原始碼我們可以大致瞭解到 timer 的實現原理,讓我們從 event loop 的主迴圈講起:- while (r != 0 && loop->stop_flag == 0) {
- /* 更新全域性時間 */
- uv_update_time(loop);
- /* 檢查計時器是否到期,並執行對應計時器回撥 */
- uv_process_timers(loop);
- /* Call idle callbacks if nothing to do. */
- if (loop->pending_reqs_tail == NULL &&
- loop->endgame_handles == NULL) {
- /* 防止event loop退出 */
- uv_idle_invoke(loop);
- }
- uv_process_reqs(loop);
- uv_process_endgames(loop);
- uv_prepare_invoke(loop);
- /* 收集 IO 事件 */
- (*poll)(loop, loop->idle_handles == NULL &&
- loop->pending_reqs_tail == NULL &&
- loop->endgame_handles == NULL &&
- !loop->stop_flag &&
- (loop->active_handles > 0 ||
- !ngx_queue_empty(&loop->active_reqs)) &&
- !(mode & UV_RUN_NOWAIT));
- /* setImmediate() 等 */
- uv_check_invoke(loop);
- r = uv__loop_alive(loop);
- if (mode & (UV_RUN_ONCE | UV_RUN_NOWAIT))
- break;
- }
- void uv_update_time(uv_loop_t* loop) {
- /* 獲取當前系統時間 */
- DWORD ticks = GetTickCount();
- /* The assumption is made that LARGE_INTEGER.QuadPart has the same type */
- /* loop->time, which happens to be. Is there any way to assert this? */
- LARGE_INTEGER* time = (LARGE_INTEGER*) &loop->time;
- /* If the timer has wrapped, add 1 to it's high-order dword. */
- /* uv_poll must make sure that the timer can never overflow more than */
- /* once between two subsequent uv_update_time calls. */
- if (ticks < time->LowPart) {
- time->HighPart += 1;
- }
- time->LowPart = ticks;
- }
該函式的內部實現,使用了 Windows 的 GetTickCount()
函式來設定當前時間。簡單地來說,在呼叫setTimeout
函式之後,經過一系列的掙扎,內部的 timer->due 會被設定為當前 loop 的時間 + timeout。在 event loop
中,先通過 uv_update_time
更新當前 loop 的時間,然後在uv_process_timers
中檢查是否有計時器到期,如果有就進入 JavaScript 的世界。通篇讀下來,event loop大概是這樣一個流程:
- 更新全域性時間
- 檢查定時器,如果有定時器過期,執行回撥
- 檢查 reqs 佇列,執行正在等待的請求
- 進入 poll 函式,收集 IO 事件,如果有 IO 事件到來,將相應的處理函式新增到 reqs 佇列中,以便在下一次 event loop 中執行。在 poll 函式內部,呼叫了一個系統方法來收集 IO 事件。這個方法會使得程序阻塞,直到有 IO 事件到來或者到達設定好的超時時間。呼叫這個方法時,超時時間設定為最近的一個 timer 到期的時間。意思就是阻塞收集 IO 事件,最大阻塞時間為 下一個 timer 的到底時間。
- staticvoid uv_poll(uv_loop_t* loop, int block) {
- DWORD bytes, timeout;
- ULONG_PTR key;
- OVERLAPPED* overlapped;
- uv_req_t* req;
- if (block) {
- /* 取出最近的一個計時器的過期時間 */
- timeout = uv_get_poll_timeout(loop);
- } else {
- timeout = 0;
- }
- GetQueuedCompletionStatus(loop->iocp,
- &bytes,
- &key,
- &overlapped,
- /* 最多阻塞到下個計時器到期 */
- timeout);
- if (overlapped) {
- /* Package was dequeued */
- req = uv_overlapped_to_req(overlapped);
- /* 把 IO 事件插入佇列裡 */
- uv_insert_pending_req(loop, req);
- } elseif (GetLastError() != WAIT_TIMEOUT) {
- /* Serious error */
- uv_fatal_error(GetLastError(), "GetQueuedCompletionStatus");
- }
- }
按照上述步驟,假設我們設定了一個 timeout = 1ms 的計時器,poll 函式會最多阻塞 1ms 之後恢復(如果期間沒有任何 IO 事件)。在繼續進入 event loop 迴圈的時候, uv_update_time
就會更新時間,然後uv_process_timers
發現我們的計時器到期,執行回撥。所以初步的分析是,要麼是uv_update_time
出了問題(沒有正確地更新當前時間),要麼是
poll 函式等待 1ms 之後恢復,這個 1ms 的等待出了問題。
查閱 MSDN,我們驚人地發現對 GetTickCount 函式的描述:
The resolution of the GetTickCount function is limited to the resolution of the system timer, which is typically in the range of 10 milliseconds to 16 milliseconds.
GetTickCount
的精度是如此的粗糙!假設 poll 函式正確地阻塞了 1ms 的時間,然而下一次執行uv_update_time
的時候並沒有正確地更新當前 loop 的時間!所以我們的定時器沒有被判定為過期,於是 poll 又等待了 1ms,又進入了下一次
event loop。直到終於 GetTickCount
正確地更新了(所謂15.625ms更新一次),loop 的當前時間被更新,我們的計時器才在 uv_process_timers
裡被判定過期。
向 WebKit 求助
Node.js 的這段原始碼看得人很無助:他使用了一個精度低下的時間函式,而且沒有做任何處理。不過我們立刻想到了既然我們使用 Node-WebKit,那麼除了 Node.js 的 setTimeout,我們還有 Chromium 的 setTimeout。寫一段測試程式碼,用我們的瀏覽器或者 Node-WebKit 跑一下:http://marks.lrednight.com/test.html#1 (#後面跟的數字表示需要測定的間隔),結果如下圖:
按照 HTML5 的規範,理論結果應該是前5次結果是1ms,以後的結果是4ms。測試用例中顯示的結果是從第3次開始的,也就是說表上的資料理論上應該是前3次都是1ms,之後的結果都是4ms。結果有一定的誤差,而且根據規定,我們能拿到的最小的理論結果是4ms。雖然我們不滿足,但顯然這比 node.js 的結果讓我們滿意多了。強大的好奇心趨勢我們看看 Chromium 的原始碼,看看他是如何實現的。(https://chromium.googlesource.com/chromium/src.git/+/38.0.2125.101/base/time/time_win.cc)
首先,在確定 loop 的當前時間方面,Chromium 使用了 timeGetTime()
函式。查閱 MSDN 可以發現這個函式的精度受系統當前 timer interval 影響。在我們的測試機上,理論上也就是上文中提到過的 1.001ms。然而 Windows 系統預設情況下,timer interval 是其最大值(測試機上也就是 15.625ms),除非應用程式修改了全域性 timer
interval。
如果你關注 IT界的新聞,你一定看過這樣的一條新聞。看起來我們的 Chromium 把計時器間隔設定得很小了嘛!看來我們不用擔心繫統計時器間隔的問題了?不要開心得太早,這樣的一條修復給了我們當頭一棒。事實上,這個問題在 Chrome 38 中已經得到了修復。難道我們要使用修復以前的 Node-WebKit?這顯然不夠優雅,而且阻止了我們使用效能更高的 Chromium 版本。
進一步檢視 Chromium 原始碼我們可以發現,在有計時器,且計時器的 timeout < 32ms 時,Chromium 會更改系統的全域性定時器間隔以實現小於 15.625ms 精度的計時器。(檢視原始碼)
啟動計時器時,一個叫HighResolutionTimerManager
的東西會被啟用,這個類會根據當前裝置的電源型別,呼叫EnableHighResolutionTimer
函式。具體來說,當前裝置用電池時,他會呼叫EnableHighResolutionTimer(false)
,而使用電源時會傳入
true。EnableHighResolutionTimer
函式的實現如下:
- void Time::EnableHighResolutionTimer(bool enable) {
- base::AutoLock lock(g_high_res_lock.Get());
- if (g_high_res_timer_enabled == enable)
- return;
- g_high_res_timer_enabled = enable;
- if (!g_high_res_timer_count)
- return;
- // Since g_high_res_timer_count != 0, an ActivateHighResolutionTimer(true)
- // was called which called timeBeginPeriod with g_high_res_timer_enabled
- // with a value which is the opposite of |enable|. With that information we
- // call timeEndPeriod with the same value used in timeBeginPeriod and
- // therefore undo the period effect.
- if (enable) {
- timeEndPeriod(kMinTimerIntervalLowResMs);
- timeBeginPeriod(kMinTimerIntervalHighResMs);
- } else {
- timeEndPeriod(kMinTimerIntervalHighResMs);
- timeBeginPeriod(kMinTimerIntervalLowResMs);
- }
- }
timeBeginPeriod
以及timeEndPeriod
是 Windows 提供的用來修改系統 timer interval 的函式。也就是說在接電源時,我們能拿到的最小的
timer interval 是1ms,而使用電池時,是4ms。由於我們的迴圈不斷地呼叫了 setTimeout,根據 W3C 規範,最小的間隔也是 4ms,所以鬆口氣,這個對我們的影響不大。又一個精度問題
回到開頭,我們發現測試結果顯示,setTimeout 的間隔並不是穩定在 4ms 的,而是在不斷地波動。而http://marks.lrednight.com/test.html#48 測試結果也顯示,間隔在 48ms 和 49ms 之間跳動。原因是,在 Chromium 和 Node.js 的 event loop 中,等待 IO 事件的那個 Windows 函式呼叫的精度,受當前系統的計時器影響。遊戲邏輯的實現需要用到 requestAnimationFrame 函式(不停更新畫布),這個函式可以幫我們將計時器間隔至少設定為 kMinTimerIntervalLowResMs(因為他需要一個16ms的計時器,觸發了高精度計時器的要求)。測試機使用電源的時候,系統的 timer interval 是 1ms,所以測試結果有 ±1ms 的誤差。如果你的電腦沒有被更改系統計時器間隔,執行上面那個#48的測試,max可能會到達48+16=64ms。
使用 Chromium 的 setTimeout 實現,我們可以將 setTimeout(fn, 1) 的誤差控制在 4ms 左右,而 setTimeout(fn, 48) 的誤差可以控制在 1ms 左右。於是,我們的心中有了一幅新的藍圖,它讓我們的程式碼看起來像是這樣:
- /* Get the max interval deviation */
- var deviation = getMaxIntervalDeviation(bucketSize); // bucketSsize = 48, deviation = 2;
- function gameLoop() {
- var now = Date.now();
- if (previousBucket + bucketSize <= now) {
- previousBucket = now;
- doLogic();
- }
- if (previousBucket + bucketSize - Date.now() > deviation) {
-
相關推薦
[NodeJS]Node.js 打造實時多人遊戲框架
【編者按】Node.js的大紅大紫也造就了一大批新應用、新工具的誕生。比如基於Node.js的開發框架、開源軟體等等。本文轉自阿里巴巴使用者體驗部有一點部落格,作者詳細描述了使用Node.js、Node-Webkitk開發的實時多人遊戲框架Spaceroom過程。 在 Node.js 如火如荼發展的今天
Vue與Node.js打造的所見即所得的企業服務產品引擎設計
測試測試本文出自 “技術老兵心得園” 博客,請務必保留此出處http://10266722.blog.51cto.com/10256722/1956822Vue與Node.js打造的所見即所得的企業服務產品引擎設計
使用Multiplayer Networking做一個簡單的多人遊戲例子-1/2
lap settings isl log atime round 窗口 bottom -m 原文地址: http://blog.csdn.net/cocos2der/article/details/51006463 本文主要講述了如何使用Multiplayer Networ
Photon多人遊戲開發教程
udp 隨機 r12 dom pid log 時機 觸發器 動作 http://gad.qq.com/article/detail/26112 PUN介紹 入門 Photon Unity Networking(首字母縮寫PUN)是一個Unity多人遊戲插件包。它提供了身份驗
Steamworks and Unity – P2P多人遊戲
working 事情 異常 研究 there 是否 ont else lock 之前我們討論過“如何把Steamworks.Net和Unity整合起來”,這是一個很好的開始,現在我們研究深一點,談一談Steam中的多人遊戲。這不是教程,但是可以指導你在你的遊戲中如何使用St
Vue.js 創建多人共享博客
template requires server 展開 case size 臃腫 push vue 多人共享博客 上一個項目:仿 CNODE 社區 剛完成,感覺有點意猶未盡,對於 登錄 這一塊老師並沒有展開,我先是用了 localStorage 自己瞎搞,跑通之後想了下,v
Unity3d學習之路-初識GameSparks多人遊戲外掛
初識GameSparks多人遊戲外掛 初識GameSparks多人遊戲外掛 簡介 GameSparks介紹 建立遊戲 雲服務配置
基於node.js打造一個簡易的聊天室
1.先寫服務端的程式碼 在服務端要先進行安裝node環境,然後用npm install socket.io 要安裝express框架 let http = require('http'); let fs = require('fs'); let ws = require('socket.
虛幻引擎開發多人遊戲聯機
Online Subsystem 藍圖部分(簡略): Create Session Find Session Join Session 配置檔案(DefaultEngine.ini) LAN: 新增如下配置 [OnlineS
論文學習筆記:曹哲 實時多人人體姿態識別 CVPR2017
最近學習了曹哲的這篇Realtime Multi-Person 2D Pose Estimation using Part Affinity Fields,認真讀了,也看了曹哲直播的視訊,雖然程式還是看不懂,但也算是學到東西了,寫個筆記記錄一下,其實大部分也就是直接
UNITY多人遊戲基礎
1.總覽。 多人遊戲基本結構:Clent/Server,分為Authoritative Server和Non-Authoritative Server兩種,前者客戶端傳送訊息,伺服器端反饋結果,好處是有效防止客戶端作弊,並統一不同客戶端之間的物理表現和互動狀況,缺陷是存
多人遊戲對戰技術(坦克大戰、狀態同步)
用狀態同步的方式實現一個坦克大戰的小遊戲,這也是一次全新的嘗試,從遊戲的效果來看,在正常的網路速度下效果符合預期。這裡跟大家分享下游戲客戶端中用到的關鍵技術點。 一、 同步方式的選擇,狀態同步or 幀同步? 狀態同步: 同步的是遊戲中的各種狀態,遊戲
Node.js 推薦20多個學習網站及書籍
Web 開發人員對 Node.js 日益增多,更多的公司和開發者開始嘗試使用 Node.js 來實現一些對實時性要求高,I/O密集型的業務。 介紹了很不錯的書籍和案例,可以提高nodejs開發進度, Node 官方網站,Node.js 學習之路就起步,開發路途遙遠
【Unity3D】 Photon多人遊戲開發教程
一、前言 Photon Unity Networking(首字母縮寫PUN)是一個Unity多人遊戲外掛包。它提供了身份驗證選項、匹配,以及快速、可靠的通過我們的Photon後端實現的遊戲內通訊。 二、原文 三、正文 Photon多人遊戲開發
Vue.js + Node.js打造個人部落格(新手向)
轉載自:http://www.jianshu.com/p/0417f242c14f 前言 做為一名立志全棧的頁面仔,一直想著要獨立開發一個專案,從前臺到後臺到資料庫,從設計到開發到上線。一般說到這樣的練手專案,通常得到的意見都是寫個部落格系統唄!剛好對之前用hex
Unity多人遊戲和網路功能(二) 使用網路管理類
[本文翻譯自Unity 5.2的官方文件] NetworkManager是一個可以管理多玩家遊戲的網路狀態的元件。實際上,它是完全用HLAPI實現的,因此開發者可以使用其他的方式實現他的所有功能。然而,NetworkManager把很多有用的功能整合在了一起,
使用Multiplayer Networking做一個簡單的多人遊戲例子-2/3(Unity3D開發之二十六)
7. 在網路中控制Player移動 上一篇中,玩家操作移動會同時控制同屏內的所有Player,且只有自己的螢幕生效。因為咱們還沒有同步Transform資訊。 下面我們通過UnityEngine.Networking元件來實現玩家控制各自Player
PHP下查詢遊戲《Minecraft》多人遊戲 伺服器的人數。
廢話不多說,直接上圖: 作為一個優雅的Minecraft伺服器,肯定需要官網的嗯。 很多伺服器的官網都有顯示當前伺服器線上人數,延遲,每一個子服線上人數,甚至出了個流量圖。 我們不搞花裡胡哨的查詢
你要是還學不會,請提刀來見 Typora+PicGo+Gitee + node.js 打造個人高效穩定優雅圖床
# 你要是還學不會,請提刀來見 Typora+PicGo+Gitee + node.js 打造個人高效穩定優雅圖床 經過前面兩彈的介紹,相信大家對圖床都不陌生了吧, 但是小魔童覺得這樣做法還是不方便,使用 `github `的倉庫來存放圖片,如果不能FQ的話是不能展示圖片的,自己可以FQ還不行,需要別
Express - 基於 Node.js 平臺的 web 應用開發框架
create down block log 功能 views div save filepath Web 應用 Express 是一個基於 Node.js 平臺的極簡、靈活的 web 應用開發框架,它提供一系列強大的特性,幫助你創建各種 Web 和移動設備應用。 API 豐