openfeign的使用原理(一)

2023年 9月 24日 69.3k 0

1、前言

在之前的文章中,我们聊了一下借助openfeign创建客户端工具供其他系统使用的简单案例。我们只写了少量的代码,就完成了http客户端工具的封装,这也得益于springboot的自动配置功能,openfeign借助这一功能,帮我们封装了一些通用的对象,来帮我们发送http请求以及接收http响应,并且它还支持负载均衡、限流、熔断等一系列扩展功能,而我们只需要告诉openfeign要访问的目标服务的路径,就能像调用本地方法一样来访问远程服务,帮我们大大简化了分布式系统的开发。那么,接下来我们就来看看openfeign具体在底层做了哪些工作。当然,限于篇幅原因,原理这块我会分两篇文章来说一下~

2、原理讲解

我们仍从之前写的openfeign相关代码入手,如果小伙伴还没看过我之前那篇文章的话,可以先去大概看一下,做到有个印象即可。另外,之前我写的代码已提交到gitee代码仓库,访问gitee官网,搜索”begin-study“,搜索出的第一个仓库就是,需要的小伙伴可以直接check到本地查看。

image-20230922230950927
或者可以直接访问以下地址:

gitee.com/xklove/begi…

好了,言归正传,回到咱们的代码里,一起学习下openfeign的精彩之处~

2.1、openfeign-starter中的自动配置类

我们在编写客户端包的时候,在客户端包的pom文件中引入了如下的依赖:

        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        

它帮我们引入了spring-cloud-openfeign-core包,我们看下该包下面META-INF/spring.factories文件,里面提供了如下的自动配置类:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=
org.springframework.cloud.openfeign.hateoas.FeignHalAutoConfiguration,
org.springframework.cloud.openfeign.FeignAutoConfiguration,
org.springframework.cloud.openfeign.encoding.FeignAcceptGzipEncodingAutoConfiguration,
org.springframework.cloud.openfeign.encoding.FeignContentGzipEncodingAutoConfiguration,
org.springframework.cloud.openfeign.loadbalancer.FeignLoadBalancerAutoConfiguration

上述几个配置类的功能介绍如下:

配置类 功能描述
FeignHalAutoConfiguration 和HATEOAS设计风格相关的配置,定义了一些转换器,来描述资源和资源的关系
FeignAutoConfiguration 主要就是根据我们的配置以及引入的http通信包生成合适的底层http通讯服务对象
FeignAcceptGzipEncodingAutoConfiguration 要求服务端对响应内容进行压缩编码的一些请求头配置
FeignContentGzipEncodingAutoConfiguration 对请求内容进行压缩的一些请求头配置
FeignLoadBalancerAutoConfiguration feign和springcloud loadbalancer负载均衡组件结合的一些配置

这几个配置类和我们写业务代码关系比较大的就是第2个和第5个。然后因为我们没引入spring-cloud-loadbalancer包,所以第5个配置类我们暂时也可以先不看。

2.1.1、FeignAutoConfiguration配置类

FeignAutoConfiguration配置类有个很重要的bean,叫feignContext,它负责为每个Feign Client维护一个context(容器)对象,并且每个context中都有一个FeignClientsConfiguration组件,该组件维护了创建 Feign Client对象所需的一些关键对象。

//FeignAutoConfiguration类
        
   @Bean
	public FeignContext feignContext() {
		FeignContext context = new FeignContext();
		context.setConfigurations(this.configurations);
		return context;
	}
public class FeignContext extends NamedContextFactory {
    
	public FeignContext() {
		super(FeignClientsConfiguration.class, "feign", "feign.client.name");
	}
	//...省略其他代码
}

由于我们没引入okhttp、apache httpclient和apache hc5包中的任意一个,所以这个配置类的其它代码我们可以先不用关注,对它们有点印象就行,以后需要的时候可以再来仔细看下它们。另外有一点可以先提下,当我们使用openfeign默认的配置时,帮我们完成底层http通讯的,是jdk包的HttpURLConnection工具类。

