Java响应式编程开发

1 篇文章 0 订阅
1 篇文章 0 订阅

引言

响应式系统特点:

  • 即时响应(responsive)
  • 回弹性(resilience)
  • 弹性(elastic)
  • 消息驱动(message driven)

简言之响应式系统在运行过程中,有请求到来随时都能处理,当系统有组件因网络或是其他原因阻塞了,系统依然可以处理请求,不过返回的数据为空,但请求会被缓存到内置消息队列中,当组件恢复后从消息队列中获取请求,处理完成后使用回调函数将数据主动从服务器返回给前端页面。

一、 关于响应式编程

早期,为使得软件系统支持更大并发量,需要使用多线程进行并行处理请求、异步处理复杂业务逻辑等,这些大部分需要手动进行编码使得线程能够良好配合以达到完成处理请求的目的。对于有资本的企业来说选择采用分布式系统架构(譬如: 熟知的微服务SpringCloud)来增加公司所产出的软件系统的并发量,引入消息队列使得系统减少请求阻塞最终目的是使得系统成为响应式系统。

现今,随着技术的迭代,JDK、Reactor、Spring官方仿照 .NET 推出了Java语言层面的响应式开发模型及相关组件,这些响应式开发模型的特点为: 天然异步、订阅发布(消息队列思想)、背压(限流)控制。使用JDK、Reactor、Spring提供的这些组件可使得我们轻松构建一个高性能的、响应式的软件系统。

二、JDK响应式开发API雏形

  1. JDK8新增的StreamAPI、Lambda表达式、函数接口
  2. JDK9新增的Flow接口,该接口遵循了 Reactive-Stream 规范在这里插入图片描述
  3. JDK11推出响应式HttpRequest、HttpResponse作为Web应用的核心接口在这里插入图片描述

当然还有JDK还有一些其他响应式开发的类或者接口,这里只是列举了目前笔者所了解到的两个对Java响应式开发比较有影响力的两个特性

三、Reactor框架

Reactor官网

此框架基于Reactive Stream规范及JDK底层封装的一些接口与实现,可帮助开发者轻松构建一个响应式(全链路非阻塞)应用。

  • 消息模型,其原理与MQ基本一致。
  • 采用多线程,所写逻辑代码天生具有异步的特性,可自定义线程池自定义异步逻辑。
  • 采用 函数式接口、流式编程思想,业务逻辑代码基本都属于链式(流水线)编程。
  • 编程方式与传统命令式编程大不相同,需要定义发布者与消费者,且业务逻辑是在函数式接口中进行编写。
    • 命令式(阻塞式)编程:A方法调B方法,只有B方法返回响应,A方法才可执行下一行代码。
    • 在Web应用中服务器默认为发布者,前端默认为消费者。

此框架核心API有如下两种(其余API可参考官方文档这里不再赘述官方文档很详细):

  1. 发布者与消费者通过 subscribe 函数绑定。
    • 当没有消费者绑定发布者时,消息不会被处理。
    • 在测试 Reactor 提供的各种API时注意要让主线程阻塞,因为默认逻辑都是走的异步。
  2. Publisher: 消息发布者
    • Mono 用于发布空或者1个数据,数据在未经过 map 相关API转格式那么最终格式为数据初始格式。
    • Flux 用于发布空、1个或者多个数据,数据在未经过 map 相关API转格式那么最终格式基本是一个数组。
 // 事件感知API:当流发生什么事的时候,触发一个回调,系统调用提前定义好的钩子函数(Hook【钩子函数】);doOnXxx;
        Flux<Integer> flux = Flux.range(1, 7)
                .delayElements(Duration.ofSeconds(1))
                .doOnComplete(() -> {
                    System.out.println("流正常结束...");
                })
                .doOnCancel(() -> {
                    System.out.println("流已被取消...");
                })
                .doOnError(throwable -> {
                    System.out.println("流出错..." + throwable);
                })
                .doOnNext(integer -> {
                    System.out.println("doOnNext..." + integer);
                }); //有一个信号:此时代表完成信号
  1. Subscriber:消息消费者
    • 这里主要是学习一些 onXXX 事件回调函数,譬如消息发送异常、消息处理完毕、消息全部处理完成、与订阅者成功绑定等。
    • 利用 request 函数控制一次向发布者索要多少数据,背压模式的核心就是 request 函数.
