Wednesday, November 9, 2016

Bài 35: Dịch vụ web (tiếp theo)

Trong bài trước chúng ta đã tìm hiểu về hai cách để xây dựng kết nối trao đổi dữ liệu cho dịch vụ web là socket và websocket. Bây giờ chúng ta sẽ tìm hiểu về hai phương thức khác là REST và RPC.

REST


REST không phải là một cách thức kết nối trao đổi dữ liệu mới trên môi trường web mà nó là kiến trúc phần mềm để xây dựng các dịch vụ web theo kiểu riêng.

REST (Representational State Transfer) được Roy Fielding đưa ra vào năm 2000 và là kiến trúc phần mềm phổ biến nhất hiện nay trong thế giới web. REST cung cấp một bộ quy tắc và ràng buộc khi xây dựng các hệ thống theo kiểu REST. Nhiều khái niệm trên REST có trong HTTP 1.1 và URI bởi Roy Fielding là một trong những đồng tác giả của HTTP 1.1 và URI. 

REST xem mọi dữ liệu là tài nguyên và nó tập trung chuẩn hóa xử lý tài nguyên. Các tài nguyên REST có các đặc tính:
- Hiển thị đa dạng: Dữ liệu phản hồi thường được thể hiện ở nhiều dạng JSON, XML, nhị phân, v.v... và nó thể hiện 1 tài nguyên xác định trên server.
- Nhận diện rõ ràng: mỗi tài nguyên sẽ được chia nhóm, phân loại và có 1 cách thể hiện và truy xuất duy nhất thông qua đường dẫn URL tương ứng. 

Quy tắc và ràng buộc của REST tựu chung lại gồm:
- Hệ thống REST hoạt động theo mô hình client-server: server có thể là tập hợp của các dịch vụ nhỏ cùng nhau xử lý một yêu cầu từ client. Đặc điểm này rất phù hợp để dùng REST cho hệ thống được xây dựng theo mô hình microservices.
- Phi trạng thái: server và client không lưu trạng thái của nhau. Mỗi khi cần, các bên phải gửi đầy đủ dữ liệu cho bên kia. Đặc điểm này giúp hệ thống dễ dàng phát triển mở rộng và bảo trì vì không cần lưu trạng thái, kiểm tra và xử lý theo trạng thái đã lưu. Nhược điểm của nó là lượng dữ liệu trao đổi sẽ cao.
- Khả năng lưu tạm (cache): dữ liệu phản hồi có thể được lưu tạm để giúp giảm xử lý từ server và client thì nhận phản hồi nhanh.
- Chuẩn hóa tương tác tài nguyên: Sử dụng giao thức HTTP một cách rõ ràng nhất quán:



  • Để tạo mới tài nguyên trên server, ta cần sử dụng phương thức POST.
  • Để truy xuất một tài nguyên, sử dụng GET.
  • Để thay đổi trạng thái một tài nguyên hoặc để cập nhật nó, sử dụng PUT.
  • Để huỷ bỏ hoặc xoá một tài nguyên, sử dụng DELETE.
- Chuẩn hóa giao tiếp: cần đặt ra một chuẩn để giữa các thành phần trong server cũng như giữa client và server có thể dễ dàng giao tiếp mà không phải mất nhiều thời gian tìm hiểu. 

RESTful API

RESTful API là hệ thống API áp dụng REST. Bây giờ chúng ta cùng tìm hiểu cách xây dựng một hệ thống RESTful API đơn giản qua ví dụ sau. Đây là hệ thống xử lý các địa điểm trên bản đồ như đã nêu ở bài 27, gồm lưu địa điểm mới, truy xuất các địa điểm, cập nhật một địa điểm cụ thể và xóa địa điểm:
- Đầu tiên là các package sử dụng:
import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    "github.com/gorilla/pat"
)
Package encoding/json giúp xử lý mã hóa, giải mã nội dung JSON, là định dạng dữ liệu trao đổi giữa client và server. Package gorilla/pat giúp tổ chức xử lý các handler theo chuẩn REST bởi nó phân biệt được phương thức gửi là GET, POST, PUT hay DELETE. Ngoài ra pat còn giúp ta xử lý các đường dẫn tài nguyên động (chứa tham số) còn handler thông thường chỉ xử lý được đường dẫn tĩnh đã khai báo.
- Cấu trúc địa điểm:
type Location struct {
    Id int                     `json:"id"`
    Name string          `json:"name"`
    Address string      `json:"adr"`
    Latitude float64    `json:"lat"`
    Longitude float64 `json:"lon"`
    Type string            `json:"type"`
}
- Biến toàn cục gồm map lưu thông tin địa điểm và biến lưu id địa điểm mới nhất được tạo:
//Lưu các địa điểm, mất khi server đóng
var locationStore = make(map[int]Location)

