Chào các bạn đến với bài thứ 12 trong series đục khoét và khám phá Javascript cũng như các thành phần của nó. Trong quá trình xác định và tìm hiểu các thành phần cốt lõi, tác giả cũng chia sẻ một số nguyên tắc mà họ đang dùng để xây dựng SessionStack, một ứng dụng Javascript hướng đến sự mạnh mẽ, hiệu năng cao và ổn định.
Như đã nói trong bài trước về render engine, tác giả bài viết tin rằng sự khác biệt giữa một Javascript developer tốt (good) và tuyệt vời (great) là dev tuyệt vời không những hiểu về các thành phần cơ bản của một ngôn ngữ mà còn cả phần cốt lõi cũng như môi trường xung quanh nó.
Nhắc sơ qua về lịch sử một chút
49 năm trước, một thứ gọi là ARPAnet được tạo ra. Nó chính là một mạng chuyển đổi gói tin sớm và cũng là mạng đầu tiên triển khai bộ TCP/IP. Mạng này cài đặt một liên kết giữa trường đại học California và Học viện nghiên cứu Stanford. 20 năm sau, Tim Berners-Lee phát hành một lời đề nghị cho "Mesh" - thứ mà sau này được biết đến là World Wide Web. Trong 49 năm đó, internet đã đi được một quãng đường dài, bắt đầu chỉ với 2 máy tính trao đổi các gói dữ liệu và giờ đạt tới hơn 75 triệu server, 3.8 tỉ người dùng internet và 1.3 tỉ websites.
Trong bài này, chúng ta sẽ thử phân tích những kỹ thuật nào trình duyệt hiện đại sử dụng để tự động đẩy mạnh hiệu năng (thậm chí bạn không biết đến điều đó), và chúng ta sẽ đặc biệt soi kỹ vào lớp networking của trình duyệt. Ở cuối bài, tác giả sẽ cung cấp một số ý tưởng làm thế nào để giúp trình duyệt đẩy mạnh hơn nữa hiệu năng của webapp của bạn.
Khái quát
Trình duyệt web hiện đại được thiết kế đặc trị cho việc truyền tải webapp/website một cách nhanh chóng, hiệu quả và an toàn bảo mật. Với hàng trăm component cùng hoạt động trên nhiều layer khác nhau, từ quản lý tiến trình và bảo mật sandbox đến các GPU pipeline, audio và video, và còn nhiều thứ khác nữa, trình duyệt trông giống như một hệ điều hành hơn là một phần mềm bình thường.
Hiệu năng tổng quát của trình duyệt được xác định bằng một cơ số các component lớn: parsing (phân giải), layout, tính toán style, quá trình thực thi Javascript & WebAssembly, rendering và dĩ nhiên là cả networking stack (ngăn xếp mạng).
Các kỹ sư thường nghĩ rằng networking stack là một nút cổ chai. Điều này xảy ra thường xuyên vì tất cả các tài nguyên đều cần phải được lấy về từ internet trước khi các bước còn lại được thực hiện. Với networking layer, để hoạt động hiệu quả nó cần phải đóng vai trò nhiều hơn là một bộ quản lý socket đơn giản. Với chúng ta, nó như một thứ núp dưới dạng một cơ chế rất đơn giản để kéo tài nguyên về nhưng đó thực sự là một nền tảng (platform) đầy đủ với các tiêu chí tối ưu hóa, APIs và service của riêng nó.
Là web developer, chúng ta không cần phải lo nghĩ về từng gói tin TCP hay UDP, định dạng request, caching và tất cả những thứ liên quan. Toàn bộ sự phức tạp này được trình duyệt gánh dùm nên ta chỉ cần tập trung vào ứng dụng mà chúng ta đang tạo ra. Tuy nhiên, hiểu rõ điều gì thực sự đang diễn ra bên trong có thể giúp chúng ta tạo ra app nhanh hơn và bảo mật tốt hơn.
Về bản chất thì dưới đây là những gì xảy ra khi user bắt đầu tương tác với trình duyệt:
- User nhập một URL vào thanh địa chỉ trên trình duyệt
- Giả sử URL đó chỉ đến 1 tài nguyên trên mạng, trình duyệt sẽ bắt đầu kiểm tra local cache và cache của ứng dụng và cố thử sử dụng một phải copy có sẵn ở local để đáp ứng request.
- Nếu cache không dùng được, tình duyệt sẽ lấy tên miền từ URL và yêu cầu địa chỉ IP của server từ một DNS. Nếu tên miền đã được cache sẵn thì không cần truy vấn đến DNS.
- Trình duyệt tạo ra một gói tin HTTP nói rằng nó yêu cầu một trang web đang cư trú tại một server từ xa.
- Gói tin được gửi đến TCP layer, layer này sẽ thêm thông tin của chính nó vào vị trí trên cùng của gói tin HTTP. Thông tin này cần thiết để duy trì phiên khởi động.
- Gói tin sau đó được trao cho IP layer với công việc chính là tìm hiểu một cách để gửi gói tin từ user đến server từ xa. Thông tin này cũng được lưu vào vị trí trên cùng của gói tin.
- Gói tin được gửi đến server từ xa.
- Khi đã nhận gói tin, một phản hồi được gửi ngược lại theo cách thức tương tự.
Đặc tính kỹ thuật của Navigation Timing từ W3C cung cấp một API trình duyệt cũng như khả năng hiển thị dữ liệu về thời gian và hiệu năng đằng sau mỗi request trên trình duyệt. Giờ thì cùng quan sát các component, mỗi phần sẽ đóng một vai trò quan trọng trong việc cung cấp các trải nghiệm người dùng (UX) tối ưu:
Toàn bộ tiến trình networking rất phức tạp và có nhiều layer khác nhau có thể trở thành một nút cổ chai. Đây là lý do các trình duyệt cố gắng phấn đấu để cải thiện hiệu năng bản thân bằng cách sử dụng rất nhiều kỹ thuật đa dạng để giảm thiểu tối đa sự ảnh hưởng của toàn bộ giao tiếp network.
Quản lý socket
Cùng khởi động với một số thuật ngữ nào:
- Origin: một bộ 3 chứa các giao thức ứng dụng, tên miền và số port (ví dụ: https, www.example.com, 443)
- Socket pool: một nhóm các socket thuộc về cùng origin (tất cả các trình duyệt lớn đều giới hạn pool size lớn nhất là 6 socket).
Javascript và WebAssembly không cho phép chúng ta quản lý vòng đời của các network socket riêng tư, và dĩ nhiên như vậy là tốt! Điều này không những giúp chúng ta dễ dàng hơn mà còn cho phép trình duyệt tự động thực hiện rất nhiều tối ưu hóa hiệu năng, một trong số đó bao gồm: sử dụng lại socket, yêu cầu sự ưu tiên và ràng buộc muộn (late binding), giao dịch giao thức, ép buộc giới hạn kết nối, vân vân.
Thật ra các trình duyệt hiện đại đã làm tốt trong việc chia tách vòng quản lý request khỏi phần quản lý socket. Các socket được tổ chức trong các pool và được nhóm lại theo origin, mỗi pool bắt buộc phải giới hạn kết nối và các ràng buộc bảo mật. Các request chờ được xếp vào trong hàng đợi, đánh thứ tự ưu tiên và sau đó gắn kết với những socket riêng tư trong pool. Trừ khi server có ý định đóng kết nối thì cùng một socket có thể được sử dụng lại một cách tự động xuyên suốt nhiều request.
Bởi vì mở mới 1 kết nối TCP thường kèm theo chi phí tốn kém cho nên tái sử dụng lại các kết nối cũ sẽ đảm bảo hiệu năng tốt hơn nhiều. Mặc định thì trình duyệt sử dụng cơ chế gọi là "keepalive" (giữ cho sống) để tiết kiệm thời gian từ việc mở mới kết nối đến server khi có request được tạo ra. Thời gian trung bình để mở 1 kết nối TCP là:
- Request đến máy local: 23ms
- Request trong nội bộ châu lục: 120ms
- Request giữa các châu lục với nhau: 225ms
Kiểu kiến trúc này mở ra cánh cửa đến với rất nhiều cơ hội để tối ưu hóa. Request có thể được thực thi với những thứ tự khác nhau tùy thuộc vào độ ưu tiên của nó. Trình duyệt có thể tối ưu hóa sự phân chia băng thông giữa toàn bộ các socket hoặc là nó có thể mở socket khi dự đoán trước về một request.
Như đã nói trước đó, toàn bộ đều được quản lý bởi trình duyệt và không yêu cầu chúng ta giúp bất cứ thứ gì. Nhưng nó không nhất thiết nghĩa là chúng ta không thể làm gì. Chọn lựa đúng pattern về giao tiếp mạng, loại và tần suất transfer, lựa chọn các giao thức và tinh chỉnh/tối ưu hóa server stack có thể đóng vai trò rất lớn trong việc cải thiện hiệu năng tổng thể của ứng dụng.
Một vài trình duyệt thậm chí còn đi xa hơn. Ví dụ, Chrome có thể tự dạy cho chính nó hoạt động nhanh hơn khi bạn sử dụng nó. Nó học hỏi dựa trên những trang bạn đã ghé thăm và kiểu duyệt web điển hình cho nên nó có thể dự đoán hành vi người dùng có khả năng và thực hiện hành động trước khi user làm gì đó. Ví dụ đơn giản nhất là tự động render trước nội dung của trang khi user rê chuột lên 1 link. Nếu bạn thấy hứng thú về chủ đề tối ưu hóa của Chrome thì có thể đọc thêm chương này https://www.igvita.com/posa/high-performance-networking-in-google-chrome/ nằm trong quyển sách High-Performance Browser Networking
Bảo mật mạng và đóng gói sandbox
Cho phép trình duyệt quản lý các socket riêng biệt có một ý nghĩa rất quan trọng: bằng cách này trình duyệt cho phép áp đặt một hệ thống đồng nhất các ràng buộc về chính sách và bảo mật lên những nguồn tài nguyên ứng dụng không đáng tin cậy. Ví dụ như trình duyệt sẽ không chấp nhận API truy xuất trực tiếp vào network socket thô vì như vậy có thể cho phép các ứng dụng độc hại tạo kết nối tùy tiện đến bất cứ host nào. Trình duyệt cũng áp đặt các giới hạn kết nối để bảo vệ server cũng như client khỏi cạn kiệt tài nguyên.
Trình duyệt định dạng tất cả các request đi ra để áp đặt sự đồng nhất và các ngữ nghĩa giao thức tốt để bảo vệ server. Tương tự, giải mã response được thực hiện một cách tự động để bảo vệ user từ những server độc hại.
Trao đổi TLS
Transport Layer Security (TLS) là một giao thức mật mã cung cấp giao tiếp bảo mật trong mạng máy tính. Nó được sử dụng rộng rãi trong nhiều ứng dụng, một trong số đó là trình duyệt web. Website có thể dùng TLS để bảo đảm an ninh cho tất cả các giao tiếp giữa server và trình duyệt web.
Toàn bộ quá trình bắt tay TLS bao gồm các bước sau:
- Client gửi một lời nhắn "Client hello" đến server, cùng với một giá trị ngẫu nhiên của client và bộ mã hóa được hỗ trợ.
- Server trả lời bằng cách gửi lời nhắn "Server hello" về cho client, cùng với giá trị ngẫu nhiên của server.
- Server gửi chứng chỉ xác thực của nó về cho client và có thể yêu cầu một chứng chỉ tương tự từ phía client. Server gửi lời nhắn "Server hello done".
- Nếu server đã yêu cầu một chứng chỉ từ client thì client phải gửi nó.
- Client tạo ra một Pre-Master Secret ngẫu nhiên và mã hóa nó với public key từ chứng chỉ của server, gửi Pre-Master Secret đã được mã hóa về cho server.
- Server nhận Pre-Master Secret. Server và client mỗi bên sẽ sinh ra Master Secret và session key (chìa khóa phiên) dựa trên Pre-Master Secret.
- Client gửi thông báo "Change cipher spec" đến server để xác định rằng client sẽ bắt đầu sử dụng session key mới để băm và mã hóa message. Client cũng đồng thời gửi tin nhắn "Client finished".
- Server nhận "Change cipher spec" và chuyển đổi trạng thái bảo mật của record layer của nó sang trạng thái bảo mật mã hóa đối xứng bằng session key. Server gửi lời nhắn "server finished" về cho client.
- Client và server giờ có thể trao đổi dữ liệu ứng dụng thông qua kênh bảo mật mà chúng đã thiết lập. Tất cả message được gửi từ client đến server và ngược lại đều được mã hóa bằng session key.
User được cảnh báo trong trường hợp một trong số xác thực nào đó bị sai, ví dụ: server đang dùng một chứng chỉ tự cấp.
Chính sách cùng origin
Hai trang có cùng origin nếu như giao thức, cổng (nếu được chỉ định) và host đều giống nhau giữa 2 trang
Dưới đây là một vài ví dụ về các tài nguyên có thể được nhúng cross-origin (xuyên origin):
- Javascript với code
<script src="…"></script>
. Thông báo lỗi cú pháp chỉ tồn tại cho những đoạn script cùng origin. - CSS với
<link rel="stylesheet" href="…">
. Do quy tắc cú pháp thoải mái của CSS nên CSS cross-origin cần một header Content-Type đúng loại. Sự hạn chế thì tùy thuộc vào trình duyệt. - Hình ảnh với thẻ
<img />
- File đa phương tiện với
<video>
và<audio>
- Plug in với
<object>
,<embed>
and<applet>
- Fonts với @font-face. Vài trình duyệt cho phép các font cross-origin, một số khác thì yêu cầu fonts trong cùng origin.
- Bất cứ thứ gì với
<frame>
và<iframe>
. Một trang có thể sử dụng header X-Frame-Options để ngăn chặn trường hợp tương tác cross-origin này.
Danh sách trên vẫn còn thiếu sót nhiều, mục đích của nó là làm nổi bật nguyên tắc "quyền hạn tối thiểu" (least privilege). Trình duyệt chỉ phô ra những API và tài nguyên cần thiết cho code của chương trình: ứng dụng hỗ trợ dữ liệu và URL, trình duyệt định dạng các request và xử lý toàn bộ vòng đời của mỗi kết nối.
Rất đáng để lưu tâm rằng hoàn toàn không có một concept cụ thể nào của "chính sách cùng origin" (same-origin policy). Thay vào đó, chỉ có 1 bộ cơ chế liên quan áp đặt các ràng buộc lên việc truy xuất DOM, cookie và quản lý trạng thái của session, mạng và các thành phần khác của trình duyệt.
Lưu đệm tài nguyên và trạng thái của client
Request nhanh nhất và tốt nhất chính là không gọi request nào cả. Trước khi điều phối một request, trình duyệt tự động kiểm tra bộ đệm tài nguyên của nó, thực hiện các kiểm tra xác nhận cần thiết và trả về một bản copy local của tài nguyên đó nếu phù hợp với những điều kiện cụ thể. Nếu tài nguyên ở local không tồn tại trong cache thì request lên mạng được gọi và response sẽ được chèn tự động vào trong cache để cho lần truy cập tiếp theo nếu được phép.
- Trình duyệt tự động đánh giá các chỉ thị lưu đệm (cache directives) cho mỗi tài nguyên.
- Trình duyệt tự động xác nhận lại các tài nguyên hết hạn khi nó có thể.
- Trình duyệt tự động quản lý kích cỡ của bộ đệm và thu hồi tài nguyên.
Quản lý bộ đệm tài nguyên một cách hiệu quả và tối ưu là rất khó. Ơn trời trình duyệt đã xử lý toàn bộ những thứ phức tạp ấy giúp chúng ta rồi, tất cả những gì ta cần làm là đảm bảo server của mình trả về cache directive phù hợp, để hiểu rõ hơn thì bạn có thể đọc bài Cache Resources on the Client. Bạn cung cấp các response headers như Cache-Control, ETag và Last-Modified cho tất cả nguồn tài nguyên trên trang của bạn, phải không?
Cuối cùng, một chức năng thường bị bỏ quả nhưng khá quan trọng của trình duyệt chính là nhiệm vụ cung cấp xác thực, session và quản lý cookie. Trình duyệt duy trì các gói cookie (cookie jars - tác giả chơi chữ "cookie - bánh quy") riêng biệt cho mỗi origin, cung cấp các ứng dụng cần thiết và server APIs để đọc/ghi cookie, session và dữ liệu xác thực mới, tự động nối & xử lý các header HTTP phù hợp để tự động hóa toàn bộ quá trình thay cho chúng ta.
Ví dụ: Một ví dụ đơn giản nhưng dễ minh họa nhất về sự tiện dụng của việc hoãn quản lý trạng thái session với trình duyệt: một session đã được xác thực có thể chia sẻ giữa nhiều tab với nhau hoặc nhiều cửa sổ trình duyệt và ngược lại; một hành động đăng xuất (sign-out) ở 1 tab sẽ vô hiệu hóa các session đang mở ở toàn bộ các cửa sổ đang mở khác.
Các API ứng dụng và giao thức
Càng đi sâu tìm hiểu về các dịch vụ network sẵn có thì cuối cùng chúng ta cũng đã tiến đến các API ứng dụng và giao thức (Application APIs & Protocols). Như ta đã biết, những layer thấp thì cung cấp một mảng rộng các dịch vụ quan trọng: quản lý socket & kết nối, xử lý request & response, áp đặt nhiều chính sách bảo mật, lưu đệm & còn nhiều nữa. Mỗi khi chúng ta khởi tạo HTTP hay XMLHttpRequest, sự kiện long-lived Server-Sent hay WebSocket session, hoặc mở kết nối WebRTC... chúng ta đang tương tác với một hoặc nhiều các dịch vụ đó.
Không có giao thức hay API nào tốt nhất. Mỗi ứng dụng phức tạp sẽ cần một tổ hợp các giao vận (transports) khác nhau dựa trên sự đa dạng của các yêu cầu: giao tiếp với bộ đệm trình duyệt, protocol overhead (metadata hoặc thông tin điều hướng mạng được gửi bởi ứng dụng), độ trễ của message, độ tin cậy, kiểu truyền tải dữ liệu, vân vân. Một số giao thức có thể đáp ứng với độ trễ thấp (ví dụ: Server-Sent Events, WebSocket), nhưng không yêu cầu các tiêu chí quan trọng khác, chẳng hạn như khả năng tận dụng bộ đệm trình duyệt hoặc hỗ trợ truyền tải nhị phân hiệu quả trong mọi trường hợp.
Một vài thứ bạn có thể làm để cải thiện hiệu năng và bảo mật của Webapp
- Luôn luôn sử dụng header
Connection: Keep-Alive
trong các request. Trình duyệt đã mặc định sẵn rồi. Đảm bảo server sử dụng cơ chế tương tự. - Sử dụng header Cache-Control, Etag và Last-Modified phù hợp để tiết kiệm thời gian download cho trình duyệt.
- Dành thời gian để tinh chỉnh và tối ưu hóa web server, phép màu sẽ xảy ra! Nhớ rằng quá trình này rất cụ thể cho từng loại webapp và kiểu dữ liệu mà bạn trao đổi.
- Luôn luôn dùng TLS! Đặc biệt nếu như bạn có bất kỳ xác thực nào trong ứng dụng của bạn.
- Nghiên cứu trình duyệt cung cấp các chính sách bảo mật nào và áp đặt chúng vào trong ứng dụng của bạn.
Hiệu năng và bảo mật là ưu tiên hàng đầu trong SessionStack. Lý do tại sao team tác giả không thể nghiêng về một bên nào hơn là bởi vì một khi đã tích hợp SessionStack vào webapp, nó bắt đầu giám sát mọi thứ từ thay đổi trên DOM, tương tác người dùng đến request mạng, biệt lệ và thông báo debug. Tất cả thông tin này được truyền về server theo thời gian thực và cho phép user có thể chạy lại các vấn đề đã xảy ra dưới dạng video & xem mọi thứ xảy ra với người dùng của bạn. Tất cả hoạt động này được thực hiện với độ trễ tối thiểu và không ảnh hưởng tới hiệu năng của app của bạn.