2.2、自定义的ClientAutoConfiguration配置类

接下来我们看下我们自定义的自动配置类的内容:

/**
 * feign客户端配置
 */
@EnableFeignClients
@Configuration
public class ClientAutoConfiguration {

    @Bean
    public TokenRequestInterceptor tokenRequestInterceptor(){
        return new TokenRequestInterceptor();
    }

    @Bean
    public FilterRegistrationBean tokenFilter() {
        FilterRegistrationBean bean = new FilterRegistrationBean();
        bean.setFilter(new TokenFilter());
        bean.setUrlPatterns(Collections.singletonList("/*"));
        return bean;
    }
}

我们在这个配置类中定义了两个bean对象,作用如下:

TokenRequestInterceptor 实现了feign自身的RequestInterceptor请求拦截器接口,用来往请求头设置token认证信息,基于openfeign的请求在从客户端发出去之前,都会先经过这些拦截器的处理。
TokenFilter 过滤器,用来完成一些鉴权操作,此处只是做了一些样例

2.2.1、@EnableFeignClients注解

接下来我们来说下这个配置类中的重点,配置类上加了一个@EnableFeignClients注解,我们来看下它的定义:

/**
 * Scans for interfaces that declare they are feign clients (via
 * {@link org.springframework.cloud.openfeign.FeignClient} @FeignClient).
 * Configures component scanning directives for use with
 * {@link org.springframework.context.annotation.Configuration}
 * @Configuration classes.
 *
 * @author Spencer Gibb
 * @author Dave Syer
 * @since 1.0
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {

	/**
	 * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation
	 * declarations e.g.: {@code @ComponentScan("org.my.pkg")} instead of
	 * {@code @ComponentScan(basePackages="org.my.pkg")}.
	 * @return the array of 'basePackages'.
	 */
	String[] value() default {};

	/**
	 * Base packages to scan for annotated components.
	 * 

* {@link #value()} is an alias for (and mutually exclusive with) this attribute. *

* Use {@link #basePackageClasses()} for a type-safe alternative to String-based * package names. * @return the array of 'basePackages'. */ String[] basePackages() default {}; /** * Type-safe alternative to {@link #basePackages()} for specifying the packages to * scan for annotated components. The package of each class specified will be scanned. *

* Consider creating a special no-op marker class or interface in each package that * serves no purpose other than being referenced by this attribute. * @return the array of 'basePackageClasses'. */ Class[] basePackageClasses() default {}; /** * A custom @Configuration for all feign clients. Can contain override * @Bean definition for the pieces that make up the client, for instance * {@link feign.codec.Decoder}, {@link feign.codec.Encoder}, {@link feign.Contract}. * * @see FeignClientsConfiguration for the defaults * @return list of default configurations */ Class[] defaultConfiguration() default {}; /** * List of classes annotated with @FeignClient. If not empty, disables classpath * scanning. * @return list of FeignClient classes */ Class[] clients() default {}; }

简单地描述下这个注解的作用就是:扫描出指定类名(clients属性)或指定包(basePackages属性)下面标记有@FeignClient的接口,为接口创建目标代理对象,供调用方(客户端)使用。

然后,这个注解上面还有个元注解@Import(FeignClientsRegistrar.class),我们知道@Import也是spring创建组件(bean)的一种方式,它可以导入@Configuration配置类、ImportSelector和ImportBeanDefinitionRegistrar接口的实现类,而FeignClientsRegistrar就是ImportBeanDefinitionRegistrar接口的实现类。

2.2.2、FeignClientsRegistrar

FeignClientsRegistrar实现了ImportBeanDefinitionRegistrar接口,所以它也覆盖了接口的registerBeanDefinitions方法,我们也看下内部实现:

	@Override
	public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
		registerDefaultConfiguration(metadata, registry);
		registerFeignClients(metadata, registry);
	}

