33 Sdk Design How to Design an Excellent Go Sdk

33 SDK Design How to Design an Excellent Go SDK #

Hello, I’m Kong Lingfei. In the next two lectures, we will explore how to design and implement an excellent Go SDK.

Backend services provide the functionality of an application through API interfaces. However, if users directly call the API interfaces, they need to write the logic for API calls, construct input parameters, and parse the returned data packets. This approach is inefficient and requires considerable development effort.

In actual project development, it is common to provide a more user-friendly SDK package for client-side calls. Many large-scale services release their SDKs along with their products. For example, Tencent Cloud provides SDKs for many of their products:

Image

Since SDKs are crucial, how can we design an excellent Go SDK? In this lecture, I will explain this in detail.

What is an SDK? #

First, let’s take a look at what an SDK is.

For SDK (Software Development Kit), it has different interpretations in different scenarios. But for a Go backend service, SDK usually refers to a software package that encapsulates the API interfaces of the Go backend service, which typically includes software-related libraries, documentation, usage examples, encapsulated API interfaces, and tools.

Calling an SDK is not much different from calling local functions, so it can greatly improve the development efficiency and experience of developers. SDKs can be provided by service providers, as well as by other organizations or individuals. In order to encourage developers to use their systems or languages, SDKs are usually provided free of charge.

Usually, service providers will provide SDKs in different languages. For example, they may offer a Python version of the SDK for Python developers, and a Go version of the SDK for Go developers. Some more professional teams also have SDK auto-generation tools, which can generate SDKs in different languages based on API interface definitions. For example, the compilation tool protoc for Protocol Buffers can generate SDKs in various language versions such as C++, Python, Java, JavaScript, PHP, etc. Aliyun and Tencent Cloud, these first-line big companies, can also generate SDKs in different programming languages based on API definitions.

SDK Design Approach #

So how can we design a good SDK? Different teams may have different design approaches for SDKs, and I have researched some excellent implementations of SDKs and found some commonalities among them. Based on my research results and my experience in actual development, I have summarized a set of SDK design approaches, which I will share with you.

How to name the SDK? #

Before discussing the design approach, let me first introduce two important points: the naming convention of SDKs and the directory structure of SDKs.

There is no unified standard for naming SDKs at the moment, but a commonly used naming convention is xxx-sdk-go / xxx-sdk-python / xxx-sdk-java. In this convention, xxx can be the project name or organization name. For example, if Tencent Cloud’s GitHub organization name is “tencentcloud”, its SDK naming would be as shown in the following image:

Image

SDK directory structure #

The directory structure of SDKs may vary for different projects, but it generally needs to include the following files or directories. The directory names may differ, but their functionalities are similar.

  • README.md: The documentation of the SDK, which contains installation, configuration, and usage instructions.
  • examples/sample/: Examples of using the SDK.
  • sdk/: Shared packages of the SDK, which encapsulate the most basic communication functionalities. If it’s an HTTP service, it is usually based on the net/http package.
  • api: If xxx-sdk-go only provides SDK for a single service, the code for encapsulating all the API interfaces of that service can be placed under the “api” directory.
  • services/{iam, tms}: If xxx-sdk-go represents an organization, the SDK might integrate APIs from many services within that organization. In this case, the code for encapsulating the API interfaces of a certain type of service can be placed under services/<service_name>. For example, the Go SDK of AWS follows this structure.

A typical directory structure is as follows:

├── examples           # Directory to store example code
│   └── authz.go
├── README.md          # SDK usage documentation
├── sdk                # Common package encapsulating SDK configuration, API requests, authentication, etc.
│   ├── client.go
│   ├── config.go
│   ├── credential.go
│   └── ...
└── services           # API encapsulation
    ├── common
    │   └── model
    ├── iam             # API interfaces for the IAM service
    │   ├── authz.go
    │   ├── client.go
    │   └── ...
    └── tms             # API interfaces for the TMS service

SDK Design Approach #

The design approach for SDKs is as shown in the following image:

Image

We can create a client by configuring a Config, for example, func NewClient(config sdk.Config) (Client, error). The configuration can specify the following information:

  • Backend service address: The backend service address can be configured through a configuration file or hardcoded in the SDK. It is recommended to configure it through a configuration file.
  • Authentication information: The most commonly used authentication method is key-based authentication, but there are also some methods that use username and password for authentication.
  • Other configurations: For example, timeouts, retry counts, cache durations, etc.

