Small knowledge, big challenge! This paper is participating in theEssentials for programmers”Creative activities

This paper has participated inProject DigginTo win the creative gift package and challenge the creative incentive money.

📖 preface

Good attitude, not so tired. In a good mood, all you see is beautiful scenery.Copy the code

"If you can't solve a problem for a while, use this opportunity to see your limitations and put yourself out of order." As the old saying goes, it's easy to let go. If you are distracted by something, learn to disconnect. Cut out the paranoia, the trash in the community, and get rid of the negative energy. Good attitude, not so tired. In a good mood, all you see is beautiful scenery.

🚓Abstract


Spring Cloud Gateway provides API Gateway support for SpringBoot applications and has powerful intelligent routing and filter functions. This article will introduce its usage in detail.

SpringCloudGateway is a new gateway framework for SpringCloud, which offers significant improvements in functionality and performance compared to the previous generation Zuul. Zuul1.x uses blocking multithreading, that is, one thread processes one connection request. The performance is poor under high concurrency conditions. Even though Zuul2.x does not block, it looks like Zuul will be abandoned in the face of continuous hops. In its place is SpringCloudGateway, which is based on Webflux, a non-blocking asynchronous framework with significant performance improvements, and includes all of Zuul’s capabilities, You can switch seamlessly from Zuul to the SpringCloudGateway


1. GatewayIntroduction to the

Gateway is an API Gateway service built on top of the Spring ecosystem, based on Spring 5, Spring Boot 2, and Project Reactor technologies. Gateway is designed to provide a simple and efficient way to route apis, as well as powerful filter features such as fuses, traffic limiting, retry, and so on.

The Spring Cloud Gateway has the following features:

  • It is constructed based on Spring Framework 5, Project Reactor and Spring Boot 2.0.

  • Dynamic routing: can match any request attribute;

  • You can specify Predicate and Filter for a route;

  • Integrated Circuit breaker function of Hystrix;

  • Integrate Spring Cloud service discovery;

  • Easy to write Predicate and Filter;

  • Request traffic limiting function;

  • Path rewriting is supported.


2. Related concepts

  • Route: A Route is the basic building block of a gateway. It consists of an ID, a target URI, a set of assertions, and filters that match the Route if the assertion is true.
  • Predicate: Refers to theJava 8Function Predicate. The input type isSpringIn the frameworkServerWebExchange. This allows developers to match everything in an HTTP request, such as request headers or request parameters. If the request matches the assertion, it is routed;
  • Filter: Refers to the Spring frameworkGatewayFilterUsing filters, you can modify requests before and after they are routed.

Introduced 3.gatewayRely on

inpom.xmlTo add dependencies (importSpringCloudGatewayThe need toPOMRemember to introduceactuatorComponent, otherwise the service will be considered offline by the Service Discovery Center and the gateway will not be able to route to the service)

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
Copy the code

