第一部分 學習使用ZeroMQ
在本書的第一部分中,你將會學習如何使用ZeroMQ。第一部分將覆蓋ZeroMQ的基礎內容,其中包括API,各種socket型別及其工作原理,可靠性以及可以在應用中使用的大量模式。從頭至尾閱讀本部分和示例程式碼,你會獲得不少的收穫。
第一章 基礎
- 修復世界
- 這本書的讀者
- 獲取例項
- 使用字串需要注意的小問題
- 一看就會
- 版本報告
- 將訊息分發出去
- 分而治之
- 使用ZeroMQ進行程式設計
- 正確獲取上下文
- 乾淨的退出
- 為什麼我們需要ZeroMQ
- 套接字擴充套件性
- 從ZeroMQ2.2升級到3.2
- 警告:不穩定正規化
修復世界
修復世界
怎樣解釋ZeroMQ是什麼呢? 我們中的一些人通過它完成的所有美好的事來解釋它:它是增強版的套接字(socket),它像是具有路由功能的郵箱,它很快!還有一些人試著與人分享忽然領悟到新思維時的感覺,一切都變得那麼顯然:事情變得更簡單了,複雜性不見了,思想更開放了。另一些人他人試著通過比較來解釋:它更小,更簡潔,而且看起來更加熟悉。我個人想要通過本書回顧為什麼ZeroMQ會獲得成功,這也許也是你想要知道的。
程式設計是把自己打扮成藝術的科學,因為大部分人都不瞭解軟體的物理世界,也很少有人告訴過你。軟體的物理世界不是演算法,資料結構,語言和抽象。我們製作這些工具,使用它們,然後把它們丟掉。真實的軟體物理世界就是人類的物理世界。
具體而言,當事情變得複雜時,我們希望將大型問題分解成小塊來解決它。這就是程式設計的科學:製作可以理解並且很容易使用的構件,一起工作來解決很大的問題。
我們生活在一個相互連線的世界,現代軟體需要為世界導航。所以對於未來大型解決方案的構件是相互連線和大規模並行的。程式碼只是“安靜健壯”(strong and silent)還遠遠不夠。程式碼還需要與其他程式碼交流。程式碼必須是健談的,社會性的,連線良好的(well-connected)。程式碼需要像人腦一樣執行,數萬億獨立的神經元互相傳送訊息構成超大規模並行網路,沒有中心控制,也沒有單點故障,還能夠解決非常困難的問題。毫無疑問未來的程式碼會像人類的大腦,每個網路的每個末端在某種程度上是人類的大腦。
如果你曾使用過執行緒,協議或是網路完成工作,你就會意識到這幾乎是不可能的,這就是一個夢想。即使通過幾個套接字連線幾個程式,在真實場景中的情況也明顯是痛苦的。數萬億?成本將是不可想象的。要將這些計算機連線起來非常困難,做這事的軟體和服務可是價值數十億美元的業務。
因此,當前網路佈線已經超前了數年,已經超過了我們的使用能力。在20世紀80年代有一次軟體危機,當時弗雷德·布魯克斯(Fred Brooks)等權威的軟體工程師相信沒有銀彈能夠保證在生產力,可靠性,簡單性上有一個數量級的提升。
布魯克斯錯過了自由和開源軟體,自由和開源軟體解決了這一危機。使我們能夠有效的分享知識。今天,我們正面對另一個軟體危機,我們對它談論的並不多。只有最大,最富有的企業才能建立相互連線的應用程式。一種解決方案是雲端計算,不過雲端計算是專有的。資料和知識從個人電腦中消失轉移到雲端,這使得我們無法訪問和參與計算。誰擁有我們的社交網路?這就像是大型機到個人電腦革命的反向過程。
我們可以離開另一本書中的政治哲學。問題的關鍵是,網際網路提供了連線大量程式碼的潛在能力,而現實的情況是大多數人接觸不到這些程式碼。所以,大型有趣的問題(健康,教育,經濟,交通等)仍然沒有得到解決,因為有沒有辦法把程式碼連線起來,所以就沒有辦法把大腦連線起來合作解決這些問題。
對於解決軟體連線帶來的挑戰,已經有過許多嘗試。有數千種IETF規範,每個解決難題的一部分。對於應用程式開發人員來說,HTTP也許是一個足夠易用的解決方案,HTTP鼓勵開發者和架構師們按照大型伺服器加簡單愚笨的客戶端的方式進行思考,在這一點可以認為HTTP使問題變得更糟。
現在人們仍然用原始的UDP,TCP,私有協議,HTTP,Websocket來連線應用程式。它仍然是痛苦的,緩慢的,難以形成規模,本質上是集中式的。分散式P2P框架多數用於遊戲而不是工作。有多少應用使用Skype或Bittorrent交換資料?
這把我們帶回到程式設計的科學。為了修復這個世界,我們需要做兩件事情。一,解決“如何把任意地點的任意程式碼連線起來”的一般問題。二,將解決方案包裝成人們可以輕鬆地理解和使用的構建。
簡單得讓人覺得好笑。也許是吧。而這就是問題的關鍵。
把訊息分發出去
第二個經典模式是單向的資料分發,即伺服器將更新推送到一組客戶端。讓我們看一個例子,天氣更新的推送服務,其中的資料包括郵政編碼,溫度和相對溼度。我們將使用隨機生成的值,真實的氣象站可能也是這麼做的。
下面是天氣更新伺服器,應用程式使用5556埠。
// Weather update server
// Binds PUB socket to tcp://*:5556
// Publishes random weather updates
//
#include "zhelpers.h"
int main (void)
{
// Prepare our context and publisher
void *context = zmq_ctx_new ();
void *publisher = zmq_socket (context, ZMQ_PUB);
int rc = zmq_bind (publisher, "tcp://*:5556");
assert (rc == 0);
rc = zmq_bind (publisher, "ipc://weather.ipc");
assert (rc == 0);
// Initialize random number generator
srandom ((unsigned) time (NULL));
while (1) {
// Get values that will fool the boss
int zipcode, temperature, relhumidity;
zipcode = randof (100000);
temperature = randof (215) - 80;
relhumidity = randof (50) + 10;
// Send message to all subscribers
char update [20];
sprintf (update, "%05d %d %d", zipcode, temperature, relhumidity);
s_send (publisher, update);
}
zmq_close (publisher);
zmq_ctx_destroy (context);
return 0;
}
wuserver: Weather update server in C
天氣的更新沒有開始和結束,就像無窮無盡的廣播節目。
下面是客戶端應用,監聽天氣的更新並獲取指定的郵政編碼對應的更新資料。預設使用紐約的郵政編碼10001,因為紐約是一切探險活動開始的好地方。
//
// Weather update client
// Connects SUB socket to tcp://localhost:5556
// Collects weather updates and finds avg temp in zipcode
//
#include "zhelpers.h"
int main (int argc, char *argv [])
{
void *context = zmq_ctx_new ();
// Socket to talk to server
printf ("Collecting updates from weather server…\n");
void *subscriber = zmq_socket (context, ZMQ_SUB);
int rc = zmq_connect (subscriber, "tcp://localhost:5556");
assert (rc == 0);
// Subscribe to zipcode, default is NYC, 10001
char *filter = (argc > 1)? argv [1]: "10001 ";
rc = zmq_setsockopt (subscriber, ZMQ_SUBSCRIBE, filter, strlen (filter));
assert (rc == 0);
// Process 100 updates
int update_nbr;
long total_temp = 0;
for (update_nbr = 0; update_nbr < 100; update_nbr++) {
char *string = s_recv (subscriber);
int zipcode, temperature, relhumidity;
sscanf (string, "%d %d %d",
&zipcode, &temperature, &relhumidity);
total_temp += temperature;
free (string);
}
printf ("Average temperature for zipcode '%s' was %dF\n",
filter, (int) (total_temp / update_nbr));
zmq_close (subscriber);
zmq_ctx_destroy (context);
return 0;
}
wuclient: Weather update client in C
注意當你使用SUB套接字時,必須像示例中那樣通過zmq_setsockopt()
設定SUBSCRIBE訂閱選項。如果沒有設定任何訂閱,就不會收到訊息。這是新手常犯的一個錯誤。訂閱者可以設定多個訂閱選項,它們都會生效。也就是說,訂閱者會收到匹配任何訂閱請求的更新。訂閱者也可以取消指定的訂閱。一個訂閱通常是可列印的字串,不過也可以是其他的東西。參閱zmq_setsockopt()
來理解這句話。
PUB-SUB釋出訂閱套接字對是非同步的。客戶端迴圈呼叫(如果只需要一次就呼叫一次) zmq_msg_recv()
,如果在SUB套接字上傳送訊息會產生錯誤。類似的,伺服器可以按需要呼叫zmq_msg_send()
,但是在不能在PUB套接字上呼叫zmq_msg_recv()
。
ZeroMQ的理論是不考慮連線的對端是什麼,對端繫結的套接字是什麼。然而在實際使用中,還是有一些文件中沒有指出的不同指出,我們會在後面解釋這些不同。現在除非網路使得繫結PUB然後連線到SUB不再可行,否則你就以這種方式使用吧。
關於PUB-SUB套接字還有一點需要注意:你不知道訂閱者何時接收訊息。即使先啟動了訂閱者,過一會再啟動釋出者,訂閱者將總是無法獲得釋出者傳送的第一個訊息。這是因為當訂閱者在向釋出者建立連線時(需要一小段時間),釋出者可能已經把訊息發出去了。
這種"延遲加入"的現象困擾了很多人,讓我們詳細的說明一下。ZeroMQ在後臺以非同步I/O方式工作。假設你有兩個節點按照順序做下面的事:
- 訂閱者連線到一個端點,然後接受訊息並計數
- 釋出者繫結到一個端點,立即傳送1000條訊息
那麼訂閱者很有可能什麼也收不到。你可能會回過頭來檢查是否設定了正確的過濾器,重試後發現訂閱者還是收不到任何東西。
建立TCP連線涉及三次握手,而三次握手需要幾毫秒的時間,這取決於網路狀況和節點之間下一跳的跳數。在這幾毫秒的時間裡,ZeroMQ可以發出很多訊息。為了支援這一觀點,假設建立連線需要5毫秒,而相同的鏈路可以每秒傳送1兆數量的訊息。在訂閱者向釋出者建立連線的5毫秒時間裡,釋出者只需要1毫秒把1千個訊息發出去。
在套接字與模式一章我們將會解釋如何同步訂閱者和釋出者,使得訂閱者建立連線就緒後,釋出者再傳送資料。有一種簡單愚蠢的延遲釋出的方法,即使用sleep休眠。不要在實際的應用中使用它,它太脆弱了,很不優雅,降低效能。先使用休眠證明發生了什麼,然後到套接字與模式一章檢視正確處理的方式。
同步的另一種替代方案是簡單假設釋出的資料流是無限的,沒有開始也沒有結束。也可以假設訂閱者不關心啟動之前發生的事情。這就是天氣客戶端的例子。
客戶端訂閱一個郵政編碼然後收集此郵政編碼的一千次更新。如果郵政編碼是隨機分佈的,伺服器大約需要更新一千萬次。你可以先啟動客戶端,然後啟動伺服器,客戶端仍會正常工作。只要你喜歡你可以停止和重啟伺服器多次,客戶端仍會正常工作。當客戶端收集到一千條更新後將計算並列印出平均值,然後退出程式。
下面是釋出 - 訂閱模式的一些要點:
- 訂閱者可以連線到不止一個釋出者,每個連線使用一次
connect
呼叫。訂閱者會從不同的連線輪流(“公平佇列”)收取資料,不會有單個的釋出者被遺漏在外。 - 如果釋出者沒有與之相連的訂閱者,釋出者就會簡單的把訊息丟棄
- 如果使用的是TCP並且訂閱者處理得比較緩慢,訊息會在釋出者一端排隊等待處理。在後面的章節中,我們會使用“高水位”來保護髮布者佇列。
- 從ZeroMQ 3.x 開始,當使用連線協議時(tcp 或 ipc)過濾操作將在釋出者一端執行。在ZeroMQ 2.x 所有的過濾操作都在訂閱者一端執行。
下面是在我的筆記本(2011-era Intel i5)上接收並過濾10M條訊息所需的時間,結果還不錯。
$ time wuclient
Collecting updates from weather server...
Average temperature for zipcode '10001 ' was 28F
real 0m4.470s
user 0m0.000s
sys 0m0.008s
一看就會
讓我們從一點程式碼開始。第一份程式碼示例當然是Hello World。我們會建立客戶端和伺服器。客戶端向伺服器傳送“Hello”,伺服器回覆“World”。下面是C語言伺服器的實現,其中在埠5555開啟了一個ZeroMQ套接字,在套接字上讀取請求,每收到一個請求就回復“World”:
int main (void)
{
void *context = zmq_ctx_new ();
// Socket to talk to clients
void *responder = zmq_socket (context, ZMQ_REP);
zmq_bind (responder, "tcp://*:5555");
while (1) {
// Wait for next request from client
zmq_msg_t request;
zmq_msg_init (&request);
zmq_msg_recv (&request, responder, 0);
printf ("Received Hello\n");
zmq_msg_close (&request);
// Do some 'work'
sleep (1);
// Send reply back to client
zmq_msg_t reply;
zmq_msg_init_size (&reply, 5);
memcpy (zmq_msg_data (&reply), "World", 5);
zmq_msg_send (&reply, responder, 0);
zmq_msg_close (&reply);
}
// We never get here but if we did, this would be how we end
zmq_close (responder);
zmq_ctx_destroy (context);
return 0;
}
hwserver.c: Hello World server
圖2 請求和應答
REQ-REP 請求應答套接字對是同步的。客戶端迴圈順序發起(如果只需要一次請求就發起一次) zmq_msg_send()
和 zmq_msg_recv()
。其他的順序(例如一行傳送兩條訊息)會導致 send
或 recv
的呼叫返回 -1 。類似的,伺服器每次需要順序發起 zmq_msg_recv()
和 zmq_msg_send()
。
ZeroMQ使用C語言作為它的參考語言並且C語言也是書中例子使用的主要語言。如果你以線上的方式閱讀本書,例子下面的連線包含有其他程式語言的實現版本。讓我們看一下服務端的C++語言實現:
//
// Hello World server in C++
// Binds REP socket to tcp://*:5555
// Expects "Hello" from client, replies with "World"
//
#include <zmq.hpp>
#include <string>
#include <iostream>
#include <unistd.h>
int main () {
// Prepare our context and socket
zmq::context_t context (1);
zmq::socket_t socket (context, ZMQ_REP);
socket.bind ("tcp://*:5555");
while (true) {
zmq::message_t request;
// Wait for next request from client
socket.recv (&request);
std::cout << "Received Hello" << std::endl;
// Do some 'work'
sleep (1);
// Send reply back to client
zmq::message_t reply (5);
memcpy ((void *) reply.data (), "World", 5);
socket.send (reply);
}
return 0;
}
hwserver.cpp: Hello World server
你會發現ZeroMQ API的C和C++版本很相似。 在類似PHP的語言中,我們可以隱藏更多的細節,程式碼也更容易閱讀:
//
// Hello World server
// Binds REP socket to tcp://*:5555
// Expects "Hello" from client, replies with "World"
//
#include <zmq.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
<?php
/*
* Hello World server
* Binds REP socket to tcp://*:5555
* Expects "Hello" from client, replies with "World"
* @author Ian Barber <ian(dot)barber(at)gmail(dot)com>
*/
$context = new ZMQContext(1);
// Socket to talk to clients
$responder = new ZMQSocket($context, ZMQ::SOCKET_REP);
$responder->bind("tcp://*:5555");
while (true) {
// Wait for next request from client
$request = $responder->recv();
printf ("Received request: [%s]\n", $request);
// Do some 'work'
sleep (1);
// Send reply back to client
$responder->send("World");
}
hwserver.php: Hello World server
下面是客戶端的程式碼
//
// Hello World client
// Connects REQ socket to tcp://localhost:5555
// Sends "Hello" to server, expects "World" back
//
#include <zmq.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
int main (void)
{
void *context = zmq_ctx_new ();
// Socket to talk to server
printf ("Connecting to hello world server…\n");
void *requester = zmq_socket (context, ZMQ_REQ);
zmq_connect (requester, "tcp://localhost:5555");
int request_nbr;
for (request_nbr = 0; request_nbr != 10; request_nbr++) {
zmq_msg_t request;
zmq_msg_init_size (&request, 5);
memcpy (zmq_msg_data (&request), "Hello", 5);
printf ("Sending Hello %d…\n", request_nbr);
zmq_msg_send (&request, requester, 0);
zmq_msg_close (&request);
zmq_msg_t reply;
zmq_msg_init (&reply);
zmq_msg_recv (&reply, requester, 0);
printf ("Received World %d\n", request_nbr);
zmq_msg_close (&reply);
}
sleep (2);
zmq_close (requester);
zmq_ctx_destroy (context);
return 0;
}
hwclient: Hello World client in C
實際用起來太簡單了,如你所見,ZeroMQ套接字太強大了。這個伺服器可以輕鬆快速的同時處理數千計的客戶端。試著玩一下,先開啟客戶端然後再開啟伺服器,它仍然工作得很好,想想這意味著什麼。
讓我們簡單解釋一下這兩個程式究竟做了什麼。它們建立了一個用來工作的ZeroMQ上下文,還有一個套接字。不用關心這些詞的意思,待會你會明白的。伺服器繫結他的REP應答套接字到5555埠。伺服器迴圈等待請求,每收到一個請求就做出回應發出應答。客戶端傳送請求並讀取伺服器返回的應答。
如果殺死伺服器(Ctrl-C)然後重啟,客戶端無法正常恢復。從崩潰程式中恢復不是很容易。建立可靠地請求應答流過於複雜了,我們會在第4章可靠請求應答模式討論。
程式了背後隱藏了很多事情,對於程式設計師關心的是怎樣寫出簡明扼要,怎樣控制程式碼在高負荷下崩潰的頻率。這就是請求應答模式,這可能是使用ZeroMQ的最簡單方式,它對應於RPC和經典的客戶端伺服器模型。
分而治之
你可能已經厭倦了內涵豐富的程式碼,想要深入探討比較抽象的語言規範。做為最後一個例項,讓我們做一個超級計算服務。超級計算服務使用典型的並行設計,其中包括:
- 一個任務發生器,產生可以並行處理的任務
- 一組處理任務的工作者
- 一個收集工作程式處理結果的任務收集器(sink)
實際應用中,工作程式可以在非常快的硬體上執行,例如用GPU處理複雜的數學運算。下面是任務發生器的程式碼,它產生100個任務,每個任務通過訊息傳送給工作者,任務的內容就是讓工作程式休眠幾毫秒。
//
// Task ventilator
// Binds PUSH socket to tcp://localhost:5557
// Sends batch of tasks to workers via that socket
//
#include "zhelpers.h"
int main (void)
{
void *context = zmq_ctx_new ();
// Socket to send messages on
void *sender = zmq_socket (context, ZMQ_PUSH);
zmq_bind (sender, "tcp://*:5557");
// Socket to send start of batch message on
void *sink = zmq_socket (context, ZMQ_PUSH);
zmq_connect (sink, "tcp://localhost:5558");
printf ("Press Enter when the workers are ready: ");
getchar ();
printf ("Sending tasks to workers...\n");
// The first message is "0" and signals start of batch
s_send (sink, "0");
// Initialize random number generator
srandom ((unsigned) time (NULL));
// Send 100 tasks
int task_nbr;
int total_msec = 0; // Total expected cost in msecs
for (task_nbr = 0; task_nbr < 100; task_nbr++) {
int workload;
// Random workload from 1 to 100msecs
workload = randof (100) + 1;
total_msec += workload;
char string [10];
sprintf (string, "%d", workload);
s_send (sender, string);
}
printf ("Total expected cost: %d msec\n", total_msec);
sleep (1); // Give 0MQ time to deliver
zmq_close (sink);
zmq_close (sender);
zmq_ctx_destroy (context);
return 0;
}
Parallel task ventilator
下面是工作者程式程式碼。工作者每接收一條訊息,從中讀出休眠時間並休眠,然後發出完成訊號。
//
// Task worker
// Connects PULL socket to tcp://localhost:5557
// Collects workloads from ventilator via that socket
// Connects PUSH socket to tcp://localhost:5558
// Sends results to sink via that socket
//
#include "zhelpers.h"
int main (void)
{
void *context = zmq_ctx_new ();
// Socket to receive messages on
void *receiver = zmq_socket (context, ZMQ_PULL);
zmq_connect (receiver, "tcp://localhost:5557");
// Socket to send messages to
void *sender = zmq_socket (context, ZMQ_PUSH);
zmq_connect (sender, "tcp://localhost:5558");
// Process tasks forever
while (1) {
char *string = s_recv (receiver);
// Simple progress indicator for the viewer
fflush (stdout);
printf ("%s.", string);
// Do the work
s_sleep (atoi (string));
free (string);
// Send results to sink
s_send (sender, "");
}
zmq_close (receiver);
zmq_close (sender);
zmq_ctx_destroy (context);
return 0;
}
下面是收集器程式程式碼。它收集100個任務的結果,然後計算總的處理時間。處理時間可以用來驗證工作者是否是並行執行的。
//
// Task sink
// Binds PULL socket to tcp://localhost:5558
// Collects results from workers via that socket
//
#include "zhelpers.h"
int main (void)
{
// Prepare our context and socket
void *context = zmq_ctx_new ();
void *receiver = zmq_socket (context, ZMQ_PULL);
zmq_bind (receiver, "tcp://*:5558");
// Wait for start of batch
char *string = s_recv (receiver);
free (string);
// Start our clock now
int64_t start_time = s_clock ();
// Process 100 confirmations
int task_nbr;
for (task_nbr = 0; task_nbr < 100; task_nbr++) {
char *string = s_recv (receiver);
free (string);
if ((task_nbr / 10) * 10 == task_nbr)
printf (":");
else
printf (".");
fflush (stdout);
}
// Calculate and report duration of batch
printf ("Total elapsed time: %d msec\n",
(int) (s_clock () - start_time));
zmq_close (receiver);
zmq_ctx_destroy (context);
return 0;
}
批處理的平均時間是5秒。分別啟用1,2,4個工作者程式,收集器列印出的結果如下:
# 1 worker
Total elapsed time: 5034 msec
# 2 workers
Total elapsed time: 2421 msec
# 4 workers
Total elapsed time: 1018 msec
讓我們更加仔細的看看程式碼的以下幾個方面:
工作者連線上游的任務發生器和下游的任務收集器。這意味著可以新增任意數量的工作者。如果將工作者繫結到自身指定的端點,那麼每新增一個工作者就需要(a)新增額外的端點,(b)修改發生器或收集器。在整體架構中,可以認為生成器和收集器是穩定部分,而工作者是動態部分。
我們需要在開始時同步批處理,等待工作者啟動執行。這是ZeroMQ中常見的問題,沒有簡單的解決方案。connect方法需要一些時間。所以,當一組工作者連線到任務發生器時,第一個工作者會在短時間內獲得大量的訊息,同時其他的工作者還在建立連線。如果不再批處理任務開始時進行同步,系統幾乎不會並行執行。試著移除等待步驟,看一下會發生什麼。
發生器的PUSH套接字把任務平均分發給工作者(假設在批處理任務開始前,工作者都已連線)。這稱作負載均衡,在後面的章節中,我們會討論負載均衡的更多細節。
收集器的PULL套接字公平的從工作者收取結果,這被稱為公平佇列。
管道模式也有“慢連線”的情況,這會導致PUSH推送套接字負載不均衡 。在使用PUSH和PULL時,如果其中一個工作者的PULL套接字建立連線的速度比其他工作者快,那它就會獲取更多的訊息,因為在其他工作者建立連線時,它已經收取了一些訊息。
相關文章
- js 部分學習整理JS
- 學習 第一部分:備份與恢復概述
- ZeroMQ–使用jzmq進行程式設計MQ行程程式設計
- 【部分】Java速成學習筆記Java筆記
- [譯] 使用深度學習自動生成 HTML 程式碼 - 第 1 部分深度學習HTML
- 5.31.ZeroMQMQ
- Python學習第一週學習總結Python
- JavaScript學習筆記——基礎部分JavaScript筆記
- Pandas學習筆記1(序列部分)筆記
- JavaWeb學習筆記後端部分JavaWeb筆記後端
- [前端學習]js特效部分學習筆記,第三天前端JS特效筆記
- 李沐動手學習深度學習 錨框部分程式碼解析深度學習
- 學習週報 (第一週)
- Java學習第一階段Java
- 第一週學習總結
- 第一週學習報告
- 自己收集的部分Angular學習資料Angular
- JavaScript學習總結(一)基礎部分JavaScript
- ZeroMQ分享-part1MQ
- 《深度學習入門:》學習基本第一章深度學習
- go 第一天學習Go
- vue 第一天學習Vue
- JavaScript 學習初篇(第一課)JavaScript
- 機器學習 第一章學習機器學習
- JavaScript第一次學習JavaScript
- 學習go第一天Go
- 學習HTML第一天HTML
- 學習java第一天Java
- 第一個JavaScript的例子學習JavaScript
- 10步學習Perl 第一課
- jquery學習第一天jQuery
- JAVA學習第一天Java
- MarkDown 學習第一天
- JavaScript學習總結(四)function函式部分JavaScriptFunction函式
- 今天增加了英語學習的部分
- java學習歷程——第一篇.如何使用java軟體Java
- 使用UML建立模組庫——第一部分(三)
- 學習筆記【深度學習2】:AI、機器學習、表示學習、深度學習,第一次大衰退筆記深度學習AI機器學習