+26

Tản mạn về API design

Vâng, đến hẹn lại lên, mỗi tháng một lần, người bạn thân thiết của (anh) chị em Framgia lại tìm đến thăm chúng ta. Ý tôi là Viblo report. Vâng, xin chào mừng các bạn quay lại với series "Những chủ đề rất thú vị nhưng thường bị lãng quên của một lập trình viên". Lady and gentleman, welcome to the next episode of "Programmer's Extremely Neglected but Interesting Subjects". Let me be your guide. ( Do you think " Programmer's Elegant Note of Important Subjects" sounds better ? Or should we go with "Extraordinary", because honestly, i think it's pretty awesome ? Damn it, i wish i have more time for this stuff. Anyway, drop by and comment if there's a title that you want to named. ) . Enough with the introduction, let's the party begin. Today, our subject will be " API Design".

Application Programming Interface, a.k.a, API, is a collection of endpoints to interact with an application. You have internal and external APIs.

API, tên gọi đầy đủ là Application Programming Interface, là tập hợp các endpoints, dùng để tương tác với một application.

Định nghĩa về API khá là đơn giản, có thể gói gọn lại trong một dòng như thế, nhưng sau khi đã làm qua một vài dự án làm API, mới thấy quả thật thiết kế API cho đúng, cho chuẩn, cũng ko phải là việc quá dễ dàng. Vậy, có những vấn đề gì cần chú ý thiết kế một hệ thống API, đặc biệt là external API ?

Cần những tiêu chí nào cho một hệ thống API

Không thế nói chuyện làm bất cứ một cái gì, nếu ta chưa biết được yêu cầu, phải không? Vậy một hệ thống API gọi là được thiết kế tốt, cần thỏa mãn những tiêu chí nào. Thật ra vẫn còn nhiều tranh cãi quanh vấn đề này, 9 người 10 ý, nhưng ta nên nhớ rằng, về bản chất, có thể hiểu nôm na, API là một dạng UI dành cho UI developer, developer sẽ tương tác với application của ta thông qua hệ thống API, và API phục vụ đối tượng end user là developer. Đặc điểm đó làm cho hệ thống API nên có những đặc điểm sau:

  • API nên tuân theo chuẩn web standard: Về lí thuyết mà nói thì developer rất thích mọi thứ theo quy chuẩn. Còn gì đẹp đẽ hơn khi ta chỉ cần nhìn thoáng qua, thấy cái tên là đã hiểu luôn bản chất. Thế nên API nên thiết kế càng theo quy chuẩn càng tốt ( hiện tại, phổ biến nhất có lẽ là theo chuẩn RESTful, cái này sẽ dưới sẽ dành riêng một phần để nói ). Tuy nhiên, developer cũng là người, không nên quá máy móc, chỗ nào thấy theo quy chuẩn hợp lí thì theo, không thì thôi. Ở dưới sẽ nêu ví dụ cụ thể. Theo chuẩn đến mức mà developer có thể dựa vào đường dẫn mà đoán ra chức năng/ mục đích của API là đẹp nhất. If calling API /stab allowing you to stab someone else, then calling /shoot shouldn't let you get shot.

  • External API, mục đích của nó là để cho các developer khác tương tác với ứng dụng của bạn. Khác với ứng dụng, ví dụ như một website chẳng hạn, phát hiện ra một lỗi, hay muốn thêm tính năng, sửa một vài chỗ giao diện, bạn có thể release một phiên bản mới, việc thay đổi API sẽ là rất khó khăn, làm sao để cho những người đang dùng API phiên bản hiện tại gặp càng ít khó khăn càng tốt, làm sao để mọi người biết và chuyển sang dùng phiên bản API mới ... Thế nên, thiết kế API cần phải ổn định và hiệu quả, cần phải có quản lí versioning và documentation rõ ràng, và cần hạn chế tối đa việc thay đổi, mỗi version mới của API cần phải thật sự đáng với công sức bỏ ra.

  • API phải dễ tùy biến, và có độ mềm dẻo nhất định. Người dùng của bạn ở đây là developer, họ sẽ dùng thông tin từ ứng dụng của bạn cho ứng dụng của họ, sẽ rất khó để có thể lường trước tất cả mọi request của người dùng, hay ép người dùng phải tuân theo đúng cách nghĩ của bạn để API có thể hoạt động. Vì thế, thiết kế API nên có một vài option để mặc cho người gọi API tùy biến ( nên áp dụng trong những trường hợp nào, đến những mức nào, xin được bàn chi tiết hơn ở phần sau ).

  • API cần hoạt động một cách ổn định. Không phải ở đây chỉ có mỗi ứng dụng của bạn nữa, performance của hệ thống API do bạn cung cấp còn ảnh hưởng tới tất cả những developer sử dụng API đó cho hệ thống của họ. Đừng để xảy ra tình trạng như nhiều người cùng gọi đến thì API thọt hay tương tự thế.

