Nhảy tới nội dung

· 17 phút để đọc
Eli Bendersky
Đức Nguyễn

Bài post này là một bài giới thiệu cơ bản về cách sử dụng TLS để chạy HTTPS server và client trong Go. Hãy tham khảo các bài viết về RSADiffie-Hellman Key Exchange; TLS sử dụng phiên bản elliptic-curve của Diffie-Hellman.

Bạn có thể tham khảo code cho bài viết này tại đây.

Giới thiệu ngắn gọn về TLS

TLS (Transport Layer Security) là một giao thức được thiết kế để cho phép giao tiếp client-server qua Internet một cách an toàn, ngăn chặn việc nghe trộm, can thiệp và giả mạo tin nhắn. Nó được mô tả trong RFC 8446.

TLS dựa trên kỹ thuật mật mã tiên tiến; đây cũng là lý do tại sao nên sử dụng phiên bản mới nhất của TLS, phiên bản 1.3 (vào cuối năm 2023). Các phiên bản của TLS sẽ loại bỏ các trường hợp có thể không an toàn, loại bỏ các thuật toán mã hóa yếu và nói chung cố gắng làm cho giao thức an toàn hơn.

Khi một client kết nối tới một server với HTTP thì nó sẽ bắt đầu gửi dữ liệu dưới dạng text được bọc trong các gói TCP ngay sau khi hoàn thành TCP handshake (SYN -> SYN-ACK -> ACK). Sử dụng TLS thì quá trình trên sẽ phức tạp hơn một chút 1:

TCP handshake:

  • Client gửi một TCP package với SYN(Synchronize) flag tới server để mờ đầu quá trình thiết lập kết nối
  • Server nhận được gói tin và gửi lại một gói tin SYN-ACK (Synchronize-Acknowledgement) để xác nhận rằng nó đã nhận được gói tin của client.
  • Client nhận được gói tin SYN-ACK và gửi lại một gói tin ACK để xác nhận rằng nó đã nhận được gói tin của server.

TLS diagram

Sau khi hoàn thành TCP handshake, server và client sẽ bắt đầu thực hiện TLS handshake để đồng thuận về các thông số bảo mật cũng như trao đổi key(key này thì unique và chỉ áp dụng trong session này) . Key này sẽ được sử dụng để mã hóa dữ liệu được trao đổi giữa server và client. Quá trình này khức tạp nhưng may mắn là đã có TLS layer lo, chúng ta chỉ cần cài đặt TLS server (hoặc client); sự khác biệt giữa một HTTP server và một HTTPS server trong Go là rất nhỏ.

TLS certificates

Trước khi chúng ta đi vào code để tạo một HTTPS server trong Go sử dụng TLS, hãy nói về certificates. Trong hình vẽ ở trên, bạn sẽ thấy rằng server gửi một certificate tới client như là một phần của ServerHello message. Chính xác thì chúng được gọi là X.509 certificates, được mô tả trong RFC 5280.

Việc mã hóa public key đóng một vai trò quan trọng trong TLS. Sử dụng ceritifcate là một cách chuẩn để bọc public key của server, cùng với định danh của nó và một chữ ký của một trusted authority(có thể hiểu là một bên khác có thẩm quyền, đáng tin cậy như Let's Encrypt, GoDaddy, DigiCert, IdenTrust, ...) - xem thêm ở đây. Giả sử bạn muốn trao đổi thông tin với https://bigbank.com; làm sao bạn có thể chắc chắn là Big Bank real đang hỏi password của bạn, nếu như có ai khác ở giữa giả vờ là Big Bank (classical MITM - man-in-the-middle attack) thì sao?

Certificates được thiết kế để giải quyết vấn đề này. Khi một client kết nối tới https://bigbank.com có sử dụng TLS, nó sẽ expect certificate của Big Bank với public key, được ký bởi một certificate authority (CA) được tin tưởng. Các chữ ký trên certificates có thể là 1 chuỗi (key của bank được ký bởi A, A được ký bởi B, B được ký bởi C,...), nhưng ở cuối chuỗi thì nó phải có một certificate authority được tin tưởng bởi client. Các trình duyệt hiện đại có một danh sách các CA được tin tưởng (cùng với các certificate của chúng) được tích hợp sẵn. Vì vậy, dù có bắt được request của bạn thì kẻ đó cũng không thể giả mạo chữ ký của một certificate được tin tưởng, không thể giả mạo Big Bank.

Generating self-signed certificates in Go

Để test thì ta việc sử dụng các with self-signed certificates cũng rất hữu ích. Một self-signed certificate là một certificate cho một entity E với public key P, nhưng key này không được ký bởi một trusted CA, mà được ký bởi chính P. Mặc dù self-signed certificates có một vài ứng dụng, nhưng chúng ta chỉ nên sử dụng chúng cho testing.

Trong Go, thư viện chuẩn có hỗ trợ tốt cho mọi thứ liên quan đến crypto, TLS và certificates. Hãy xem cách tạo một self-signed certificate trong Go!

privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
}

Đoạn code này sử dụng crypto/ecdsa, crypto/ellipticcrypto/rand packages để gen một cặp key mới2, sử dụng elliptic curve P-256, đây là một trong những elliptic curve được cho phép trong TLS 1.3.

Tiếp theo, ta sẽ tạo một certificate template:

serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Failed to generate serial number: %v", err)
}

template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"My Corp"},
},
DNSNames: []string{"localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(3 * time.Hour),

KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}

Mỗi certificate cần một unique serial number; thường thì các certificate authority sẽ lưu chúng trong một database nhưng với mục đích local thì một số ngẫu nhiên 128-bit là đủ.

Tiếp theo là subject của certificate, nó là một struct pkix.Name với một số trường như Organization, Country, Locality, Province, StreetAddress, PostalCode, SerialNumber (xem thêm [ở đây](crypto/x509 package docsRFC 5280)), CommonNameNames. Trong ví dụ này, chúng ta chỉ cần OrganizationCommonName (được sử dụng để định danh cho certificate). Lưu ý rằng certificate này chỉ có thể được sử dụng cho localhost domain.

Tiếp theo:

derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
log.Fatalf("Failed to create certificate: %v", err)
}

Tạo certificate từ template, và được ký bởi private key mà chúng ta đã tạo ở trên. Lưu ý rằng &template được truyền vào cả cho template và parent parameters của CreateCertificate. Điều này làm cho certificate này self-signed.

Và vậy là ta đã có private key và certificate cho server của mình. Tất cả những gì còn lại là serialize chúng thành files. Đầu tiên là certificate:

pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
if pemCert == nil {
log.Fatal("Failed to encode certificate to PEM")
}
if err := os.WriteFile("cert.pem", pemCert, 0644); err != nil {
log.Fatal(err)
}
log.Print("wrote cert.pem\n")

Sau đó là private key:

privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
log.Fatalf("Unable to marshal private key: %v", err)
}
pemKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
if pemKey == nil {
log.Fatal("Failed to encode key to PEM")
}
if err := os.WriteFile("key.pem", pemKey, 0600); err != nil {
log.Fatal(err)
}
log.Print("wrote key.pem\n")

Ta có 2 PEM files, trông như sau (certificate):

-----BEGIN CERTIFICATE-----
MIIBbjCCARSgAwIBAgIRALBCBgLhD1I/4S0fRZv6yfcwCgYIKoZIzj0EAwIwEjEQ
MA4GA1UEChMHTXkgQ29ycDAeFw0yMTAzMjcxNDI1NDlaFw0yMTAzMjcxNzI1NDla
MBIxEDAOBgNVBAoTB015IENvcnAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASf
wNSifB2LWDeb6xUAWbwnBQ2raSQTqqpaR1C1eEiy6cgqUiiOlr4jUDDiFCly+AS9
pNNe8o63/Gab/98dwFNQo0swSTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAUBgNVHREEDTALgglsb2NhbGhvc3QwCgYI
KoZIzj0EAwIDSAAwRQIgYlJYGIwSvA+AmsHe8P34B5+hlfWEK4+kBmydJ65XJZMC
IQCzg5aihUXh7Rm0L1K3JrG7eRuTuFSkHoAhzk4cy6FqfQ==
-----END CERTIFICATE-----

Nếu bạn từng set up SSH keys thì sẽ thấy định dạng này quen thuộc. Ta có thể sử dụng openssl để xem nội dung của nó:

openssl x509 -in cert.pem -text
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
b0:42:06:02:e1:0f:52:3f:e1:2d:1f:45:9b:fa:c9:f7
Signature Algorithm: ecdsa-with-SHA256
Issuer: O = My Corp
Validity
Not Before: Mar 27 14:25:49 2021 GMT
Not After : Mar 27 17:25:49 2021 GMT
Subject: O = My Corp
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:9f:c0:d4:a2:7c:1d:8b:58:37:9b:eb:15:00:59:
bc:27:05:0d:ab:69:24:13:aa:aa:5a:47:50:b5:78:
48:b2:e9:c8:2a:52:28:8e:96:be:23:50:30:e2:14:
29:72:f8:04:bd:a4:d3:5e:f2:8e:b7:fc:66:9b:ff:
df:1d:c0:53:50
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
DNS:localhost
Signature Algorithm: ecdsa-with-SHA256
30:45:02:20:62:52:58:18:8c:12:bc:0f:80:9a:c1:de:f0:fd:
f8:07:9f:a1:95:f5:84:2b:8f:a4:06:6c:9d:27:ae:57:25:93:
02:21:00:b3:83:96:a2:85:45:e1:ed:19:b4:2f:52:b7:26:b1:
bb:79:1b:93:b8:54:a4:1e:80:21:ce:4e:1c:cb:a1:6a:7d

HTTPS server in Go

Bây giờ chúng ta đã có certificate và private key, chúng ta đã sẵn sàng để chạy HTTPS server! Một lần nữa thì rất dễ để setup HTTPS server với std library, mặc dù cần lưu ý rằng bảo mật là một vấn đề rất khó khăn. Trước khi expose server của bạn ra Internet, hãy xem xét tham khảo ý kiến từ security engineer về các best practices và các tùy chọn cấu hình khác 3.

func main() {
addr := flag.String("addr", ":4000", "HTTPS network address")
certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
keyFile := flag.String("keyfile", "key.pem", "key PEM file")
flag.Parse()

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(w, req)
return
}
fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
})

srv := &http.Server{
Addr: *addr,
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
PreferServerCipherSuites: true,
},
}

log.Printf("Starting server on %s", *addr)
err := srv.ListenAndServeTLS(*certFile, *keyFile)
log.Fatal(err)
}

Ta có thể thấy ListenAndServeTLS nhận vào 2 tham số là đường dẫn tới certificate và private key. TLSConfig có nhiều fields nhưng ở đây ta chỉ cần chọn MinVersion là 1.3 vì bản 1.3 đã có độ bảo mật cao.

Phiên bản này chỉ khác đâu đó khoảng 10 dòng codes so với bản HTTP. Điều quan trọng là code của server (handlers cho các routes cụ thể) hoàn toàn không cần biết về protocol bên dưới và sẽ không thay đổi.

Tuy nhiên nếu bạn chạy code trên thì sẽ nhận được một lỗi:

Chrome warning

Điều này xảy ra vì mặc định trình duyệt sẽ không chấp nhận một self-signed certificate. Như đã nói ở trên thì trình duyệt sẽ có một danh sách các CA được tin tưởng, và certificate của chúng ta không có trong danh sách đó. Ta vẫn có thể tiếp tục bằng cách click vào Advanced và cho phép Chrome tiếp tục. Sau đó nó sẽ hiển thị website (với một biểu tượng "Not secure" màu đỏ ở thanh địa chỉ).

Nếu ta cố gắng thử curl tới server thì cũng sẽ nhận được error:

curl -Lv  https://localhost:4000

* Trying 127.0.0.1:4000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS alert, unknown CA (560):
* SSL certificate problem: unable to get local issuer certificate
* Closing connection 0
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

Bằng cách sử dụng --cacert flag thì có thể fix được lỗi trên:

curl -Lv --cacert <path/to/cert.pem>  https://localhost:4000

* Trying 127.0.0.1:4000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /home/eliben/eli/private-code-for-blog/2021/tls/cert.pem
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: O=My Corp
* start date: Mar 29 13:30:25 2021 GMT
* expire date: Mar 29 16:30:25 2021 GMT
* subjectAltName: host "localhost" matched cert's "localhost"
* issuer: O=My Corp
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x557103006e10)
> GET / HTTP/2
> Host: localhost:4000
> user-agent: curl/7.68.0
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< content-length: 33
< date: Mon, 29 Mar 2021 13:31:34 GMT
<
* Connection #0 to host localhost left intact
Proudly served with Go and HTTPS!

Success!

Để test thì ta có thể viết một HTTPS clients như sau:

func main() {
addr := flag.String("addr", "localhost:4000", "HTTPS server address")
certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
flag.Parse()

cert, err := os.ReadFile(*certFile)
if err != nil {
log.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
log.Fatalf("unable to parse cert from %s", *certFile)
}

client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
},
},
}

r, err := client.Get("https://" + *addr)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()

html, err := io.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v\n", r.Status)
fmt.Printf(string(html))
}

Sự khác biệt duy nhất so với một HTTP client thông thường là phần setup TLS. Phần quan trọng là phần RootCAs của struct tls.Config. Đây là cách Go xác định các certificates mà client có thể tin tưởng.

Một vài lựa chọn khác để gen certificates