Here are the dependencies I used


      
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>DreamChardonnayCloud</artifactId>
        <groupId>com.cyj.dream</groupId>
        <version>1.0 the SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.cyj.dream.gateway</groupId>
    <artifactId>dream-gateway</artifactId>
    <version>1.0 the SNAPSHOT</version>
    <name>dream-gateway</name>
    <packaging>jar</packaging>
    <description>The gateway</description>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>de.codecentric</groupId>
            <artifactId>spring-boot-admin-starter-client</artifactId>
            <version>${admin-server.version}</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <! -- SpringBoot monitor client -->
        <dependency>
            <groupId>de.codecentric</groupId>
            <artifactId>spring-boot-admin-starter-client</artifactId>
            <version>${spring-boot-admin.version}</version>
        </dependency>

        <! Gateway -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
            <version>${gateway.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
            <version>${gateway.version}</version>
        </dependency>

        <! Mysql > introduce database password encryption -->
        <dependency>
            <groupId>com.github.ulisesbocchio</groupId>
            <artifactId>jasypt-spring-boot-starter</artifactId>
            <version>${jasypt.version}</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>${common-pool.version}</version>
        </dependency>

        <! Redis database dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <! -- <exclusions> <exclusions> <exclusion> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </exclusion> <exclusion> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </exclusion> </exclusions> -->
        </dependency>

        <! -- Add captcha -->
        <dependency>
            <groupId>com.cyj.dream.captcha</groupId>
            <artifactId>dream-common-captcha</artifactId>
            <version>1.0 the SNAPSHOT</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <! -- Introducing core -->
        <dependency>
            <groupId>com.cyj.dream.core</groupId>
            <artifactId>dream-common-core</artifactId>
            <version>1.0 the SNAPSHOT</version>
            <! -- https://blog.csdn.net/qq_41686190/article/details/107280990 -->
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>com.cyj.dream.swagger</groupId>
            <artifactId>dream-swagger</artifactId>
            <version>1.0 the SNAPSHOT</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal><! You can package all dependent packages into the generated Jar package.
                        </goals>
                        <! Can generate non-executable Jar packages without dependencies
                        <configuration>
                            <classifier>exec</classifier>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>


</project>
Copy the code

4. The startup classes are as follows:

package com.cyj.dream.gateway; import cn.hutool.core.date.DateUtil; import com.cyj.dream.swagger.annotation.EnableDreamSwagger2; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; Gateway gateway startup class * @belongsproject: DreamChardonnay * @belongspackage: com.cyj.dream. Gateway * @author: ChenYongJia * @CreateTime: 2021-09-27 * @Email: [email protected] * @Version: 1.0 */ @slf4j @enableDreamSwagger2 @enableDiscoveryClient @ SpringBootApplication (exclude = {DataSourceAutoConfiguration. Class}) public class DreamGatewayApplication {/ * * * method of project startup * * @param args the input arguments * @date 2021-9-26 * @author Sunny Chen */ public static void main(String[] args) { Log.info (" Dream cloud -- gateway gateway start ing! ======>{}", DateUtil.now()); SpringApplication application = new SpringApplication(DreamGatewayApplication.class); / / this setting mode application. SetWebApplicationType (WebApplicationType. REACTIVE); application.run(args); Log.info (" Dream cloud -- gateway startup success ing....... ! ======>{}", DateUtil.now()); }}Copy the code

5. ymlconfiguration

I’m just going to show you aboutgatewayYou can handle other configurations by yourself

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/ * *]':
            allow-credentials: true
            allowed-originPatterns: "*"
            allowed-methods: "*"
            allowed-headers: "*"
        add-to-simple-url-handler-mapping: true
      locator:
        enabled: true
      routes:
        # Certification Center
        - id: dream-auth
          uri: lb://dream-auth
          predicates:
            - Path=/auth/**
          filters:
            # Captcha processing
            - ValidateCodeGatewayFilter
            # front-end password decryption
        # - PasswordDecoderFilter
        # Code generation module
        - id: dream-codegen
          uri: lb://dream-codegen
          predicates:
            - Path=/dsconf/**
        # File management module
        - id: dream-file-management
          uri: lb://dream-file-management
          predicates:
            - Path=/file/**
  The # springBoot2.x version requires the following configuration to set the size of large file processing
  servlet:
    multipart:
      # 1GB
      max-file-size: 1024MB
      max-request-size: 1024MB
  Allow overriding bean definitions
  main:
    allow-bean-definition-overriding: true
  # redis configuration
  redis:
    # redis service address
    host: 
    # Redis server connection port
    port: 
    # Redis server connection password (default null)
    password: 
    # Libraries used
    database: 0
    lettuce:
      pool:
        # maximum number of connections in the pool (use negative values to indicate no limit)
        max-active: 300
        Maximum connection pool blocking wait time (negative value indicates no limit)
        max-wait: 300ms
        The maximum number of free connections in the connection pool
        max-idle: 16
        Minimum free connection in connection pool
        min-idle: 8
    Connection timeout (ms)
    timeout: 60000

gateway:
  encode-key: 'thanks,dreamChardonnay'
  ignore-clients:
  # - test2
  allow-paths:
    - /dreamAuth/**
    - /auth/**
    - /oauth/**
    - /login
    - /v2/**
    - /allowFile/**
    - /sse/**
    - /form/**
Copy the code

6. Route Configuration information

I added the image verification operation in the route, you can find it and add it

/ * * *@Description: Route configuration information *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.config
 * @Author: ChenYongJia
 * @CreateTime: "* 2021-09-27@Email: [email protected]
 * @Version: 1.0 * /
@Slf4j
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class RouterFunctionConfiguration {

    private final ImageCodeHandler imageCodeHandler;

    @Bean
    public RouterFunction routerFunction(a) {
        return RouterFunctions.route(
                RequestPredicates.path("/code").and(RequestPredicates.accept(MediaType.TEXT_PLAIN)), imageCodeHandler); }}Copy the code

7. Routing current-limiting

/ * * *@Description: Route traffic limiting *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.config
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Configuration(proxyBeanMethods = false)
public class RateLimiterConfiguration {

    @Bean(value = "remoteAddrKeyResolver")
    public KeyResolver remoteAddrKeyResolver(a) {
        returnexchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()); }}Copy the code

8. The gateway configuration

GatewayConfigProperties.javaGateway configuration file

/ * * *@Description: gateway configuration file *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.config
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Data
@RefreshScope
@ConfigurationProperties("gateway")
public class GatewayConfigProperties {

    /** * key {@link PasswordDecoderFilter}
     */
    public String encodeKey;

    /** * Permission permission address */
    private List<String> allowPaths;

}
Copy the code