Với những tiêu chí trên đã được liệt kê ra, ta hãy cùng nhau điểm qua một vài best practice trong thiết kế API.

Sử dụng RESTful URL và action

Vâng, RESTful API, mấy tiếng thân thương, thiên hạ nói đến nó nhiều đến nỗi, đôi khi ta có thể tưởng rằng hai từ này gắn liền làm một, đi đâu cũng phải có nhau. REST ( Representational State Transfer - nhớ lầy sau này đi phỏng vấn còn chém gió ) , người thì gọi nó là một kiểu cấu trúc thiết kế ( architectural style ), người thì bảo là một cách chuẩn thiết kế ( design patter ), mình xin tạm nghiêng về ý thứ 2 hơn. RESTful API có thể hiểu là hệ thống API được thiết kế tuân theo chuẩn mực này ( kiểu như vẽ tranh theo trường phái siêu thực hay siêu tưởng vậy =)) ). Nguyên tắc của chuẩn REST là phân chia dữ liệu thành những loại tài nguyên ( logical resources ) khác nhau. Mỗi loại hành động xử lí những tài nguyên này sẽ được gắn với các HTTP request tương ứng. Cụ thể là xử lí thế nào, ứng với mỗi method là hành động nào, thì tài liệu đã có quá nhiều, và ở mỗi ứng dụng đôi khi lại có sự chia nhỏ khác nhau, có thể tự tìm hiểu. Chỉ xin được điểm lại vài điểm đặc biệt cần lưu ý với nhau :

  • Một nguyên tắc khá hay để đảm bảo ứng dụng do ta thiết kế tuân thủ chuẩn RESTful, đó là tránh dùng động từ trong đường dẫn URL. URL chỉ nên chứa danh từ, để biểu thị loại tài nguyên mà ta đang xử lí tới, còn việc áp dụng hành động nào lên tài nguyên đó thì hãy để xét thêm tới method HTTP của request mà quyết định.

  • Về cơ bản thì tên resource nên đặt là số nhiều, kể cả với phương thức show() chẳng hạn, tuy rằng ta chỉ show ra một đối tượng đơn lẻ ở đây, nhưng cũng đừng vì thế mà đặt url thành số ít. Cái này là chuẩn chung rồi, thiên hạ theo cả, đừng cãi.

  • Lí thuyết nói rằng, thiết kế theo chuẩn RESTful sẽ chỉ có GET, POST, PUT, PATCH, DELETE ... các kiểu, thế nhưng không phải lúc nào cũng máy móc tuân theo chuẩn này cũng là hay. Ví dụ như khi activate một user chẳng hạn, tuy rằng ở đây, ta có thể chỉ đơn giản là update một trường activated trong bảng users, nhưng ko ai lại đi đặt đường dẫn ở đây là PUT /users/:id cả. Hay như khi ta cần chức năng tìm kiếm chẳng hạn, rõ ràng endpoint /search hoàn toàn không có trong chuẩn RESTful, nhưng sử dụng endpoint này cho mục đích ta cần là hoàn toàn hợp lí, ko ai bắt bẻ những chỗ như này cả.