flux.subscribe(new BaseSubscriber<Integer>() {
            @Override
            protected void hookOnSubscribe(Subscription subscription) {
                System.out.println("订阅者和发布者绑定好了:" + subscription);
                request(1); //背压
            }

            @Override
            protected void hookOnNext(Integer value) {
                System.out.println("元素到达:" + value);
                if (value < 5) {
                    request(1);
                    if (value == 3) {
                        int i = 10 / 0;
                    }
                } else {
                    cancel();//取消订阅
                }
                ; //继续要元素
            }

            @Override
            protected void hookOnComplete() {
                System.out.println("数据流结束");
            }

            @Override
            protected void hookOnError(Throwable throwable) {
                System.out.println("数据流异常");
            }

            @Override
            protected void hookOnCancel() {
                System.out.println("数据流被取消");
            }

            @Override
            protected void hookFinally(SignalType type) {
                System.out.println("结束信号:" + type);
                // 正常、异常
//                try {
//                    //业务
//                }catch (Exception e){
//
//                }finally {
//                    //结束
//                }
            }
        });

四、Spring WebFlux

Spring6 WebFlux官网

在JavaEE开发中,主要就是做服务端Web应用,而在现今对Web应用的吞吐量、高可用等特性要求高的情况下,Spring 官方在基于 Reactor 框架基础上推出了 响应式Web应用框架 WebFlux。WebFlux的出现可在不使用 Servlet 的情况下构建Web应用,默认使用 Netty 作为服务器。开发者仅需熟悉 Reactor ,在使用 WebFlux 上的体验与WebMVC 差别不大,不过编写业务逻辑代码的方式与之前差别较大,需要着重适应。

1. 与WebMVC组件对比

功能Servlet-阻塞式WebWebFlux-响应式Web
前端控制器DispatcherServletDispatcherHandler
处理器ControllerWebHandler/Controller
请求、响应ServletRequestServletResponseServerWebExchange:ServerHttpRequest、ServerHttpResponse
过滤器Filter(HttpFilter)WebFilter
异常处理器HandlerExceptionResolverDispatchExceptionHandler
Web配置@EnableWebMvc@EnableWebFlux
自定义配置WebMvcConfigurerWebFluxConfigurer
返回结果任意Mono、Flux、任意
发送REST请求RestTemplateWebClient

2. HttpHandle、HttpServer

  • HttpHandle 请求处理器接口
  • HttpServer Reactor框架中的Netty服务器接口
    public static void main(String[] args) throws IOException {
        //快速自己编写一个能处理请求的服务器

        //1、创建一个能处理Http请求的处理器。 参数:请求、响应; 返回值:Mono<Void>:代表处理完成的信号
        HttpHandler handler = (ServerHttpRequest request,
                                   ServerHttpResponse response)->{
            URI uri = request.getURI();
            System.out.println(Thread.currentThread()+"请求进来:"+uri);
            //编写请求处理的业务,给浏览器写一个内容 URL + "Hello~!"
//            response.getHeaders(); //获取响应头
//            response.getCookies(); //获取Cookie
//            response.getStatusCode(); //获取响应状态码;
//            response.bufferFactory(); //buffer工厂
//            response.writeWith() //把xxx写出去
//            response.setComplete(); //响应结束

            //数据的发布者:Mono<DataBuffer>、Flux<DataBuffer>

            //创建 响应数据的 DataBuffer
            DataBufferFactory factory = response.bufferFactory();

            //数据Buffer
            DataBuffer buffer = factory.wrap(new String(uri.toString() + " ==> Hello!").getBytes());


            // 需要一个 DataBuffer 的发布者
            return response.writeWith(Mono.just(buffer));
        };

        //2、启动一个服务器,监听8080端口,接受数据,拿到数据交给 HttpHandler 进行请求处理
        ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler);


        //3、启动Netty服务器
        HttpServer.create()
                .host("localhost")
                .port(8080)
                .handle(adapter) //用指定的处理器处理请求
                .bindNow(); //现在就绑定

        System.out.println("服务器启动完成....监听8080,接受请求");
        System.in.read();
        System.out.println("服务器停止....");


    }

3.处理Web请求

  • 依赖
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
  • 配置类
@Configuration
public class MyWebConfiguration {
    //配置底层
    @Bean
    public WebFluxConfigurer webFluxConfigurer(){
        return new WebFluxConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedHeaders("*")
                        .allowedMethods("*")
                        .allowedOrigins("localhost");
            }
        };
    }
}
  • 过滤器