GatewayConfiguration.javaThe gateway configuration

/ * * *@Description: Gateway configuration *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.config
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(GatewayConfigProperties.class)
public class GatewayConfiguration {

    @Bean
    public PasswordDecoderFilter passwordDecoderFilter(GatewayConfigProperties configProperties) {
        return new PasswordDecoderFilter(configProperties);
    }

    @Bean
    public GlobalExceptionHandler globalExceptionHandler(ObjectMapper objectMapper) {
        return newGlobalExceptionHandler(objectMapper); }}Copy the code

8. Filter interceptor configuration

PasswordDecoderFilterPassword related

/ * * *@Description: Password decryption filter *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.filter
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Slf4j
@RequiredArgsConstructor
public class PasswordDecoderFilter extends AbstractGatewayFilterFactory {

    private static final String PASSWORD = "password";

    private static final String QRCODE = "QRCode";

    private static final String KEY_ALGORITHM = "AES";

    private final GatewayConfigProperties configProperties;

    private static String decryptAES(String data, String pass) {
        AES aes = new AES(Mode.CBC, Padding.NoPadding, new SecretKeySpec(pass.getBytes(), KEY_ALGORITHM),
                new IvParameterSpec(pass.getBytes()));
        byte[] result = aes.decrypt(Base64.decode(data.getBytes(StandardCharsets.UTF_8)));
        return new String(result, StandardCharsets.UTF_8);
    }

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            // Instead of a login request, execute directly down
            if(! StrUtil.containsAnyIgnoreCase(request.getURI().getPath(), SecurityConstants.OAUTH_TOKEN_URL)) {return chain.filter(exchange);
            }

            URI uri = exchange.getRequest().getURI();
            String queryParam = uri.getRawQuery();
            Map<String, String> paramMap = HttpUtil.decodeParamMap(queryParam, CharsetUtil.CHARSET_UTF_8);
            // Parse the password
            String password = paramMap.get(PASSWORD);
            if (StrUtil.isNotBlank(password)) {
                try {
                    password = decryptAES(password, configProperties.getEncodeKey());
                }
                catch (Exception e) {
                    log.error("Password decryption failed :{}", password);
                    return Mono.error(e);
                }
                paramMap.put(PASSWORD, password.trim());
            }
            / / parsing QRCode
            String QRCode= paramMap.get(QRCODE);
            if (StrUtil.isNotBlank(QRCode)) {
                try {
                    QRCode = decryptAES(QRCode, configProperties.getEncodeKey());
                }
                catch (Exception e) {
                    log.error("QRCode decryption failed :{}", QRCode);
                    return Mono.error(e);
                }
                paramMap.put(QRCODE, QRCode.trim());
            }

            URI newUri = UriComponentsBuilder.fromUri(uri).replaceQuery(HttpUtil.toParams(paramMap)).build(true)
                    .toUri();

            ServerHttpRequest newRequest = exchange.getRequest().mutate().uri(newUri).build();
            returnchain.filter(exchange.mutate().request(newRequest).build()); }; }}Copy the code

Global interceptor for all microservices

  • Process the parameters in the request header. Clean the FROM parameters
  • Override StripPrefix = 1 to support global
  • This header is forwardedto Forwardedto Forwarded-Prefix. This header is forwardedto Forwardedto Forwarded-Prefix.
package com.cyj.dream.gateway.filter;

import com.cyj.dream.core.constant.SecurityConstants;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.Arrays;
import java.util.stream.Collectors;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl;

/ * * *@Description: global interceptor for all microservices *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.filter
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
public class RequestGlobalFilter implements GlobalFilter.Ordered {