内部调用了两个方法,从方法名上看,第一个是注册默认的配置类到容器中,第二个方法是注册feign client对象到容器中。当然这里注册的都是BeanDefinition对象,它内部封装了具体要实例化的bean对象信息,供后期spring初始化bean的阶段使用。

2.2.2.1、registerDefaultConfiguration方法

该方法内部实现如下:

private void registerDefaultConfiguration(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
		Map defaultAttrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName(), true);

		if (defaultAttrs != null && defaultAttrs.containsKey("defaultConfiguration")) {
			String name;
			if (metadata.hasEnclosingClass()) {
				name = "default." + metadata.getEnclosingClassName();
			}
			else {
				name = "default." + metadata.getClassName();
			}
			registerClientConfiguration(registry, name, defaultAttrs.get("defaultConfiguration"));
		}
	}

该方法是用来注册默认的client配置类到Spring容器中,而这些配置类的作用也就是提供Decoder、Encoder、Contract、Client等类型的bean对象,具体的可参考FeignClientsConfiguration中的写法,而FeignClientsConfiguration本身是全局的一个Client配置类,当FeignContext为每个client创建context的时候,就会将@EnableFeignClients注解上指定的defaultConfiguration配置类数组注册到client专属的context容器中。当然由于此处我们也没指定defaultConfiguration属性,所以这段代码相当于没有起什么作用。

2.2.2.2、registerFeignClients方法

该方法内部实现如下:

	public void registerFeignClients(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {

		LinkedHashSet candidateComponents = new LinkedHashSet();
        //获取我们使用@EnableFeignClients注解时设置的属性信息
		Map attrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName());
		final Class[] clients = attrs == null ? null : (Class[]) attrs.get("clients");
        //如果我们使用@EnableFeignClients注解时,没有指定需要启用的clients属性列表,则扫描标记有@FeignClient注解的接口,至于扫描的包,就是使用@EnableFeignClients注解的配置类所在的包及其子包
		if (clients == null || clients.length == 0) {
			ClassPathScanningCandidateComponentProvider scanner = getScanner();
			scanner.setResourceLoader(this.resourceLoader);
			scanner.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));
			Set basePackages = getBasePackages(metadata);
			for (String basePackage : basePackages) {
				candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
			}
		}
		else {
            //如果指定了需要启用的clients列表,则只创建指定的clients列表中的beanDefinition对象
			for (Class clazz : clients) {
				candidateComponents.add(new AnnotatedGenericBeanDefinition(clazz));
			}
		}

		for (BeanDefinition candidateComponent : candidateComponents) {
			if (candidateComponent instanceof AnnotatedBeanDefinition) {
				// verify annotated class is an interface
				AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
				AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
                //此处限定死@FeignClient注解只能用在接口上面,不然会直接报错
				Assert.isTrue(annotationMetadata.isInterface(), "@FeignClient can only be specified on an interface");
				//获取我们使用@FeignClient注解时,为它设置的属性参数信息
				Map attributes = annotationMetadata
						.getAnnotationAttributes(FeignClient.class.getCanonicalName());

				String name = getClientName(attributes);
				registerClientConfiguration(registry, name, attributes.get("configuration"));
                //重点,创建接口代理对象的beanDefinition信息
				registerFeignClient(registry, annotationMetadata, attributes);
			}
		}
	}

如上所示,该方法主要是处理我们需要用到的标记了@FeignClient注解的接口信息,根据接口信息,生成相应的beanDefinition对象。

2.2.2.3、registerFeignClient方法

