Reverse Ajax, Phần 2: WebSockets

WebSockets mới xuất hiện trong HTML5, là một kỹ thuật Reverse Ajax mới hơn Comet. WebSockets cho phép các kênh giao tiếp song song hai chiều và hiện đã được hỗ trợ trong nhiều trình duyệt (Firefox, Google Chrome và Safari). Kết nối được mở thông qua một HTTP request (yêu cầu HTTP), được gọi là liên kết WebSockets với những header đặc biệt. Kết nối được duy trì để bạn có thể viết và nhận dữ liệu bằng JavaScript như khi bạn đang sử dụng một TCP socket đơn thuần.

pdf12 trang | Chia sẻ: lylyngoc | Lượt xem: 1827 | Lượt tải: 1download
Bạn đang xem nội dung tài liệu Reverse Ajax, Phần 2: WebSockets, để tải tài liệu về máy bạn click vào nút DOWNLOAD ở trên
Reverse Ajax, Phần 2: WebSockets WebSockets WebSockets mới xuất hiện trong HTML5, là một kỹ thuật Reverse Ajax mới hơn Comet. WebSockets cho phép các kênh giao tiếp song song hai chiều và hiện đã được hỗ trợ trong nhiều trình duyệt (Firefox, Google Chrome và Safari). Kết nối được mở thông qua một HTTP request (yêu cầu HTTP), được gọi là liên kết WebSockets với những header đặc biệt. Kết nối được duy trì để bạn có thể viết và nhận dữ liệu bằng JavaScript như khi bạn đang sử dụng một TCP socket đơn thuần. Một URL WebSocket được bắt đầu bằng cách gõ ws:// hoặc wss:// (trên SSL). Hình 1 cho thấy cách giao tiếp khi sử dụng WebSockets. Một liên kết HTTP được gửi đến máy chủ với các header cụ thể. Sau đó, một loại socket Javascript có sẵn trên máy chủ hoặc máy khách sẽ được sử dụng để nhận dữ liệu không đồng bộ thông qua một trình xử lý sự kiện. Hình 1. Reverse Ajax với WebSockets Bạn có thể tải về mã nguồn cho bài này. Khi bạn chạy ví dụ này, bạn sẽ thấy kết quả tương tự như Liệt kê 1. Nó cho thấy các sự kiện đã xảy ra bên phía máy chủ và cũng xuất hiện ngay lập tức bên phía máy khách như thế nào. Khi máy khách gửi đi dữ liệu nào đó, máy chủ sẽ báo lại cho máy khách. Liệt kê 1. Ví dụ mẫu WebSocket bằng JavaScript [client] WebSocket connection opened [server] 1 events [event] ClientID = 0 [server] 1 events [event] At Fri Jun 17 21:12:01 EDT 2011 [server] 1 events [event] From 0 : qqq [server] 1 events [event] At Fri Jun 17 21:12:05 EDT 2011 [server] 1 events [event] From 0 : vv Thông thường, trong JavaScript bạn sẽ sử dụng WebSockets như được trình bày trong Liệt kê 2, nếu trình duyệt của bạn có hỗ trợ nó. Liệt kê 2. Mã JavaScript ở máy khách var ws = new WebSocket('ws://127.0.0.1:8080/async'); ws.onopen = function() { // called when connection is opened }; ws.onerror = function(e) { // called in case of error, when connection is broken in example }; ws.onclose = function() { // called when connexion is closed }; ws.onmessage = function(msg) { // called when the server sends a message to the client. // msg.data contains the message. }; // Here is how to send some data to the server ws.send('some data'); // To close the socket: ws.close(); Dữ liệu được gửi và nhận có thể là kiểu bất kỳ nào. Có thể xem WebSockets giống như TCP socket, vì thế tùy thuộc vào máy khách và máy chủ để biết kiểu dữ liệu nào đang được gửi qua. Ví dụ ở Liệt kê 2 đang gửi các chuỗi JSON. Khi một đối tượng Websocket Javascript được tạo ra, nếu xem kỹ các HTTP request trong giao diện trình duyệt (hoặc Firebug) của lần kết nối đó, bạn sẽ thấy các header đặc trưng của WebSocket. Liệt kê 3 là một ví dụ. Liệt kê 3. Ví dụ mẫu về HTTP request và các header phản hồi Request URL:ws://127.0.0.1:8080/async Request Method:GET Status Code:101 WebSocket Protocol Handshake Request Headers Connection:Upgrade Host:127.0.0.1:8080 Origin: Sec-WebSocket-Key1:1 &1~ 33188Yd]r8dp W75q Sec-WebSocket-Key2:1 7; 229 *043M 8 Upgrade:WebSocket (Key3):B4:BB:20:37:45:3F:BC:C7 Response Headers Connection:Upgrade Sec-WebSocket-Location:ws://127.0.0.1:8080/async Sec-WebSocket-Origin: Upgrade:WebSocket (Challenge Response):AC:23:A5:7E:5D:E5:04:6A:B5:F8:CC:E7:AB:6D:1A:39 Các header được dùng trong các liên kết WebSocket để ủy quyền và thiết lập các kết nối long- lived. Đối tượng WebSocket JavaScript cũng chứa hai đặc tính hữu dụng: ws.url Trả về URL của máy chủ WebSocket. ws.readyState Trả về giá trị của trạng thái kết nối hiện tại:  CONNECTING = 0  OPEN = 1  CLOSED = 2 Về phía máy chủ, việc xử lý WebSockets phức tạp hơn một chút. Vẫn chưa có đặc tả Java chuẩn hỗ trợ WebSockets. Để sử dụng các tính năng WebSockets của web container (ví dụ, Tomcat hoặc Jetty), bạn phải ghép mã ứng dụng với các thư viện đặc thù. Ví dụ trong thư mục websocket của mã nguồn mẫu sử dụng API WebSocket của Jetty do chúng tôi đang sử dụng Jetty container. Liệt kê 4 cho thấy trình xử lý WebSocket. (Phần 3 của loạt bài này sẽ sử dụng các API WebSocket khác nhau cho các tầng bên dưới). Liệt kê 4. Trình xử lý WebSocket cho một Jetty container public final class ReverseAjaxServlet extends WebSocketServlet { @Override protected WebSocket doWebSocketConnect(HttpServletRequest request, String protocol) { return [...] } } Với Jetty, có một số cách để xử lý một liên kết WebSocket. Một cách dễ dàng là phân lớp WebSocketServlet của Jetty và thực thi phương thức doWebSocketConnect Phương thức này sẽ yêu cầu bạn trả về một thể hiện của WebSocket interface. Bạn phải thực thi interface này và trả về một loại thông tin gọi là endpoint đại diện cho liên kết WebSocket. Liệt kê 5 cung cấp một ví dụ mẫu. Liệt kê 5. Ví dụ về thực thi WebSocket class Endpoint implements WebSocket { Outbound outbound; @Override public void onConnect(Outbound outbound) { this.outbound = outbound; } @Override public void onMessage(byte opcode, String data) { // called when a message is received // you usually use this method } @Override public void onFragment(boolean more, byte opcode, byte[] data, int offset, int length) { // when a fragment is completed, onMessage is called. // Usually leave this method empty. } @Override public void onMessage(byte opcode, byte[] data, int offset, int length) { onMessage(opcode, new String(data, offset, length)); } @Override public void onDisconnect() { outbound = null; } } Để gửi một thông báo đến máy khách, bạn xuất thông báo ra outbound, như thể hiện trong Liệt kê 6: Liệt kê 6. Gửi một thông báo đến máy khách if (outbound != null && outbound.isOpen()) { outbound.sendMessage('Hello World !'); } Để ngắt kết nối máy khách và đóng kết nối WebSocket, hãy sử dụng outbound.disconnect();. WebSockets là một cách rất mạnh để thực hiện giao tiếp hai chiều mà không có độ trễ nào. Firefox, Google Chrome, Opera và các trình duyệt hiện đại khác đều hỗ trợ nó. Theo trang web jWebSocket:  Chrome có WebSockets nguyên gốc kể từ phiên bản 4.0.249.  Safari 5.x có WebSockets nguyên gốc.  Firefox 3.7a6 và 4.0b1+ có WebSockets nguyên gốc.  Opera có WebSockets nguyên gốc kể từ phiên bản 10.7.9067. Để biết thêm thông tin về jWebSocket, xem phần Tài nguyên. Ưu điểm WebSockets cung cấp khả năng giao tiếp hai chiều mạnh mẽ, có độ trễ thấp và dễ xử lý lỗi. Không cần phải có nhiều kết nối như phương pháp Comet long-polling và cũng không có những nhược điểm như Comet streaming. API cũng rất dễ sử dụng trực tiếp mà không cần bất kỳ các tầng bổ sung nào, so với Comet, thường đòi hỏi một thư viện tốt để xử lý kết nối lại, thời gian chờ timeout, các Ajax request (yêu cầu Ajax), các tin báo nhận và các dạng truyền tải tùy chọn khác nhau (Ajax long-polling và jsonp polling). Nhược điểm Những nhược điểm của WebSockets gồm có:  Nó là một đặc tả mới của HTML5, nên nó vẫn chưa được tất cả các trình duyệt hỗ trợ.  Không có phạm vi yêu cầu nào. Do WebSockets là một TCP socket chứ không phải là HTTP request, nên không dễ sử dụng các dịch vụ có phạm vi-yêu cầu, như SessionInViewFilter của Hibernate. Hibernate là một framework kinh điển cung cấp một bộ lọc xung quanh một HTTP request. Khi bắt đầu một request, nó sẽ thiết lập một contest (chứa các transaction và liên kết JDBC) được ràng buộc với luồng request. Khi request đó kết thúc, bộ lọc hủy bỏ contest này. Về đầu trang FlashSockets Đối với các trình duyệt không hỗ trợ WebSockets, một số thư viện có khả năng quay lại FlashSockets (các socket thông qua Flash). Các thư viện thường cung cấp một API WebSocket chính thức tương tự, nhưng chúng thực hiện nó bằng cách ủy quyền các cuộc gọi đến một thành phần Flash ẩn được tích hợp trên trang web. Ưu điểm FlashSockets cung cấp tính năng WebSockets một cách trong suốt, ngay cả trên các trình duyệt không hỗ trợ WebSockets của HTML5. Nhược điểm FlashSockets có những nhược điểm sau đây:  Cần phải cài thêm Flash plug-in (thường thì tất cả các trình duyệt đều có sẵn).  Cần phải mở port 843 trong tường lửa để cho thành phần Flash có thể thực hiện một HTTP request để lấy ra một tệp chính sách có chứa thông tin ủy quyền. Nếu không thể thông qua được port 843, thư viện cần quay trở lại hoặc đưa ra một lỗi. Tất cả quá trình xử lý này đều mất thời gian (nhiều nhất lên đến 3 giây, tùy thuộc vào thư viện), sẽ làm chậm trang web.  Nếu máy khách có sử dụng proxy, thì có thể kết nối tới port 843 sẽ bị từ chối. Dự án WebSocketJS có thể giúp hỗ trợ WebSockets cho các trình duyệt Firefox 3, Internet Explorer 8 và Internet Explorer 9, tuy nhiên nó yêu cầu phải cài đặt Flash từ bản 10 trở đi. Khuyến cáo So với Comet, WebSockets mang lại nhiều lợi ích hơn và ngày càng được phát triển để nhanh chóng hỗ trợ trên máy khách và tạo ra ít request hơn (do đó phương pháp này tiêu thụ ít băng thông hơn). Tuy nhiên, do không phải tất cả các trình duyệt đều đang hỗ trợ WebSockets, nên tốt nhất khi sử dụng kỹ thuật Reverse Ajax là ta sẽ thêm một tính năng giúp phát hiện xem WebSocket có được hỗ trợ hay không, nếu không thì chuyển sang phương pháp Comet (long- polling). Do hai kỹ thuật này là cần thiết để nhận được sự lựa chọn tốt nhất trong tất cả các trình duyệt và duy trì tính tương thích, nên điều quan trọng là bạn sử dụng một thư viện JavaScript máy khách, cung cấp một tầng trừu tượng dựa trên các kỹ thuật này. Phần 3 và Phần 4 của loạt bài này sẽ tìm hiểu một số thư viện và Phần 5 sẽ cho bạn thấy cách ứng dụng chúng. Về phía máy chủ, những việc này đều phức tạp hơn một chút, như được thảo luận trong phần tiếp theo. Về đầu trang Các ràng buộc Reverse Ajax ở phía máy chủ Bây giờ bạn có một tổng quan về các giải pháp Reverse Ajax có sẵn bên phía máy khách, chúng ta hãy tìm hiểu các giải pháp Reverse Ajax trên máy chủ. Cho tới giờ thì các ví dụ trong bài chỉ được sử dụng chủ yếu trên máy khách. Về phía máy chủ, để chấp nhận các kết nối Reverse Ajax, một số kỹ thuật yêu cầu các tính năng đặc trưng để xử lý các kết nối long-lived so với các HTTP request ngắn mà bạn đã quen thuộc. Để có thể linh động hơn, ta nên sử dụng một mô hình luồng mới, nó đòi hỏi phải có một API Java đặc trưng để có thể tạm dừng các yêu cầu. Ngoài ra, đối với WebSockets, bạn phải quản lý đúng phạm vi của các dịch vụ được dùng trong ứng dụng. I/O threading và I/O non-blocking Thông thường, một máy chủ web liên kết một luồng hoặc một quá trình cho mỗi kết nối HTTP đến. Kết nối này có thể tồn tại lâu bền (được duy trì) sao cho một số yêu cầu đi qua cùng một kết nối. Trong ví dụ của bài này, có thể cấu hình máy chủ web Apache theo các mô hình mpm_fork hoặc mpm_worker để thay đổi hành vi này. Các máy chủ Java web (bao gồm cả các máy chủ ứng dụng tương tự) thường sử dụng một luồng cho mỗi kết nối đến. Việc sinh ra một luồng mới dẫn đến việc tiêu thụ bộ nhớ và lãng phí tài nguyên vì nó không đảm bảo sẽ sử dụng luồng mới được sinh ra. Kết nối có thể thiết lập được, nhưng không có dữ liệu nào từ máy khách hoặc máy chủ được gửi đi. Cho dù có sử dụng luồng này hay không, thì nó vẫn tiêu thụ bộ nhớ và tài nguyên CPU cho việc lập lịch trình và các khóa chuyển đổi contest. Và, khi cấu hình một máy chủ bằng cách sử dụng một mô hình luồng, bạn thường phải cấu hình một nhóm luồng (thiết lập một số lượng tối đa các luồng để xử lý các kết nối đến). Nếu giá trị này bị cấu hình sai và quá thấp, bạn sẽ kết thúc bằng một vấn đề thiếu luồng; các yêu cầu sẽ chờ cho đến khi có một luồng khác để xử lý chúng. Thời gian đáp ứng sẽ chậm khi đạt được kết nối tối đa đồng thời. Mặt khác, việc cấu hình một giá trị cao có thể dẫn đến một trường hợp ngoại lệ là thiếu bộ nhớ. Việc sinh ra quá nhiều luồng sẽ tiêu thụ tất cả các kích cỡ heap (vùng lưu trữ đặc biệt trong bộ nhớ) của JVM và dẫn đến sự cố cho máy chủ. Gần đây Java đã giới thiệu một API I/O (API vào/ra) mới được gọi là I/O non-blocking. API này sử dụng một bộ lựa chọn để tránh ràng buộc với một luồng mỗi khi thực hiện một kết nối HTTP tới máy chủ. Khi dữ liệu đến, một sự kiện được thu nhận và một luồng được cấp phát để xử lý yêu cầu. Như vậy, việc này được gọi là một mô hình luồng cho mỗi yêu cầu. Nó cho phép các máy chủ web, chẳng hạn như WebSphere và Jetty, điều chỉnh co giãn và xử lý một số lượng lớn các kết nối người dùng gia tăng với một số luồng cố định. Với cùng một cấu hình phần cứng, các máy chủ web đang chạy trong chế độ này điều chỉnh co giãn tốt hơn nhiều so với ở chế độ luồng cho mỗi kết nối. Trong blog của mình, Philip McCarthy (tác giả của Comet and Reverse Ajax) có một đánh giá thú vị về khả năng điều chỉnh co giãn của hai mô hình luồng (xem phần Tài nguyên để thấy liên kết tới bài viết). Trong Hình 2 bạn sẽ thấy một mẫu tương tự: một mô hình luồng ngừng làm việc với quá nhiều các kết nối. Hình 2. Đánh giá về các mô hình luồng Mô hình luồng cho mỗi kết nối (đường Threads trong Hình 2) thường có thời gian đáp ứng tốt hơn, do tất cả các luồng đã thiết lập, sẵn sàng và chờ đợi, nhưng nó dừng phục vụ khi số lượng kết nối quá cao. Trong mô hình luồng cho mỗi yêu cầu (đường Continuations trong Hình 2), một luồng được sử dụng để phục vụ yêu cầu đến và kết nối được xử lý thông qua một bộ lựa chọn NIO. Thời gian đáp ứng có thể chậm hơn một chút, nhưng vì các luồng được sử dụng lại nên giải pháp này co giãn tốt hơn với nhiều kết nối. Để hiểu đằng sau việc tạo các luồng ra sao, hãy tưởng tượng một khối LEGO™ như là bộ lựa chọn. Mỗi kết nối đến đi tới khối LEGO này và được xác định bằng một chân. Khối LEGO/bộ lựa chọn sẽ có nhiều chân (nhiều khóa) như các kết nối. Sau đó, chỉ có một luồng cần thiết để lặp lại qua các chân khi nó chờ các sự kiện mới xảy ra. Khi có một điều gì đó xảy ra, luồng của bộ lựa chọn sẽ lấy ra các khóa dùng cho các sự kiện đã xảy ra và một luồng có thể được sử dụng để phục vụ yêu cầu đến. "Hướng dẫn Rox Java NIO" là ví dụ về việc sử dụng NIO trong Java (xem phần Tài nguyên). Về đầu trang Các dịch vụ phạm vi-yêu cầu Nhiều framework cung cấp các dịch vụ hoặc các bộ lọc, xử lý một yêu cầu web đến trong một servlet. Ví dụ, một bộ lọc sẽ:  Ràng buộc một kết nối JDBC đến một luồng yêu cầu sao cho chỉ có một kết nối được sử dụng cho toàn bộ yêu cầu.  Cam kết các thay đổi vào cuối mỗi yêu cầu. Một ví dụ khác là phần mở rộng Guice Servlet của Google Guice (một thư viện dependency injection). Giống như Spring, Guice có thể kết buộc các dịch vụ trong một phạm vi yêu cầu. Một cá thể sẽ được tạo ra nhiều nhất là một lần cho mỗi yêu cầu mới (xem phần Tài nguyên để biết thêm thông tin). Cách sử dụng điển hình sẽ gồm lưu trữ trong bộ nhớ đệm một đối tượng người dùng được lấy ra từ một kho lưu trữ (ví dụ, một cơ sở dữ liệu) theo yêu cầu bằng cách sử dụng id (mã định danh) của người dùng được lấy từ vùng session HTTP. Trong Google Guice, bạn có thể có mã tương tự như Liệt kê 7. Liệt kê 7. Ràng buộc có phạm vi-yêu cầu @Provides @RequestScoped Member member(AuthManager authManager, MemberRepository memberRepository) { return memberRepository.findById(authManager.getCurrentUserId()); } Khi một thành viên được tích hợp vào trong một lớp, Guice sẽ cố gắng tìm nạp nó từ yêu cầu đó. Nếu không tìm thấy, nó sẽ thực hiện cuộc gọi kho lưu trữ và giao kết quả cho yêu cầu đó. Có thể sử dụng các dịch vụ phạm vi-yêu cầu với bất kỳ giải pháp Reverse Ajax nào trừ WebSockets. Bất kỳ giải pháp khác nào dựa trên các HTTP request ngắn hoặc long-lived, nên mỗi yêu cầu thường có hệ thống gửi servlet và các bộ lọc được thực thi. Khi tạm dừng một HTTP request (long-lived), bạn sẽ thấy trong các phần tiếp theo của loạt bài này cũng có một tùy chọn để thực hiện yêu cầu thông qua chuỗi bộ lọc lại. Đối với WebSockets, dữ liệu trực tiếp đi tới sự kiện onMessage, giống như trong một TCP socket. Do vẫn không có bất kỳ HTTP request nào gửi dữ liệu này đến, nên không có yêu cầu contest nào mà từ đó có thể nhận và lưu trữ các đối tượng có phạm vi. Như vậy, việc sử dụng các dịch vụ yêu cầu các đối tượng có phạm vi từ một sự kiện onMessage sẽ thất bại. Ví dụ mẫu guice và websocket trong mã nguồn cho thấy cách vượt qua hạn chế và vẫn sử dụng các đối tượng có phạm vi-yêu cầu trong một sự kiện onMessage. Khi bạn chạy ví dụ mẫu này và nhấn vào từng button trên trang web để kiểm tra một cuộc gọi Ajax (có phạm vi-yêu cầu) và một cuộc gọi WebSocket. Một cuộc gọi WebSocket có một yêu cầu mô phỏng được quy định phạm vi, bạn sẽ nhận được kết quả như trong Hình 3. Hình 3. Kết quả của một trình xử lý WebSocket khi sử dụng các dịch vụ có phạm vi-yêu cầu Bạn có thể gặp các vấn đề như vậy khi bạn dùng:  Spring.  Hibernate.  Bất kỳ framework khác nào đòi hỏi mô hình có phạm vi-yêu cầu hoặc một mô hình cho mỗi yêu cầu, chẳng hạn như OpenSessionInViewFilter.  Bất kỳ hệ thống nào đang sử dụng phương tiện ThreadLocal để quy định phạm vi các biến cho một luồng yêu cầu trong một bộ lọc và truy cập chúng sau này. Guice có độ phân giải đẹp, như thể hiện trong Liệt kê 8: Liệt kê 8. Mô phỏng một phạm vi-yêu cầu từ sự kiện onMessage của WebSocket // The reference to the request is hold when the // doWebSocketMethod is called HttpServletRequest request = [...] Map, Object> bindings = new HashMap, Object>(); // I have a service which needs a request to get the session, // so I provide the request, but you could provide any other // binding that may be needed bindings.put(Key.get(HttpServletRequest.class), request); ServletScopes.scopeRequest(new Callable() { @Override public Object call() throws Exception { // call your repository or any service using the scoped objects outbound.sendMessage([...]); return null; } }, bindings).call(); Về đầu trang Tạm dừng các yêu cầu long-lived Đối với Comet, có một trở ngại khác. Một máy chủ có thể tạm dừng một yêu cầu long-lived như thế nào mà không ảnh hưởng đến hiệu năng và sau đó phục hồi và hoàn thành nó ngay khi một sự kiện máy chủ đến? Rõ ràng, bạn không thể chỉ đơn giản giữ các yêu cầu và đáp ứng, mà nó có thể gây ra thiếu luồng và tiêu thụ bộ nhớ cao. Việc tạm dừng một yêu cầu long-polling, trong số thiết bị I/O non- blocking, đòi hỏi phải có một API đặc trưng. Theo Java, đặc tả Servlet 3.0 cung cấp một API như vậy (xem Phần 1 của loạt bài này). Liệt kê 9 thể hiện ví dụ. Liệt kê 9. Định nghĩa một servlet không đồng bộ với Servlet 3.0 <web-app version="3.0" xmlns="" xmlns:j2ee="" xmlns:xsi="" xsi:schemaLocation=" /ns/j2ee/web-app_3.0.xsd"> events ReverseAjaxServlet true events /ajax Khi bạn đã xác định một servlet không đồng bộ, bạn có thể sử dụng API Servlet 3.0 để tạm dừng và tiếp tục lại một yêu cầu, như trong Liệt kê 10: Liệt kê 10. Treo và tiếp tục lại một yêu cầu AsyncContext asyncContext = req.startAsync(); // Hold the asyncContext reference somewhere // Then when needed, in another thread you can resume or complete HttpServletResponse req = (HttpServletResponse) asyncContext.getResponse(); req.getWriter().write("data"); req.setContentType([...]); asyncContext.complete(); Trước Servlet 3.0, mỗi container đã có cơ chế riêng của mình. Continuations của Jetty là một ví dụ nổi tiếng; nhiều thư viện Reverse Ajax theo Java phụ thuộc vào các continuations của Jetty. Đây không phải là một vấn đề được quan tâm và không đòi hỏi bạn chạy ứng dụng của mình trong một container Jetty. API có đủ thông minh để phát hiện ra container mà bạn đang chạy và quay lại API Servlet 3.0, nếu có, khi chạy trong một container khác như Tomcat hay Grizzly. Điều này đúng với Comet, nhưng nếu bạn muốn tận dụng lợi thế của WebSockets, hiện tại bạn không có sự lựa chọn nào khác trừ sử dụng các tính năng container đặc trưng. Đặc tả Servlet 3.0 vẫn chưa được phát hành, nhưng rất nhiều
Tài liệu liên quan