//Biến tạo khóa cho map dữ liệu, id địa điểm
var id int = 0
- Hàm main xử lý web server theo REST với gorilla/pat:
func main() {
    r := pat.New()
    r.Get("/api/locations", GetLocationHandler)
    r.Post("/api/locations", PostLocationHandler)
    r.Put("/api/locations/{id}", PutLocationHandler)
    r.Delete("/api/locations/{id}", DeleteLocationHandler)

    http.Handle("/", r)
    http.ListenAndServe(":8080", nil)
}
Nhìn qua 2 dòng lệnh cuối ta có thể thấy ở đây sử dụng DefaultServeMux (xem lại bài 26) và chỉ khai báo r đóng vai trò handler nhưng sau khi nhận nó sẽ phân phối theo đúng hàm xử lý theo hành động đã khai báo bên trên. Ở đây khai báo xử lý 4 hành động thêm mới, xem, sửa và xóa ứng với 4 hàm xử lý. Chúng ta thấy là đường dẫn ứng với hành động PUT và DELETE có chứa tham số {id}. Đây là cách hỗ trợ đường dẫn động của pat. 
- Hàm xử lý thêm mới: PostLocationHandler
//HTTP Post - /api/locations
func PostLocationHandler(w http.ResponseWriter, r *http.Request) {
    var location Location
    // Giải thông tin địa điểm từ json
    err := json.NewDecoder(r.Body).Decode(&location)
    if err != nil {
        panic(err)
    }

    id++
    location.Id = id
    locationStore[id] = location
    j, err := json.Marshal(location)
    if err != nil {
        panic(err)
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    w.Write(j)
}
Đầu tiên dữ liệu dạng JSON do client gửi đến sẽ được phân giải và đổ vào biến location thuộc struct Location tương đồng dịnh dạng JSON client gửi. Tiếp theo tăng giá trị biến id để tạo ID mới cho địa điểm mới. ID mới này sẽ được gán vào trường Id của location do dữ liệu từ client không có giá trị ID này hoặc nếu có thì cũng xem là rác. Tiếp theo địa điểm mới này sẽ được thêm vào map lưu thông tin địa điểm có khóa là giá trị id và giá trị là giá trị biến location. Cuối cùng giá trị location được gửi về lại client như biểu thị đã thêm nó thành công bên cạnh trạng thái gửi về là http.StatusCreated (201). 

- Hàm xử lý lấy thông tin địa điểm: GetLocationHandler
//HTTP Get - /api/locations
func GetLocationHandler(w http.ResponseWriter, r *http.Request) {
    var locations []Location
    for _, v := range locationStore {
        locations = append(locations, v)
    }
    w.Header().Set("Content-Type", "application/json")
    j, err := json.Marshal(locations)
    if err != nil {
        panic(err)
    }
    w.WriteHeader(http.StatusOK)
    w.Write(j)
}
Hàm này tạo slice kiểu Location chứa thông tin địa điểm sau đó chúng ta chuyển chúng thành định dạng JSON và gửi về client. Quá đơn giản phải không?

- Hàm xử lý cập nhật thông tin địa điểm: PutLocationHandler
//HTTP Put - /api/locations/{id}
func PutLocationHandler(w http.ResponseWriter, r *http.Request) {
    k, err := strconv.Atoi(r.URL.Query().Get(":id"))
    if err != nil {
        panic(err)
    }
    var locationToUpd Location
    // Giải thông tin địa điểm từ json
    err = json.NewDecoder(r.Body).Decode(&locationToUpd)
    if err != nil {
        panic(err)
    }
    if _, ok := locationStore[k]; ok {
        // Xóa địa điểm hiện thêm địa điểm cập nhật
        delete(locationStore, k)
        locationToUpd.Id = k
        locationStore[k] = locationToUpd
    } else {
        log.Printf("Không tìm thấy khóa %s để xóa", k)
    }
    w.WriteHeader(http.StatusNoContent)
}
Đầu tiên là lấy tham số id để xác định địa điểm cần xóa. Pat cung cấp cách lấy giá trị id qua r.URL.Query().Get(":id") rồi chuyển thành số lưu vào biến k. Lưu ý dấu : trước tên tham số. Tiếp theo địa điểm mới được phân giải JSON rồi đổ vào biến locationToUpd kiểu struct Location. Nếu tồn tại giá trị ứng với khóa k thì ta sẽ thay giá trị cũ bằng giá trị ở locationToUpd. Trước đó giá trị k trường Id của locationToUpd được cập nhật.

- Hàm xử lý xóa địa điểm: DeleteLocationHandler
//HTTP Delete - /api/locations/{id}
func DeleteLocationHandler(w http.ResponseWriter, r *http.Request) {
    k, err := strconv.Atoi(r.URL.Query().Get(":id"))
    if err != nil {
        panic(err)
    }
    // Xóa khỏi map lưu trữ
    if _, ok := locationStore[k]; ok {
        // Xóa địa điểm ứng khóa k
        delete(locationStore, k)
    } else {
        log.Printf("Không tìm thấy khóa %s để xóa", k)
    }
    w.WriteHeader(http.StatusNoContent)
}
Bây giờ chúng ta cùng thử xem server ở trên hoạt động như thế nào với việc sử dụng Postman đóng vai trò client. Đây là ứng dụng giúp tạo ra các yêu cầu HTTP gửi về server. Postman chạy trên Chrome, Linux và Mac.

Tạo mới địa điểm với POST:
- Chọn phương thức POST, URL: localhost:8080/api/locations
- Phần thân (body), chọn loại nội dung là raw, định dạng JSON và nhập nội dung như trên hình.
- Chọn "Send".
- Trạng thái phản hồi là 201 (Created)
- Nội dung phản hồi như khi yêu cầu thêm mới, id đã được cập nhật.
- Thực hiện thêm mới một địa điểm nữa như sau:


Nội dung GET:
- Chọn phương thức GET, nhập URL rồi nhấn "Send".
- Phản hồi nhận được có mã trạng thái 200 (OK), phần thân như trên hình, định dạng JSON.

Yêu cầu PUT:
- Chọn phương thức PUT, URL thêm /1 là id của địa điểm mới thêm vào, các phần khác làm như POST.
- Phản hồi không có nội dung, mã trạng thái 204, cập nhật thành công.

Yêu cầu xóa với DELETE:
- Chọn phương thức DELETE, URL tương tự như PUT, chọn "Send".
- Phản hồi không có nội dung, mã trạng thái 204, xóa thành công.

Kết quả chạy GET lại:
- Địa điểm có id=1 đã được cập nhật (thêm TPHCM vào địa chỉ)
- Địa điểm có id=2 đã bị xóa.

RPC


Trong các phần trước, chúng ta tìm hiểu cách xây dựng các dịch vụ web sử dụng các kiểu kết nối và trao đổi qua socket và HTTP. Chúng ta đều thấy chúng sử dụng mô hình trao đổi thông tin, client yêu cầu và server phản hồi. Thông tin được đóng gói theo định dạng nhất định. Bây giờ chúng ta sẽ tìm hiểu một cách thức kết nối và trao đổi hoàn toàn mới đó là thay vì yêu cầu thông tin, chúng yêu cầu thực thi tác vụ như gọi hàm.

Remote Procedure Call (RPC) là một loại trao đổi giữa các tiến trình ứng dụng, cho phép gọi thực thi một hàm thuộc tiến trình khác trong môi trường mạng. Client thực thi RPC y như gọi hàm của nó vậy, có điều lúc này hàm thực thi nằm trên server. Client đóng gói tham số và gửi yêu cầu RPC về server. Server nhận tham số, thực thi rồi phản hồi kết quả về client.

Go hỗ trợ RPC ở ba cấp là TCP RPC, HTTP RPC và JSON RPC. Cũng cần lưu ý là không giống RPC truyền thống, RPC trong Go buộc client và server đều phải viết bằng Go do Go sử dụng Gob để đóng gói tham số và kết quả RPC.

Các hàm được cung cấp của dịch vụ RPC trong Go cần đáp ứng các tiêu chuẩn sau:

  • Các hàm có thể truy xuất từ bên ngoài (viết hoa chữ cái đầu).
  • Có 2 tham số là kiểu có thể truy xuất từ bên ngoài.
  • Tham số đầu để nhận dữ liệu từ client và tham số sau là con trỏ chứa thông tin để trả dữ liệu về client.
  • Hàm trả về giá trị kiểu error.
Ví dụ mẫu về hàm RPC:
func (t *T) MethodName(argType T1, replyType *T2) error
T1 và T2 được mã hóa bởi Gob.

TCP RPC

TCP RPC sử dụng kết nối gần giống như TCP cocket. Chúng ta cùng tìm hiểu ví dụ dùng RPC để lấy thông tin khoảng cách giữa 2 điểm trên bản đồ khi biết tọa độ của chúng:
Phần server:
- Đầu tiên là khai báo các package sử dụng:
import (
    "log"
    "math"
    "net"
    "net/rpc"
)
Package net và net/rpc để phục vụ cho kết nối và xử lý RPC. Package math dùng để tính khoảng cách.
- Tiếp theo là khai báo các struct sử dụng:
type Point struct {
    Lat, Lon float64
}

type Points struct {
    A Point
    B Point
}

type Distance int
Struct Point chứa thông tin vĩ độ, kinh độ của địa điểm. Points lưu thông tin 2 điểm và sẽ là kiểu tham số thứ nhất cho hàm xử lý tính khoảng cách. Distance chứa thông tin khoảng cách và ta sẽ khai báo phương thức tính khoảng cách cho nó.
- Phương thức tính khoảng cách:
const EARTH_RADIUS = 6378137 // Bán kính trái đất tính theo mét

func (t *Distance) Calculate(args *Points, dis *Distance) error {
    p1 := args.A
    p2 := args.B
    dLat := (p2.Lat - p1.Lat) * (math.Pi / 180.0)
    dLon := (p2.Lon - p1.Lon) * (math.Pi / 180.0)

    lat1 := p1.Lat * (math.Pi / 180.0)
    lat2 := p2.Lat * (math.Pi / 180.0)

    a := (math.Sin(dLat/2) * math.Sin(dLat/2)) + (math.Sin(dLon/2) * math.Sin(dLon/2) * math.Cos(lat1) * math.Cos(lat2))

    c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))

    *dis = Distance(EARTH_RADIUS * c)
    return nil
}
Trước tiên là hằng số bán kính trái đất tính bằng mét. Phương thức Calculate có 2 tham số có kiểu và kiểu trả về đáp ứng yêu cầu RPC. Cách tính ở phương thức này dựa trên công thức Haversine.
- Hàm main được xây dựng như sau:
func main() {

    dis := new(Distance)
    rpc.Register(dis)

    tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
    if err != nil {
        log.Fatal("Lỗi phân giải địa chỉ" + err.Error())
    }

    listener, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        log.Println("Lỗi lắng nghe kết nối" + err.Error())
    }

    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        rpc.ServeConn(conn)
    }
}
Tất cả các bước khá giống với socket hướng TCP, chỉ khác là khi socket được tạo kết nối với client, dùng hàm ServeConn để xử lý:
func ServeConn(conn io.ReadWriteCloser)