The created client is a struct or Go interface. Here, I suggest using the interface type so that the definition and concrete implementation can be decoupled. The client has some methods, such as CreateUser, DeleteUser, etc. Each method corresponds to an API interface. The following is an example definition of a client:

type Client struct {
    client *sdk.Request
}

func (c *Client) CreateUser(req *CreateUserRequest) (*CreateUserResponse, error) {
    // normal code
    resp := &CreateUserResponse{}
    err := c.client.Send(req, resp)
    return resp, err
}

Calling client.CreateUser(req) will send an HTTP request. The req can specify the HTTP request method, path, and request body. In the CreateUser function, c.client.Send(req) is called to execute the actual HTTP request.

c.client is a variable of type *Request, which has some methods to construct the request path, authentication headers, and request body based on the passed request parameters req and the config. It then uses the net/http package to perform the final HTTP request, and finally unmarshals the response result into the provided resp struct.

Based on my research, there are currently two SDK design approaches worth considering. One is the design approach adopted by major public cloud vendors, and the other is the design approach of Kubernetes client-go. Both of these design approaches have the same design philosophy as the one I just explained.

SDK Design Patterns Adopted by Public Cloud Providers #

Here I will briefly introduce the SDK design patterns adopted by public cloud providers. The SDK framework is shown in the following figure:

Image

The SDK framework is divided into two layers: the API layer and the underlying layer. The API layer is mainly used to construct client instances and invoke the methods provided by the client instances to complete API requests. Each method corresponds to an API interface. The API layer ultimately calls the capabilities provided by the underlying layer to complete the REST API request. The underlying layer completes the specific REST API request by sequentially performing three major steps: building request parameters (Builder), issuing and adding authentication headers (Signer), and executing HTTP requests (Request).

To help you better understand the design patterns of public cloud SDKs, I will combine some real code to explain the specific designs of the API layer and the underlying layer. The SDK code can be found in medu-sdk-go.

API Layer: Creating Client Instances #

When clients use the SDK of Service A, they first need to create a client instance for Service A based on the Config configuration. The client instance, Client, is actually a struct defined as follows:

type Client struct {
    sdk.Client
}

When creating a client instance, configuration information such as authentication (such as keys, usernames/passwords) and backend service addresses needs to be passed in. For example, you can use the NewClientWithSecret method to build a client with a secret key pair:

func NewClientWithSecret(secretID, secretKey string) (client *Client, err error) {
    client = &Client{}
    config := sdk.NewConfig().WithEndpoint(defaultEndpoint)
    client.Init(serviceName).WithSecret(secretID, secretKey).WithConfig(config)
    return
}

Note that when creating the client, the passed-in secret key pair is ultimately used in the underlying layer to issue a JWT token.

The Client has multiple methods (Senders) such as Authz, where each method represents an API interface. The Sender method receives a pointer to a struct type, such as AuthzRequest, as input parameters. We can call client.Authz(req) to execute the REST API call. Some business logic processing can be added in the client.Authz method. The client.Authz code is as follows:

type AuthzRequest struct {
    *request.BaseRequest
    Resource *string `json:"resource"`
    Action *string `json:"action"`
    Subject *string `json:"subject"`
    Context *ladon.Context
}

func (c *Client) Authz(req *AuthzRequest) (resp *AuthzResponse, err error) {
    if req == nil {
        req = NewAuthzRequest()
    }

    resp = NewAuthzResponse()
    err = c.Send(req, resp)
    return
}

The fields in the request struct are all pointer types. The advantage of using pointers is that we can determine whether the input parameters are specified. If req.Subject == nil, it means the Subject parameter was not passed in. If req.Subject != nil, it means the Subject parameter was passed in. Different business logic can be executed based on whether a certain parameter is passed in. This is very common in Go API interface development.

In addition, because the Client inherits the Client in the underlying layer anonymously, the Client created in the API layer can directly call the Send(req, resp) method provided by the Client in the underlying layer to execute the RESTful API call and save the result in resp.

To distinguish it from the Client in the API layer, I will refer to the Client in the underlying layer as sdk.Client.

Finally, below is an example of a complete client invocation code:

package main