Có thể bạn không biết rằng Go cũng có một tool để gen self-signed TLS certificates, ngay trong standard installation. Nếu bạn đã cài đặt Go tại /usr/local/go, bạn có thể chạy tool này với:

go run /usr/local/go/src/crypto/tls/generate_cert.go -help

Nhìn chung thì nó cũng có thể làm được những gì chúng ta đã làm ở trên, nhưng trong khi snippet code của chúng ta là một ví dụ đơn giản, thì tool này có thể được cấu hình với nhiều flags và hỗ trợ nhiều lựa chọn khác nhau.

Như ta đã thấy, mặc dù self-signed certificates có thể được sử dụng cho testing, nhưng chúng không thể được sử dụng trong thực tế. Vì vậy, chúng ta cần một cách khác để gen certificates cho các domain thực. Có nhiều lựa chọn, nhưng một trong những lựa chọn phổ biến nhất là Let's Encrypt. Let's Encrypt là một certificate authority (CA) miễn phí, được tin tưởng bởi các trình duyệt hiện tại. Nó cũng có một client tool gọi là certbot để giúp bạn tạo và quản lý các certificates. Trong Go, các thư viện như certmagic có thể tự động hóa việc tương tác với Let's Encrypt cho server.

Một lựa chọn khác để gen local certifications cho testing là mkcert tool. Nó sẽ tạo ra một local certificate authority (CA), và thêm nó vào danh sách các CA được tin tưởng của hệ thống. Sau đó nó sẽ gen certificates được ký bởi CA này cho bạn, vì vậy về cơ bản thì trình duyệt sẽ tin tưởng chúng.

Nếu bạn chạy một HTTPS server đơ ngỉan với certificate/key được gen bởi mkcert, Chrome sẽ không cảnh báo gì và bạn có thể thấy chi tiết trong tab Security của developer tools:

Chrome security tab

curl cũng sẽ thành công mà không cần cacert flag, vì nó đã kiểm tra các CA được tin tưởng của hệ thống.

Client authentication (mTLS)

Qua các ví dụ ở trên thì ta đã có một server có cung cấp certificate cho client để chứng minh rằng bản thân là server mà client đang kết nối tới (chẳng hạn như website của ngân hàng, trước khi bạn đồng ý cung cấp password của mình).

Ví dụ trên cũng có thể dễ dàng được mở rộng cho mutual authentication, trong đó client cũng có một certificate được ký bởi một CA để chứng minh bản thân là client. Trong thế giới của TLS, điều này được gọi là mTLS (cho mutual TLS), và có thể hữu ích trong nhiều trường hợp khi các service bên trong phải giao tiếp với nhau một cách an toàn. Public-key crypto được xem là an toàn hơn password.

Dưới đây là một HTTPS server đơn giản với client authentication. Các dòng code đã thay đổi so với ví dụ HTTPS server ở trên được highlight:

func main() {
addr := flag.String("addr", ":4000", "HTTPS network address")
certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
keyFile := flag.String("keyfile", "key.pem", "key PEM file")
clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client authentication")
flag.Parse()

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(w, req)
return
}
fmt.Fprintf(w, "Proudly served with Go and HTTPS!")
})

// Trusted client certificate.
clientCert, err := os.ReadFile(*clientCertFile)
if err != nil {
log.Fatal(err)
}
clientCertPool := x509.NewCertPool()
clientCertPool.AppendCertsFromPEM(clientCert)

srv := &http.Server{
Addr: *addr,
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
PreferServerCipherSuites: true,
ClientCAs: clientCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
},
}

log.Printf("Starting server on %s", *addr)
err = srv.ListenAndServeTLS(*certFile, *keyFile)
log.Fatal(err)
}

Ngoài việc load certificate/key cho server, ta cũng load certificate của client và set TLSConfig để tin tưởng nó.

Và đây là một HTTPS client với mTLS. Các dòng code đã thay đổi so với ví dụ HTTPS client ở trên được highlight:

func main() {
addr := flag.String("addr", "localhost:4000", "HTTPS server address")
certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
clientCertFile := flag.String("clientcert", "clientcert.pem", "certificate PEM for client")
clientKeyFile := flag.String("clientkey", "clientkey.pem", "key PEM for client")
flag.Parse()

// Load our client certificate and key.
clientCert, err := tls.LoadX509KeyPair(*clientCertFile, *clientKeyFile)
if err != nil {
log.Fatal(err)
}

// Trusted server certificate.
cert, err := os.ReadFile(*certFile)
if err != nil {
log.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
log.Fatalf("unable to parse cert from %s", *certFile)
}

client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
Certificates: []tls.Certificate{clientCert},
},
},
}

r, err := client.Get("https://" + *addr)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()

html, err := io.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v\n", r.Status)
fmt.Printf(string(html))
}

Trước khi chúng ta chạy thử, ta cần thay đổi generate script để tạo ra các certificate phù hợp cho client. Thay đổi ở dòng này:

ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},

thành:

ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},

Bây giờ hãy chạy thử. Bắt đầu bằng cách generate certificates:

# client cert

$ go run tls-self-signed-cert.go
2021/04/03 05:51:25 wrote cert.pem
2021/04/03 05:51:25 wrote key.pem
$ mv cert.pem clientcert.pem
$ mv key.pem clientkey.pem

# server cert

$ go run tls-self-signed-cert.go
2021/04/03 05:51:42 wrote cert.pem
2021/04/03 05:51:42 wrote key.pem

Chạy mTLS server

$ go run https-server-mtls.go
2021/04/03 05:54:51 Starting server on :4000

Chạy (non-mTLS) client, expect lỗi:

$ go run https-client.go
2021/04/03 05:55:24 Get "https://localhost:4000": remote error: tls: bad certificate
exit status 1

Và server log sẽ hiển thị "client didn't provide a certificate". Chạy mTLS client:

$ go run https-client-mtls.go
200 OK
Proudly served with Go and HTTPS!

Bài viết đã thành công thể hiện cơ chế của mTLS, trong thực tế thì còn nhiều thứ khác phải làm, đặc biệt là quản lý certificates, certificate renewal và revocation, và trusted CAs. Điều này được gọi là Public Key Infrastructure (PKI), và đó là một chủ đề lớn nằm ngoài phạm vi của bài viết này.

Sources

Footnotes

  1. Ảnh này được lấy từ hpbn.co, licensed under CC BY-NC-ND 4.0.

  2. Giá trị trả về bởi GenerateKey có type là ecdsa.PrivateKey có chứa một ecdsa.PublicKey cho nên nó thực ra là một cặp key.

  3. Một lựa chọn khác có thể cân nhắc là setup một reverse proxy giữa service của bạn và thế giới bên ngoài. Reverse proxies như Nginx hoặc Caddy(được viết bằng Go) đã được kiểm tra và có hướng dẫn rõ ràng cho việc setup. Một lợi ích khác của reverse proxies là chúng có thể cung cấp load-balancing đơn giản cho các service có tải nặng.