@Component
public class MyWebFilter implements WebFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        System.out.println("请求处理放行到目标方法之前...");
        Mono<Void> filter = chain.filter(exchange); //放行
        //流一旦经过某个操作就会变成新流
        Mono<Void> voidMono = filter.doOnError(err -> {
                    System.out.println("目标方法异常以后...");
                }) // 目标方法发生异常后做事
                .doFinally(signalType -> {
                    System.out.println("目标方法执行以后...");
                });// 目标方法执行之后
        //上面执行不花时间。
        return voidMono; //看清楚返回的是谁 !!!
    }
}
  • 全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ArithmeticException.class)
    public String error(ArithmeticException exception){
        System.out.println("发生了数学运算异常"+exception);

        //返回这些进行错误处理;
//        ProblemDetail:
//        ErrorResponse :

        return "炸了,哈哈...";
    }
}
  • Controller
//SpringMVC 以前怎么用,基本可以无缝切换。
// 底层:需要自己开始编写响应式代码
@ResponseBody
@Controller
public class HelloController {

    //WebFlux: 向下兼容原来SpringMVC的大多数注解和API;
    @GetMapping("/hello")
    public String hello(@RequestParam(value = "key",required = false,defaultValue = "哈哈") String key,
                        ServerWebExchange exchange,
                        WebSession webSession,
                        HttpMethod method,
                        HttpEntity<String> entity,
                        @RequestBody String s,
                        FilePart file){

//        file.transferTo() //零拷贝技术;
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String name = method.name();
        Object aaa = webSession.getAttribute("aaa");
        webSession.getAttributes().put("aa","nn");
        return "Hello World!!! key="+key;
    }



    // Rendering:一种视图对象。
    @GetMapping("/bai")
    public Rendering render(){
//        Rendering.redirectTo("/aaa"); //重定向到当前项目根路径下的 aaa
       return   Rendering.redirectTo("http://www.baidu.com").build();
    }

    //现在推荐的方式
    //1、返回单个数据Mono: Mono<Order>、User、String、Map
    //2、返回多个数据Flux: Flux<Order>
    //3、配合Flux,完成SSE: Server Send Event; 服务端事件推送

    @GetMapping("/haha")
    public Mono<String> haha(){
//        ResponseEntity.status(305)
//                .header("aaa","bbb")
//                .contentType(MediaType.APPLICATION_CBOR)
//                .body("aaaa")
//                .
        return Mono.just(0)
                .map(i-> 10/i)
                .map(i->"哈哈-"+i);
    }

    @GetMapping("/hehe")
    public Flux<String> hehe(){
        return Flux.just("hehe1","hehe2");
    }

