一、购物车

     购物车分为用户登录购物车和未登录购物车操作:
     之前,京东是用户 登录 和 不登录 都可以操作购物车,如果用户不登录,操作购物车可以将数据存储到 Cookie / WebSQL 或者 SessionStorage 中,用户登录后购物车数据可以存储到 Redis 中,再将之前未登录加入的购物车合并到 Redis 中即可。
     淘宝天猫则采用了另外一种实现方案,用户要想将商品加入购物车,必须先登录才能操作购物车。(现在京东也是这样。)
     本系统实现的购物车是类似于天猫的解决方案,即 用户必须先登录才能使用购物车功能。

1、购物车分析

(1)需求分析:
     用户在商品详细页点击加入购物车,提交商品 SKU 编号和购买数量,添加到购物车。
购物车展示页面如下:
在这里插入图片描述
(2)购物车实现思路
    我们实现的是用户登录后的购物车,用户将商品加入购物车的时候,直接将要加入购物车的详情存入到 Redis 即可。每次查看购物车的时候直接从 Redis 中获取。

(3)表结构分析
     用户登录后将商品加入购物车,需要存储商品详情以及购买数量。
changgou_order 数据库中有 tb_order_item 订单详情表
在这里插入图片描述
     可以看到,商品图片、商品名称、单价、数量,其实就是购物车的展示页面的那几个属性(所以可以借用 orderItem 的实体 bean),只是目前临时 存储数据到Redis,使用 hash 类型,一个 key 对应多个 field 和 value等用户下单后才将数据从 Redis 取出存入到 MySQL 中。
    

2、搭建订单购物车微服务

     来搭建订单购物车微服务工程,工程名为 changgou-service-order 并搭建对应的 api 工程 changgou-service-order-api,使用代码生成器,将生成好的代码拷贝到工程中:
在这里插入图片描述

在这里插入图片描述

引入依赖:

<dependency>
    <groupId>com.changgou</groupId>
    <artifactId>changgou-service-order-api</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

在 resources 中添加 application.yml 配置文件,代码如下:

server:
  port: 18090
spring:
  application:
    name: order
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://192.168.211.132:3306/changgou_order?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 123456
  redis:
    host: 192.168.211.132
    port: 6379
  main:
    allow-bean-definition-overriding: true

eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:7001/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true

还需要和 changgou-user-oauth 一样,提供配置类,暂时对 /spec/* 路径进行放行:

@Configuration
@EnableResourceServer
//激活方法上的 PreAuthorize 注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    //公钥
    private static final String PUBLIC_KEY = "public.key";

    /***
     * 定义JwtTokenStore
     * @param jwtAccessTokenConverter
     * @return
     */
    @Bean
    public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
        return new JwtTokenStore(jwtAccessTokenConverter);
    }

    /***
     * 定义JJwtAccessTokenConverter
     * @return
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setVerifierKey(getPubKey());
        return converter;
    }
    /**
     * 获取非对称加密公钥 Key
     * @return 公钥 Key
     */
    private String getPubKey() {
        Resource resource = new ClassPathResource(PUBLIC_KEY);
        try {
            InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
            BufferedReader br = new BufferedReader(inputStreamReader);
            return br.lines().collect(Collectors.joining("\n"));
        } catch (IOException ioe) {
            return null;
        }
    }

    /***
     * Http 安全配置,对每个到达系统的 http 请求链接进行校验
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        //所有请求必须认证通过
        http.authorizeRequests()
                //下边的路径放行
                .antMatchers(
                        "/spec/**"). //配置放行地址
                permitAll()
                .anyRequest().
                authenticated();    //其他地址需要认证授权
    }
}

创建启动类:

@SpringBootApplication
@EnableEurekaClient
@MapperScan("com.changgou.order.dao")
@EnableFeignClients(basePackages = {"com.changgou.goods.feign"})
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class,args);
    }
}
3、添加商品到购物车

思路分析:
在这里插入图片描述
     可以看到,用户添加购物车,只需要将要加入购物车的商品存到 Redis 中即可,一个用户可以将多件商品加入购物车。
     使用 Hash 类型,将用户的用户名作为 namespace 的一部分,将指定商品加入购物车,则往对应的 namespace 中增加一个 key 和 value,key 是商品 ID,value 是加入购物车的商品详情,如下图:
在这里插入图片描述

    
代码实现:
(1)feign创建
    下订单需要调用 feign 查看商品信息,我们先创建 feign 分别根据 ID 查询 Sku 和 Spu 信息,在 changgou-service-goods-api 工程中提供 SkuFeign 和 SpuFeign:

/***
 * 根据 ID 查询 SKU 信息
 * @param id : sku的ID
 */