Phần client:
- Đầu tiên là các package sử dụng:
import (
    "fmt"
    "log"
    "net/rpc"
)
- Tiếp theo là các cấu trúc địa điểm như bên server:
type Point struct {
    Lat, Lon float64
}

type Points struct {
    A Point
    B Point
}
- Hàm main được xây dựng như sau:
func main() {
    client, err := rpc.Dial("tcp", "localhost:1234")
    if err != nil {
        log.Fatal("Lỗi kết nối:", err.Error())
    }
    // Synchronous call
    args := Points{Point{10.832052230834961, 106.68563842773438}, Point{10.827040672302246, 106.68864440917969}}
    var res int
    err = client.Call("Distance.Calculate", args, &res)
    if err != nil {
        log.Fatal("Lỗi gọi rpc Distance.CalculateDistance:", err)
    }
    fmt.Printf("Khoảng cách từ %v đến %v %dm\n", args.A, args.B, res)
}
Đầu tiên là gọi hàm Dial để kết nối server. Tiếp theo là tạo biến  args chứa tham số cho gọi hàm RPC ở server. Sau đó sử dụng phương thức Call của biến trả về từ hàm kết nối để gọi RPC. Kết quả trả về ở trong biến res. Ở server trả về kiểu Distance nhưng nó vốn là int nên ở client có thể dùng int trực tiếp.
- Kết quả in ra màn hình sau khi thực thi client (server đã thực thi trước đó):
Khoảng cách từ {10.832052230834961 106.68563842773438} đến {10.827040672302246 106.68864440917969} 647m