Authentication

Tất nhiên là nếu đã thiết kế API public ra bên ngoài cho người khác sử dụng, kiểu gì chúng ta cũng phải có một cơ chế xác thực nào đó chứ. Có thể điểm qua một vài cơ chế phổ biến.

HTTP Basic

Là một trong những cách đơn giản nhất ( nghe cái tên Basic cũng đoán được rồi ). Và tất nhiên, cái gì cũng có cái giá của nó, phương thức này nhanh, tiện, nhưng về độ bảo mật và an toàn thì cũng thuộc hàng kém nhất.Cần hết sức hạn chế sử dụng cách này, nhất là trên những connections ko phải SSL/HTTPS. Cách dùng, về phía client, đơn giản chỉ cần gửi thông tin userrname:password được mã hóa base64 trong header của mỗi request. Đến đây, chắc bạn đã hiểu vì sao cách làm này có độ bảo mật không cao, và không được khuyến khích sử dụng.

JSON Web Token

Một ví dụ trực quan cho dễ hình dung về JSON Web Token (JIT)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzY290Y2guaW8iLCJleHAiOjEzMDA4MTkzODAsIm5hbWUiOiJDaHJpcyBTZXZpbGxlamEiLCJhZG1pbiI6dHJ1ZX0.03f329983b86f7d9a9f5fef85305880101d5e302afafa20154d094b229f75773

Tinh mắt nhìn kĩ thì trong token kia, có 3 phần được phân cách bởi dấu . Tất cả JIT đều có cấu trúc như vậy, còn nếu muốn tìm hiểu kĩ hơn về cấu tạo của một JIT, cách nó được tạo ra như thế nào, mỗi phần bao hàm những thông tin / ý nghĩa gì, bạn có thể tham khảo thêm bài viết The Anatomy of a JSON Web Token . Vì bài đó khá dài, nếu bê nguyên cả vào reporrt này thì e cũng ko tiện, tuy nhiên cũng khuyến khích nên đọc nếu bạn tò mò, khá thú vị và ko quá mất thời gian.

Quay trờ lại bài viết của ta, về flow thì khi sử dụng JWT, trước tiên userr sẽ phải gừi thông tin xác thực lên một endpoint register, endpoint này sẽ trả về một token tương tự như trên. Sau đó, các request tới hệ thống API của ta sẽ phải có gắn thông tin token này để xác thực. Cần lưu ý là nên để mỗi token này chỉ có hiệu lực trong một khoảng thời gian nhất định. Sau đó, token expried, và nên yêu cầu user phải gửi xác thực lấy token mới.

OAuth2

Cơ chế phổ biến nhất hiện nay.Tuy nhiên, đáng tiếc là trong khuôn khổ bài viết này không đủ để làm rõ sự khác biệt giữa 2 loại cơ chế JWT và OAuth2 này. Nếu chỉ nhìn thoáng qua, từ flow xác thực, cho đến nguyên lí chung, JWT và OAuth2 khá là tương đồng, có khác chăng chỉ là về cấu trúc của mỗi loại token. Nếu bạn thực sự muốn tìm hiểu thêm, có thể đọc kĩ hơn tại trang OauthBible, còn nếu ko thì xin dành phần này lại cho bài viết tới.

Tóm lại, về cơ chế xác thực có rất nhiều loại, tùy theo nhu cầu của hệ thống mà người thiết kế phải lựa chọn áp dụng loại cơ chế nào cho hợp lí.

Data structure

Phần tiếp theo này có lẽ là phần quan trọng nhất cần nghĩ tới khi thiết kế API. Về cơ bản thì người dùng (developer ) sẽ sử dụng API do ta thiết kế để truy vấn hoặc xử lí dữ liệu, vậy thì vấn đề cần quan tâm là ta nên trả về dữ liệu dưới dạng như thế nào để người dùng dễ tiếp nhận. Và như đã nói từ đầu, developer rất thích những thứ thuộc về quy chuẩn, và không còn gì ghét hơn khi phải làm việc với một hệ thống API mà một mình nó trình bày một kiểu chả giống ai, vậy thì hãy cùng điểm qua một vài cấu trúc API hiện đang được sử dụng.