@GetMapping(value = "/{id}")
public Result<Sku> findById(@PathVariable(value = "id", required = true) Long id);
/***
 * 根据SpuID查询Spu信息
 * @param id
 * @return
 */
@GetMapping("/{id}")
public Result<Spu> findById(@PathVariable(name = "id") Long id);

(2)业务层
在 changgou-service-order 微服务中创建 CartService 接口:

public interface CartService {

    /***
     * 添加购物车
     * @param num:购买商品数量
     * @param id:购买ID
     * @param username:购买用户
     * @return
     */
    void add(Integer num, Long id, String username);
}

实现:

 @Override
    public void add(Integer num, Long id,String username) {

        // 查询商品详情
        Result<Sku> skuResult = skuFeign.findById(id);
        Sku sku=skuResult.getData();
        Result<Spu> spuResult = spuFeign.findById(sku.getSpuId());
        Spu spu=spuResult.getData();

        // 将加入购物车的商品信息封装成 OrderItem
        OrderItem orderItem=new OrderItem();
        orderItem.setCategoryId1(spu.getCategory1Id());
        orderItem.setCategoryId2(spu.getCategory2Id());
        orderItem.setCategoryId3(spu.getCategory3Id());
        orderItem.setSpuId(spu.getId());
        orderItem.setSkuId(id);
        orderItem.setName(sku.getName());
        orderItem.setPrice(sku.getPrice());
        orderItem.setNum(num);
        orderItem.setMoney(num*orderItem.getPrice());
        orderItem.setImage(spu.getImage());

        // 存入 Redis
        redisTemplate.boundHashOps("cart_"+username).put(id,orderItem);
    }
}

(3)控制层
提供 CartController :

@RestController
@RequestMapping(value = "/cart")
public class CartController {

    @Autowired
    private CartService cartService;

    /**
     * 添加商品到购物车
     * @param num 商品数量
     * @param id 商品 ID
     * @return
     */
    @GetMapping("/add")
    public Result add(Integer num,Long id){
        cartService.add(num,id,"jia");
        return new Result(true, StatusCode.OK,"加入购物车成功");
    }
}

测试添加购物车功能:在这里插入图片描述

Redis 缓存中的数据:
在这里插入图片描述

4、查看购物车列表

(1)业务层
     在 CartService 接口添加购物车列表方法,代码如下:

/**
 * 查询购物车商品列表
 * @param username
 * @return
 */
List<OrderItem> list(String username);

实现:

 @Override
    public List<OrderItem> list(String username) {
            // 获取指定命名空间下所有数据
        return redisTemplate.boundHashOps("cart_"+username).values();
    }

(2)控制层

 @GetMapping("/list")
    public Result<List<OrderItem>> list(){
        // 获取用户登录名
        String username="jia";

        // 查询购物车列表
        List<OrderItem> orderItems=cartService.list(username);
        return new Result<>(true,StatusCode.OK,"购物车列表查询成功",orderItems);
    }

测试,访问 GET http://localhost:18090/cart/list :在这里插入图片描述

5、删除购物车商品

     现在有个问题,就是用户将商品加入购物车,无论数量是正负,都会执行添加购物车,如果数量如果 <=0,是应该移除该商品的。
     需要在 add 方法中添加判断逻辑:

  // 当商品数量 <=0 时,需要移除商品
        if (num <= 0) {
            redisTemplate.boundHashOps("cart_" + username).delete(id);

            // 如果此时购物车无商品,需要将购物车也移除
            if (redisTemplate.boundHashOps("cart_" + username).size() <= 0) {
                redisTemplate.delete("cart_" + username);
            }
            return;
        }

     还有一个问题,数据精度丢失问题,SkuId 是 Long 类型,在页面输出的时候会存在精度丢失问题,需要在 OrderItem 的 SkuId 上加上字符串序列化类型:
在这里插入图片描述

二、用户身份识别

1、授权

1558232917323
     购物车功能已经做完了,但用户名是写 si 的。用户要想将商品加入购物车,必须得先登录授权,登录授权后再经过微服务网关,微服务网关需要过滤判断用户请求是否存在令牌,如果存在令牌,才能再次访问微服务,此时网关会通过过滤器将令牌数据再次存入到头文件中,然后访问模板渲染服务,模板渲染服务再调用订单购物车微服务,此时也需要将令牌数据存入到头文件中,将令牌数据传递给购物车订单微服务,到了购物车订单微服务的时候,此时微服务需要校验令牌数据,如果令牌正确,才能使用购物车功能,并解析令牌数据获取用户信息。