    //text/event-stream
    //SSE测试; chatgpt都在用; 服务端推送
    @GetMapping(value = "/sse",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> sse(){
        return Flux.range(1,10)
                .map(i-> {

                    //构建一个SSE对象
                   return    ServerSentEvent.builder("ha-" + i)
                            .id(i + "")
                            .comment("hei-" + i)
                            .event("haha")
                            .build();
                })
                .delayElements(Duration.ofMillis(500));
    }

五、Spring Data R2DBC

R2DBC为关系型数据库响应式开发解决方案
Spring Data R2DBC 基于 R2DBC,非关系型数据库可参考Spring官方文档描述。
R2DBC 官网
Spring Data R2DBC官网

使用起来与MyBatis Plus很像,通过继承 R2dbcRepository 接口,可获得一些操作数据库基本层面的函数。同时也可自定义函数,有个显著的特点是只要名字起的符合官方标准可自动生成对应的SQL,无需手写SQL。也有个显著的缺点默认不支持关联查询,解决方案是分批查通过Java代码逻辑组装或者采用 DatabaseClient 手写SQL。由此可以推断出 SpringDataR2DBC 常用的接口为 DatabaseClient 与 R2dbcRepository 。

  • 依赖
<dependencies>
        <!-- https://mvnrepository.com/artifact/io.asyncer/r2dbc-mysql -->
        <dependency>
            <groupId>io.asyncer</groupId>
            <artifactId>r2dbc-mysql</artifactId>
            <version>1.0.5</version>
        </dependency>
        <!--   响应式 Spring Data R2dbc-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-r2dbc</artifactId>
        </dependency>
        <!--   响应式Web  -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
  • 配置类
@EnableR2dbcRepositories //开启 R2dbc 仓库功能;jpa
@Configuration
public class R2DbcConfiguration {


    @Bean //替换容器中原来的
    @ConditionalOnMissingBean
    public R2dbcCustomConversions conversions(){

        //把我们的转换器加入进去; 效果新增了我们的 Converter
        return R2dbcCustomConversions.of(MySqlDialect.INSTANCE,new BookConverter());
    }
}
  • 数据Bean
@Table("t_author")
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TAuthor {

    @Id
    private Long id;
    private String name;

    //1-N如何封装
    @Transient //临时字段,并不是数据库表中的一个字段
//    @Field(exist=false)
    private List<TBook> books;
}
  • DAO接口
@Repository
public interface AuthorRepositories extends R2dbcRepository<TAuthor,Long> {

    //默认继承了一堆CRUD方法; 像mybatis-plus

    //QBC: Query By Criteria
    //QBE: Query By Example

    //成为一个起名工程师  where id In () and name like ?
    //仅限单表复杂条件查询
    Flux<TAuthor> findAllByIdInAndNameLike(Collection<Long> id, String name);

    //多表复杂查询

    @Query("select * from t_author") //自定义query注解,指定sql语句
    Flux<TAuthor> findHaha();


    // 1-1:关联
    // 1-N:关联
    //场景:
    // 1、一个图书有唯一作者; 1-1
    // 2、一个作者可以有很多图书: 1-N
    
}
  • 测试
    //最佳实践:  提升生产效率的做法
    //1、Spring Data R2DBC,基础的CRUD用 R2dbcRepository 提供好了
    //2、自定义复杂的SQL(单表): @Query;
    //3、多表查询复杂结果集: DatabaseClient 自定义SQL及结果封装;
    //Spring Data 提供的两个核心底层组件
    @Autowired  // join查询不好做; 单表查询用
    R2dbcEntityTemplate r2dbcEntityTemplate; //CRUD API;

    @Autowired  //贴近底层,join操作好做; 复杂查询好用
    DatabaseClient databaseClient; //数据库客户端
    @Autowired
    AuthorRepositories authorRepositories;
    @Autowired
    BookRepostory bookRepostory;
    @Autowired
    BookAuthorRepostory bookAuthorRepostory;
    @Autowired
    R2dbcCustomConversions r2dbcCustomConversions;


    @Test
    void oneToN() throws IOException {
        
        Flux<TAuthor> flux = databaseClient.sql("select a.id aid,a.name,b.* from t_author a  " +
                        "left join t_book b on a.id = b.author_id " +
                        "order by a.id")
                .fetch()
                .all()
                .bufferUntilChanged(rowMap -> Long.parseLong(rowMap.get("aid").toString()))
                .map(list -> {
                    TAuthor tAuthor = new TAuthor();
                    Map<String, Object> map = list.get(0);
                    tAuthor.setId(Long.parseLong(map.get("aid").toString()));
                    tAuthor.setName(map.get("name").toString());
                    //查到的所有图书
                    List<TBook> tBooks = list.stream()
                            .map(ele -> {
                                TBook tBook = new TBook();

                                tBook.setId(Long.parseLong(ele.get("id").toString()));
                                tBook.setAuthorId(Long.parseLong(ele.get("author_id").toString()));
                                tBook.setTitle(ele.get("title").toString());
                                return tBook;
                            })
                            .collect(Collectors.toList());

                    tAuthor.setBooks(tBooks);
                    return tAuthor;
                });//Long 数字缓存 -127 - 127;// 对象比较需要自己写好equals方法
        
        flux.subscribe(tAuthor -> System.out.println("tAuthor = " + tAuthor));
        System.in.read();

    }

六、集成 SpringSecurity

SpringSecurity官方文档

SpringSecurity 是Spring官方推出的基于 Oauth 2.0 RABC模型的一款用来提升系统安全性的框架,简单来说就是可以轻松实现认证、鉴权。由于 Spring WebFlux 响应式开发框架的出现,SpringSecurity 在 6.0之后也开始支持响应式。

  • 依赖
 <dependencies>
        <!-- https://mvnrepository.com/artifact/io.asyncer/r2dbc-mysql -->
        <dependency>
            <groupId>io.asyncer</groupId>
            <artifactId>r2dbc-mysql</artifactId>
            <version>1.0.5</version>
        </dependency>
        <!--        响应式 Spring Data R2dbc-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-r2dbc</artifactId>
        </dependency>

        <!--        响应式Web  -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>


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

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
  • 配置类
@Configuration
@EnableReactiveMethodSecurity //开启响应式 的 基于方法级别的权限控制
public class AppSecurityConfiguration {
    @Autowired
    ReactiveUserDetailsService appReactiveUserDetailsService;

    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        //1、定义哪些请求需要认证,哪些不需要
        http.authorizeExchange(authorize -> {
            //1.1、允许所有人都访问静态资源;
            authorize.matchers(PathRequest.toStaticResources()
                    .atCommonLocations()).permitAll();


            //1.2、剩下的所有请求都需要认证(登录)
            authorize.anyExchange().authenticated();
        });

        //2、开启默认的表单登录
        http.formLogin(formLoginSpec -> {
//            formLoginSpec.loginPage("/haha");
        });

        //3、安全控制:
        http.csrf(csrfSpec -> {
            csrfSpec.disable();
        });
        // 目前认证: 用户名 是 user  密码是默认生成。
        // 期望认证: 去数据库查用户名和密码

        //4、配置 认证规则: 如何去数据库中查询到用户;
        // Sprinbg Security 底层使用 ReactiveAuthenticationManager 去查询用户信息
        // ReactiveAuthenticationManager 有一个实现是
        //   UserDetailsRepositoryReactiveAuthenticationManager: 用户信息去数据库中查
        //   UDRespAM 需要  ReactiveUserDetailsService:
        // 我们只需要自己写一个 ReactiveUserDetailsService: 响应式的用户详情查询服务
        http.authenticationManager(
                new UserDetailsRepositoryReactiveAuthenticationManager(
                        appReactiveUserDetailsService)
        );
//        http.addFilterAt()
        //构建出安全配置
        return http.build();
    }


    @Primary
    @Bean
    PasswordEncoder passwordEncoder(){
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        return encoder;
    }
}
  • 认证、鉴权数据预热组件
@Component  // 来定义如何去数据库中按照用户名查用户
public class AppReactiveUserDetailsService implements ReactiveUserDetailsService {