Thêm một lưu ý nhỏ ở đây, là mặc dù trên lí thuyết, không hạn chế dữ liệu trả về của một API phải theo định dạng nào, nhưng với tình hình hầu như tất cả đều có xu hướng dùng JSON, phần này xin được mặc định luôn là API của chúng ta sẽ trả về JSON.

JSON API

API trả về dữ liệu dạng JSON, theo cấu trúc JSON API, thật ko còn gì đơn giản hơn để nói nữa =)). Joke aside, thay vì giải thích loằng ngoằng, làm một ví dụ minh họa trực quan có lẽ sẽ giúp bạn dễ hình dung ra kiểu cấu trúc này hơn ( ví dụ lấy trên http://jsonapi.org/ về luôn =)) )

{
  "links": {
    "self": "http://example.com/articles",
    "next": "http://example.com/articles?page[offset]=2",
    "last": "http://example.com/articles?page[offset]=10"
  },
  "data": [{
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "JSON API paints my bikeshed!"
    },
    "relationships": {
      "author": {
        "links": {
          "self": "http://example.com/articles/1/relationships/author",
          "related": "http://example.com/articles/1/author"
        },
        "data": { "type": "people", "id": "9" }
      },
      "comments": {
        "links": {
          "self": "http://example.com/articles/1/relationships/comments",
          "related": "http://example.com/articles/1/comments"
        },
        "data": [
          { "type": "comments", "id": "5" },
          { "type": "comments", "id": "12" }
        ]
      }
    },
    "links": {
      "self": "http://example.com/articles/1"
    }
  }],
  "included": [{
    "type": "people",
    "id": "9",
    "attributes": {
      "first-name": "Dan",
      "last-name": "Gebhardt",
      "twitter": "dgeb"
    },
    "links": {
      "self": "http://example.com/people/9"
    }
  }, {
    "type": "comments",
    "id": "5",
    "attributes": {
      "body": "First!"
    },
    "relationships": {
      "author": {
        "data": { "type": "people", "id": "2" }
      }
    },
    "links": {
      "self": "http://example.com/comments/5"
    }
  }, {
    "type": "comments",
    "id": "12",
    "attributes": {
      "body": "I like XML better"
    },
    "relationships": {
      "author": {
        "data": { "type": "people", "id": "9" }
      }
    },
    "links": {
      "self": "http://example.com/comments/12"
    }
  }]
}

Nhìn hơi loằng ngoằng một chút phải không, chịu khó nhìn kĩ , phân tách ra một chút thì sẽ thấy, cái response này, về đại thể có mấy phần sau :

  • Trước tiên là có object links, trong đây có chứa các thông tin về đường dẫn, dùng cho việc phân trang.

  • object data , trong này chứa bản thân dữ liệu trả về, và các tài nguyên có quan hệ với nó.

  • object included, trong này là nội dung của các tài nguyên có quan hệ.

Bản thân nó hơi loằng ngoằng một chút, nhằm phục vụ cho việc tuân thủ constrain HATEOAS của chuẩn REST ( HATEOAS cũng là một khái niệm khá hay, và tương đối mới. Nói một cách thật ngắn gọn ,thì HATEOAS muốn phía client không cần biết chút nào về cấu trúc phía server, client chỉ cần request đến một URL duy nhất, rồi từ đó mọi đường đi nước bước tiếp theo sẽ do chỉ dẫn của phía server trả về )

JSend

{
  "status": "success",
  "data": {
    "post": {
      "id": 1,
      "title": "A blog post",
      "body": "Some useful content"
    }
  }
}