我们来看下这些标记了@FeignClient注解的接口是怎么被spring使用的。

	private void registerFeignClient(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata,
			Map attributes) {
		String className = annotationMetadata.getClassName();
		Class clazz = ClassUtils.resolveClassName(className, null);
		ConfigurableBeanFactory beanFactory = registry instanceof ConfigurableBeanFactory
				? (ConfigurableBeanFactory) registry : null;
		String contextId = getContextId(beanFactory, attributes);
		String name = getName(attributes);
        //FactoryBean类型的对象,专门用于生成标记了@FeignClient注解的接口实例对象
		FeignClientFactoryBean factoryBean = new FeignClientFactoryBean();
		factoryBean.setBeanFactory(beanFactory);
		factoryBean.setName(name);
		factoryBean.setContextId(contextId);
        //要创建对象的真实类型为我们的接口类型
		factoryBean.setType(clazz);
		factoryBean.setRefreshableClient(isClientRefreshEnabled());
        //创建beanDefinition对象,类型就是标记了@FeignClient注解的接口的类型
		BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
			factoryBean.setUrl(getUrl(beanFactory, attributes));
			factoryBean.setPath(getPath(beanFactory, attributes));
			factoryBean.setDecode404(Boolean.parseBoolean(String.valueOf(attributes.get("decode404"))));
			Object fallback = attributes.get("fallback");
			if (fallback != null) {
				factoryBean.setFallback(fallback instanceof Class ? (Class) fallback
						: ClassUtils.resolveClassName(fallback.toString(), null));
			}
			Object fallbackFactory = attributes.get("fallbackFactory");
			if (fallbackFactory != null) {
				factoryBean.setFallbackFactory(fallbackFactory instanceof Class ? (Class) fallbackFactory
						: ClassUtils.resolveClassName(fallbackFactory.toString(), null));
			}
            //返回我们需要的真实对象,比如我们例子中的OrderClient实例对象
			return factoryBean.getObject();
		});
		definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
		definition.setLazyInit(true);
		validate(attributes);

		AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
		beanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, className);
		beanDefinition.setAttribute("feignClientsRegistrarFactoryBean", factoryBean);

		// has a default, won't be null
		boolean primary = (Boolean) attributes.get("primary");

		beanDefinition.setPrimary(primary);

		String[] qualifiers = getQualifiers(attributes);
		if (ObjectUtils.isEmpty(qualifiers)) {
			qualifiers = new String[] { contextId + "FeignClient" };
		}
        //注册生成的beanDefinition对象到容器中
		BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, qualifiers);
		BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);

		registerOptionsBeanDefinition(registry, contextId);
	}

里面有这么一行代码:

FeignClientFactoryBean factoryBean = new FeignClientFactoryBean();

这个类就是用来创建各种标记了@FeignClient注解的接口实例的,比如spring在将OrderClient注入到我们的controller中时,就会调用FeignClientFactoryBean的getObject方法来返回OrderClient类型的对象。

2.2.2.4、FeignClientFactoryBean的getObject方法

getObject方法的实现如下:

	@Override
	public Object getObject() {
		return getTarget();
	}

	/**
	 * @param  the target type of the Feign client
	 * @return a {@link Feign} client created with the specified data and the context
	 * information
	 */
	 T getTarget() {
        //从容器中获取FeignContext对象,2.1部分提到过它
		FeignContext context = beanFactory != null ? beanFactory.getBean(FeignContext.class)
				: applicationContext.getBean(FeignContext.class);
        //从feignContext中获取当前feign client对象专属的context,然后从context中获取Feign.Builder实例,用来构造feign client对象实例(也即我们例子中的orderClient接口实例)
		Feign.Builder builder = feign(context);

        //如果@FeignClient注解没有指定url,则默认当做要通过服务名称借助负载均衡组件来完成对目标服务的访问
		if (!StringUtils.hasText(url)) {

			if (LOG.isInfoEnabled()) {
				LOG.info("For '" + name + "' URL not provided. Will try picking an instance via load-balancing.");
			}
			if (!name.startsWith("http")) {
				url = "http://" + name;
			}
			else {
				url = name;
			}
			url += cleanPath();
			return (T) loadBalance(builder, context, new HardCodedTarget(type, name, url));
		}
		if (StringUtils.hasText(url) && !url.startsWith("http")) {
			url = "http://" + url;
		}
		String url = this.url + cleanPath();
        // 从feign client对象专属的context中存在用于发送http请求的Client对象,如果不存在,就使用默认的对象
		Client client = getOptional(context, Client.class);
		if (client != null) {
			if (client instanceof FeignBlockingLoadBalancerClient) {
				// not load balancing because we have a url,
				// but Spring Cloud LoadBalancer is on the classpath, so unwrap
				client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
			}
			if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
				// not load balancing because we have a url,
				// but Spring Cloud LoadBalancer is on the classpath, so unwrap
				client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
			}
			builder.client(client);
		}

		applyBuildCustomizers(context, builder);

		Targeter targeter = get(context, Targeter.class);
        //target方法最终会返回一个代理对象,内部会对feign client接口内部标有@RequestMapping注解的方法进行解析,当我们调用代理对象的某个方法时,就会发起对应的http请求
		return (T) targeter.target(this, builder, context, new HardCodedTarget(type, name, url));
	}