    @Autowired
    DatabaseClient databaseClient;

    // 自定义如何按照用户名去数据库查询用户信息

    @Autowired
    PasswordEncoder passwordEncoder;
    @Override
    public Mono<UserDetails> findByUsername(String username) {


//        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        //从数据库查询用户、角色、权限所有数据的逻辑
        Mono<UserDetails> userDetailsMono = databaseClient.sql("select u.*,r.id rid,r.name,r.value,pm.id pid,pm.value pvalue,pm.description " +
                        "from t_user u " +
                        "left join t_user_role ur on ur.user_id=u.id " +
                        "left join t_roles r on r.id = ur.role_id " +
                        "left join t_role_perm rp on rp.role_id=r.id " +
                        "left join t_perm pm on rp.perm_id=pm.id " +
                        "where u.username = ? limit 1")
                .bind(0, username)
                .fetch()
                .one()// all()
                .map(map -> {
                    UserDetails details = User.builder()
                            .username(username)
                            .password(map.get("password").toString())
                            //自动调用密码加密器把前端传来的明文 encode
//                            .passwordEncoder(str-> passwordEncoder.encode(str)) //为啥???
                            //权限
//                            .authorities(new SimpleGrantedAuthority("ROLE_delete")) //默认不成功
                            .roles("admin", "sale","haha","delete") //ROLE成功
                            .build();

                    //角色和权限都被封装成 SimpleGrantedAuthority
                    // 角色有 ROLE_ 前缀, 权限没有
                    // hasRole:hasAuthority
                    return details;
                });

        return userDetailsMono;
    }
}
  • 实体类, 只举其一
@Data
@Table(name = "t_perm")
public class TPerm {
    @Id
    private Long id;

    private String value;
    private String uri;
    private String description;
    private Instant createTime;
    private Instant updateTime;


}
  • 鉴权controller
@RestController
public class HelloController {

    @PreAuthorize("hasRole('admin')")
    @GetMapping("/hello")
    public Mono<String> hello(){

        return Mono.just("hello world!");
    }


    // 角色 haha: ROLE_haha:角色
    // 没有ROLE 前缀是权限
    //复杂的SpEL表达式
    @PreAuthorize("hasRole('delete')")
    @GetMapping("/world")
    public Mono<String> world(){
        return Mono.just("world!!!");
    }
}

文档声明

  • 本文档主要目的是为了帮助未接触过响应式开发模式的Java开发人员了解响应式开发究竟是什么、该如何使用。
  • 因官方文档描述的较详细,且基本可以满足日常开发需求。所以本文档有多个响应式开发组件的官方文档外链。

寄语:在大家学习一门新技术时,最好的方式是通过质量较高的播客找到核心信息脑海中有个大概之后,找到技术对应的官方文档进行学习。如果有教学视频,尽量不要像刷短剧一样无脑观看,可在练习过程中遇到复杂问题后再观看教学视频。

由于笔者文笔水平、技术水平有限,在表达时有语义不通顺、专业术语不正确的地方请谅解,若有开发伙伴对于响应式开发有自己独特的见解欢迎在评论区讨论。

  • 27
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值