DEV Community

zhonghua
zhonghua

Posted on • Edited on

Practical HarmonyOS Development: The Art of Network Layer - Elegant Encapsulation and Construction Guide (Part 1)

Practical HarmonyOS Development: The Art of Network Layer - Elegant Encapsulation and Construction Guide (Part 1)

In the vast world of HarmonyOS development, the network layer, acting as a bridge for information exchange, is of undeniable importance. Today, I will guide you through how to artistically and elegantly encapsulate HarmonyOS's official network library to build an efficient and flexible network layer for our applications. In the next installment, we will delve into how to utilize this well-encapsulated network library to effortlessly master the development and usage of the network layer.

  1. Encapsulation Goals: Expandability and Interceptor Capability

In HarmonyOS application development, the encapsulation of network requests is not only to simplify the development process but also to enhance the reusability and maintainability of the code. Our encapsulation goals primarily revolve around the following two points:

  1. Expandability: Allows developers to easily extend the functionality of network requests according to business needs, such as adding custom request headers, setting request timeout durations, etc.
  2. Interceptor Capability: Provides an interception mechanism for network requests, enabling us to perform a series of operations before the request is sent or after the response is received, such as adding log records, error handling, etc.

  3. Defining Basic Elements: Error Constants and Strings

  4. Error Constants Definition

To centrally manage error codes in network requests, we define a NetworkServiceErrorConst class to store various error codes that network requests might encounter:

export class NetworkServiceErrorConst {
  // Network unavailable
  static readonly UN_AVAILABLE: number = 100000;
  // URL error
  static readonly URL_ERROR: number = 100001;
  // URL does not exist error
  static readonly URL_NOT_EXIST_ERROR: number = 100002;
  // Network error
  static readonly NET_ERROR: number = 100003;
  // ...other possible error codes
}
Enter fullscreen mode Exit fullscreen mode
  1. Error Strings Definition

Additionally, we need to define error strings corresponding to the error codes to display to users within the application:

{
"name": "network_unavailable",
"value": "Network unavailable"
},
{
"name": "invalid_url_format",
"value": "Invalid URL format"
},
{
"name": "invalid_url_not_exist",
"value": "URL does not exist"
}
Enter fullscreen mode Exit fullscreen mode
  1. Utility Toolkit

URL Validation

To ensure the correctness of the URL format in network requests, we provide an isValidUrl function that uses a regular expression to validate the validity of the URL.

private isValidUrl(url: string): boolean {
    // Regular expression to match various possible URL formats
    const urlPattern = new RegExp(
        '^(https?:\\/\\/)?' + // Protocol
        '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // Domain name
        '((\\d{1,3}\\.){3}\\d{1,3}))' + // Or IPv4 address
        '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // Port and path
        '(\\?[;&a-z\\d%_.~+=-]*)?' + // Query string
        '(\\#[-a-z\\d_]*)?$', // Fragment identifier
        'i' // Case insensitive
    );
    return urlPattern.test(url); // Return validation result
}

// Usage example
if (isValidUrl("http://example.com")) {
    console.log("URL is valid.");
} else {
    console.log("URL is invalid.");
}
Enter fullscreen mode Exit fullscreen mode

Query Parameter Concatenation

When needing to append query parameters to a URL, the appendQueryParams function can help us achieve this easily. It supports handling parameters with single values or array values and automatically handles encoding.

