Có nhiều bạn đặt câu hỏi là tại sao khi tôi truy cập đến uri:
  1. /api/users/user1.
  2. /api/users/user2.

Lại có thể trả về thông tin của 2 người dùng khác nhau? Có lẽ nào mã nguồn của controller là kiểu này?

@Controller('/api/v1')
public class UserController {
    
    @DoGet("/users/user1")
    public ResponseEntity usersUser1Get() {}

    @DoGet("/users/user1")
    public ResponseEntity usersUser1Get() {}
}

Trên thực tế đây không phải là một câu hỏi ngây ngô, mà là một câu hỏi chính mình đã từng đặt ra từ hồi mới viết ezyhttp. Lúc đó mình chưa biết đến khái niệm path variable, nên mình phải cài đặt API dạng /users?username=abc, tức là tên người dùng sẽ được truyền qua tham số của request để lấy được thông tin người dùng khác nhau. Sau đó mình thấy cách này bất tiện vô cùng vì sẽ có thêm các điều kiện lọc khác nhau nữa nên mã nguồn của hàm lấy thông tin người dùng cứ bị dài mãi ra.

Path variable

URI là một phần của giao thức HTTP, ví dụ bạn có một đường dẫn đầy đủ http://localhost:8080/api/v1/users/user1 thì /api/v1/users/user1 là URI.

Tuy nhiên khi chúng ta cài đặt mã nguồn xử lý ở máy chủ thì không thể nào cứ một người dùng chúng ta lại tạo ra một hàm xử lý được, ví có cả hàng ngàn, hàng triệu người dùng và không biết trước được username của họ là gì. Chính vì vậy một khái niệm mới được sinh ra đó là là path variable. Một URI sẽ ở dạng động (dynamic) kiểu này /api/v1/users/{username} thì username chính là path variable, tương ứng với mỗi yêu cầu mà biến username này sẽ có giá trị khác nhau, ví dụ user1, user2, ... Mã nguồn của hàm xử lý yêu cầu cũng sẽ chỉ cần duy nhất như sau:

@Controller('/api/v1')
public class UserController {

    @DoGet("/users/{username}")
    public ResponseEntity usersUsernameGet() {}
}

Nguyên lý ánh xạ

Nghe thì dễ hiểu, tuy nhiên để ánh xạ được từ một URI cụ thể đến được hàm xử lý lại là cả một quá trình phức tạp, sẽ thế nào nếu có hai hàm thế này:

@Controller('/api/v1')
public class UserController {

    @DoGet("/users/{username}")
    public ResponseEntity usersUsernameGet() {}

   @DoGet("/users/me")
    public ResponseEntity usersAddGet() {}
}

Một hàm lấy thông tin người dùng tuỳ biến, một hàm lấy thông tin của người dùng là chính mình, thì me có bị hiểu là một username không?

Ngày nay có lẽ nhiều bạn đã đọc nhiều tài liệu và hiểu được nguyên lý cơ bản của việc ánh xạ URI này là dùng cấu trúc dữ liệu cây, tuy nhiên dùng cây thế nào cũng là cả một vấn đề. EzyPlatform được thừa hưởng giải pháp từ EzyHTTP như sau:

Ví dụ chúng ta có 3 URI:

  1. /api/v1/users/{username}.
  2. /api/v1/users/me.
  3. /api/v2/admin/{username}.

Chúng ta sẽ có một cây kiểu này:

EzyPlatform URI Mapping.png
  1. Gốc của cây sẽ là api.
  2. Hai nhánh lớp là v1v2.
  3. Nhánh v1 có một nhánh là users.
  4. Nhánh v2 có một nhánh là admins.
  5. Nhánh user có hai nhành là me{}.
  6. Nhánh admins có một nhánh là {}.

Điều bí ẩn ở đấy chính là tại sao path variable username mà chính ta lại sử dụng {} mà không phải {username}? Câu trả lời đó là giả sử client yêu cầu URI là /api/v1/users/user1, thì URI này sẽ được cắt nhỏ ra thành 1 mảng gồm các phần tử api,v1,users,user1 và sẽ trải qua các bước so sánh với cây như sau:

  1. So sánh api, tìm thấy cây.
  2. So sánh với v1, tìm thấy cây theo nhánh v1.
  3. So sánh với users, tìm thấy cây.
  4. So sánh user với me thấy không bằng, mặc định lấy nhánh {}.
  5. Không còn nhánh nào nữa, kết thúc.

Như vậy chúng ta sẽ tìm thấy nhánh api - v1 - users - {}. Và trong mã nguồn quản lý chúng ta cũng lưu /api/v1/users/{} ánh xạ với /api/v1/users/{username}, như vậy kết quả chúng ta sẽ tìm thấy /api/v1/users/{username} tương ứng với hàm hàm xử lý usersUsernameGet.

Mã nguồn cài đặt

Các bước có thể phức tạp, nhưng mã nguồn cài đặt khá đơn giản như sau:

public class URITree {

    protected String uri;
    protected Map<String, URITree> children;

    public void addURI(String uri) {
        URITree lastChild = this;
        String[] paths = uri.split("/");
        for (String s : paths) {
            if (lastChild.children == null) {
                lastChild.children = new HashMap<>();
            }
            String path = s;
            if (PathVariables.isPathVariable(path)) {
                path = "{}";
            }
            URITree child = lastChild.children.get(path);
            if (child == null) {
                child = new URITree();
                lastChild.children.put(path, child);
            }
            lastChild = child;
        }
        lastChild.uri = uri;
    }

    public String getMatchedURI(String uri) {
        URITree lastChild = this;
        String[] paths = uri.split("/");
        for (String path : paths) {
            if (lastChild.children == null) {
                return null;
            }
            URITree child = lastChild.children.get(path);
            if (child == null) {
                child = lastChild.children.get("{}");
            }
            if (child == null) {
                child = lastChild.children.get("*");
                return child == null ? null : child.uri;
            }
            lastChild = child;
        }
        return lastChild.uri;
    }
}

Bạn có thể tham khảo mã nguồn đầy đủ tại đây.

Tổng kết

Tưởng chừng như URI là một kiến thức gì đó quá cơ bản mà hàng ngày hàng giờ chúng ta vẫn sử dụng, tuy nhiên khi phải làm xuống đến mức thư viện framework mới thấy nó là cả một bí quyết. Mình hy vọng rằng các bạn có thể tham khảo cách làm của EzyPlatform để sáng tạo ra những cách làm khác thông minh hơn nhé.