上面代码中有个重要的对象是builder,它是Feign.Builder类型的,用来构造Feign client 对象。

//feign(context);
protected Feign.Builder feign(FeignContext context) {
		FeignLoggerFactory loggerFactory = get(context, FeignLoggerFactory.class);
		Logger logger = loggerFactory.create(type);

		// @formatter:off
		Feign.Builder builder = get(context, Feign.Builder.class)
				// required values
				.logger(logger)
				.encoder(get(context, Encoder.class))
				.decoder(get(context, Decoder.class))
				.contract(get(context, Contract.class));
		// @formatter:on
        //内部会往builder中设置我们自定义的请求拦截器TokenRequestInterceptor
		configureFeign(context, builder);

		return builder;
	}

上面的feign方法内部做了很多工作,它会先从FeignContext获取当前client对象专属的context对象,如果context不存在,就会先创建,并且会往context里面注册默认的配置类组件FeignClientsConfiguration,而FeignClientsConfiguration又会创建SpringMvcContract、SpringEncoder、SpringDecoder等和解析构造http请求相关的bean对象,并放到专属的context容器中。

接下来我们再来看getTarget方法内部结尾处调用的方法:

return (T) targeter.target(this, builder, context, new HardCodedTarget(type, name, url));

此处targeter对象的真实类型是DefaultTargeter(另一种类型是FeignCircuitBreakerTargeter,但是因为我们没启用断路器的功能,所以不存在这种类型的bean)。

class DefaultTargeter implements Targeter {

	@Override
	public  T target(FeignClientFactoryBean factory, Feign.Builder feign, FeignContext context,
			Target.HardCodedTarget target) {
		return feign.target(target);
	}

}

最终上面的feign.target方法调用会进入的Feign.class文件中。

2.2.2.5、Feign的target方法

折腾了这么久,在前期工作都准备完了之后,终于来到了创建客户端代理对象最关键的地方了。

//Feign.class    
   public  T target(Target target) {
      return build().newInstance(target);
    }

    public Feign build() {
      Client client = Capability.enrich(this.client, capabilities);
      Retryer retryer = Capability.enrich(this.retryer, capabilities);
      List requestInterceptors = this.requestInterceptors.stream()
          .map(ri -> Capability.enrich(ri, capabilities))
          .collect(Collectors.toList());
        //以下是对Feign内部组件的一些个性化增强处理。由于capabilities为空,所以实际什么处理也没做
      Logger logger = Capability.enrich(this.logger, capabilities);
      Contract contract = Capability.enrich(this.contract, capabilities);
      Options options = Capability.enrich(this.options, capabilities);
      Encoder encoder = Capability.enrich(this.encoder, capabilities);
      Decoder decoder = Capability.enrich(this.decoder, capabilities);
        //这里也是重点,它会创建一个InvocationHandler对象,供jdk创建代理对象
      InvocationHandlerFactory invocationHandlerFactory =
          Capability.enrich(this.invocationHandlerFactory, capabilities);
      QueryMapEncoder queryMapEncoder = Capability.enrich(this.queryMapEncoder, capabilities);

      SynchronousMethodHandler.Factory synchronousMethodHandlerFactory =
          new SynchronousMethodHandler.Factory(client, retryer, requestInterceptors, logger,
              logLevel, decode404, closeAfterDecode, propagationPolicy, forceDecoding);
       //转换处理器,对我们的客户端接口方法进行转换,提取出目标服务的http路径、请求方式(get、post等)|参数的封装信息以及响应的封装信息等等
      ParseHandlersByName handlersByName =
          new ParseHandlersByName(contract, options, encoder, decoder, queryMapEncoder,
              errorDecoder, synchronousMethodHandlerFactory);
      return new ReflectiveFeign(handlersByName, invocationHandlerFactory, queryMapEncoder);
    }
  }