import (
    "fmt"

    "github.com/ory/ladon"

    "github.com/marmotedu/medu-sdk-go/sdk"
    iam "github.com/marmotedu/medu-sdk-go/services/iam/authz"
)

func main() {
    client, _ := iam.NewClientWithSecret("XhbY3aCrfjdYcP1OFJRu9xcno8JzSbUIvGE2", "bfJRvlFwsoW9L30DlG87BBW0arJamSeK")

    req := iam.NewAuthzRequest()
    req.Resource = sdk.String("resources:articles:ladon-introduction")
    req.Action = sdk.String("delete")
    req.Subject = sdk.String("users:peter")
    ctx := ladon.Context(map[string]interface{}{"remoteIPAddress": "192.168.0.5"})
    req.Context = &ctx

    resp, err := client.Authz(req)
    if err != nil {
        fmt.Println("err1", err)
        return
    }
    fmt.Printf("get response body: `%s`\n", resp.String())
    fmt.Printf("allowed: %v\n", resp.Allowed)
}

Underlying Layer: Building and Executing HTTP Requests #

Above, we created a client instance and called its Send method to complete the final HTTP request. Here, let’s see how the Send method specifically builds the HTTP request.

The Send method of sdk.Client completes the final API call. The code is as follows:

func (c *Client) Send(req request.Request, resp response.Response) error {
    method := req.GetMethod()
    builder := GetParameterBuilder(method, c.Logger)
    jsonReq, _ := json.Marshal(req)
    encodedUrl, err := builder.BuildURL(req.GetURL(), jsonReq)
    if err != nil {
        return err
    }

    endPoint := c.Config.Endpoint
    if endPoint == "" {
        endPoint = fmt.Sprintf("%s/%s", defaultEndpoint, c.ServiceName)
    }
    reqUrl := fmt.Sprintf("%s://%s/%s%s", c.Config.Scheme, endPoint, req.GetVersion(), encodedUrl)

    body, err := builder.BuildBody(jsonReq)
    if err != nil {
        return err
    }

    sign := func(r *http.Request) error {
        signer := NewSigner(c.signMethod, c.Credential, c.Logger)
        _ = signer.Sign(c.ServiceName, r, strings.NewReader(body))
        return err
    }

    rawResponse, err := c.doSend(method, reqUrl, body, req.GetHeaders(), sign)
    if err != nil {
        return err
    }
    ...
}
return response.ParseFromHttpResponse(rawResponse, resp)

}

The code above can be roughly divided into four steps.

Step 1: Builder: Build Request Parameters

Based on the provided AuthzRequest and client configuration Config, construct the HTTP request parameters, including the request path and request body.

Next, let’s take a look at how to construct the HTTP request parameters.

  1. Build HTTP Request Path

When creating the client, we use the NewAuthzRequest function to create the AuthzRequest struct for the /v1/authz REST API interface. The code is as follows:

func NewAuthzRequest() (req *AuthzRequest) {
    req = &AuthzRequest{ 
        BaseRequest: &request.BaseRequest{
            URL:     "/authz",
            Method:  "POST",
            Header:  nil,
            Version: "v1",
        },
    }
    return         
}

As we can see, the req includes the API version (Version), API path (URL), and request method (Method). With this, we can build the request path in the Send method:

endPoint := c.Config.Endpoint                  
if endPoint == "" {                             
    endPoint = fmt.Sprintf("%s/%s", defaultEndpoint, c.ServiceName) 
}                                             
reqUrl := fmt.Sprintf("%s://%s/%s%s", c.Config.Scheme, endPoint, req.GetVersion(), encodedUrl)

In the above code, c.Config.Scheme is http or https, endPoint is iam.api.marmotedu.com:8080, req.GetVersion() is v1, and encodedUrl can be considered as /authz. So, the final constructed request path will be http://iam.api.marmotedu.com:8080/v1/authz.

  1. Build HTTP Request Body

Build the request body in the BuildBody method. BuildBody marshals req into a JSON string format, and the HTTP request will use this string as the body parameter.

Step 2: Signer: Issue and Add Authentication Header

Accessing IAM’s API interface requires authentication. Therefore, before sending the HTTP request, an authentication header needs to be added to it.

The medu-sdk-go code provides two authentication methods: JWT and HMAC. The JWT authentication method is ultimately used. The Sign method for JWT authentication issuance is as follows:

