剝開比原看程式碼08:比原的Dashboard是怎麼做出來的?
作者:freewind
比原專案倉庫:
Github地址:https://github.com/Bytom/bytom
Gitee地址:https://gitee.com/BytomBlockchain/bytom
在前面的幾篇文章中,我們一直在研究如何與一個比原節點建立連線,並且從它那裡請求區塊資料。然而我很快就遇到了瓶頸。
因為當我處理拿到的區塊資料時,發現我已經觸及到了比原鏈的核心,即區塊鏈的資料結構以及分叉的處理。如果不能完全理解這一塊,就沒有辦法正確的處理區塊資料。然而它涉及的內容太多了,在短時間之內把它理解透徹是一件非常困難的事情。
之前我的做法就好像我想了解一個城市,於是沿著一條路從外圍向市中心進發。前面一直很順利,但等到了市中心時,發現這裡人多路雜,有點迷失了。在這種情況下,我覺得我應該暫停研究核心,而是從另外一條路開始,由外向內再來一遍。因為在行進的過程中,我可以慢慢的積累更多的知識,讓自己處於學習區而非恐慌區。這條路的終點也將是觸及到核心,但是不深入進去。這樣的話,等我多走了幾條路之後,積累的知識夠了,再研究核心就不會覺得迷茫了。
所以本文字來是想去研究一下,當別的節點把區塊資料發給我們之後,我們應該怎麼處理,現在換成研究比原的Dashboard是怎麼做出來的。為什麼選擇這個呢?因為它非常以一種非常直觀的方式,展示了比原向我們提供的各種資訊和功能。在本文中,我們並不過多的講解它上面的功能,而是把關注點放在比原到底是如何在程式碼層面上實現了這樣的一個Dashboard。它上面的功能,將會在以後慢慢研究。
我們今天的問題是“比原的Dashboard是怎麼做出來的”,但是這個問題有點大,並且不夠具體,所以我們還是跟以前一樣,先來把它細分一下:
- 我們怎樣在比原中啟用Dashboard功能?
- Dashboard中提供了哪些資訊和功能?
- 比原是如何實現了http伺服器?
- Dashboard使用了什麼樣的前端框架?
- Dashboard上面的資料,是以什麼樣的方式從後臺拿到的?
我們下面開始一一探討。
我們怎樣在比原中啟用Dashboard功能?
當我們使用bytomd node
啟動比原節點的時候,不需要任何配置,它就會自動啟用Dashboard功能,並且會在瀏覽器中開啟頁面,非常方便。
如果是第一次執行,還沒有建立過帳戶,它會提示我們建立一個帳戶及相關的私鑰:
我們可以通過填寫帳戶別名、金鑰別名和相應的密碼來建立,或者點選下面的"Restore wallet"來恢復之前的帳號(如果之前備份過的話):
點選"Register"後,就會建立成功,並進入管理頁面:
注意它的地址是:http://127.0.0.1:9888/dashboard
如果我們檢視配置檔案config.toml
,可以在其中看到它的身影:
fast_sync = true
db_backend = "leveldb"
api_addr = "0.0.0.0:9888"
chain_id = "solonet"
[p2p]
laddr = "tcp://0.0.0.0:46658"
seeds = ""
注意其中的api_addr
,就是dashboard以及web-api的地址。比原在啟動之後,其BaseConfig.ApiAddress
會從配置檔案中取到相應的值:
type BaseConfig struct {
// ...
ApiAddress string `mapstructure:"api_addr"`
// ...
}
然後在啟動時,比原的web api以及dashboard會使用該地址,並且在瀏覽器中開啟dashboard。
然而此處有一個奇怪的問題,就是不論這裡的值是什麼,瀏覽器總是開啟http://localhost:9888
這個地址。為什麼呢?因為它寫死在了程式碼中。
在程式碼中,http://localhost:9888
一共出現在了三個地方,一個是用來表示dashboard的訪問地址,位於node/node.go
中:
const (
webAddress = "http://127.0.0.1:9888"
expireReservationsPeriod = time.Second
maxNewBlockChSize = 1024
)
這裡的webAddress
,只在從程式碼中開啟瀏覽器顯示dashboard時使用:
func lanchWebBroser() {
log.Info("Launching System Browser with :", webAddress)
if err := browser.Open(webAddress); err != nil {
log.Error(err.Error())
return
}
}
比原通過"github.com/toqueteos/webbrowser"
這個第三方的庫,可以在節點啟動的時候,呼叫系統預設的瀏覽器,並開啟指定的網址,方便了使用者。(注意這段程式碼中有不少錯別字,比如lanch
、broser
,已在後續版本中修正了)
另一個地方,是用於bytomcli
這個命令列工具的,只是奇怪的是它放在了util/util.go
下面:
var (
coreURL = env.String("BYTOM_URL", "http://localhost:9888")
)
為什麼說它是屬於bytomcli
的呢?因為這個coreURL
最終被用在util
包下的一個ClientCall(...)
函式中,用於從程式碼中向指定的web api傳送請求,並使用其回覆資訊。但是這個方法在bytomcli
所在的包使用。如果是這樣的話,coreURL
及相關的函式,應該移到bytomcli
包裡才對。
第三個地方,跟第二個非常像,但是位於tools/sendbulktx/core/util.go
中,它是用於另一個命令列工具sendbulktx
的:
tools/sendbulktx/core/util.go#L26-L28
var (
coreURL = env.String("BYTOM_URL", "http://localhost:9888")
)
一模一樣,對吧。其實不光是這裡,還有一堆相關的方法和函式,也是一模一樣的,一看就是跟第二處互相複製過來的。
關於這裡的問題,我提了兩個issue:
dashboard和web api的地址寫在配置檔案config.toml中,但是同時寫死在程式碼中:這裡在實現上的確是有一定難度的,原因是在配置檔案中,寫的是
0.0.0.0:9998
,但是從瀏覽器或者命令列工具中去訪問時,需要使用一個具體的ip(而不是0.0.0.0
),否則某些功能會不正常。另外,在後面的程式碼分析處會看到,除了配置檔案中的這個地址,比原還會優先從環境變數中取得LISTEN
所對應的地址web api的地址。所以這裡需要更多的研究才能正確修復。與讀取webapi相關的程式碼出現大量重複:官方解釋說
sendbulktx
這個工具在未來將從bytom專案中獨立出去,所以程式碼是重複的,如果是這樣的話,可以接受。
Dashboard中提供了哪些資訊和功能?
下面我們快速過一遍比原的Dashboard提供了哪些資訊和功能。由於在本文中,我們關注的重點不是這些具體的功能,所以會不會細究。另外,前面剛建立好的帳號裡,很多資料都是沒有的,為了展示方便,我事先做了一些資料。
首先是金鑰:
這裡顯示了當前有幾個金鑰,其別名是什麼,並且顯示出來了主公鑰。我們可以點選右上角的“新建”按鈕建立多個金鑰,但是這裡不再展示。
帳戶:
資產:
預設只定義了BTM
這一種資產,可以通過“新建”按鈕增加多種資產。
餘額:
看起來我還是相當有錢的(可惜不能用)。
交易:
展示了多筆交易,實際上是在本機挖礦挖出來的。由於挖礦出來的BTM是由系統直接轉到我們的帳戶上的,所以也可以看作是一種交易。
建立交易:
我們也可以像這樣自己建立交易,把我們持有的某種資產(比如BTM)轉到另一個地址。
未花費輸出:
簡單的理解就是與我相關的每一筆交易都被記錄下來,有輸入和輸出部分,其中的輸出可能又是另一個交易的輸入。這裡顯示的是還沒有花費掉的輸出(可以根據它來計算我當前到底還剩下多少餘額)
檢視核心狀態:
定義訪問控制:
備份和還原操作:
另外每個頁面左側欄的下面,還有關於連線的鏈的型別(此處為solonet
),以及同步情況和與當前節點連線的其它節點數。
這裡展示的資訊和功能我們還不需要細究,但是這裡出現的名詞卻是要留意的,因為它們都是比原的核心概念。等我們以後研究比原內部區塊鏈核心功能的時候,實際上都是圍繞著它們來的。這裡的每一個概念,可能都需要一到多篇文章專門討論。
我們在今天關注的是技術實現層面,下面我們要開始進入程式碼時間了。
比原是如何實現了http伺服器?
首先讓我們從比原節點啟動開始,一直找到啟動http服務的地方:
func main() {
cmd := cli.PrepareBaseCmd(commands.RootCmd, "TM", os.ExpandEnv(config.DefaultDataDir()))
cmd.Execute()
}
cmd/bytomd/commands/run_node.go#L41-L54
func runNode(cmd *cobra.Command, args []string) error {
// Create & start node
n := node.NewNode(config)
if _, err := n.Start(); err != nil {
// ..
}
func (n *Node) OnStart() error {
// ...
n.initAndstartApiServer()
// ...
}
很快找到了,initAndstartApiServer
:
func (n *Node) initAndstartApiServer() {
// 1.
n.api = api.NewAPI(n.syncManager, n.wallet, n.txfeed, n.cpuMiner, n.miningPool, n.chain, n.config, n.accessTokens)
// 2.
listenAddr := env.String("LISTEN", n.config.ApiAddress)
env.Parse()
// 3.
n.api.StartServer(*listenAddr)
}
可以看到,該方法分成了三部分:
- 通過傳入大量的引數,來構造一個
API
物件。進去後會看到大量的與url相關的配置。 - 先從環境中取得
LISTEN
對應的值,如果沒有的話,再使用config.toml
中指定的api_addr
值,作為api服務的入口地址 - 真正啟動服務
由於2比較簡單,所以我們下面將仔細分析1和3.
先找到1處所對應的api.NewAPI
方法:
func NewAPI(sync *netsync.SyncManager, wallet *wallet.Wallet, txfeeds *txfeed.Tracker, cpuMiner *cpuminer.CPUMiner, miningPool *miningpool.MiningPool, chain *protocol.Chain, config *cfg.Config, token *accesstoken.CredentialStore) *API {
api := &API{
sync: sync,
wallet: wallet,
chain: chain,
accessTokens: token,
txFeedTracker: txfeeds,
cpuMiner: cpuMiner,
miningPool: miningPool,
}
api.buildHandler()
api.initServer(config)
return api
}
它主要就是把傳進來的各引數拿住,供後面使用。然後就是api.buildHandler
來配置各個功能點的路徑和處理函式,以及用api.initServer
來初始化服務。
進入api.buildHandler()
。這個方法有點長,把它分成幾部分來講解:
func (a *API) buildHandler() {
walletEnable := false
m := http.NewServeMux()
看來http服務使用的是Go自帶的http
包。
向下是,當使用者的錢包功能沒有禁用的話,就會配置與錢包相關的各功能點(比如帳號、交易、金鑰等):
if a.wallet != nil {
walletEnable = true
m.Handle("/create-account", jsonHandler(a.createAccount))
m.Handle("/list-accounts", jsonHandler(a.listAccounts))
m.Handle("/delete-account", jsonHandler(a.deleteAccount))
m.Handle("/create-account-receiver", jsonHandler(a.createAccountReceiver))
m.Handle("/list-addresses", jsonHandler(a.listAddresses))
m.Handle("/validate-address", jsonHandler(a.validateAddress))
m.Handle("/create-asset", jsonHandler(a.createAsset))
m.Handle("/update-asset-alias", jsonHandler(a.updateAssetAlias))
m.Handle("/get-asset", jsonHandler(a.getAsset))
m.Handle("/list-assets", jsonHandler(a.listAssets))
m.Handle("/create-key", jsonHandler(a.pseudohsmCreateKey))
m.Handle("/list-keys", jsonHandler(a.pseudohsmListKeys))
m.Handle("/delete-key", jsonHandler(a.pseudohsmDeleteKey))
m.Handle("/reset-key-password", jsonHandler(a.pseudohsmResetPassword))
m.Handle("/build-transaction", jsonHandler(a.build))
m.Handle("/sign-transaction", jsonHandler(a.pseudohsmSignTemplates))
m.Handle("/submit-transaction", jsonHandler(a.submit))
m.Handle("/estimate-transaction-gas", jsonHandler(a.estimateTxGas))
m.Handle("/get-transaction", jsonHandler(a.getTransaction))
m.Handle("/list-transactions", jsonHandler(a.listTransactions))
m.Handle("/list-balances", jsonHandler(a.listBalances))
m.Handle("/list-unspent-outputs", jsonHandler(a.listUnspentOutputs))
m.Handle("/backup-wallet", jsonHandler(a.backupWalletImage))
m.Handle("/restore-wallet", jsonHandler(a.restoreWalletImage))
} else {
log.Warn("Please enable wallet")
}
錢包功能預設是啟用的,使用者如何才能禁用它呢?方法是在配置檔案config.toml
中,加上這一節程式碼:
[wallet]
disable = true
在前面的程式碼中,在配置功能點時,使用了大量的m.Handle("/create-account", jsonHandler(a.createAccount))
這樣的程式碼,它是什麼意思呢?
/create-account
:該功能的路徑,比如對於這個,使用者需要在瀏覽器或者命令列中,使用地址http://localhost:9888/create-account
來訪問a.createAccount
:用於處理使用者的訪問,比如拿到使用者提供的資料,處理完後再返回某個資料給使用者,會在下面詳解jsonHandler
:是一箇中間層,把使用者傳送的JSON資料轉成第2步handler需要的Go型別引數,或者把2返回的Go資料轉成JSON給使用者m.Handle(path, handler)
:用來把功能點路徑和相應的處理函式對應起來
這裡先看第3步中的jsonHandler
的程式碼:
func jsonHandler(f interface{}) http.Handler {
h, err := httpjson.Handler(f, errorFormatter.Write)
if err != nil {
panic(err)
}
return h
}
它裡面用到了httpjson
,它是比原始碼中提供的一個包,位於net/http/httpjson 。它的功能主要是為了在http訪問與Go的函式之間增加了一層轉換。通常使用者通過http與api互動的時候,傳送和接收的都是JSON資料,而我們在第2步的handler中定義的是Go函式,通過httpjson
,可以在兩者之間自動轉換,使得我們在寫Go程式碼的時候,不需要考慮JSON以及http協議相關的問題。相應的,為了與jsonhttp配合使用,第2步中的handler在格式上也會有一些要求,詳情可參見這裡的詳細註釋:net/http/httpjson/doc.go#L3-L40 。由於httpjson所涉及的程式碼還比較多,這裡就不詳述,以後有機會專開一篇。
然後我們再看第2步的a.createAccount
的程式碼:
func (a *API) createAccount(ctx context.Context, ins struct {
RootXPubs []chainkd.XPub `json:"root_xpubs"`
Quorum int `json:"quorum"`
Alias string `json:"alias"`
}) Response {
acc, err := a.wallet.AccountMgr.Create(ctx, ins.RootXPubs, ins.Quorum, ins.Alias)
if err != nil {
return NewErrorResponse(err)
}
annotatedAccount := account.Annotated(acc)
log.WithField("account ID", annotatedAccount.ID).Info("Created account")
return NewSuccessResponse(annotatedAccount)
}
這個函式的內容我們在這裡不細究,需要注意的反而是它的格式,因為前面說了,它需要跟jsonHandler
配合使用。格式的要求大概就是,第一個引數是Context
,第二個引數是可以從JSON資料轉換過來的引數,返回值是一個Response以及一個Error,但是這四個又全部是可選的。
讓我們回到api.buildHandler()
,繼續往下:
m.Handle("/", alwaysError(errors.New("not Found")))
m.Handle("/error", jsonHandler(a.walletError))
m.Handle("/create-access-token", jsonHandler(a.createAccessToken))
m.Handle("/list-access-tokens", jsonHandler(a.listAccessTokens))
m.Handle("/delete-access-token", jsonHandler(a.deleteAccessToken))
m.Handle("/check-access-token", jsonHandler(a.checkAccessToken))
m.Handle("/create-transaction-feed", jsonHandler(a.createTxFeed))
m.Handle("/get-transaction-feed", jsonHandler(a.getTxFeed))
m.Handle("/update-transaction-feed", jsonHandler(a.updateTxFeed))
m.Handle("/delete-transaction-feed", jsonHandler(a.deleteTxFeed))
m.Handle("/list-transaction-feeds", jsonHandler(a.listTxFeeds))
m.Handle("/get-unconfirmed-transaction", jsonHandler(a.getUnconfirmedTx))
m.Handle("/list-unconfirmed-transactions", jsonHandler(a.listUnconfirmedTxs))
m.Handle("/get-block-hash", jsonHandler(a.getBestBlockHash))
m.Handle("/get-block-header", jsonHandler(a.getBlockHeader))
m.Handle("/get-block", jsonHandler(a.getBlock))
m.Handle("/get-block-count", jsonHandler(a.getBlockCount))
m.Handle("/get-difficulty", jsonHandler(a.getDifficulty))
m.Handle("/get-hash-rate", jsonHandler(a.getHashRate))
m.Handle("/is-mining", jsonHandler(a.isMining))
m.Handle("/set-mining", jsonHandler(a.setMining))
m.Handle("/get-work", jsonHandler(a.getWork))
m.Handle("/submit-work", jsonHandler(a.submitWork))
m.Handle("/gas-rate", jsonHandler(a.gasRate))
m.Handle("/net-info", jsonHandler(a.getNetInfo))
可以看到還是各種功能的定義,主要是跟區塊資料、挖礦、訪問控制等相關的功能,這裡就不詳述了。
再繼續:
handler := latencyHandler(m, walletEnable)
handler = maxBytesHandler(handler)
handler = webAssetsHandler(handler)
handler = gzip.Handler{Handler: handler}
a.handler = handler
}
這裡是把前面定義的功能點配置包成了一個handler,然後在它外面包了一層又一層,新增上了更多的功能:
latencyHandler
:我目前還不能準確說出它的作用,留待以後補充maxBytesHandler
:防止使用者提交的資料過大,目前值約為10MB
。對於除signer/sign-block
以外的url有效webAssetsHandler
:向使用者提供dashboard相關的前端頁面資源(比如網頁、圖片等等)。可能是為了效能和方便性方面的考慮,前端檔案都經過混淆後,以字串形式嵌入在dashboard/dashboard.go中,真正的程式碼在另一個專案中 https://github.com/Bytom/dashboard,我們在後面會看一下gzip.Handler
:對http客戶端進行是否支援gzip
的檢測,並且在支援的情況下,傳輸資料時使用gzip壓縮
然後讓我們回到主線,看看前面的NewAPI
中最後呼叫的api.initServer(config)
:
func (a *API) initServer(config *cfg.Config) {
// The waitHandler accepts incoming requests, but blocks until its underlying
// handler is set, when the second phase is complete.
var coreHandler waitHandler
var handler http.Handler
coreHandler.wg.Add(1)
mux := http.NewServeMux()
mux.Handle("/", &coreHandler)
handler = mux
if config.Auth.Disable == false {
handler = AuthHandler(handler, a.accessTokens)
}
handler = RedirectHandler(handler)
secureheader.DefaultConfig.PermitClearLoopback = true
secureheader.DefaultConfig.HTTPSRedirect = false
secureheader.DefaultConfig.Next = handler
a.server = &http.Server{
// Note: we should not set TLSConfig here;
// we took care of TLS with the listener in maybeUseTLS.
Handler: secureheader.DefaultConfig,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
// Disable HTTP/2 for now until the Go implementation is more stable.
// https://github.com/golang/go/issues/16450
// https://github.com/golang/go/issues/17071
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
}
coreHandler.Set(a)
}
這個方法在本文不適合細講,因為它更多的是涉及到http層面的一些東西,不是本文的重點。值得關注的地方是,方法建立了一個Go提供的http.Server
,把前面我們辛苦配置好的handler塞進去,萬事俱備,只欠啟動。
下面就是啟動啦。我們終於可以回到最新的initAndstartApiServer
方法了,還記得它的第3塊內容嗎?主要就是呼叫了n.api.StartServer(*listenAddr)
:
func (a *API) StartServer(address string) {
// ...
listener, err := net.Listen("tcp", address)
// ...
go func() {
if err := a.server.Serve(listener); err != nil {
log.WithField("error", errors.Wrap(err, "Serve")).Error("Rpc server")
}
}()
}
這塊比較簡單,就是使用Go的net.Listen
來監聽傳入的web api地址,得到相應的listener之後,把它傳給我們在前面建立的http.Server
的Serve
方法,就大功告成了。
這一塊程式碼分析寫得十分痛苦,主要原因是它的web api這裡幾乎涉及到了所有比原提供的功能,很龐雜。還有不少跟http協議相關的東西。同時,因為暴露出了介面,這裡就容易出現安全風險,所以程式碼裡面還有不少涉及到使用者輸入、安全檢查等。這些東西當然是非常重要的,但是從程式碼閱讀的角度上來講又難免枯燥,除非我們就是為了研究安全性。
本文的任務主要是研究比原是如何提供http服務的,關於比原在安全性方面做了哪些事情,以後會有專門的分析。
Dashboard使用了什麼樣的前端框架?
比原的前端程式碼是在另一個獨立的專案中:https://github.com/Bytom/dashboard
本文我們並不去探討程式碼細節,而僅僅去看一下它使用了哪些前端框架,有個大概印象即可。
通過https://github.com/Bytom/dashboard/blob/master/package.json我們就可以大概瞭解到,比原前端使用了:
- 構建工具:直接利用
npm
的Scripts
- 前端框架:
React
+Redux
- CSS方面:
bootstrap
- JavaScript:ES6
- http請求:
fetch-ponyfill
- 資源打包:
webpack
- 測試:
mocha
Dashboard上面的資料,是以什麼樣的方式從後臺拿到的?
以Account相關的程式碼為例:
const accountsAPI = (client) => {
return {
create: (params, cb) => shared.create(client, '/create-account', params, {cb, skipArray: true}),
createBatch: (params, cb) => shared.createBatch(client, '/create-account', params, {cb}),
// ...
listAddresses: (accountId) => shared.query(client, 'accounts', '/list-addresses', {account_id: accountId}),
}
}
這些函式主要是通過fetch-ponyfill
庫中提供的方法,向向前面使用go建立的web api介面傳送http請求,並且拿到相應的回覆資料。而它們又將在React元件中被呼叫,拿回來的資料用於填充頁面。
同樣,更細節的內容在本文就不講啦。
終於,經過這一大篇的分析,我覺得我對於比原的Dashboard是怎麼做出來的,有了一些基本的印象。剩下的,就是在以後,針對其中的功能進行細緻的研究。
相關文章
- 剝開比原看程式碼14:比原的挖礦流程是什麼樣的?
- 剝開比原看程式碼04:如何連上一個比原節點
- 剝開比原看程式碼05:如何從比原節點拿到區塊資料?
- 剝開比原看程式碼02:比原啟動後去哪裡連線別的節點
- 剝開比原看程式碼03:比原是如何監聽p2p埠的
- 剝開比原看程式碼11:比原是如何通過介面/create-account建立帳戶的
- 剝開比原看程式碼07:比原節點收到“請求區塊資料”的資訊後如何應答?
- 剝開比原看程式碼06:比原是如何把請求區塊資料的資訊發出去的
- 剝開比原看程式碼01:初始化時生成的配置檔案在哪兒
- 原來 Element 的元件原始碼還能這麼看元件原始碼
- UI設計學習的對比原則怎麼運用?UI
- 領導駕駛艙是怎麼做出來的?
- Derek解讀Bytom原始碼-protobuf生成比原核心程式碼原始碼
- 覺得還是敲程式碼比較來勁
- 程式媛眼中的程式猿原來是這樣子的!
- 從vue2.6.10原始碼看vue是怎麼跑起來的Vue原始碼
- 原來Java的發家史是這麼回事Java
- 這 10 行比較字串相等的程式碼給我整懵逼了,不信你也來看看|原創版字串
- 那些讓你無法自拔的遊戲們是怎麼做出來的遊戲
- CyclicBarrier原來是這樣的
- 一比一還原axios原始碼(零)—— 概要iOS原始碼
- 為什麼說無程式碼開發比低程式碼開發更好?
- 原畫人場景原畫教程,畫場景的思路是怎麼樣的?
- 《Pizza Tower》:以“速度”為核心的平臺遊戲是怎麼做出來的?遊戲
- Event Loop 原來是這麼回事OOP
- 原來你是這樣的switch~
- 原來你是這樣的FlutterFlutter
- 原來你是這樣的PromisePromise
- 一比一還原axios原始碼(八)—— 其他功能iOS原始碼
- 一比一還原axios原始碼(四)—— Axios類iOS原始碼
- 一比一還原axios原始碼(六)—— 配置化iOS原始碼
- 程式碼是怎麼執行的?
- 原來不懂程式碼的人也可以開發簡單的小應用
- JavaScript 中的原型原來是這樣的JavaScript原型
- 【原創】InnoDB 和TokuDB的讀寫分析與比較
- 疫情之下,這幾款App Store獲獎遊戲是怎麼做出來的?APP遊戲
- 求助,像這種 VR 看房的網頁到底是怎麼做出來的,需要哪些技術棧?要用到哪些 JS?VR網頁JS
- 【Spring】原來SpringBoot是這樣玩的Spring Boot