微服务之间认证:
1558269362528
    还可以看到,微服务之间并没有传递头文件 Http Headers。
    所以我们需要定义一个拦截器,每次微服务 feign 调用之前都先检查下头文件 Http Headers,将其中的令牌数据再放入到 Http Headers 中,再调用其他微服务。
    
先参照由 Spring Security Oauth 生成的令牌,先提供个工具类,用于 生成管理员令牌

public class AdminToken {
    public static String adminToken() {

        // 加载证书
        ClassPathResource resource = new ClassPathResource("changgou.jks");

        // 读取证书数据
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource, "changgou".toCharArray());

        // 获取一对密钥
        KeyPair keyPair = keyStoreKeyFactory.getKeyPair("changgou", "changgou".toCharArray());

        // 获取私钥
        RSAPrivateKey aPrivate = (RSAPrivateKey) keyPair.getPrivate();

        // 创建令牌,使用私钥加盐,非对称加密
        Map<String, Object> payload = new HashMap<>();

        payload.put("authorities", new String[]{"accountant", "user", "salesman"});
        Jwt jwt = JwtHelper.encode(JSON.toJSONString(payload), new RsaSigner(aPrivate));

        // 获取令牌
        String token = jwt.getEncoded();
        return token;
    }
}

在 com.changgou.oauth.interceptor 包下提供一个 FeignInterceptor 拦截器(因为 feign 在发送请求之前都会调用该接口的 apply 方法 ):

package com.changgou.oauth.interceptor;

@Configuration
public class FeignInterceptor implements RequestInterceptor {
    /**
     * Feign 执行之前进行拦截
     *
     * @param requestTemplate
     */
    @Override
    public void apply(RequestTemplate requestTemplate) {

        // 生成 Admin 令牌
        String token = AdminToken.adminToken();

        // 把令牌放在 Http Headers 中
        requestTemplate.header("Authorization","bearer "+token);
    }
}

这样的话,会在所有的 Feign 调用之前生成含有 "authorities:["accountant","user","saleman"]" 信息的令牌,令牌还会被放到 Http Headers 中,所以在 loadUserByUsername 方法中,调用 /load/{id} 对应的 userFeign.findById 是经过验证的,可以去掉之前对 /load/{id}的放行了。

这时进行测试:
在这里插入图片描述

2、微服务之间的认证拦截器

    需要在 goods 微服务中先导入依赖包:

		<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

    还需要在 goods 微服务中提供 public.key。

    刚刚的例子是在 changgou-oauth-user 工程中,为管理员生成了令牌的逻辑,接下来在 changgou-service-order 中,需要需要把逻辑改成获取用户令牌:

public class FeignInterceptor implements RequestInterceptor {
    /**
     * Feign 执行之前进行拦截
     *
     * @param requestTemplate
     */
    @Override
    public void apply(RequestTemplate requestTemplate) {

        /***
         *  获取用户令牌
         */
        try {
            ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

            if (requestAttributes != null) {

                // 获取所有 Http Headers 的 key
                Enumeration<String> headerNames = requestAttributes.getRequest().getHeaderNames();

                if (headerNames != null) {
                    while (headerNames.hasMoreElements()) {
                        // Http Headers 的 key
                        String name = headerNames.nextElement();

                        // Http Headers 的 value
                        /*String values = request.getHeader(name);*/
                        String values = requestAttributes.getRequest().getHeader(name);

                        // 将令牌数据添加到 Http Headers 中
                        requestTemplate.header(name, values);

                        System.out.println(name + ":" + values);
                    }
                }
            }
        } catch (Exception e) {
        }
    }
}

    可以把这个 FeignInterceptor 类去掉 @Configuration 注解,拷贝到 common 工程中,抽取作工具类,在 order 的启动类中创建一个 feign 拦截器:
在这里插入图片描述
    这时不带参数地访问购物车清单:
在这里插入图片描述
    需要带令牌(这个令牌是通过 访问 http://localhost:9001/user/login?username=xxx&password=xxx 生成的)访问才行:
在这里插入图片描述
    接下来带令牌访问添加购物车,路径为 http://localhost:18090/cart/add?num=123&id=1148477873175142400,这时遇到报错 401 UnAuthorizated ,因为 add 方法中有用到 SkuFeign,于是先单独访问 SkuController 的 findById 方法,发现报错(如果传的 bearer token 有误,不能通过验证,就会报这个错的):
在这里插入图片描述
这时重新生成含有 公钥、私钥 的 changgou.jks 和 public.key,再访问 findById,就没问题了:
在这里插入图片描述
再访问添加购物车:
在这里插入图片描述
    OK,访问添加购物车,就会调用到 skuFeign、spuFeign,对应 goods 工程里的方法, 因为在 orders 工程里提供了 FeignInterceptor,所以在 Feign 调用前,就会把令牌数据封装到 Headers 中,这样就可以进行 Feign 调用了。各大微服务之间的认证,其实就是令牌的传递过程

    要注意两点:
一、令牌传递到微服务了;
二、令牌是有效的,能通过验证的。
    

3、微服务认证熔断开启模式

    当 order 工程的配置文件中设置:
在这里插入图片描述
    开启熔断时,默认的熔断模式是线程池隔离,访问添加购物车,会发现 FeignInterceptor 类中的 ServletRequestAttributes 类型的变量始终为空 null ,因为RequestContextHolder.getRequestAttributes() 方法是从 ThreadLocal 变量里面取得相应信息的,当 hystrix 熔断器的隔离策略为 THREAD 时,开启了新的线程,和用户请求的线程不是同一个,所以无法取得用户请求所在线程的 ThreadLocal 中的值。

  • 解决方案:hystrix 隔离策略换为 SEMAPHORE,这样才不会开启新的线程

     修改 changgou-web-order 的 application.yml 配置文件:

#hystrix 配置
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 10000
          strategy: SEMAPHORE
4、获取用户信息

之前是写 si 的用户名,现在需要获取用户信息。先在 order 的 config 包下提供解析令牌的 TokenDecode 类:

@Component
public class TokenDecode {

    private static final String PUBLIC_KEY = "public.key";

    // 获取令牌
    public String getToken() {
        OAuth2AuthenticationDetails authentication = (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();

        String tokenValue = authentication.getTokenValue();//ejebaldsfjalfjaljflajflajflajfla

        return tokenValue;
    }

    /**
     * 获取当前的登录的用户的用户信息
     *
     * @return
     */
    public Map<String, String> getUserInfo() {
        //1.获取令牌
        String token = getToken();

        //2.解析令牌  公钥
        String pubKey = getPubKey();

        Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(pubKey));
        String claims = jwt.getClaims();//{}

        System.out.println(claims);
        //3.返回
        Map<String, String> map = JSON.parseObject(claims, Map.class);
        return map;
    }

    private String getPubKey() {
        Resource resource = new ClassPathResource(PUBLIC_KEY);
        try {
            InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
            BufferedReader br = new BufferedReader(inputStreamReader);
            return br.lines().collect(Collectors.joining("\n"));
        } catch (IOException ioe) {
            return null;
        }
    }
}

修改查看购物车列表的方法逻辑:

@GetMapping("/list")
    public Result<List<OrderItem>> list(){

        // 获取用户详细信息
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();

        // 获取令牌
        String token=details.getTokenValue();

        // 解析令牌
        Map<String, String> userInfo = tokenDecode.getUserInfo();

        // 获取用户登录名
        String username=userInfo.get("username");
        
        // 查询购物车列表
        List<OrderItem> orderItems=cartService.list(username);
        return new Result<>(true,StatusCode.OK,"购物车列表查询成功",orderItems);
    }
}

同理,添加购物车也可以改成获取用户信息。

5、网关过滤

    为了不给微服务带来一些无效的请求,我们可以在网关中过滤用户请求,先看看头文件中是否有 Authorization ,如果有再看看 cookie 中是否有 Authorization,如果都通过了才允许请求到达微服务。
(1)application.yml配置
    修改微服务网关 changgou-gateway-web 的 application.yml 配置文件,添加 order 的路由过滤配置,配置如下:

 #订单微服务
    - id: changgou_order_route
      uri: lb://order
      predicates:
      - Path=/api/cart/**,/api/categoryReport/**,/api/orderConfig/**,/api/order/**,/api/orderItem/**,/api/orderLog/**,/api/preferential/**,/api/returnCause/**,/api/returnOrder/**,/api/returnOrderItem/**
      filters:
      - StripPrefix=1    

(2)过滤配置
     在微服务网关 changgou-gateway-web 中添加com.changgou.filter.URLFilter 过滤类,用于过滤需要用户登录的地址:

public class URLFilter {

    // 要放行的路径
    private static final String noAuthorizeurls = "/api/user/add,/api/user/login";


    /**
     * 判断当前请求的地址是否在已有的不拦截的地址中,
     * 如果存在 则返回 true,表示不拦截                                                                                           
     * 否则返回 false
     *
     * @param uri 获取到的当前的请求的地址
     * @return
     */
    public static boolean hasAuthorize(String uri) {
        String[] split = noAuthorizeurls.split(",");

        for (String s : split) {
            if (s.equals(uri)) {
                return true;
            }
        }
        return false;
    }
}