Kiểu này thì đơn giản, nhìn dễ hiểu hơn hẳn rồi. Kiểu JSend sẽ chỉ bao gồm, đầu tiên là status của request, thành công hay thất bại, tiếp theo là nội dung dữ liệu trả về, có sao thì đưa ra vậy. Các hệ thống nhỏ, hay những hàm gọi AJAX, dữ liệu thường được trả về theo kiểu này.

OData JSON Protocol

Từ đây và kiểu cấu trúc tiếp theo, thật sự là mình cũng ko hiểu nổi là nó có lợi điểm gì ngoài chuyện rối mắt, và khi nào thì dùng. Nhưng thôi, đưa vào chỉ mặt điểm tên cho đủ số

{
    "@odata.context": "serviceRoot/$metadata#People",
    "@odata.nextLink": "serviceRoot/People?%24skiptoken=8",
    "value": [
        {
            "@odata.id": "serviceRoot/People('russellwhyte')",
            "@odata.etag": "W08D1694BD49A0F11",
            "@odata.editLink": "serviceRoot/People('russellwhyte')",
            "UserName": "russellwhyte",
            "FirstName": "Russell",
            "LastName": "Whyte",
            "Emails": [
                "Russell@example.com",
                "Russell@contoso.com"
            ],
            "AddressInfo": [
                {
                    "Address": "187 Suffolk Ln.",
                    "City": {
                        "CountryRegion": "United States",
                        "Name": "Boise",
                        "Region": "ID"
                    }
                }
            ],
            "Gender": "Male",
            "Concurrency": 635404796846280400
        },
        {
            "@odata.id": "serviceRoot/People('keithpinckney')",
            "@odata.etag": "W08D1694BD49A0F11",
            "@odata.editLink": "serviceRoot/People('keithpinckney')",
            "UserName": "keithpinckney",
            "FirstName": "Keith",
            "LastName": "Pinckney",
            "Emails": [
                "Keith@example.com",
                "Keith@contoso.com"
            ],
            "AddressInfo": [],
            "Gender": "Male",
            "Concurrency": 635404796846280400
        }
    ]
}

HAL

{
    "_links": {
        "self": { "href": "/orders" },
        "curies": [{ "name": "ea", "href": "http://example.com/docs/rels/{rel}", "templated": true }],
        "next": { "href": "/orders?page=2" },
        "ea:find": {
            "href": "/orders{?id}",
            "templated": true
        },
        "ea:admin": [{
            "href": "/admins/2",
            "title": "Fred"
        }, {
            "href": "/admins/5",
            "title": "Kate"
        }]
    },
    "currentlyProcessing": 14,
    "shippedToday": 20,
    "_embedded": {
        "ea:order": [{
            "_links": {
                "self": { "href": "/orders/123" },
                "ea:basket": { "href": "/baskets/98712" },
                "ea:customer": { "href": "/customers/7809" }
            },
            "total": 30.00,
            "currency": "USD",
            "status": "shipped"
        }, {
            "_links": {
                "self": { "href": "/orders/124" },
                "ea:basket": { "href": "/baskets/97213" },
                "ea:customer": { "href": "/customers/12369" }
            },
            "total": 20.00,
            "currency": "USD",
            "status": "processing"
        }]
    }
}

Cái gì phức tạp hơn OData ? Vâng, OData có support thêm HATEOAS, nó chính là thằng này 😐

Túm lại một điều, cũng như Authentication, bố trí cấu trúc dữ liệu trả về làm sao cho người dùng dễ đọc cũng có nhiều cách, và bạn nên lựa chọn trả về của mình sao cho tuân theo các chuẩn định dạng đang có kia, để tránh gây bối rối cho người dùng, khi tự nhiên gặp phải một cái hệ thống dở hơi, chả giống ai hết.

Đôi điều lặt vặt khác

Sorting and Filtering

Vì nhu cầu của developer khi sử dụng API là rất khó để lường trước được hết, với những tài nguyên thường được lấy ra dưới dạng list, nên bỏ ngỏ sẵn option cho người dùng thực hiện những xử lí thường gặp như sort ( theo bất kì thứ tự nào ) hay filter ( lọc theo những điều kiện này ) , bằng cách gửi kèm theo params. Nên có document rõ ràng cho những option này.