    /**
     * Process the Web request and (optionally) delegate to the next {@code WebFilter}
     * through the given {@link GatewayFilterChain}.
     * @param exchange the current server exchange
     * @param chain provides a way to delegate to the next filter
     * @return {@code Mono<Void>} to indicate when request processing is complete
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. Clean the from parameter in the request header
        ServerHttpRequest request = exchange.getRequest().mutate()
                .headers(httpHeaders -> httpHeaders.remove(SecurityConstants.FROM)).build();

        // 2. Override StripPrefix
        addOriginalRequestUrl(exchange, request.getURI());
        String rawPath = request.getURI().getRawPath();
        String newPath = "/" + Arrays.stream(StringUtils.tokenizeToStringArray(rawPath, "/")).skip(1L)
                .collect(Collectors.joining("/"));
        ServerHttpRequest newRequest = request.mutate().path(newPath).build();
        exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newRequest.getURI());

        return chain.filter(exchange.mutate().request(newRequest.mutate().build()).build());
    }

    @Override
    public int getOrder(a) {
        return -1000; }}Copy the code

tokenValidation filter

Note that if used online, look at the code to note out local annotations

/ * * *@Description: Token authentication filter *@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.filter
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Component
@RefreshScope
public class TokenFilter implements GlobalFilter.Ordered {
    @Resource
    private RedisTemplate redisTemplate;
    @Autowired
    private ObjectMapper objectMapper;
    @Resource
    private GatewayConfigProperties gatewayConfigProperties;
    /** * can be used to verify user login status, set some request information, etc. * /
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String apiPath=request.getPath().toString();

        // If it is not a GET request, check whether you have logged in and whether the token has expired
        // Get the token in the cookie
        HttpHeaders headers = exchange.getRequest().getHeaders();
// List
      
        cookies = multiValueMap.get("token");
      

        if( headers.size() ! =0) {
            String token = headers.getFirst("Authorization");
            // Token is not allowed to be empty
            /*if (StrUtil.isNotBlank(token)) { return chain.filter(exchange); }else{ if(this.isAllow(apiPath)){ return chain.filter(exchange); }} * /
            // No token is required locally
            return chain.filter(exchange);
        }
        return response.writeWith(Mono.create(monoSink -> {
            try {
                byte[] bytes = objectMapper.writeValueAsBytes(ResponseUtil.error("Login expired please log in first."));
                DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);

                monoSink.success(dataBuffer);
            } catch(JsonProcessingException jsonProcessingException) { monoSink.error(jsonProcessingException); }})); }@Override
    public int getOrder(a) {
        return 0;
    }

    /** * rematches the address **@param path
     * @return* /
    private boolean isAllow(String path) {
        Pattern pattern = null;
        String orginalUrl = path.split("[?] ") [0];
        for (String regex : getRegexList()) {
            pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
            if (pattern.matcher(orginalUrl).matches()) {
                return true; }}return false;
    }

    /** * get the regular matching rule list **@return* /
    private List<String> getRegexList(a) {
        // The first time this is loaded, and when configuration changes are made (this is not implemented yet, so it has to be loaded every time)
        List<String> regexList=null;
        Object regexListJson = redisTemplate.opsForValue().get("regexList");
        if (regexListJson == null) {
            regexList = new ArrayList<String>();
        }else{
            regexList = JSON.parseObject((String)regexListJson,new TypeReference<ArrayList<String>>() {
            });
            return regexList;
        }
        for (String url : gatewayConfigProperties.getAllowPaths()) {
            StringBuilder regex = new StringBuilder("\\S*").append(url.replace("/ * *"."\\S*")).append("\\S*");
            regexList.add(regex.toString());
        }
        redisTemplate.opsForValue().set("regexList",JSON.toJSONString(regexList));
        returnregexList; }}Copy the code

9. The processor

Gateway exception general-purpose handler that only works onwebfluxIn the environment, the priority is lower than{@link ResponseStatusExceptionHandler}perform

/ * * *@Description: Gateway exception common processor. It applies only to WebFlux environments and has a lower priority than {@link* ResponseStatusExceptionHandler}@BelongsProject: DreamChardonnay
 * @BelongsPackage: com.cyj.dream.gateway.handler
 * @Author: ChenYongJia
 * @CreateTime: 2021-09-27
 * @Email: [email protected]
 * @Version: 1.0 * /
@Slf4j
@Order(-1)
@RequiredArgsConstructor
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    private final ObjectMapper objectMapper;

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        ServerHttpResponse response = exchange.getResponse();

        if (response.isCommitted()) {
            return Mono.error(ex);
        }

        // header set
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        if (ex instanceof ResponseStatusException) {
            response.setStatusCode(((ResponseStatusException) ex).getStatus());
        }

        return response.writeWith(Mono.fromSupplier(() -> {
            DataBufferFactory bufferFactory = response.bufferFactory();
            try {
                return bufferFactory.wrap(objectMapper.writeValueAsBytes(ResponseUtil.error(ex.getMessage())));
            }
            catch (JsonProcessingException e) {
                log.error("Error writing response", ex);
                return bufferFactory.wrap(new byte[0]); }})); }}Copy the code

10. Quick — I won’t verify the results

This code, involving more introduction, I did not put out one by one to everyone, if you want to see the source code, and so on I sentgitBar, actual use needs you to combine for reference, avoid by all means copy and paste !!!!!!


PS:The recent output of the article to the practical resistance to build, I hope to help you rather than a long story is all theoretical combat weak chicken, finally thank you for your patience to watch the end, leave a thumbs-up collection is your biggest encouragement to me!


🎉 summary:

  • For more references, see here:The Blog of Chan Wing Kai

  • Like the small partner of the blogger can add a concern, a thumbs-up oh, continue to update hey hey!