· 4 phút để đọc
Eli Bendersky
Đức Nguyễn

Giả sử ta có một `struct chứa một map và ta muốn thay đổi map trong method của struct ấy. Xem ví dụ sau:

package main

import "fmt"

type Container struct {
counters map[string]int
}

func (c Container) inc(name string) {
c.counters[name]++
}

func main() {
c := Container{counters: map[string]int{"a": 0, "b": 0}}

doIncrement := func(name string, n int) {
for i := 0; i < n; i++ {
c.inc(name)
}
}

doIncrement("a", 100000)

fmt.Println(c.counters)
}

Nếu ta chạy đoạn script này thì nó sẽ in ra:

map[a:100000 b:0]

Bây giờ giả sử ta muốn 2 goroutines gọi inc một cách concurrent. Để phòng trường hợp xảy ra race conditions thì ta sẽ sử dụng Mutex để lock:

package main

import (
"fmt"
"sync"
"time"
)

type Container struct {
sync.Mutex // <-- Added a mutex
counters map[string]int
}

func (c Container) inc(name string) {
c.Lock() // <-- Added locking of the mutex
defer c.Unlock()
c.counters[name]++
}

func main() {
c := Container{counters: map[string]int{"a": 0, "b": 0}}

doIncrement := func(name string, n int) {
for i := 0; i < n; i++ {
c.inc(name)
}
}

go doIncrement("a", 100000)
go doIncrement("a", 100000)

// Wait a bit for the goroutines to finish
time.Sleep(300 * time.Millisecond)
fmt.Println(c.counters)
}

Bạn nghĩ output sẽ như thế nào?

fatal error: concurrent map writes

goroutine 5 [running]:
runtime.throw(0x4b765b, 0x15)

<...> more goroutine stacks
exit status 2

Tại sao ta đã cẩn thẩn sử dụng Mutex rồi mà vẫn lỗi? Sai ở đâu? Gợi ý: chỉ cần thay đổi 1 ký tự!

Vấn đề ở đây là khi inc được gọi, thì c được copy vì ta defined inc với receiver là Container chứ k phải *Container nên inc không thể thay đổi được c.

Nhưng khoan, vậy tại sao ví dụ đầu tiên lại không lỗi? Ở Ví dụ đầu ta cũng dùng Container chứ k phải *Container. Vì map có type là type tham chiếu không phải type giá trị, counters trong Containers không chứa giá trị thực của map mà chứa một pointer. Cho nên khi ta tạo copy của Container thì counters của nó vẫn có cùng data.

Vậy thì ví dụ ban đầu mặc dù không lỗi nhưng nó sai, nó không tuân theo guidelines; các methods mà thay đổi object thì nên được defined với pointers, không phải values. Sử dụng map ở đây dẫn tới vấn đề bảo mật.

Mutex có type giá trị (xem định nghĩa ở Go's source, bao gồm cả phần bình luận khuyên rằng không nên copy mutexes), cho nên copy là không đúng.

func (c *Container) inc(name string) {
c.Lock()
defer c.Unlock()
c.counters[name]++
}

Ở đây thì c là một pointer và thực sự tham chiếu đến cùng instance Container trong bộ nhớ.

Đây là một lỗi khá thường gặp, nhất là ở những chỗ như HTTP handlers, chúng thường được gọi concurrently mà không cần dùng go statement. Ta sẽ nói về vấn đề này ở một bài viết khác.

Lỗi này thực sự giúp chúng ta thấy rõ sự khác biệt giữa value receivers và pointer receivers trong Go. Đây là một ví dụ khác không liên quan đến 2 ví dụ trên, nó sử dụng khả năng tạo pointer của Go với &%p.

package main

import "fmt"

type Container struct {
i int
s string
}

func (c Container) byValMethod() {
fmt.Printf("byValMethod got &c=%p, &(c.s)=%p\n", &c, &(c.s))
}

func (c *Container) byPtrMethod() {
fmt.Printf("byPtrMethod got &c=%p, &(c.s)=%p\n", c, &(c.s))
}

func main() {
var c Container
fmt.Printf("in main &c=%p, &(c.s)=%p\n", &c, &(c.s))

c.byValMethod()
c.byPtrMethod()
}

Cho ra output như sau:

in main &c=0xc00000a060, &(c.s)=0xc00000a068
byValMethod got &c=0xc00000a080, &(c.s)=0xc00000a088
byPtrMethod got &c=0xc00000a060, &(c.s)=0xc00000a068

Hàm main tạo một Container và in ra address của nó cũng như address của field s. Sau đó nó gọi 2 methods của Container.

byValMethod có value receiver, nó in ra address khác vì receiver của nó là 1 bản copy của Container. Mặc khác, byPtrMethod có pointer receiver và in ra cùng address với cái được in ra bởi hàm main vì receiver là address của Container không phải là copy.

Nguồn

· 5 phút để đọc
Eli Bendersky
Đức Nguyễn

Một trong những tranh luận phổ biến nhất trong ngành phần mềm là dependencies tốt hay xấu. Có nên tự viết tất cả hoặc hầu hết các chức năng của dự án, hay là nên sử dụng các thư viện có sẵn để thực hiện các tác vụ con mà dự án cần thực hiện.

Một mặt, dependencies giúp các teams nhỏ có thể phát triển các ứng dụng tương đối phức tạp. Mặt khác thì việc có vô số thư viện và frameworks cũng là một vấn đề biểu hiện qua nhiều mặt khác nhau, từ leftpad fiasco cho đến các frameworks ra đời tràn lan làn, frameworks mới thay frameworks cũ, hợc chưa xong framework này thì đã có một framework khác.

Nếu bạn hay theo dõi tin tức về lập trình thì mọi người tranh luận về vấn đề này ở mọi nơi. Các bài viết như viết web apps (chỉ sử dụng std lib) gây nên tranh cãi, một mặt thì ủng hộ "no-dependencies", ca ngợi tính rõ ràng, ít phụ thuộc, dễ dàng maintain và deploy. Mặt khác thì cũng có nhiều người cho rằng việc "reinvent the wheel" làm phí phạm những điều đã khó khăn để đạt được nhờ những người thư viện kia.

Bài viết này muốn đề ra một công thức đơn giản để xoa dịu bớt những cuộc tranh luận tương tự, vì theo ý kiến cá nhân thì cả 2 bên đều đúng - dựa vào từng tình huống cụ thể.

Lợi ích của việc sử dụng dependencies thì tỷ lệ nghịch với lượng công sức bỏ ra cho dự án.

benefits-vs-effort.png

Càng nhiều nỗ lực phải bỏ ra cho một dự án thì càng ít lợi ích nhận được khi sử dụng dependencies. Những dự án cần ít effort hì càng sẽ hưởng lợi từ dependencies. Với các dự án lớn, lâu dài thì lợi ích là không nhiều, thậm chí thì hại còn nhiều hơn lợi.

Công thức này dựa trên sự quan sát trong suốt một sự nghiệp dài phát triển phần mềm, quản lý các nhà phát triển phần mềm, và quan sát kỹ càng thế giới của phát triển phần mềm.

Chẳng hạn với phát triển web. Nếu bạn là một nhà thầu thường xuyên phát triển web apps cho khách hàng mỗi 2-3 tuần, thì chắc chắn dự án của bạn sẽ sử dụng các thư viện và frameworks. Nó tiết kiệm rất nhiều thời gian và công sức, vậy tại sao không?

Tuy nhiên, nếu công ty bạn có một web app lớn và phức tạp mà 4 kỹ sư đã phát triển trong vài năm qua (và sẽ tiếp tục phát triển trong tương lai), thì khả năng là bạn chỉ sử dụng các thư viện cơ bản nhất (ví dụ như jQuery), và phần còn lại được phát triển trong công ty.

Sự khác biệt giữa các thư viện cơ bản và các thư viện khác còn là vấn đề về quy mô. Không nhiều công ty sẽ tự viết một database cho dự án của họ, nhưng nếu bạn phát triển một dự án với quy mô của Google thì việc tự viết một database là có thể hiểu được.

reinventing-the-wheel.jpg

Điều thú vị là một dự án có thể đi qua các điểm khác nhau trên đường cong lợi ích vs effort trong suốt quá trình phát triển. Nhiều dự án bắt đầu nhỏ và đơn giản, phụ thuộc vào các dependencies. Tuy nhiên, khi thời gian trôi qua và nhiều effort được bỏ vào dự án, thì không thể tránh khỏi việc dependencies bị thay thế bằng các thư viện trong công ty. Điều này thường xảy ra khi các dependencies không còn đáp ứng được tất cả các use case mà dự án cần. Những lý do khác bao gồm tốc độ phát triển nhanh hơn; để cập nhật một dependency, cần phải push changes đến lib/frameworks và, chờ được aprroved và merged. Không phải team nào cũng thích chờ đợi.

Một ví dụ điển hình là các nhà phát triển game 3D. Hầu hết các studio nhỏ và các nhà phát triển đều bắt đầu bằng việc sử dụng một trong các game engine có sẵn và tập trung vào nội dung của game. Tuy nhiên, sau một thời gian, nhiều studio lớn hơn lại phát triển các engine riêng để phục vụ nhu cầu của riêng họ. Công sức bỏ ra cho dự án lớn hơn, vì vậy dependencies không còn có lợi nữa.

Một trong những bài viết trong nhất về chủ đề này là của Joel Spolsky's - In Defense of Not-Invented-Here Syndrome (from 2001). Trong bài này thì Joel đã chỉ ra cách mà Microsoft Excel team đã phải đấu tranh để bỏ tất cả các dependencies ra khỏi dự án của họ, và tự viết riêng một C compiler. Họ không làm thế vì ngu ngốc hay kiêu ngạo mà đơn giản vì nó phù hợp cho dự án khổng lồ của họ.

Quan điểm của Joel hơi khác so với của tội - anh ấy cho rằng các tính năng cốt lõi nên tự phát triển. Điều này là đúng tuy nhiên công thức nêu trên muốn nhìn theo một góc độ khác. Khi mới bắt đầu thì framework mà bạn sử dụng không phải là tính năng cốt lõi - chỉ là một công cụ. Tuy nhiên theo thời gian thì có thể xem nó như là một tính năng cốt lõi, vì đã có rất nhiều effort đã được dành cho dự án; cái giá để loại bỏ nó được giảm bớt.

Nguồn

· 10 phút để đọc
Eli Bendersky
Đức Nguyễn

Go là một ngôn ngữ phổ biến và rất phù hợp để viết HTTP servers. Bài viết này thảo luận về vòng đời của một HTTP request và sẽ đề cập đến một số chủ đề như routers, middleware và một số thứ liên quan khác chẳng hạn như concurrency.

Code cụ thể đễ theo dõi bài viết có thể xem tại đây

package main

import (
"fmt"
"net/http"
)

func hello(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "hello\n")
}

func headers(w http.ResponseWriter, req *http.Request) {
for name, headers := range req.Header {
for _, h := range headers {
fmt.Fprintf(w, "%v: %v\n", name, h)
}
}
}

func main() {
http.HandleFunc("/hello", hello)
http.HandleFunc("/headers", headers)

http.ListenAndServe(":8090", nil)
}

Ta bắt đầu truy vết vòng đời của HTTP request bằng cách kiểm tra hàm http.ListenAndServe:

func ListenAndServe(addr string, handler Handler) error

Dưới đây là một sơ đồ đơn giản về cái gì sẽ xảy ra khi hàm trên được gọi

http-request-listenandserve

Phiên bản này đã được cắt gọn đi rất nhiều, nhưng phiên bản gốc cũng không quá khó để theo dõi cho lắm.

ListenAndServe lắng nghe trên cổng TCP theo địa chỉ cho trước, sau đó các vòng lặp chấp nhận các connections. Với mỗi connection thì Go sẽ dùng 1 goroutine để xử lý. Việc xử lý mỗi connection thì nằm trong vòng lặp sau:

  • Parse HTTP request từ connection và tạo ra http.Request
  • Pass http.Request đến 1 handler đã được user defined

Handler là bất cứ thứ gì có implement http.Handler interface:

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

Default handler

Ở ví dụ trên, ListenAndServe được gọi với tham số thứ 2 là nil trong khi đáng nhẽ phải là 1 handler đã được user defined.

Sơ đồ của chúng ta đã giản lược đi vài chi tiết, thực tết thì khi http package phục vụ 1 request. nó không gọi trực tiếp tới handler của user mà sử dụng adapter này:

type serverHandler struct {
srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}

Chú ý đến những dòng đã được highlighted. Nếu handler == nil thì http.DefaultServeMux sẽ được sử dụng như 1 handler. Đây là server mux mặc định - 1 global instance của http.ServeMux nằm trong http package.

Chúng ta có thể viết lại server như sau mà không sử dụng mux mặc định. Chỉ hàm main thay đổi nên ở đây sẽ không show helloheaders nữa nhưng bạn có thể xem full code ở đây 1.

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", hello)
mux.HandleFunc("/headers", headers)

http.ListenAndServe(":8090", mux)
}

ServeMux chỉ là Handler

Khi đọc ví dụ về Go server thì sẽ dễ dàng đi đến kết luận rằng ListenAndServe nhận "mux" là một tham số, nhưng điều này là không chính xác. Như chúng ta đã thấy ở trên thì ListenAndServe nhận một giá trị có implement http.Handler interface. Chúng ta có thể viết một server mà không sử dụng bất cứ mux nào:

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
ps := &PoliteServer{}
log.Fatal(http.ListenAndServe(":8090", ps))
}

Chúng ta chưa định tuyết nên tất cả các HTTP requests sẽ được đưa đến ServeHTTP method của PoliteServer và nó sẽ phản hồi với cùng 1 message cho tất cả.

Chúng ta cũng có thể làm server đơn giản hơn nữa bằng cách sử dụng http.HandlerFunc

func politeGreeting(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
log.Fatal(http.ListenAndServe(":8090", http.HandlerFunc(politeGreeting)))
}

HandlerFunc ở đây là một adapter trong http package:

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

Nếu bạn để ý http.HandleFunc trong ví dụ đầu tiên 2 thì nó cũng sử dụng cùng adapter.

Giống như PoliteServer thì http.ServeMux là một type có implement http.Handler interface. Bạn có thể xem kỹ hơn ở đây:

  • ServeMux chứa 1 slice các cặp {pattern, handler} đã được sắp xếp(theo độ dài).
  • Handle hoặc HandleFunc thêm một handler mới vào slice.
  • ServeHTTP:
    • Tìm handler phù hợp cho request dựa vào path (bằng cách tìm kiếm trên các cặp được sắp xếp ở trên)
    • Gọi tới ServeHTTP method của handler.

Như vậy, có thể xem mux như là một forwarding handler; pattern này rất hay gặp ở trong HTTP server - middleware

http.Handler Middleware

Rất khó để định nghĩa Middleware một cách chính xác vì nó mang ý nghĩa khác nhau dựa vào contexts, languages và frameworks.

Đơn giản hóa sơ đồ ban đầu mà ta có, dấu đi một vài chi tiết trong http package ta có:

http-request-simplified.png

Bây giờ thì sau khi thêm middleware nó sẽ trở thành:

http-request-with-middleware.png

Trong Go thì middleware chỉ là một HTTP handler bọc một handler khác.

Ở trên thì ta đã thấy một ví dụ về middleware - http.ServeMux; trường hợp này thì middleware sử dụng để select handler phù hợp dựa vào path của request.

Một ví dụ cụ thể khác. Quay lại ví dụ với polite server và thêm logging middleware.

type LoggingMiddleware struct {
handler http.Handler
}

func (lm *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) {
start := time.Now()
lm.handler.ServeHTTP(w, req)
log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
}

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
ps := &PoliteServer{}
lm := &LoggingMiddleware{handler: ps}
log.Fatal(http.ListenAndServe(":8090", lm))
}

chú ý rằng LoggingMiddleware thực chất là một http.Handler có một field là user handler. Khi ListenAndServe gọi ServeHTTP method cảu nó:

  1. Tiền xử lý: tạo 1 time stamp trước khi user handler chạy.
  2. Gọi tới user handler với request và response writer.
  3. Hậu xử lý: log request details cùng với thời gian đã dành để xử lý request.

Điều tuyệt vời về middleware là nó composable. Chúng có thể nối với nhau tạo thành một chuỗi http.Handler bao lấy nhau. Thực tế thì đây là một pattern thường thấy

func politeGreeting(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
next.ServeHTTP(w, req)
log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
})
}

func main() {
lm := loggingMiddleware(http.HandlerFunc(politeGreeting))
log.Fatal(http.ListenAndServe(":8090", lm))
}

Thay vì tạo một struct cùng với methods thì loggingMiddleware sử dụng http.HandlerFunc với một closure giúp code ngắn gọn hơn trong khi vẫn giữ được đầy đủ tính năng. Quan trọng hơn thì pattern này đã được chuẩn hóa: một function nhận một http.Handler và trả về một http.Handler khác. Handler được trả về sẽ được sử dụng thay cho handler đã được truyền vào middleware và nó sẽ thực hiện chức năng gốc cùng với tính năng mới của middleware.

Ví dụ, thư viện chuẩn có sẵn một vài middleware:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler

Ta có thể sử dụng nó như sau:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")

Đoạn code trên tạo một version handler mới cùng với cơ chế timeout trong vòng 2 giây.

Tính kết hợp của middleware có thể thấy qua ví dụ sau:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")
handler = loggingMiddleware(handler)

Sau 2 dòng trên thì handler bây giờ có thêm tính năng timeout và logging. Nếu chú ý thì có thể thấy việc nối một chuỗi dài các middlewares sẽ trở nên khá phiền phức và đã có một vài package phổ biến để giải quyết vấn đề này nhưng nó nằm ngoài scope của bài viết.

Nhân tiện thì chính http package cũng sử dụng middleware đễ có thể xử lý trường hợp nil handlers.

Concurrency và xử lý panic

Để kết thúc bài viết thì ta sẽ nói thêm về 2 chủ đề: concurrency và xử lý panic

Đầu tiên về concurrency. Như đã đề cập ở trên thì mỗi connection được xử lý bằng 1 goroutine.

Đây là một tính năng mạnh mẽ của net/http, nó đã tận dụng khả năng concurrency của Go, bằng cách sử dụng goroutines thì nó cung cấp một model cho HTTP handler rất clean. Một handler có thể block (chẳng hạn việc đọc dữ liệu từ database) mà không phải lo về việc hoãn lại các handlers khác mặc dù nó yêu cầu chúng ta phải cẩn thân khi viết handler có tương tác với shared data. Xem bài viết này để biết thêm chi tiết.

Cuối cùng là về xử lý panic. Một HTTP server điển hình thì cần phải là một process có thể chạy liên tục. Giả sử có lỗi gì đấy trong handler nào đấy của user(chẳng hạn như bug dẫn tới runtime panic). Điều này có thể gây sập toàn bộ server. Để phòng trường hợp điều này xảy ra thì bạn có thể nghĩ tới việc sử dụng recover ở trong main, nhưng nó sẽ không hiệu quả vì một vài lý do sau đây:

  1. Tại thời điểm chạy tới hàm main thì ListenAndServe đã chạy và sẽ không tiếp tục serve nữa.
  2. Vì mỗi connection được xử lý bởi 1 goroutine nên khi panics xảy ra ở handlers nó sẽ không chạy đến hàm main mà sẽ crash.

Để giải quyết vấn đề này thì net/http có recovery được tích hợp sẵn cho từng goroutine (nằm trong conn.serve method). Chúng ta có thể thấy được qua ví dụ sau:

func hello(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "hello\n")
}

func doPanic(w http.ResponseWriter, req *http.Request) {
panic("oops")
}

func main() {
http.HandleFunc("/hello", hello)
http.HandleFunc("/panic", doPanic)

http.ListenAndServe(":8090", nil)
}

Nếu ta chạy server và dùng curl tới /panic thì đây là output:

curl localhost:8090/panic
curl: (52) Empty reply from server

Và server sẽ in ra logs:

2021/02/16 09:44:31 http: panic serving 127.0.0.1:52908: oops
goroutine 8 [running]:
net/http.(*conn).serve.func1(0xc00010cbe0)
/usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x654840, 0x6f0b80)
/usr/local/go/src/runtime/panic.go:975 +0x47a
main.doPanic(0x6fa060, 0xc0001401c0, 0xc000164200)
[... rest of stack dump here ...]

Tuy nhiên, server vẫn sẽ tiép tục chạy và ta vẫn có thể tương tác với nó.

Mặc dù tính năng này tốt hơn việc sập server nhưng có thể thấy rằng nó cũng có một vài hạn chế. Tất cả những gì nó làm là đón connection và log error; sẽ hữu ích hơn nếu nó trả về cho client error response (chẳng hạn như 500 - internal server error).

Tuy nhiên sau khi đọc bài này thì chắc hẳn bạn có thể viết 1 middleware cho tính năng này.

Sources

Footnotes

  1. Có một vài lý do bạn nên sử dụng cách này thay default mux. Sử dụng default mux khá rủi ro, vì nó global nên nó có thể bị thay đổi bởi 1 package nào đấy và sử dụng cho 1 mục đích bất chính.

  2. Cẩn thận: http.HandleFunchttp.HandlerFunc mặc dù liên quan nhưng chúng là 2 đối tượng khác nhau.

· 9 phút để đọc
Eli Bendersky
Đức Nguyễn

Package có sẵn net/http của Go rất tiện lợi và hiệu năng cao, nó giúp việc phát triển cả web servers trở nên dễ dàng hơn. Để đạt được hiệu năng tốt thì net/http sử dụng concurrency; mặc dù đúng thật là nó đem lại hiệu năng rất tốt tuy nhiên cũng kèm theo một số vấn đề. Ta sẽ tìm hiểu về trong bài viết này.

Đảm bảo handlers truy cập đồng thời vào data một khác an toàn

Bắt đầu bằng một ví dụ đơn giản - một HTTP server cho một bảng đếm - cho phép user truy cập. Ta có thể tạo counters (hoặc set giá trị của những counters đang tồn tại) bằng query set?name=N&val=V, lấy giá trị của chúng bằng query get?name=N và tăng giá trị với query inc?name=N.

Đây là một ví dụ cách user tương tác với server:

curl "localhost:8000/set?name=x&val=0"
ok
curl "localhost:8000/get?name=x"
x: 0
curl "localhost:8000/inc?name=x"
ok
curl "localhost:8000/get?name=x"
x: 1

Và đây là một server cơ bản có các chức năng trên:

package main

import (
"fmt"
"log"
"net/http"
"os"
"strconv"
)

type CounterStore struct {
counters map[string]int
}

func (cs CounterStore) get(w http.ResponseWriter, req *http.Request) {
log.Printf("get %v", req)
name := req.URL.Query().Get("name")
if val, ok := cs.counters[name]; ok {
fmt.Fprintf(w, "%s: %d\n", name, val)
} else {
fmt.Fprintf(w, "%s not found\n", name)
}
}

func (cs CounterStore) set(w http.ResponseWriter, req *http.Request) {
log.Printf("set %v", req)
name := req.URL.Query().Get("name")
val := req.URL.Query().Get("val")
intval, err := strconv.Atoi(val)
if err != nil {
fmt.Fprintf(w, "%s\n", err)
} else {
cs.counters[name] = intval
fmt.Fprintf(w, "ok\n")
}
}

func (cs CounterStore) inc(w http.ResponseWriter, req *http.Request) {
log.Printf("inc %v", req)
name := req.URL.Query().Get("name")
if _, ok := cs.counters[name]; ok {
cs.counters[name]++
fmt.Fprintf(w, "ok\n")
} else {
fmt.Fprintf(w, "%s not found\n", name)
}
}

func main() {
store := CounterStore{counters: map[string]int{"i": 0, "j": 0}}
http.HandleFunc("/get", store.get)
http.HandleFunc("/set", store.set)
http.HandleFunc("/inc", store.inc)

portnum := 8000
if len(os.Args) > 1 {
portnum, _ = strconv.Atoi(os.Args[1])
}
log.Printf("Going to listen on port %d\n", portnum)
log.Fatal(http.ListenAndServe("localhost:"+strconv.Itoa(portnum), nil))
}

Đoạn code trên rất đơn giản, quá đơn giản, đơn giản đến mức sai 😂. Ta đã sử dụng curl theo trình tự, từng request một. Vấn đề xảy ra khi xuất hiện các connection đồng thời. Có một cách để giả lập concurrent connections là sử dụng ApacheBench:

ab -n 20000 -c 200 "127.0.0.1:8000/inc?name=i"

Benchmarking 127.0.0.1 (be patient)
Completed 2000 requests
Completed 4000 requests

Test aborted after 10 failures

apr_socket_connect(): Connection reset by peer (104)
Total of 4622 requests completed

Oops... điều gì xảy ra vậy? Hãy check logs của server, ta sẽ thấy :

<normal server logs>
fatal error: concurrent map writes

goroutine 6118 [running]:
runtime.throw(0x6b0a5c, 0x15)
/usr/local/go/src/runtime/panic.go:608 +0x72 fp=0xc00060dba8 sp=0xc00060db78 pc=0x42ba12

Handler của chúng ta có thể chạy concurrently nhưng chúng đều đang cố gắng thay đổi CounterStore. Điều này dẫn tới race condition vì trong Go, map operations are not atomic. May mắn là Go runtime phát hiện ra điều này và dừng lại với một thông báo hữu ích; nếu dữ liệu bị thay đổi mà không có thông báo thì sẽ tệ hơn nhiều.

Giải pháp đơn giản nhất là tuần tự hóa các truy cập đến map sử dụng mutex.

The simplest solution is to serialize all map accesses using a mutex. Dưới đây là một đoạn code trích từ ví dụ hoàn chỉnh:

type CounterStore struct {
sync.Mutex
counters map[string]int
}

func (cs *CounterStore) inc(w http.ResponseWriter, req *http.Request) {
log.Printf("inc %v", req)
cs.Lock()
defer cs.Unlock()
name := req.URL.Query().Get("name")
if _, ok := cs.counters[name]; ok {
cs.counters[name]++
fmt.Fprintf(w, "ok\n")
} else {
fmt.Fprintf(w, "%s not found\n", name)
}
}

Có 2 thay đổi cần chú ý:

  1. chúng ta nhúng sync.Mutex vào CounterStore, mỗi handler bắt đầu bằng việc lock mutex và defer unlock.
  2. Thay đổi hàm inc sử dụng pointer *CounterStore - các methods thay đổi dữ liệu luôn nên được defined với pointer receivers. Pointers receivers là một phần cực kỳ quan trọng khi sử dụng mutexes.

Chạy lại ab benchmark ta sẽ thấy race condition không còn nữa, server đã được fixed.

Đồng bộ hóa sử dụng channels so với sử dụng mutexes

Đối với những lập trình viên có kinh nghiệm thì việc thêm mutex để đồng bộ hóa truy cập tới CounterStore là một giải pháp đương nhiên. Tuy nhiên một trong những khẩu hiệu của Go là "Chia sẻ bộ nhớ bằng cách giao tiếp, đừng giao tiếp bằng cách chia sẻ dữ liệu", liệu có áp dụng ở đây ?

Thay vì sử dụng mutexes thì ta có thể dùng channels để đồng bộ hóa truy cập đến dữ liệu được chia sẻ. Code mẫu này thay thế mutexes bằng channels. Bắt đầu bằng việc define một "counter manager" là một background goroutine truy cập tới một closure - nơi lưu trữ data.

type CommandType int

const (
GetCommand = iota
SetCommand
IncCommand
)

type Command struct {
ty CommandType
name string
val int
replyChan chan int
}

func startCounterManager(initvals map[string]int) chan<- Command {
counters := make(map[string]int)
for k, v := range initvals {
counters[k] = v
}

cmds := make(chan Command)

go func() {
for cmd := range cmds {
switch cmd.ty {
case GetCommand:
if val, ok := counters[cmd.name]; ok {
cmd.replyChan <- val
} else {
cmd.replyChan <- -1
}
case SetCommand:
counters[cmd.name] = cmd.val
cmd.replyChan <- cmd.val
case IncCommand:
if _, ok := counters[cmd.name]; ok {
counters[cmd.name]++
cmd.replyChan <- counters[cmd.name]
} else {
cmd.replyChan <- -1
}
default:
log.Fatal("unknown command type", cmd.ty)
}
}
}()
return cmds
}

Thay vì truy cập trực tiếp vào map của counters, handlers sẽ gửi Commands tới một channel và sẽ nhận được phản hồi qua một channel khác.

Object được shared cho các handlers bây giờ là một Server:

type Server struct {
cmds chan<- Command
}

Và đây là inc handler:

func (s *Server) inc(w http.ResponseWriter, req *http.Request) {
log.Printf("inc %v", req)
name := req.URL.Query().Get("name")
replyChan := make(chan int)
s.cmds <- Command{ty: IncCommand, name: name, replyChan: replyChan}

reply := <-replyChan
if reply >= 0 {
fmt.Fprintf(w, "ok\n")
} else {
fmt.Fprintf(w, "%s not found\n", name)
}
}

Mỗi handler sẽ gọi tới manager một cách đồng bộ; gửi Command sẽ block và handler sẽ tiếp tục chạy khi reply channel nhận được tín hiệu. Chú ý là ở đây không dùng mutex, ta có thể loại bỏ được mutex vì tại 1 thời điểm chỉ có 1 goroutine duy nhất có thể truy cập data.

Mặc dù trông có vẻ như một kỹ thụât thú vị, đối với ví dụ của chúng ta thì dùng channels là overkill. Trên thực tế thì việc sử dụng channels nhiều quá mức là một trong những vấn đề thường gặp ở Go beginers. Trích từ Go Wiki entry:

Mọi vấn đề liên quan đến locking đều có thể được giải quyết bằng channels hoặc locks.

Vậy bạn nên sử dụng cái nào?

Sử dụng cái nào đơn giản nhất.

Thường thì mutexes được preferred hơn để bảo vệ shared state vì mutex cho cảm giác tự nhiên hơn.

Giới hạn sự truy cập đồng thời gian server

Ngoài vấn đề đồng bộ thì một vấn đề khác là overloading. Chẳng hạn bạn expose server cho internet và không có bất cứ biện pháp bảo vệ nào thì server của bạn rất dễ bị đánh sập bằng DoS. Điều này có cũng thể xảy ra một cách không chủ ý. Trong những trường hợp như vậy thì sập server là không thể tránh khỏi nhưng nên tránh hậu quả nghiêm trọng.

Điều này rất dễ thực hiện trong Go, và cũng có nhiều cách khác nhau. Một trong những cách đơn giản nhất là rate limiting - có nghĩa là sẽ hạn chế số lượng connections đồng thời, hoặc giới hạn số connections theo đơn vị thời gian. Với cách tiếp cận thứ nhất thì ta có thể sử middlewares

// limitNumClients is HTTP handling middleware that ensures no more than
// maxClients requests are passed concurrently to the given handler f.
func limitNumClients(f http.HandlerFunc, maxClients int) http.HandlerFunc {
// Counting semaphore using a buffered channel
sema := make(chan struct{}, maxClients)

return func(w http.ResponseWriter, req *http.Request) {
sema <- struct{}{}
defer func() { <-sema }()
f(w, req)
}
}

Bọc handler với middleware trên

// Limit to max 10 connections for this handler.
http.HandleFunc("/inc", limitNumClients(store.inc, 10))

Điều này đảm bảo rằng không có nhiều hơn 10 clients có thể chạy inc một cách đồng thời 1. Việc mở rộng ví dụ trên sử dụng một channel duy nhất để hạn chế truy cập trên nhiều handlers cũng không quá khó khăn, hoặc bạn có thể sử dụng một giải pháp hơi overkill hơn là giới hạn số connections đồng thời ở mức độ listener sử dụng netutil.LimitListener.

Một cách tiếp cận khác là giới hạn thời gian. Thay vì "không có nhiều hơn 10 requests cùng thời điểm" thì ta sử dụng "không có nhiều hơn 1 request mỗi 50ms". Go cung cấp cho ta một channel để có thể dễ dàng thực hiện điều này - time.Tick, có thể xem ví dụ ở đây

Phụ lục: where net.http goes concurrent

Với những ai muốn tìm hiểu về cách mà net/http thực hiện concurrency thì có thể xem code ở https://golang.org/src/net/http/server.go để tham khảo.

ListenAndServe sẽ chạy Serve - thằng này gọi Accept trong một vòng lặp. Accept tương tự với accept syscall trong socket, tạo một connection mới khi mà có client accepted, connection này sau đó được sử dụng để tương tác với client nó có type là type conn - là một private type chứa server state. Hàm serve sau đó đọc dữ liệu cần thiết và chạy các handlers đã được đăng ký trước.

http.Server.Serve gọi conn.serve như sau:

go c.serve(ctx)

Concurrency nằm ở đây. Từ đây trở đi thì có 1 goroutine riêng sẽ handle connection này.

Sources

Footnotes

  1. For a somewhat more robust approach, we could select between grabbing the semaphore and request's Context.Done() channel, to ensure that cancelled requests don't take place in line for the semaphore. A full treatment of robust context handling it outside the scope of this post, however.