Shortcut endpoint

Đôi khi, có những hành động thường xuyên được gọi đến, chẳng hạn như Viblo cung cấp API cho phép lấy 10 bài viết gần nhất chẳng hạn. Về lí thuyết thì nó là lấy danh sách bài viết, và kèm thêm option sort theo thời gian, limit số lượng bản ghi lấy ra. Nhưng nếu như nhu cầu sử dụng hành động là cao, đừng ngần ngại tạo riêng cho nó một endpoint , /recentPosts chẳng hạn. API, mục đích cuối cùng là để người khác dùng, nên càng tiện thì càng tốt.

Limiting output data, pagination

Nghe thì có vẻ như tất lẽ dĩ ngẫu, nhưng không phải lúc nào điều này cũng được nhớ. Nhìn chung mà nói thì, khi truy vấn dữ liệu, rất ít khi người dùng API muốn lấy ra tất cả, thế nên hãy thiết kế làm sao để cho họ có khả năng chọn lựa xem, họ muốn lấy ra chỉ những trường dữ liệu nào, bằng cách cho phép nhân param fields chẳng hạn. Với dữ liệu dạng list thì, đừng có bao giờ quên việc thực hiện phân trang. API một khi đã phát hành ra, sẽ rất mất công sức để nâng lên version mới, nên nếu lỡ quên việc tối đơn giản này thì việc khắc phục sẽ không hề nhẹ nhàng.

Status codes

Một hệ thống API tốt phải tận dụng hiệu quả các status code. Status code là cách nhanh nhất để người dùng kiểm tra được tình trạng request / response mà không cần phải đi vào xem chi tiết. Ở mức tối đơn giản, API cũng phải trả về được loạt mã lỗi 400 của giao thức HTTP, tốt hơn thì nên implement cả loạt mã lỗi 500. Lí tưởng nhất, API nên có hệ thống báo lỗi riêng, với mã lối custom cho từng case lỗi. Những thông tin này cũng cần phải được documented lại một cách rõ ràng, dễ hiểu.

Versioning

Quản lí version là một phần không thể thiếu của bất kì hệ thống API nào, thế nhưng, do bản chất của việc nâng cấp version sẽ chỉ xảy ra ở tương lai xa, nên đôi khi, vẫn có những dự án làm API mà hoàn toàn không tính đến chuyện quản lí version. Phát hiện sai sót này càng chậm, thì công sức bỏ ra để khắc phục nó sẽ càng mất nhiều hơn. Về việc thực hiện quản lí version ra sao, hiện chủ yếu có hai phương pháp. URL based versioning sẽ gắn thêm một đoạn biểu thị version vào trong đường dẫn url của API, dạng như api/v1.0/users . Cách thứ hai là Header based versioning, gắn thông tin version vào trong Header, dạng như Accept: application/vnd.github.v3+json . Hiện vẫn còn một vài tranh cãi quanh vấn đề sử dụng cách nào là tối ưu, nên có thể tạm nói rằng, cả hai cách đều khả dĩ, và việc lựa chọn là hoàn toàn tùy thuộc vào người thiết kế.

Documentation

Cuối cùng, và có lẽ cũng là phần quan trọng nhất. Một hệ thống external API không thể gọi là hoàn thiện, nếu không có phần Documentation tốt đi kèm. Mục đích của API là để người khác dùng, nếu không có hướng dẫn sử dụng, thì liệu có ai muốn động đến? Về cơ bản , documentation, ngoài việc liệt kê đầy đủ, nếu muốn gọi là tốt, phải cover hết từng input / output của mỗi hàm, cả mandatory lẫn optional. Nếu có thể, sample input / output phải là sample thực, chuẩn xác, người đọc có thể lập tức lấy sample input ra làm theo, và cho ra kết quả giống như của sample output.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.