HTTP RPC

So với TCP RPC thì HTTP RPC chỉ đổi chút ít ở phần kết nối và xử lý kết nối như sau:
Phần server:
func main() {
    dis := new(Distance)
    rpc.Register(dis)

    rpc.HandleHTTP()

    err := http.ListenAndServe(":1234", nil)
    if err != nil {
        fmt.Println("Lỗi kết nối:" + err.Error())
    }
}
Phần client:
func main() {
    client, err := rpc.DialHTTP("tcp", "localhost:1234")
    if err != nil {
        log.Fatal("Lỗi kết nối:", err.Error())
    }
    // Synchronous call
    args := Points{Point{10.832052230834961, 106.68563842773438}, Point{10.827040672302246, 106.68864440917969}}
    var res int
    err = client.Call("Distance.Calculate", args, &res)
    if err != nil {
        log.Fatal("Lỗi gọi rpc Distance.CalculateDistance:", err)
    }
    fmt.Printf("Khoảng cách từ %v đến %v %dm\n", args.A, args.B, res)
}

JSON RPC

Thay vì mã hóa dữ liệu trao đổi bằng gob, JSON sẽ được sử dụng để định dạng dữ liệu truyền. Sử dụng thêm package "net/rpc/jsonrpc" để xử lý JSON RPC:
- Phần server: gọi phương thức ServeConn của jsonrpc thay vì rpc
func main() {
    dis := new(Distance)
    rpc.Register(dis)

    tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
    if err != nil {
        log.Fatal("Lỗi phân giải địa chỉ" + err.Error())
    }

    listener, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        log.Println("Lỗi lắng nghe kết nối" + err.Error())
    }

    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        jsonrpc.ServeConn(conn)
    }
}
- Phần client: gọi hàm Dial của jsonrpc thay vì rpc
func main() {
    client, err := jsonrpc.Dial("tcp", "localhost:1234")
    if err != nil {
        log.Fatal("Lỗi kết nối:", err.Error())
    }
    // Synchronous call
    args := Points{Point{10.832052230834961, 106.68563842773438}, Point{10.827040672302246, 106.68864440917969}}
    var res int
    err = client.Call("Distance.Calculate", args, &res)
    if err != nil {
        log.Fatal("Lỗi gọi rpc Distance.CalculateDistance:", err)
    }
    fmt.Printf("Khoảng cách từ %v đến %v %dm\n", args.A, args.B, res)
}
Cùng với REST, RPC là một trong những phương thức trao đổi dữ liệu ưa thích trên môi trường mạng, đặc biệt khi xây dựng server theo mô hình microservice.

Trong bài tới chúng ta sẽ tìm hiểu về triển khai và bảo trì dịch vụ web.
Tóm tắt:
- REST xem mọi dữ liệu là tài nguyên và quy định chuẩn hóa tương tác: tạo dùng POST, xem dùng GET, cập nhật dùng PUT/PATCH, xóa dùng DELETE.
- RESTful API là hệ thống API theo chuẩn REST.
- RPC cung cấp phương thức gọi hàm xử lý từ xa.
- Go hỗ trợ TCP RPC, HTTP RPC và JSON RPC. Dữ liệu trao đổi được mã hóa bởi Gob nên RPC ở Go đòi hỏi cả server và client cùng xây dựng từ Go.

No comments:

Post a Comment