private appendQueryParams(url: string, queryParams: Map<string, any> | undefined): string {
    if (!queryParams || queryParams.size === 0) {
        return url;
    }

    const paramsArray: string[] = [];
    queryParams.forEach((value, key) => {
        if (Array.isArray(value)) {
            for (let i = 0; i < value.length; i++) {
                paramsArray.push(`${encodeURIComponent(`${key}[${i}]`)}=${encodeURIComponent(value[i].toString())}`);
            }
        } else {
            paramsArray.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}`);
        }
    });

    // Check if the URL already contains query parameters and decide whether to use '?' or '&' as the separator accordingly
    const separator = url.includes('?') ? '&' : '?';
    return url + separator + paramsArray.join('&');
}

// Usage example
const baseUrl = "http://example.com/search";
const params = new Map<string, any>();
params.set("q", "test");
params.set("page", 2);
const urlWithParams = appendQueryParams(baseUrl, params);
console.log(urlWithParams); // Output: http://example.com/search?q=test&page=2
Enter fullscreen mode Exit fullscreen mode

With the above two utility functions, we can ensure that the URLs of network requests are both correct and contain the required query parameters, thereby enhancing the accuracy and reliability of network requests.

  1. Writing Network Interceptors

In the process of network requests and responses, network interceptors (Interceptor) are a very important concept. They allow us to execute specific logic before the request is sent, after the response is received, or when an error occurs, such as adding network parameters, logging, handling errors, etc.

First, we define a NetworkInterceptor interface that specifies the methods that interceptors must implement:

import { http } from '@kit.NetworkKit'; 
import { RequestOptions } from '../NetworkService'; 

export interface NetworkInterceptor {
  beforeRequest(request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void;
  afterResponse(response: http.HttpResponse | object, request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void;
  onError(error: Error, request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void;
}
Enter fullscreen mode Exit fullscreen mode

Next, we implement a default interceptor DefaultInterceptor that implements the NetworkInterceptor interface:

import { http } from '@kit.NetworkKit';
import { RequestOptions } from '../NetworkService';
import { LibLogManager, TAG } from '../LogService'; 

export class DefaultInterceptor implements NetworkInterceptor {

  beforeRequest(request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void {
    // You can add network parameters here or perform other processing on the request
    httprequest.on('headersReceive', (header) => {
      LibLogManager.getLogger().info(TAG, 'Received headers: ' + JSON.stringify(header));
    });

    // If there are asynchronous operations, a Promise needs to be returned
    // There are no asynchronous operations here, so return directly
  }

  afterResponse(response: http.HttpResponse | object, request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void {
    // After the response is received, you can process the response data or log it
    httprequest.off('headersReceive'); // Remove event listener
    LibLogManager.getLogger().info(TAG, 'Response received: ' + JSON.stringify(response));

    // If there are asynchronous operations, a Promise needs to be returned
    // There are no asynchronous operations here, so return directly
  }

  onError(error: Error, request: RequestOptions, httprequest: http.HttpRequest): Promise<void> | void {
    // When an error occurs, you can log the error or handle it
    httprequest.off('headersReceive'); // Remove event listener
    LibLogManager.getLogger().error(TAG, 'Network error occurred: ' + JSON.stringify(error));

    // If there are asynchronous operations, a Promise needs to be returned
    // There are no asynchronous operations here, so return directly
  }
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  1. In the beforeRequest method above, I added a listener for the headersReceive event.

  2. In the afterResponse and onError methods, I called httprequest.off('headersReceive') to remove the previously added event listener. This is to avoid memory leaks, as if you keep sending new requests without removing old listeners, these listeners will remain in memory indefinitely.

  3. In actual projects, you may need to adjust the implementation of these interceptors according to your network library and project requirements. For example, you might need to add request headers, authentication tokens, etc., in the beforeRequest method. In the afterResponse method, you might need to process JSON response data or convert it to other formats. In the onError method, you might need to implement more complex error handling logic, such as retry mechanisms, error reporting, etc.

  4. Core Network Request Encapsulation Class

In network programming, initiating HTTP requests is a common task. To simplify this process and make it more standardized and maintainable, we have created a core class for network request encapsulation. This class provides a set of flexible APIs that allow users to initiate various HTTP requests in a configuration-based manner.

  1. Request Configuration Class: RequestOptions

First, we define a RequestOptions interface that includes all the configuration parameters required to initiate an HTTP request. The design of this interface is very flexible and can adapt to various complex HTTP request scenarios.

export interface RequestOptions {
  baseUrl?: string; // Base URL
  act?: string; // Request action or path
  method?: RequestMethod; // Request method, default is GET
  queryParams?: Map<string, any>; // Query parameters, supports various data types
  header?: Object; // Request header information
  extraData?: string | Object | ArrayBuffer; // Additional request data
  expectDataType?: http.HttpDataType; // Expected response data type
  usingCache?: boolean; // Whether to use cache
  priority?: number; // Request priority
  connectTimeout?: number; // Connection timeout
  readTimeout?: number; // Read timeout
  multiFormDataList?: Array<http.MultiFormData>; // List of form data for POST form requests
}
Enter fullscreen mode Exit fullscreen mode
  1. Request Method Enumeration: RequestMethod

To support various HTTP request methods, we define a RequestMethod enumeration. This enumeration includes all standard HTTP request methods, such as GET, POST, PUT, etc.

export enum RequestMethod {
  OPTIONS = "OPTIONS",
  GET = "GET",
  HEAD = "HEAD",
  POST = "POST",
  PUT = "PUT",
  DELETE = "DELETE",
  TRACE = "TRACE",
  CONNECT = "CONNECT"
}
Enter fullscreen mode Exit fullscreen mode
  1. Core Network Request Encapsulation Class: NetworkService

Based on RequestOptions and RequestMethod, we have created a core network request encapsulation class named NetworkService. This class provides a request method for initiating HTTP requests. The request method accepts a RequestOptions object as a parameter and initiates the corresponding HTTP request according to the configuration of this object.

In addition to the request method, the NetworkService class also supports registering interceptors (Interceptor). Interceptors can perform additional processing before the request is sent and after the response is returned, such as adding request headers, processing response data, etc. This allows users to flexibly customize the behavior of network requests.


export class NetworkService {
  baseUrl:string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  private interceptors: NetworkInterceptor[] = [];

  addInterceptor(interceptor: NetworkInterceptor): void {
    this.interceptors.push(interceptor);
  }

  async request(requestOption: RequestOptions): Promise<http.HttpResponse | null> {
    let response: http.HttpResponse | null = null;
    let error: Error | null = null;
    // Each httpRequest corresponds to an HTTP request task and is not reusable
    let httpRequest = http.createHttp();
    // Start sending the request
    try {

      // If the URL is provided, use the provided URL
      requestOption.baseUrl = requestOption.baseUrl ? requestOption.baseUrl : this.baseUrl;
      // Call the interceptor's beforeRequest method
      for (const interceptor of this.interceptors) {
        await interceptor.beforeRequest(requestOption, httpRequest);
      }

      if(requestOption.baseUrl === null || requestOption.baseUrl.trim().length === 0){
        throw new NetworkError(NetworkServiceErrorConst.URL_NOT_EXIST_ERROR, Application.getInstance().resourceManager.getStringSync($r("app.string.invalid_url_not_exist")))
      }

      if (!LibNetworkStatus.getInstance().isNetworkAvailable()) {
        LibLogManager.getLogger().error("HttpCore","Network unavailable")
        throw new NetworkError(NetworkServiceErrorConst.UN_AVILABLE, Application.getInstance().resourceManager.getStringSync($r("app.string.network_unavailable")))
      }

      if (!this.isValidUrl(requestOption.baseUrl)) {
        LibLogManager.getLogger().error("HttpCore","Invalid URL format")
        throw new NetworkError(NetworkServiceErrorConst.URL_ERROR, Application.getInstance().resourceManager.getStringSync($r("app.string.invalid_url_format")))
      }

      let defalutHeader :Record<string,string> = {
        'Content-Type': 'application/json'
      }

      let response = await httpRequest.request(this.appendQueryParams(requestOption.baseUrl, requestOption.queryParams), {
        method: requestOption.method,
        header: requestOption.header || defalutHeader,
        extraData: requestOption.extraData, // When using POST request, this field is used to pass content
        expectDataType: requestOption.expectDataType||http.HttpDataType.STRING, // Optional, specify the type of response data
        usingCache: requestOption.usingCache, // Optional, default is true
        priority: requestOption.priority, // Optional, default is 1
        connectTimeout: requestOption.connectTimeout, // Optional, default is 60000ms
        readTimeout: requestOption.readTimeout, // Optional, default is 60000ms
        multiFormDataList: requestOption.multiFormDataList,
      })

      if (http.ResponseCode.OK !== response.responseCode) {
        response = response;
      } else{
        throw new NetworkResponseError(response.responseCode,Application.getInstance().resourceManager.getStringSync($r("app.string.network_unavailable")))
      }

      // Call the interceptor's afterResponse method
      for (const interceptor of this.interceptors) {
        await interceptor.afterResponse(response, requestOption, httpRequest );
      }

    } catch (e) {
      error = e;
    }

    // Call the interceptor's afterResponse or onError method depending on whether there is an error
    if (error) {
      for (const interceptor of this.interceptors) {
        await interceptor.onError(error, requestOption, httpRequest);
      }
      httpRequest.destroy();
      throw error; // Re-throw the error so the caller can handle it
    } else{
      httpRequest.destroy();
      return response;
    }

  }

  private isValidUrl(url: string): boolean {

  }

  private appendQueryParams(url: string, queryParams: Map<string, number|string|boolean|Array< number> | Array | Array >|undefined): string {

}
}

Enter fullscreen mode Exit fullscreen mode

By using the NetworkService class, users can initiate HTTP requests in a more professional and concise manner and enjoy the convenience brought by configuration-based, interceptor, and other advanced features. This not only improves development efficiency but also makes the code clearer and more maintainable.

6. Putting It All Together

Now that we have defined the core components of our network layer, let's see how they work together in a practical example. Suppose we want to make a GET request to fetch some data from a server. Here's how you can do it using the NetworkService class:

// Create an instance of NetworkService with a base URL
const networkService = new NetworkService("http://api.example.com");

// Define request options
const requestOptions: RequestOptions = {
  act: "/data", // The path to the resource
  method: RequestMethod.GET, // The request method
  queryParams: new Map<string, any>([
    ["param1", "value1"],
    ["param2", "value2"]
  ]), // Query parameters
  header: {
    "Authorization": "Bearer your_token_here"
  } // Custom headers
};

// Make the request
networkService.request(requestOptions)
  .then(response => {
    if (response) {
      console.log("Response received:", response.data);
    } else {
      console.log("No response received.");
    }
  })
  .catch(error => {
    console.error("Error occurred:", error.message);
  });
Enter fullscreen mode Exit fullscreen mode

In this example, we first create an instance of NetworkService with a base URL. Then, we define the request options, including the path to the resource, the request method, query parameters, and custom headers. Finally, we call the request method of NetworkService to initiate the HTTP request and handle the response or error accordingly.

  1. Conclusion

In this part of the guide, we have explored the importance of the network layer in HarmonyOS development and how to encapsulate the official network library to create a flexible and efficient network layer. We have defined error constants and strings, provided utility functions for URL validation and query parameter concatenation, and created a core network request encapsulation class with support for interceptors.

In the next installment, we will dive deeper into how to use this well-encapsulated network library to handle various network scenarios, including error handling, caching, and more. Stay tuned for more insights into mastering the network layer in HarmonyOS development.

Top comments (0)