(3)全局过滤器修改
     修改之前的 com.changgou.filter.AuthorizeFilter 的过滤方法,将是否需要用户登录过滤也加入其中:
在这里插入图片描述

三、总结

(1)本系统实现的购物车是 用户必须先登录才能使用的。
    加入购物车,需要传入商品 sku ID 和 数量,存入 Redis 中,使用 hash 类型,一个 key 对应多个 field 和 value,将用户名放入命名空间,商品 ID 作为 key,商品详情作为 value。
     查看购物车也是从 Redis 中获取。
    等用户下单后才把数据从 Redis 存到 MySQL 中。
    
(2)在 oauth 工程中,定义 feign 拦截器(因为在颁发令牌之前,会使用 UserFeign 访问 user 的信息,这时还没生成令牌呢,本来应该报 401 的,所以需要先生成一个令牌,让它能够通过 user 工程里的校验),使用 changgou.jks 里的私钥加盐,非对称性加密,以 角色,比如 "accountant", "user", "salesman" 作为载荷,生成令牌。之后导入了认证依赖 spring-cloud-starter-oauth2 的工程里用到的 feign,都会先生成这样的令牌,并把令牌放在 Http headers 中。
    
(3)在 order 工程中,定义 feign 拦截器,因为 feign 在发送请求之前都会调用 RequestInterceptor 接口的 apply 方法,所以实现这个接口,并覆写方法即可。逻辑是:获取当前的 Http Headers ,并放到 Http Headers 中进后续的请求,相当于 Http Headers 的传递。各大微服务之间的认证,其实就是令牌的传递过程。令牌传递起来了,就可以获取其中的用户信息并解析,比如,TokenDecode 类中有 getUserInfo 方法,可以获取用户名 username。
    
(4)在 order 工程的 application.yml 中,配置的是:

feign:
   hystrix:
     enable: true

     默认的熔断模式是线程池隔离,总是开启新线程,需要使用 SEMAPHORE 。

🎉Java 序列化 JSON 时 long 型数值丢失精度问题

     《阿里巴巴Java开发手册》,有关于前后端超大整数返回的规约:
在这里插入图片描述
解决办法:

  • 使用 ToStringSerializer 的注解,序列化时保留相关精度,比如本篇博客中的:
  @JsonSerialize(using=ToStringSerializer.class)
    private Long skuId;

     不过需要在每个对象都使用该注解,比较于繁锁。

  • 对于 fastjson
         使用全局配置,使用 ToStringSerializer 实现序列化
   @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(
                SerializerFeature.DisableCircularReferenceDetect,
                SerializerFeature.BrowserCompatible);

        //解决 Long 转 json 精度丢失的问题
        SerializeConfig serializeConfig = SerializeConfig.globalInstance;
        serializeConfig.put(BigInteger.class, ToStringSerializer.instance);
        serializeConfig.put(Long.class, ToStringSerializer.instance);
        serializeConfig.put(Long.TYPE, ToStringSerializer.instance);
        fastJsonConfig.setSerializeConfig(serializeConfig);

        fastConverter.setSupportedMediaTypes(fastMediaTypes);
        fastConverter.setFastJsonConfig(fastJsonConfig);
        converters.add(fastConverter);
    }
  • 对于 Jackson
         它有个配置参数 WRITE_NUMBERS_AS_STRINGS,可以强制将所有数字全部转成字符串输出,使用方法很简单,只需要配置参数即可:spring.jackson.generator.write_numbers_as_strings=true
         这种方式的优点是使用方便,不需要调整代码;缺点是颗粒度太大,所有的数字都被转成字符串输出了,包括按照 timestamp 格式输出的时间也是如此。
         Jackson 提供了 Long 转 字符串 的支持,可以对 ObjectMapper 进行定制,具体代码如下所示:
public class JacksonConfiguration {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder
                .serializerByType(Long.class, ToStringSerializer.instance)
                .serializerByType(Long.TYPE, ToStringSerializer.instance);
    }
}

     通过定义 Jackson2ObjectMapperBuilderCustomizer,对 Jackson2ObjectMapperBuilder 对象进行定制,对 Long 型数据进行了定制,使用ToStringSerializer 来进行序列化。( Jackson 是 Spring Boot 默认绑定的 JSON 类库,可用于 Json 和 XML 与 JavaBean 之间的序列化和反序列化,在 Spring Boot 当中,spring-boot-starter-web 间接引入了 Jackson 组件,也就是说,如果使用了 SpringBoot 框架,那么项目中已经有了 Jackson 依赖)。详见 https://blog.csdn.net/wo541075754/article/details/103788077

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