func (v1 SignatureV1) Sign(serviceName string, r *http.Request, body io.ReadSeeker) http.Header {
    tokenString := auth.Sign(v1.Credentials.SecretID, v1.Credentials.SecretKey, "medu-sdk-go", serviceName+".marmotedu.com")
    r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenString))
    return r.Header
    
}

The auth.Sign method issues a JWT token based on the SecretID and SecretKey.

Next, we can call the doSend method to execute the HTTP request. The calling code is as follows:

rawResponse, err := c.doSend(method, reqUrl, body, req.GetHeaders(), sign)
if err != nil {                               
    return err    
}

As seen above, we pass in the HTTP request method (method), HTTP request URL (reqUrl), HTTP request body (body), and the sign method used to issue the JWT token. We specified the HTTP method when calling NewAuthzRequest to create req, so method := req.GetMethod(), reqUrl, and the request body are all built by the builder.

Step 3: Request: Execute HTTP Request

Call the doSend method to execute the HTTP request. The doSend method sends the HTTP request using the http.NewRequest method provided by the net/http package. After executing the HTTP request, it returns a *http.Response type response. The code is as follows:

func (c *Client) doSend(method, url, data string, header map[string]string, sign SignFunc) (*http.Response, error) {
    client := &http.Client{Timeout: c.Config.Timeout}

    req, err := http.NewRequest(method, url, strings.NewReader(data))
    if err != nil {
        c.Logger.Errorf("%s", err.Error())
        return nil, err
    }

    c.setHeader(req, header)

    err = sign(req)
    if err != nil {
        return nil, err
    }

    return client.Do(req)
}

Step 4: Handling HTTP Request Return

After calling the doSend method and receiving the *http.Response type response, the Send method calls the ParseFromHttpResponse function to handle the HTTP response. The ParseFromHttpResponse function is as follows:

func ParseFromHttpResponse(rawResponse *http.Response, response Response) error {
    defer rawResponse.Body.Close()
    body, err := ioutil.ReadAll(rawResponse.Body)
    if err != nil {
        return err
    }
    if rawResponse.StatusCode != 200 {
        return fmt.Errorf("request fail with status: %s, with body: %s", rawResponse.Status, body)
    }

    if err := response.ParseErrorFromHTTPResponse(body); err != nil {
        return err
    }

    return json.Unmarshal(body, &response)
}

As we can see, in the ParseFromHttpResponse function, it first checks if the HTTP response status code is 200. If it is not 200, an error is returned. If it is 200, it calls the ParseErrorFromHTTPResponse method provided by the resp variable to unmarshal the HTTP response body into the resp variable. After these four steps, the SDK caller has called the API and obtained the API response resp.

The following public cloud providers’ SDKs also adopt this design pattern:

IAM’s public cloud SDK implementation is medu-sdk-go.

In addition, IAM has designed and implemented a Go SDK in the Kubernetes client-go style: marmotedu-sdk-go. The marmotedu-sdk-go is also the SDK used by the IAM Go SDK. In the next lecture, I will introduce the design and implementation of marmotedu-sdk-go in detail.

Summary #

In this lecture, I mainly introduced how to design an excellent Go SDK. By providing an SDK, it can improve API calling efficiency and reduce the difficulty of API calling. Therefore, large-scale applications usually provide an SDK. Different teams have different SDK design methods, but currently the better implementation is the SDK design method used by public cloud vendors.

In the SDK design method of public cloud vendors, the SDK can be divided into three modules in the order of calling from top to bottom, as shown in the following figure:

image

The Client constructs the SDK client. When constructing the client, it will create the request parameters req. The req will specify the API version, HTTP request method, API request path, and other information.

The Client will request the Builder and Signer to construct various parameters for the HTTP request, including the HTTP request method, HTTP request path, HTTP authentication header, and HTTP request body. The Builder and Signer construct these HTTP request parameters based on the configuration of req.

After the construction is complete, the Request module is requested. The Request module uses the net/http package to execute the HTTP request and return the request result.

Exercises #

  1. Take a moment to think about how to implement an SDK package that supports multiple API versions. How would you write the code for such implementation?

  2. In this lesson, we introduced one way to implement an SDK. Throughout your Go development career, have you come across any other better methods for SDK implementation? Feel free to share in the comments section.

Looking forward to exchanging thoughts and discussing with you in the comments. See you in the next lesson.