上面build方法返回了ReflectiveFeign对象,而到这里,我们创建feignClient对象的前期工作就都准备完毕了。

3、小结

本篇我们主要看了下openfeign内部为创建Feign client对象所做的一系列准备,比如通过FeignContext实例对象为每个client对象创建专属的ApplicationContext上下文,并且往每个context上下文自动注入一些组件,供创建client对象时使用。其实上面的代码,因为是自动创建,要考虑到通用性,看起来有点复杂。如果我们自己来手动创建Client对象,代码上可能更容易理解一点,比如我新写了一个openfeign-provider-client2包,里面的主要代码如下:

/**
 * feign客户端配置
 */
@Import(FeignClientsConfiguration.class)
@Configuration
public class ClientAutoConfiguration {

    @Value("${app.order.url}")
    private String appOrderUrl;

    @Bean
    public TokenRequestInterceptor tokenRequestInterceptor(){
        return new TokenRequestInterceptor();
    }

    @Bean
    public FilterRegistrationBean tokenFilter() {
        FilterRegistrationBean bean = new FilterRegistrationBean();
        bean.setFilter(new TokenFilter());
        bean.setUrlPatterns(Collections.singletonList("/*"));
        return bean;
    }

    @Bean
    public OrderClient orderClient2(Encoder encoder, Decoder decoder, Contract contract) {
        return Feign.builder()
                //.client(client) //此处可以指定用来发送http请求的Client对象,如果不指定,则使用默认的Default对象
                .encoder(encoder)
                .decoder(decoder)
                .contract(contract)
                .requestInterceptor(new TokenRequestInterceptor())
                .target(OrderClient.class, appOrderUrl);
        //因为目前没有接入服务发现组件,所在.target(OrderClient.class, "xxx")方法的第二个参数不使用服务名称,
        //若使用服务名称,则是类似如下的形式.target(OrderClient.class, "http://PROD-SVC");
        //另外,如果把服务名当做Map的key,那么它对应的value其实就是"ip:port"
    }
}
/**
 * 客户端接口
 * url的值可以写死,比如:http://127.0.0.1:8080
 * 为了使用的灵活性,此处已变量形式注入
 */
//@FeignClient(name="order",url="${app.order.url}")
public interface OrderClient{

    @GetMapping("/order/{id}")
    @ResponseBody
    OrderDTO findById(@PathVariable Long id);
}

和之前的openfeign-provider-client包相比,这里的OrderClient去掉了@FeignClient注解,然后ClientAutoConfiguration自动配置类去掉了@EnableFeignClients注解,新增了@Import(FeignClientsConfiguration.class)注解,并且配置类内部手动创建了OrderClient类型的bean实例对象,而orderClient2方法中的这些参数bean,就使用FeignClientsConfiguration配置类提供的对象,通过这种手动创建client对象的方式,代码最终也会执行我们2.2.2.5部分提到的target方法。怎么样,现在看起来是不是简单了些呢?

好了,我们本篇就先谈到这里,下一篇我们就来看下target方法内部是怎么调用ReflectiveFeign的newInstance方法来实例化客户端对象的~

觉得有收获的朋友,可以点击关注我,这样方便接收我后续的文章,多谢支持~

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论