SpringBoot基础(二)

SpringBoot基础(二)

spring官网:https://spring.io/

https://mp.weixin.qq.com/mp/homepage?__biz=Mzg2NTAzMTExNg==&hid=1&sn=3247dca1433a891523d9e4176c90c499

该笔记记录的是SpringBoot2,更新的SpringBoot3版本需要参考最新官方文档

7、连接数据库

7.0 SpringData

对于数据访问层,无论是 SQL 还是 NoSQL,Spring Boot 底层都是采用 Spring Data 的方式进行统一处理。

SpringData官网:https://spring.io/projects/spring-data

数据库相关启动器:https://docs.spring.io/spring-boot/docs/2.2.5.RELEASE/reference/htmlsingle/#using-boot-starter

7.1 整合JDBC

创建一个springboot项目,引入JDBC相关依赖

在这里插入图片描述

自动生成的pom.xml依赖

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

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

编写application.yml配置文件连接数据库

spring:
  datasource:
    username: root
    password: 密码
    #?serverTimezone=UTC用于解决时区报错
    url: jdbc:mysql://localhost:3306/springboot?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver

springboot底层会处理这些配置内容。接下来可以直接使用jdbc

@SpringBootTest
public class SpringbootDataJdbcTest {
    //注入数据源。配置文件完成后,底层会构造出dataSource对象
    @Resource
    DataSource dataSource;

    @Test
    public void contextLoads() throws SQLException{
        //查看默认的数据源
        System.out.println(dataSource.getClass());
        //获取连接
        Connection connection = dataSource.getConnection();
        System.out.println(connection);
        //关闭连接
        connection.close();
    }
}

注:项目创建后会生成一个总测试类,自己写的测试类要和这个总测试类处于同级目录

输出

在这里插入图片描述

由输出可以看到,默认配置的数据源是class com.zaxxer.hikari.HikariDataSource

数据源的自动配置写在DataSourceAutoConfiguration类中,打开它的源码,源码中有一处默认数据源配置相关的内部类

@Configuration(
    proxyBeanMethods = false
)
@Conditional({DataSourceAutoConfiguration.PooledDataSourceCondition.class})
@ConditionalOnMissingBean({DataSource.class, XADataSource.class})

@Import({Hikari.class, Tomcat.class, Dbcp2.class, OracleUcp.class, Generic.class, DataSourceJmxConfiguration.class})
protected static class PooledDataSourceConfiguration {
    protected PooledDataSourceConfiguration() {
    }
}

源码中通过@Import注解导入了Hikari.class,即前面打印的默认配置数据源。

可以在配置文件中设置spring.datasources.type的值来配置数据源类型(mysql、oracle…)。

JDBCTemplate

目的和MyBatis差不多,都是为了简化数据库相关的代码书写。JDBCTemplate也是springboot底层的一个默认配置。

相关配置类是JdbcTemplateConfiguration

常用方法:

  • excute:执行sql语句
  • update、batchUpdate:前者执行增删改语句,后者执行批处理相关语句
  • query、queryForXXX:执行查询语句
  • call:执行存储过程、函数相关的语句

使用例

编写一个Controller,注入 jdbcTemplate,编写测试方法进行访问测试.

@RestController
@RequestMapping("/jdbc")
public class JdbcController {

    //使用默认配置的JdbcTemplate
    @Autowired
    JdbcTemplate jdbcTemplate;

    //查
    //List 中的一个 Map 对应数据库的一行数据
    //Map 中的 key 对应数据库的字段名,value 对应数据库的字段值
    @GetMapping("/list")
    public List<Map<String,Object>> userList(){
        String sql="select * from user";
        List<Map<String, Object>> maps = jdbcTemplate.queryForList(sql);
        return maps;
    }

    //增
    @GetMapping("/add")
    public String addUser(){
        String sql="insert into user(id,name,pwd) values(5,'田七',888888)";
        jdbcTemplate.update(sql);
        return "addOk";
    }

    //删
    @GetMapping("/del/{id}")
    public String delUser(@PathVariable("id")int id){
        String sql="delete from user where id=?";
        jdbcTemplate.update(sql,id);
        return "deleteOk";
    }

    //改
    @GetMapping("/update/{id}")
    public String updateUser(@PathVariable("id")int id){
        String sql="update user set name=?,pwd=? where id="+id;
        //修改数据
        Object[] objects = new Object[2];
        objects[0]="XXXX";
        objects[1]="999999";
        jdbcTemplate.update(sql,objects);
        return "updateOk";
    }
}

测试

在这里插入图片描述

7.2 整合Druid

开源仓库:https://github.com/alibaba/druid

Druid 是阿里巴巴开源平台上一个数据库连接池实现,结合了 C3P0、DBCP 等 DB 池的优点,同时加入了日志监控。Druid能够提供强大的监控和扩展功能。

com.alibaba.druid.pool.DruidDataSource 基本配置参数:https://github.com/alibaba/druid/wiki/DruidDataSource%E9%85%8D%E7%BD%AE%E5%B1%9E%E6%80%A7%E5%88%97%E8%A1%A8

添加依赖

<!-- https://mvnrepository.com/artifact/com.alibaba/druid -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.8</version>
</dependency>

在配置文件中修改数据源类型

spring:
  datasource:
    username: root
    password: 密码
    url: jdbc:mysql://localhost:3306/mybatis?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource # 自定义数据源

利用之前写好的SpringbootDataJdbcTest测试类打印查看当前的默认数据源

在这里插入图片描述

根据官方提供的参数,在配置文件中进行设置。

spring:
  datasource:
    username: root
    password: 密码
    url: jdbc:mysql://localhost:3306/mybatis?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource # 自定义数据源

    #以下属性Spring Boot 默认不注入,需要自己配置
    #druid 数据源专有配置
    initialSize: 5  #连接池初始化大小
    minIdle: 5    #最小连接池数量
    maxActive: 20   #最大连接数
    maxWait: 60000   #最大等待时间
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1 FROM DUAL
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    poolPreparedStatements: true

    #配置监控统计拦截的filters。stat:监控统计、log4j:日志记录、wall:防御sql注入
    #如果允许时报错  java.lang.ClassNotFoundException: org.apache.log4j.Priority
    #则导入 log4j 依赖即可
    filters: stat,wall,log4j
    maxPoolPreparedStatementPerConnectionSize: 20
    useGlobalDataSourceStat: true
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500

导入Log4j依赖

<!-- https://mvnrepository.com/artifact/log4j/log4j -->
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

配置文件中写好的Druid参数需要手动绑定属性,再添加到容器中。

创建一个config.DruidConfig类进行绑定

@Configuration
public class DruidConfig {
    /*
       将自定义的 Druid数据源添加到容器中,不再让 Spring Boot 自动创建
       绑定全局配置文件中的 druid 数据源属性到 com.alibaba.druid.pool.DruidDataSource从而让它们生效
       @ConfigurationProperties(prefix = "spring.datasource"):将全局配置文件中
       前缀为 spring.datasource的属性值匹配注入到 com.alibaba.druid.pool.DruidDataSource 的同名参数中
     */
    @ConfigurationProperties(prefix="spring.datasource")
    @Bean
    public DataSource druidDataSource(){
        return (DataSource) new DruidDataSource();
    }
}

测试类

@SpringBootTest
public class SpringbootDataDruidTest {
    @Autowired
    DataSource dataSource;

    @Test
    public void contextLoads() throws SQLException{
        System.out.println(dataSource.getClass());
        Connection connection = dataSource.getConnection();
        System.out.println(connection);

        DruidDataSource druidDataSource=(DruidDataSource) dataSource;
        System.out.println("druidDateSource 数据源的最大连接数:"+druidDataSource.getMaxActive());
        System.out.println("druidDateSource 数据源的初始化连接数:"+druidDataSource.getInitialSize());
        connection.close();
    }
}

输出

在这里插入图片描述

配置Druid数据源监控

Druid 数据源具有监控的功能,并提供了一个 web 界面方便用户查看。

DruidConfig类中添加以下方法来设置 Druid 的后台管理页面,如账号、密码。

@Bean
public ServletRegistrationBean statViewServlet() {
    ServletRegistrationBean bean = new ServletRegistrationBean(new StatViewServlet(), "/druid/*");

    // 这些参数可以在 com.alibaba.druid.support.http.StatViewServlet
    // 的父类 com.alibaba.druid.support.http.ResourceServlet 中找到
    Map<String, String> initParams = new HashMap<>();
    initParams.put("loginUsername", "admin"); //后台管理界面的登录账号
    initParams.put("loginPassword", "123456"); //后台管理界面的登录密码

    //后台允许谁可以访问
    //initParams.put("allow", "localhost"):表示只有本机可以访问
    //initParams.put("allow", ""):为空或者为null时,表示允许所有访问
    initParams.put("allow", "");
    //deny:Druid 后台拒绝谁访问
    //initParams.put("xxxxxx", "192.168.1.20");表示禁止此ip访问

    //设置初始化参数
    bean.setInitParameters(initParams);
    return bean;
}

测试

在这里插入图片描述

配置Druidweb监控的过滤器(filter)

DruidConfig类中添加以下方法来设置监控网页的过滤器

@Bean
public FilterRegistrationBean webStatFilter(){
    FilterRegistrationBean bean = new FilterRegistrationBean();
    bean.setFilter(new WebStatFilter());

    //exclusions:设置哪些请求要被过滤器筛除,不进行统计
    Map<String, String> initParams = new HashMap<>();
    initParams.put("exclusions", "*.js,*.css,/druid/*,/jdbc/*");
    bean.setInitParameters(initParams);

    //"/*"表示过滤所有请求
    bean.setUrlPatterns(Arrays.asList("/*"));
    return bean;
}

7.3 整合Mybatis

官方文档:http://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/

Maven仓库地址:https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-starter/2.1.1

添加依赖

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>

配置文件中的数据库连接信息保持之前(7.2)的配置。(测试数据库能否连上)

使用例

创建实体类

package com.example.springboot04data.pojo;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private int id;
    private String name;
    private String pwd;
}

创建mapper目录以及实体类对应的Mapper接口

@Mapper  //表示本类是一个 MyBatis 的 Mapper
@Repository
public interface UserMapper {
    //获取所有用户信息
    List<User> getUsers();
    //通过id获取用户
    User getUser(int id);
    //增
    int addUser(User user);
    //删
    int deleteUser(int id);
    //改
    int updateUser(User user);
}

resources目录下创建mybatis/mapper目录,编写对应的Mapper映射文件。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.springboot04data.mapper.UserMapper">

    <select id="getUsers" resultType="User">
        select * from user
    </select>

    <select id="getUser" resultType="User" parameterType="int">
        select * from user where id=#{id}
    </select>

    <insert id="addUser" parameterType="User">
        insert into user(id,name,pwd) values(#{id},#{name},#{pwd})
    </insert>

    <delete id="deleteUser" parameterType="int">
        delete from user where id=#{id}
    </delete>

    <update id="updateUser" parameterType="User">
        update user set name=#{name}, pwd=#{pwd} where id=#{id}
    </update>

</mapper>

在配置文件中添加以下配置以简化对象返回值的书写(否则resultType要写出完整对象名)

mybatis:
  type-aliases-package: com.example.springboot04data.pojo
  mapper-locations: classpath:mybatis/mapper/*.xml

编写UserController进行测试

@RestController
public class UserController {
    @Autowired
    UserMapper userMapper;

    //查询所有用户
    @GetMapping("/getUsers")
    public List<User> getUsers(){
        return userMapper.getUsers();
    }

    //通过id查询用户
    @GetMapping("/getUser/{id}")
    public User getUser(@PathVariable("id") int id){
        return userMapper.getUser(id);
    }

    //增
    public String addUser(User user){
        userMapper.addUser(user);
        return "addOk";
    }
    //删
    public String deleteUser(int id){
        userMapper.deleteUser(id);
        return "delOk";
    }
    //改
    public String updateUser(User user){
        userMapper.updateUser(user);
        return "updateOk";
    }
}

测试

在这里插入图片描述

8、信息安全

目的:保护用户信息安全、后期系统维护

市面上可用的框架:Shiro、Spring Security

Spring Security是一个功能强大且高度可定制身份验证和访问控制的框架(基于Spring)。侧重于为Java应用程序提供身份验证和授权。与所有Spring项目一样,Spring安全性的真正强大之处在于它易于扩展以满足定制需求。

一般来说,Web 应用的安全性包括用户认证(Authentication)和用户权限(Authorization)两个部分。

在用户认证方面,Spring Security 框架支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTTP 摘要认证、OpenID 和 LDAP 等。在用户授权方面,Spring Security 提供了基于角色的访问控制和访问控制列表(Access Control List,ACL),可以对应用中的领域对象进行细粒度的控制。

8.1 spring Security

8.1.1 环境搭建

新建一个springboot项目,引入web、thymeleaf模块

导入静态资源

在这里插入图片描述

创建controller.RouterController实现页面跳转

@Controller
public class RouterController {
    @RequestMapping({"/","/index"})
    public String index(){
        return "index";
    }
    @RequestMapping("toLogin")
    public String toLogin(){
        return "views/login";
    }
    @RequestMapping("/level1/{id}")
    public String level1(@PathVariable("id") int id){
        return "views/level1/"+id;
    }
    @RequestMapping("/level2/{id}")
    public String level2(@PathVariable("id") int id){
        return "views/level2/"+id;
    }

    @RequestMapping("/level3/{id}")
    public String level3(@PathVariable("id") int id){
        return "views/level3/"+id;
    }
}

测试

在这里插入图片描述

8.1.2 认证和授权

导入依赖

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

常见的相关类:

  • WebSecurityConfigurerAdapter: 自定义Security策略
  • AuthenticationManagerBuilder: 自定义认证策略
  • @EnableWebSecurity: 开启WebSecurity模式

认证(Authentication):

  • 通过用户名、密码等凭据验证用户的身份。
  • 身份验证通常通过用户名和密码完成,有时与身份验证因素结合使用。

授权(Authorization):

  • 系统成功验证您的身份后,最终会授予您访问资源(如信息,文件,数据库,资金,位置,几乎任何内容)的完全权限(可以联想数据库的权限)。

编写SpringSecurity配置类

参考官网:https://spring.io/projects/spring-security

https://docs.spring.io/spring-security/reference/servlet/index.html

编写基础配置类config.SecurityConfig

@EnableWebSecurity  //开启WebSecurity模式
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //定制请求的授权规则
    @Override
    protected void configure(HttpSecurity http) throws Exception{
        //首页允许所有人访问
        // antMarcher先择相关的路径,hasRole设置授权角色
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/level1/**").hasRole("vip1")
                .antMatchers("/level2/**").hasRole("vip2")
                .antMatchers("/level3/**").hasRole("vip3");
    }
}

测试发现只有首页能显示,其他页面由于权限无法进入

在这里插入图片描述

修改configure()方法,开启自动配置的登录功能

@EnableWebSecurity  //开启WebSecurity模式
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //定制请求的授权规则
    @Override
    protected void configure(HttpSecurity http) throws Exception{
        // 开启自动配置的登录功能
        // /login 请求来到登录页
        // /login/error 重定向到这里表示登录失败
        http.formLogin();
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/level1/**").hasRole("vip1")
                .antMatchers("/level2/**").hasRole("vip2")
                .antMatchers("/level3/**").hasRole("vip3");
    }
}

在这里插入图片描述

重写configure(AuthenticationManagerBuilder auth) 方法,实现自定义认证规则

//定义认证规则
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    //这里在内存中定义。后续可以去数据库中去拿
    auth.inMemoryAuthentication()
        .withUser("alice").password("123456").roles("vip2","vip3")
        .and()
        .withUser("root").password("123456").roles("vip1","vip2","vip3")
        .and()
        .withUser("guest").password("123456").roles("vip1","vip2");
}

不过测试发现登录失败

在这里插入图片描述

前端传到后端的密码需要进行加密,修改之前的代码

//定义认证规则
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    //Spring security 5.0中新增了多种加密方式,也改变了密码的格式。
    //将前端传过来的密码进行某种方式加密
    //spring security 官方推荐的是使用bcrypt加密方式。

    auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
        .withUser("alice").password(new BCryptPasswordEncoder().encode("123456")).roles("vip2","vip3")
        .and()
        .withUser("root").password(new BCryptPasswordEncoder().encode("123456")).roles("vip1","vip2","vip3")
        .and()
        .withUser("guest").password(new BCryptPasswordEncoder().encode("123456")).roles("vip1","vip2");
}

在这里插入图片描述

8.1.3 权限控制和注销

configure(HttpSecurity http)方法中开启自动配置的注销功能

//定制请求的授权规则
@Override
protected void configure(HttpSecurity http) throws Exception{
    //......
    //开启自动配置的注销的功能
    // /logout 发出注销请求
    http.logout();
}

index.html中增加一个注销按钮以发送/logout请求

<a class="item" th:href="@{/logout}">
   <i class="address card icon"></i> 注销
</a>

测试发现注销后会退到登录界面,为了实现注销后回到主页可以修改configure(HttpSecurity http)方法

//定制请求的授权规则
@Override
protected void configure(HttpSecurity http) throws Exception{
    //......
    //开启自动配置的注销的功能
    // /logout 发出注销请求
    http.logout().logoutSuccessUrl("/");
}

需求:希望没有登录时,主页右上角有登录按钮。登录后,主页右上角的按钮由用户名代替。另外,主页中只显示当前登录用户有权限点进去的链接。比如alice没有进入level1的权限,那level1这一块链接就不显示

这里需要使用thymeleaf中的另一些功能

导入依赖

<!-- https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity4 -->
<dependency>
   <groupId>org.thymeleaf.extras</groupId>
   <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

修改前端页面index.html

给页面导入命名空间xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"

修改导航栏,增加认证判断(其他页面会复用主页的导航栏代码)

<!--登录注销-->
<div class="right menu">

    <!--如果未登录-->
    <div sec:authorize="!isAuthenticated()">
        <a class="item" th:href="@{/login}">
            <i class="address card icon"></i> 登录
        </a>
    </div>

    <!--如果已登录-->
    <div sec:authorize="isAuthenticated()">
        <a class="item">
            <i class="address card icon"></i>
            用户名:<span sec:authentication="principal.username"></span>
            角色:<span sec:authentication="principal.authorities"></span>
        </a>
    </div>

    <div sec:authorize="isAuthenticated()">
        <a class="item" th:href="@{/logout}">
            <i class="address card icon"></i> 注销
        </a>
    </div>
</div>

测试

在这里插入图片描述

注销后可能会出现404,是因为它默认防止csrf跨站请求伪造,因为会产生安全问题,可以将请求改为post表单提交,或者在spring security中关闭csrf功能;在 configure(HttpSecurity http)方法中增加 http.csrf().disable();进行配置

修改主页中的代码实现根据用户权限显示对应的链接

<!-- sec:authorize="hasRole('vip1')" -->
<div class="column" sec:authorize="hasRole('vip1')">
    <div class="ui raised segment">
        <div class="ui">
            <div class="content">
                <h5 class="content">Level 1</h5>
                <hr>
                <div><a th:href="@{/level1/1}"><i class="bullhorn icon"></i> Level-1-1</a></div>
                <div><a th:href="@{/level1/2}"><i class="bullhorn icon"></i> Level-1-2</a></div>
                <div><a th:href="@{/level1/3}"><i class="bullhorn icon"></i> Level-1-3</a></div>
            </div>
        </div>
    </div>
</div>

<div class="column" sec:authorize="hasRole('vip2')">
    <div class="ui raised segment">
        <div class="ui">
            <div class="content">
                <h5 class="content">Level 2</h5>
                <hr>
                <div><a th:href="@{/level2/1}"><i class="bullhorn icon"></i> Level-2-1</a></div>
                <div><a th:href="@{/level2/2}"><i class="bullhorn icon"></i> Level-2-2</a></div>
                <div><a th:href="@{/level2/3}"><i class="bullhorn icon"></i> Level-2-3</a></div>
            </div>
        </div>
    </div>
</div>

<div class="column" sec:authorize="hasRole('vip3')">
    <div class="ui raised segment">
        <div class="ui">
            <div class="content">
                <h5 class="content">Level 3</h5>
                <hr>
                <div><a th:href="@{/level3/1}"><i class="bullhorn icon"></i> Level-3-1</a></div>
                <div><a th:href="@{/level3/2}"><i class="bullhorn icon"></i> Level-3-2</a></div>
                <div><a th:href="@{/level3/3}"><i class="bullhorn icon"></i> Level-3-3</a></div>
            </div>
        </div>
    </div>
</div>

在这里插入图片描述

8.1.4 记住我功能

需求:记住用户名密码,重开浏览器后可以快速登录

configure(HttpSecurity http)方法中添加http.rememberMe();即可,具体实现可以读源码

//定制请求的授权规则
@Override
protected void configure(HttpSecurity http) throws Exception {
   //......
   //记住我
   http.rememberMe();
}

测试登录时会出现记住我的选项

登录后,重开浏览器,再进入主页,发现会自动登录

在这里插入图片描述

注销后,springsecurity会自动删除cookie

在这里插入图片描述

8.1.5 定制登录页

前面的登录页面样式是spring security 默认的,下面要使用自定义样式

configure(HttpSecurity http)方法中修改http.formLogin();,指定自己的登录页面

http.formLogin().loginPage("/toLogin");

index.html中指向自定义的login请求

<a class="item" th:href="@{/toLogin}">
    <i class="address card icon"></i> 登录
</a>

登录时需要将这些信息发送到哪里,也需要自己配置。

可以在login.html 配置提交请求及方式,方式必须为post

<div class="ui form">
    <form th:action="@{/login}" method="post">
        <div class="field">
            <label>Username</label>
            <div class="ui left icon input">
                <input type="text" placeholder="Username" name="username">
                <i class="user icon"></i>
            </div>
        </div>
        <div class="field">
            <label>Password</label>
            <div class="ui left icon input">
                <input type="password" name="password">
                <i class="lock icon"></i>
            </div>
        </div>
        <input type="submit" class="ui blue submit button"/>
    </form>
</div>

请求提交后,还需要验证。可以查看formLogin()方法的源码。

配置接收登录的用户名和密码的参数

http.formLogin()
  .usernameParameter("username")
  .passwordParameter("password")
  .loginPage("/toLogin")
  .loginProcessingUrl("/login"); // 登陆表单提交请求

在登录页中增加记住我的多选框

<input type="checkbox" name="remember"> 记住我

后端验证处理

//定制记住我的参数!
http.rememberMe().rememberMeParameter("remember");

测试

在这里插入图片描述

SecurityConfig完整代码

@EnableWebSecurity  //开启WebSecurity模式
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //定制请求的授权规则
    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginPage("/toLogin")
                .loginProcessingUrl("/login"); // 登陆表单提交请求
        //首页允许所有人访问
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/level1/**").hasRole("vip1")
                .antMatchers("/level2/**").hasRole("vip2")
                .antMatchers("/level3/**").hasRole("vip3");
        http.logout().logoutSuccessUrl("/");
        http.rememberMe().rememberMeParameter("remember");
    }

    //定义认证规则
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //Spring security 5.0中新增了多种加密方式,也改变了密码的格式。
        //将前端传过来的密码进行某种方式加密
        //spring security 官方推荐的是使用bcrypt加密方式。

        auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
                .withUser("alice").password(new BCryptPasswordEncoder().encode("123456")).roles("vip2","vip3")
                .and()
                .withUser("root").password(new BCryptPasswordEncoder().encode("123456")).roles("vip1","vip2","vip3")
                .and()
                .withUser("guest").password(new BCryptPasswordEncoder().encode("123456")).roles("vip1","vip2");
    }
}

8.2 Shiro

官网:https://shiro.apache.org/

github地址:https://github.com/apache/shiro

Shiro是一个Apache的java安全框架(是个maven项目),可以实现认证、授权、加密、会话管理、web集成、缓存等功能

8.2.0 Shiro外部架构

在这里插入图片描述

  • subject: 代码的直接交互对象,即Shiro的对外API的核心是Subject类。Subject代表当前用户,与当前应用交互的任何要素都可以是Subject类,如网络爬虫,机器人等,与Subject类的所有交互都会委托给SecurityManager执行。
  • SecurityManager: 安全管理器,所有安全相关的操作都会与SecurityManager交互,并且它管理着所有的Subject,是shiro的核心。它负责与Shiro的其他组件进行交互,相当于SpringMVC的DispatcherServlet
  • Realm: shiro通过Realm获取安全数据(用户、角色、权限),SecurityManager负责验证用户身份,而验证对象需要通过Realm获取,用来判定当前输入的用户身份是否合法。另外,也需要通过Realm得到用户相应的角色、权限,来验证用户的操作是否能够执行。Realm类似于DataSource。

8.2.1 使用例

新建maven项目。

下载官方源码,将源码的\samples\quickstart文件内容复制到项目中。

依赖

<dependencies>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-core</artifactId>
        <version>1.9.0</version>
    </dependency>

    <!-- configure logging -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>jcl-over-slf4j</artifactId>
        <version>1.7.21</version>
    </dependency>
    <dependency>
        <groupId>org.apache.logging.log4j</groupId>
        <artifactId>log4j-slf4j-impl</artifactId>
        <version>2.17.2</version>
    </dependency>
    <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>1.2.17</version>
    </dependency>
</dependencies>

main/javamain/resources下的内容复制源码即可

QuickStart源码

public class Quickstart {

    private static final transient Logger log = LoggerFactory.getLogger(Quickstart.class);


    public static void main(String[] args) {

        Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
        SecurityManager securityManager = factory.getInstance();
        SecurityUtils.setSecurityManager(securityManager);

        // 获取当前的用户对象
        Subject currentUser = SecurityUtils.getSubject();

        // 通过当前用户获取session
        Session session = currentUser.getSession();
        session.setAttribute("someKey", "aValue");
        String value = (String) session.getAttribute("someKey");
        if (value.equals("aValue")) {
            log.info("Retrieved the correct value! [" + value + "]");
        }

        // 判断当前用户是否通过认证
        if (!currentUser.isAuthenticated()) {
            // token: 令牌。没有获取到则随机生成
            UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
            token.setRememberMe(true);
            try {
                currentUser.login(token); //执行登录操作
            } catch (UnknownAccountException uae) {
                log.info("There is no user with username of " + token.getPrincipal());
            } catch (IncorrectCredentialsException ice) {
                log.info("Password for account " + token.getPrincipal() + " was incorrect!");
            } catch (LockedAccountException lae) {
                log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                        "Please contact your administrator to unlock it.");
            }
            // ... catch more exceptions here (maybe custom ones specific to your application?
            catch (AuthenticationException ae) {
                //unexpected condition?  error?
            }
        }

        //say who they are:
        //print their identifying principal (in this case, a username):
        log.info("User [" + currentUser.getPrincipal() + "] logged in successfully.");

        //test a role:
        if (currentUser.hasRole("schwartz")) {
            log.info("May the Schwartz be with you!");
        } else {
            log.info("Hello, mere mortal.");
        }

        //检测权限(粗粒度)
        if (currentUser.isPermitted("lightsaber:wield")) {
            log.info("You may use a lightsaber ring.  Use it wisely.");
        } else {
            log.info("Sorry, lightsaber rings are for schwartz masters only.");
        }

        //检测权限(细粒度)
        if (currentUser.isPermitted("winnebago:drive:eagle5")) {
            log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  " +
                    "Here are the keys - have fun!");
        } else {
            log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!");
        }

        //all done - log out!
        currentUser.logout();

        System.exit(0);
    }
}

源码中会有一个shiro.ini文件,需要预先下载相关的插件

配置/resources/log4j.properties

log4j.rootLogger=INFO, stdout

log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %n %n

# General Apache libraries
log4j.logger.org.apache=WARN

# spring
log4j.logger.org.springframework=WARN

# default shiro logging
log4j.logger.org.apache.shiro=INFO

# disable verbase logging
log4j.logger.org.apache.shiro.util.ThreadContext=WARN
log4j.logger.org.apache.shiro.cache.ehcache.EhCache=WARN

启动测试

在这里插入图片描述

8.2.2 springboot整合shiro

新建springboot项目,添加web、thymeleaf模块

新建\template\index.html首页

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>

        <h1>首页</h1>
        <p th:text="${msg}"></p>
    </body>
</html>

创建controller.MyController

@Controller
public class MyController {
    @RequestMapping({"/","/index"})
    public String toIndex(Model model){
        model.addAttribute("msg","hello,Shiro");
        return "index";
    }
}

至此,初步搭建结束。

导入依赖

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
</dependency>

编写config.UserRealm

//自定义UserRealm
public class UserRealm extends AuthorizingRealm {
    //授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("执行了=>授权doGetAuthorizationInfo");
        return null;
    }
    //认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println("执行了=>授权doGetAuthenticationInfo");
        return null;
    }
}

编写配置类config.ShiroConfig

@Configuration
public class ShiroConfig {
    // ShiroFilterFactoryBean
    @Bean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(
            @Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        //设置安全管理器
        bean.setSecurityManager(securityManager);
        return bean;
    }

    // DefaultWebSecurityManager
    @Bean(name="defaultWebSecurityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("userRealm") UserRealm userRealm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //关联UserRealm
        securityManager.setRealm(userRealm);
        return securityManager;
    }


    // 创建realm对象
    @Bean(name="userRealm")
    public UserRealm userRealm(){
        return new UserRealm();
    }
}

新建前端页面templates/user/add.htmltemplates/user/update.html用于测试。

MyController中添加相关方法

@RequestMapping("user/add")
public String add(){
    return "user/add";
}
@RequestMapping("user/update")
public String update(){
    return "user/update";
}

index.html中添加相关跳转链接

<a th:href="@{/user/add}">add</a>
<a th:href="@{/user/update}">update</a>
实现登录拦截

修改ShiroConfig的内容

@Configuration
public class ShiroConfig {
    // ShiroFilterFactoryBean
    @Bean(name="shiroFilterFactoryBean")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(
            @Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        //设置安全管理器
        bean.setSecurityManager(securityManager);

        //添加shiro的内容过滤器
        /*
            anon:无需认证就能访问
            authc:必须认证才能访问
            user:必须拥有 记住我功能 才能使用
            perms:拥有对某个资源的权限才能访问
            role:拥有某个角色的权限才能访问
        */
        Map<String,String> filterMap=new LinkedHashMap<>();

        filterMap.put("/user/add","authc");
        filterMap.put("/user/update","authc");

        bean.setFilterChainDefinitionMap(filterMap);

        return bean;
    }
	//......
}

在这里插入图片描述

编写一个登录页面templates/login.html

<form th:action="@{/login}">
    <p>用户名: <input type="text" name="username"></p>
    <p>密码: <input type="text" name="password"></p>
    <p><input type="submit"></p>
</form>

MyController中添加相关方法

@RequestMapping("/toLogin")
public String toLogin() {
    return "login";
}

ShiroConfiggetShiroFilterFactoryBean()方法中设置登录的请求

@Bean(name="shiroFilterFactoryBean")
public ShiroFilterFactoryBean getShiroFilterFactoryBean(
    @Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager){
    ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
    bean.setSecurityManager(securityManager);
    
    Map<String,String> filterMap=new LinkedHashMap<>();
    filterMap.put("/user/add","authc");
    filterMap.put("/user/update","authc");
    bean.setFilterChainDefinitionMap(filterMap);
    
    //设置登录请求
    bean.setLoginUrl("/toLogin");
    return bean;
}

在这里插入图片描述

实现用户认证

MyController中添加登录请求相关方法

@RequestMapping("/login")
public String login(String username,String password,Model model){
    //获取当前的用户
    Subject subject = SecurityUtils.getSubject();
    //封装用户的登录数据
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    try{
        subject.login(token); //登录
        return "index";
    }
    catch (UnknownAccountException e){
        model.addAttribute("msg","用户名错误");
        return "login";
    }
    catch (IncorrectCredentialsException e){
        model.addAttribute("msg","密码错误");
        return "login";
    }
}

修改login.html中的内容

<p th:text="${msg}" style="color:red"></p>
<form th:action="@{/login}">
    <p>用户名: <input type="text" name="username"></p>
    <p>密码: <input type="text" name="password"></p>
    <p><input type="submit"></p>
</form>

UserRealm中修改认证方法

//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    System.out.println("执行了=>授权doGetAuthenticationInfo");
    //用户名,密码。这里先不从数据库中取
    String name="root";
    String password="123456";

    UsernamePasswordToken userToken = (UsernamePasswordToken) token;

    if(!userToken.getUsername().equals(name)){
        return null;  //用户名错误
    }

    //密码认证
    return new SimpleAuthenticationInfo("",password,"");
}

在这里插入图片描述

整合Mybatis

导入依赖

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.12</version>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>

编写application.yml,进行如下配置

spring:
  datasource:
    username: root
    password: 密码
    url: jdbc:mysql://localhost:3306/mybatis?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource # 自定义数据源

    #以下属性Spring Boot 默认不注入,需要自己配置
    #druid 数据源专有配置
    initialSize: 5  #连接池初始化大小
    minIdle: 5    #最小连接池数量
    maxActive: 20   #最大连接数
    maxWait: 60000   #最大等待时间
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1 FROM DUAL
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    poolPreparedStatements: true

    #配置监控统计拦截的filters。stat:监控统计、log4j:日志记录、wall:防御sql注入
    #如果允许时报错  java.lang.ClassNotFoundException: org.apache.log4j.Priority
    #则导入 log4j 依赖即可
    filters: stat,wall,log4j
    maxPoolPreparedStatementPerConnectionSize: 20
    useGlobalDataSourceStat: true
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500

application.properties中配置mybatis相关选项

mybatis.type-aliases-package=com.infinite.shirosptingboot.pojo
mybatis.mapper-locations=classpath:mapper/*.xml

编写实体类pojo.User

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private int id;
    private String name;
    private String pwd;
}

(下面的内容和7.3差不多)

编写mapper.UserMapper

@Repository
@Mapper
public interface UserMapper {
    public User queryUserByName(String name);
}

编写resorces/mapper/UserMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.infinite.shirospringboot.mapper.UserMapper">

    <select id="queryUserByName" parameterType="String" resultType="User">
        select * from mybatis.user where name=#{name}
    </select>
    
</mapper>

编写service.UserService接口

public interface UserService {
    public User queryUserByName(String name);
}

实现这个接口

@Service
public class UserServiceImpl implements UserService{
    @Autowired
    UserMapper userMapper;
    
    @Override
    public User queryUserByName(String name) {
        return userMapper.queryUserByName(name);
    }
}

修改UserRealm的内容,使其连接数据库

//自定义UserRealm
public class UserRealm extends AuthorizingRealm {

    @Autowired
    UserService userService;

    //授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("执行了=>授权doGetAuthorizationInfo");
        return null;
    }

    //认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        System.out.println("执行了=>授权doGetAuthenticationInfo");
        //从数据库获取信息
        UsernamePasswordToken userToken = (UsernamePasswordToken) token;
        User user = userService.queryUserByName(userToken.getUsername());
        if(user==null){ //不存在这个人
            return null; //UnknownAccountException
        }
        
        //密码认证
        return new SimpleAuthenticationInfo("",user.getPwd(),"");
    }
}

在这里插入图片描述

请求授权实现

(user表中增加了权限字段)

需求:如果没有进入一个网页的相关权限时,自动跳转到未授权提示页

MyController中添加未授权时跳转的方法

@RequestMapping("/noauth")
@ResponseBody
public String unauthorized(){
    return "未经授权无法访问此页面";
}

修改ShiroConfig中的内容

//修改前
filterMap.put("/user/add","authc");
filterMap.put("/user/update","authc");

//修改后
// 授权。没有权限会返回401
filterMap.put("/user/add","perms[user:add]");
filterMap.put("/user/update","perms[user:update]");
filterMap.put("/user/*","authc");

//增加未授权页面跳转语句
bean.setUnauthorizedUrl("/noauth");

修改UserRealm中的授权方法

//自定义UserRealm
public class UserRealm extends AuthorizingRealm {

    @Autowired
    UserService userService;

    //授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("执行了=>授权doGetAuthorizationInfo");

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.addStringPermission("user:add"); //添加权限

        //获取当前登录的用户对象
        Subject subject= SecurityUtils.getSubject();
        User currentUser = (User)subject.getPrincipal();
        //设置当前用户的权限
        info.addStringPermission(currentUser.getPerms());

        return info;
    }

    //认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //......
        
        //密码认证。 这里第一个参数会修改
        return new SimpleAuthenticationInfo(user,user.getPwd(),"");
    }
}

在这里插入图片描述

整合thymeleaf

需求:登录后只显示可以进入的链接

导入依赖

<dependency>
    <groupId>com.github.theborakompanioni</groupId>
    <artifactId>thymeleaf-extras-shiro</artifactId>
    <version>2.1.0</version>
</dependency>

ShiroConfig中添加整合thymeleaf相关的方法

//整合shiro和thymeleaf
@Bean
public ShiroDialect getShiroDialect(){
    return new ShiroDialect();
}

修改index.html内容

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:shiro="http://www.thymeleaf.org/thymeleaf-extras-shiro">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <h1>首页</h1>
        <p th:text="${msg}"></p>
        <hr>

        <p>
            <a th:href="@{/toLogin}">登录</a>
        </p>

        <div shiro:hasPermission="user:add">
            <a th:href="@{/user/add}">add</a>
        </div>
        <div shiro:hasPermission="user:update">
            <a th:href="@{/user/update}">update</a>
        </div>
    </body>
</html>

测试

在这里插入图片描述

下面配置一下session

修改UserRealm中的认证方法

//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    System.out.println("执行了=>授权doGetAuthenticationInfo");
    //从数据库获取信息
    //......
    
    Subject currentSubject = SecurityUtils.getSubject();
    Session session = currentSubject.getSession();
    session.setAttribute("loginUser",user);

    //密码认证
    return new SimpleAuthenticationInfo(user,user.getPwd(),"");
}

修改index.html中的登录链接

<div th:if="session.loginUser==null">
    <a th:href="@{/toLogin}">登录</a>
</div>
<div th:if="session.loginUser!=null">
    <p th:text="${user.getUsername()}"></p>
</div>

MyControllerlogin()方法中,在try-catch的登录成功部分添加model.addAttribute("user",token);以向前端传输数据。

在这里插入图片描述

9、Swagger

前后端分离

后端时代:前端只用管理静态页面:html。 后端使用模板引擎,如jsp(主要部分)

前后端分离时代:

  • 后端:后端控制层,服务层,数据访问层
  • 前端:前端控制层,视图层
    • 前后端未合并前,前端可以伪造后端数据,即json,测试时不用后端也能运行

前后端通过API、json交互。

前后端可以部署到不同的服务器上。

缺点:两个部分的人比较难做到及时协商。

解决方案:

  • 先制定schema(提纲),实时更新最新的API,降低集成的风险
  • 前端测试后端接口:postman
  • 后端提供接口,需要实时更新相关消息

Swagger

  • 目前比较流行的API框架
  • RestFul API文档在线自动生成工具。API文档与API定义同步更新
  • 可以在线测试API接口
  • 支持多种程序语言(java、php…)

https://swagger.io/

在项目中使用swagger需要用到springfox

  • swagger2
  • ui

9.1 springboor集成Swagger

新建springboot项目

导入依赖

<!-- https://mvnrepository.com/artifact/io.springfox/springfox-swagger2 -->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>3.9.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.springfox/springfox-swagger-ui -->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>

编写一个hello测试(HelloController)springboot程序(代码不再写出)

配置swagger,创建config.SwaggerConfig

@Configuration
@EnableSwagger2   //开启swagger2
public class SwaggerConfig {
}

测试时可能因为空指针问题不能启动,可以在pom文件中将springboot改成2.5.1版本

不要使用3+版本的swagger,此版本下的swagger-ui源码下没有swagger-ui.html,导致后面测试时的查询路径返回404

测试,访问localhost:8080/swagger-ui.html

在这里插入图片描述

9.2 配置swagger

完善SwaggerConfig的内容

@Configuration
@EnableSwagger2   //开启swagger2
public class SwaggerConfig {

    // 配置了Swagger的Docket的bean实例
    @Bean
    public Docket docket(){
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo());
    }

    //配置Swagger的信息
    private ApiInfo apiInfo(){
        //作者信息
        Contact contact = new Contact("infinite", "http://xxx.com", "123456@qq.com");
        return new ApiInfo(
                "自建SwaggerAPI文档",
                "This is a description",
                "va.b",
                "http://xxx.com",
                contact,
                "Apache2.0",
                "http://www.apache.org/lisence/LISENCE-2.0",
                new ArrayList()
        );
    }
}

Docket源码中有一个DocumentationType,进入这个类的源码可以了解具体定义,常量SWAGGER_2就写在其中

在这里插入图片描述

9.3 Swagger配置扫描接口

完善docket()方法

// 配置了Swagger的Docket的bean实例
@Bean
public Docket docket(){
    return new Docket(DocumentationType.SWAGGER_2)
        .apiInfo(apiInfo())
        .select()
        //RequestHandlerSelectors:配置要扫描接口的方式
        //basePackage:指定要扫描的包
        //any():全部扫描
        //none(): 不扫描
        //withClassAnnotation(): 扫描类上的注解,参数是一个注解的反射对象
        //withMethodAnnotation:扫描方法上的注解
        .apis(RequestHandlerSelectors.basePackage("com.infinite/springbootswagger.controller"))
        //paths():扫描指定路径
        .paths(PathSelectors.ant("/xxx/**"))
        .build();
}

在这里插入图片描述

另外,可以在apiInfo()方法后面跟一个enable()方法来设置swagger是否启动。在多配置环境下(如测试版本、发行版本)可以使用。

// 配置了Swagger的Docket的bean实例
@Bean
public Docket docket(){
    return new Docket(DocumentationType.SWAGGER_2)
        .apiInfo(apiInfo())
        .enable(false) //不启动swagger
        .select()
        .apis(RequestHandlerSelectors.basePackage("com.infinite/springbootswagger.controller"))
        .build();
}

下面要设置多种配置环境

新建application-dev.properties代表测试环境

新建application-pro.properties代表发布环境

application.properties中设置spring.profiles.active=dev来指向测试环境的配置文件

再次修改docket()方法

@Bean
public Docket docket(Environment environment){

    //设置要显示的Swagger环境
    Profiles profiles = Profiles.of("dev","test");
    //通过environment.acceptsProfiles()判断当前是否在自己设定的环境中
    boolean flag = environment.acceptsProfiles(profiles);

    return new Docket(DocumentationType.SWAGGER_2)
        .apiInfo(apiInfo())
        .enable(flag)
        .select()
        .apis(RequestHandlerSelectors.basePackage("com.infinite/springbootswagger.controller"))
        .build();
}

9.4 API文档的分组和接口注释

docket()中的apiInfo()方法后跟一个groupName(分组名)方法以设置一个分组

配置多个Docket对象即可实现设置多个分组

@Configuration
@EnableSwagger2   //开启swagger2
public class SwaggerConfig {

    @Bean
    public Docket docket1(){
        return new Docket(DocumentationType.SWAGGER_2).groupName("A");
    }
    @Bean
    public Docket docket2(){
        return new Docket(DocumentationType.SWAGGER_2).groupName("B");
    }
    @Bean
    public Docket docket3(){
        return new Docket(DocumentationType.SWAGGER_2).groupName("C");
    }
    //......
}

在这里插入图片描述

编写实体类pojo.User

@ApiModel("用户实体类")   //swagger-ui.html会给出相关提示
public class User {
    @ApiModelProperty("用户名")  	//swagger-ui.html会给出相关提示
    public String username;
    @ApiModelProperty("密码")
    public String password;
}

HelloController中调用这个实体类

@RestController
public class HelloController {
    @RequestMapping(value="/hello")
    public String hello(){
        return "hello";
    }

    //返回值若存在实体类,它就会被扫描到swagger中
    @PostMapping(value="/user")
    public User user(){
        return new User();
    }
}

在这里插入图片描述

另外,在controller类的头上添加@ApiOperation("注释")可以呈现同样的注释效果

@RestController
public class HelloController {
	
    //......
    
    @ApiOperation("Hello控制类")
    @GetMapping(value="/hello2")
    public String hello2(String username){
        return "hello, "+username;
    }
}

在这里插入图片描述

在这里插入图片描述

小结

  1. 可以通过Swagger给一些比较难理解的属性或接口,增加注释信息易于阅读
  2. 接口文档实时更新
  3. 在线测试接口
  4. 项目发行前要关闭swagger

10、任务

10.1 异步任务

新建springboot项目,导入web模块

新建service.AsyncService

@Service
public class AsyncService {
    public void hello(){
        try{
            Thread.sleep(3000);
        }
        catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("数据正在处理......");
    }
}

新建controller.AsyncController

@RestController
public class AsyncController {
    @Autowired
    AsyncService asyncService;

    @RequestMapping("/hello")
    public String hello(){
        asyncService.hello();  //停3秒
        return "OK";
    }
}

到这一步,测试时若输入相关请求,浏览器会加载3秒后才跳转到相关页面,用户体验较差。

下面要实现加载和跳转两件事一同执行(即所谓的异步任务),可以利用@Async注解

@Service
public class AsyncService {
    @Async //告诉spring这是一个异步的方法
    public void hello(){
        try{
            Thread.sleep(3000);
        }
        catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("数据正在处理......");
    }
}

还要在主程序类上添加@EnableAsync以开启异步任务功能

10.2 邮件任务

导入依赖

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

相关的自动配置类MailSenderAutoConfiguration,配置类MailProperties

完善配置文件application.properties

spring.mail.username=邮箱
spring.mail.password=密码   #qq邮箱可以通过手机短信获取另一种更安全的口令
spring.mail.host=smtp.qq.com

# 开启加密验证
spring.mail.properties.mail.smtp.ssl.enable=true

发送一个简单邮件

@SpringBootTest
class SpringbootMailTest(){
    @Autowired
    JavaMailSenderImpl mailSender;
    
    @Test
    void contextLoads1(){
        //一个简单的邮件
        SimpleMailMessage mailMessage=new SimpleMailMessage();
        
        mailMessage.setSubject("This is a title");
		mailMessage.setText("xxxxxxxxxxxxxxxxxxxx");
        
        mailMessage.setFrom("发送方邮箱");
        mailMessage.setTo("接受方邮箱");
        mailSender.send(mailMessage);
   	}
    
    @Test
    void contextLoads2(){
        //一个复杂的邮件
        MimeMailMessage mimeMessage = mailSender.createMimeMessage();
        //组装
        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
        helper.setSubject("This is a mime title");
		helper.setText("<p style='color:red'>xxxxxxxxxxxxxxxxxxxx</p>",true);
        //附件
        helper.addAttachment("1.jpg",new File("file1"));
        helper.addAttachment("2.jpg",new File("file2"));
        
        mailMessage.setFrom("发送方邮箱");
        mailMessage.setTo("接受方邮箱");
        mailSender.send(mimeMessage);
   	}
}

10.3 定时任务

相关的接口TaskExecutor(执行任务)、TaskScheduler(调度任务)

给主启动类添加@EnableScheduling以开启定时任务功能

创建一个类service.ScheduledService

@Service
public class ScheduledService {
    //在一个特定的时间内执行这个方法
    //cron 表达式
    // 秒  分  时  日  月  周几
    /* 例
        30 15 10 * * ?   每天10:15:30 执行一次
        30 0/5 10,18 * * ?   每天10点和18点,每歌5分钟执行一次
        0 15 10 ? * 1-6   每个月的周一到周六的10:15执行一次
    */
    @Scheduled(cron="0/2 * * * * ?")   //每2秒执行一次
    public void hello(){
        System.out.println("hello ,schedule test");
    }
}

cron表达式

cron在线生成器

11、Redis

https://spring.io/projects/spring-data-redis

新建一个springboot项目,导入lombok、springbootdevtools、springConfigurationProcessor、springWeb这几个基础依赖,再导入一个SpringDataRedis(Access+Driver)依赖

注:springboot2.x后,原本使用的jedis被替换成了lettuce

jedis:采用直连方式,多线程操作的情况下不安全,若要避免,需要用到jedis pool连接池。更像BIO模式

lettuce:采用netty,实例可以在多个线程中进行共享,不存在线程不安全的情况,可以减少线程数据。更像NIO模式

源码

相关自动配置类RedisAutoConfiguration、配置类RedisProperties

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnClass({RedisOperations.class})
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
    public RedisAutoConfiguration() {
    }

    @Bean
    @ConditionalOnMissingBean(
        name = {"redisTemplate"}
    ) //若自己实现了一个redisTemplate,则可以替换掉这个默认的模板
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        //默认的RedisTemplate没有过多的设置,redis对象都需要序列化
        //两个类型都是object,后续使用时需要用到强制转换
        RedisTemplate<Object, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        return new StringRedisTemplate(redisConnectionFactory);
    }
}

11.1 使用例

依赖

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

完善配置文件内容

#配置redis
spring.redis.host=127.0.0.1
spring.redis.port=6379

编写一个测试类

@SpringBootTest
class Springboot08RedisApplicationTests {

    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    void contextLoads() {
        //redisTemplate 可以操作不同的数据类型
        //opsForValue  操作字符串
        //opsForList 操作list
        //其他redis中的数据类型同理

        //除了基本操作,还可以执行事务和基本的CRUD

        // 获取redis的连接对象(不常用)
        //RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
        //connection.flushDb();
        //connection.flushAll();
        
        redisTemplate.opsForValue().set("myKey","infinite");
        System.out.println(redisTemplate.opsForValue().get("myKey"));
    }
}

注:本地要先开启redis服务

在这里插入图片描述

11.2 自定义RedisTemplate

进入RedisTemplate源码,会发现有几个RedisSerializer属性,这些属性是RedisTemplate的序列化配置

public void afterPropertiesSet() {
    super.afterPropertiesSet();
    boolean defaultUsed = false;
    if (this.defaultSerializer == null) {
        // JdkSerializationRedisSerializer 表示默认选择jdk序列化
        this.defaultSerializer = new JdkSerializationRedisSerializer(this.classLoader != null ? this.classLoader : this.getClass().getClassLoader());
    }
    //......
}

假如要实现用json序列化,可以自己实现一个RedisTemplate,内部具体实现可以参考RedisAutoConfiguration的源码

创建config.RedisConfig

@Configuration
public class RedisConfig {
    @Bean
    @ConditionalOnMissingBean(
            name = {"redisTemplate"}
    )
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

编写实体类pojo.User

@Data
@AllArgsConstructor
@NoArgsConstructor
@Component
public class User {
    private String name;
    private int age;
}

编写测试方法

@Test
public void test() throws JsonProcessingException {
    User user=new User("infinite",20);
    //进行json序列化,redis中存入json字符串。推荐使用json传输数据
    String jsonUser = new ObjectMapper().writeValueAsString(user);
    redisTemplate.opsForValue().set("user",jsonUser);
    System.out.println(redisTemplate.opsForValue().get("user"));
}

在这里插入图片描述

假如想要在redis中直接存入对象,则要将对象序列化,即User类要实现Serializable接口。

需求:自定义一个序列化方式

修改RedisConfig代码

//固定模板,完成后就不用再改动了
@Configuration
public class RedisConfig {
    @Bean
    @SuppressWarnings("all")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        //为了开发使用方便,一般直接使用<String,Object>
        RedisTemplate<String, Object> template = new RedisTemplate<String,Object>();
        template.setConnectionFactory(factory);

        //Json序列化配置
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        //String的序列化
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        //key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        //hash的key采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        //value采用jackson序列化方式
        template.setValueSerializer(jackson2JsonRedisSerializer);
        //hash的value采用jackson的序列化方式
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }
}

为了防止命名冲突,测试类中的redisTemplate需要通过@Qualifier()注解进行标识

@Autowired
@Qualifier("redisTemplate")
private RedisTemplate<String,Object> redisTemplate;

实际开发中,为了提高代码复用性,可以将项目中redis的常见操作进行抽象集成,整合到自定义的RedisUtil工具类中(和之前写jdbc、mybatis同理)。哪个类需要redis操作时,通过@Autowired装配这个工具类即可

可参考的自定义Redis工具类

12、分布式(待完善)

分布式系统的定义:分布式系统时若干独立计算机的集合,这些计算机对于用户来说就像单个系统。

分布式系统中的计算机之间通过网络进行通信,目的是利用更多的机器,处理更多的数据

著有当单个结点的处理能力无法满足日益增长的计算、存储任务,且硬件提升的成本高、应用程序难以进一步优化的时候,才需要考虑分布式系统。

12.1 分布式(待完善)

前置内容

背景

https://dubbo.apache.org/zh/index.html

背景相关的内容可以查看https://dubbo.apache.org/zh/docsv2.7/user/preface/background/

RPC

RPC(Remote Procedure Call):远程过程调用,是一种进程间的通信方式,本质是一种思想而非规范。RPC允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不是程序员显式编码这个远程调用的细节,即调用本地和调用远程两种方式的代码基本是相同的。

例:现有两台服务器A、B,一个应用部署在A上,A现在想要调用B服务器上应用提供的函数/方法,由于不再一个内存空间中,A不能直接进行调用,需要通过网络来表达调用的予以和传输调用的数据。

为什么要用到RPC?假设有一个业务,它无法在一个进程内(或一台计算机内)通过本地调用的方式完成,比如不同系统(组织)之间的通讯。由于计算能力需要横向扩展,需要在多台机器组成的集群上部署应用,RPC实现的就是要项调用本地的函数一样去调用远程的函数。

https://www.jianshu.com/p/2accc2840a1b

在这里插入图片描述

在这里插入图片描述

RPC的两个核心模块:通讯、序列化(方便数据传输)

Dubbo概念

Dubbo是一款高性能、轻量级的开源java RPC框架。它提供了三大核心功能:面向接口的远程方法调用、只能容错和负载均衡、服务自动注册和实现

https://dubbo.apache.org/zh/docs/concepts/service-discovery/

在这里插入图片描述

  • Provider(服务提供者): 向服务提供方暴露服务,服务提供者在启动时,向注册中心注册自己提供的服务。
  • Consumer(服务消费者): 调用远程服务的服务消费方,服务消费者在启动时,向注册中心订阅自己所需的服务。消费者从提供者的地址列表中,基于软负载均衡算法,选择一台提供者进行调用,如果调用失败就换一台。
  • Registry(注册中心): 返回服务提供者的地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
  • Monitor(监控中心): 服务消费者和提供者,让内存中累计调用次数和调用时间,每分钟发送一次统计数据到监控中心

zookeeper

充当注册中心的一个开源项目

使用:

  1. 下载项目压缩包并解压

  2. 执行bin/zkServer.cmd,发现闪退。可以在zkServer.cmd添加pause以排错
    在这里插入图片描述

    在这里插入图片描述

  3. 修改conf/zoo.cfg配置文件(没有的话就复制同目录下的zoo_sample.cfg),重新以管理员模式运行bin/zkServer.cmd,以及bin.zkCli.cmd
    修改zoo.cfg中的dataDirdataLogDir

    dataDir=C:\TOOLS\apache-zookeeper-3.8.0\data
    dataLogDir=C:\TOOLS\apache-zookeeper-3.8.0\log
    

    在这里插入图片描述

可能出现的问题
1.ZooKeeper audit is disabled

​ 修改zkServer.cmd ,call %JAVA%中添加"-Dzookeeper.audit.enable=true"

客户端下测试

ls /:列出zookeeper根下保存的所有节点

在这里插入图片描述

create -e /indinite : 创建一个节点,后面如果跟一个值可以设置存放的内容

在这里插入图片描述

get /infinite: 获取/infinite节点的信息

在这里插入图片描述

安装dubbo-admin

这是一个springboot项目,作为监控管理后台,查看注册了哪些服务

github上下载源码并解压

进入项目,解压lib中的jar包,修改dubbo-admin\src\main\resources\application.properties文件中指定的zookeeper地址(默认为本地)

dubbo.registry.address=zookeeper://127.0.0.1:2181

打包项目

C:\TOOLS\dubbo-admin-0.2.0>mvn clean package -D maven.test.skip=true

可能会打包失败,可以参考https://blog.csdn.net/qq_43612538/article/details/103548650

最终打包成dubbo-admin-0.0.1-SNAPSHOT.jar

先启动zookeeper的service端,再启动这个的jar包

12.2 分布式Dubbo+Zookeeper+SpringBoot

新建一个空项目

新建一个provider-server模块(spring),进入这个模块,端口配置为server.port=8001

新建接口service.TicketService

public interface TicketService {
    public String getTicket();
}

实现接口

public class TicketServiceImpl implements TicketService{
    @Override
    public String getTicket(){
        return "infinite";
    }
}

新建一个consumer-server模块(spring),进入这个模块,端口配置为server.port=8002

创建service.UserService


导入依赖(两个模块分别导入)

<dependency>
    <groupId>org.apache.dubbo</groupId>
    <artifactId>dubbo-spring-boot-starter</artifactId>
    <version>3.0.7</version>
</dependency>
<dependency>
    <groupId>com.github.sgroschupf</groupId>
    <artifactId>zkclient</artifactId>
    <version>0.1</version>
</dependency>

<!--引入zookeeper-->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.4.13</version>
    <!--排除slf4j-log4j12-->
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </exclusion>
    </exclusions>
</dependency>

完善provider-server的配置文件

server.port=8001

#服务应用名
dubbo.application.name=provider-server
#注册中心地址
dubbo.registry.address=zookeeper://127.0.0.1:2181
#哪些服务需要注册
dubbo.scan.base-packages=com.infinite.service

TicketServiceImpl添加dubbo的@DubboService注解,启动时可以被扫描到并注册

@DubboService
public class TicketServiceImpl implements TicketService{
    @Override
    public String getTicket(){
        return "infinite";
    }
}

给主启动类添加@EnableDubbo注解

启动zookeeper的服务端,再启动provider-server,测试是否注册成功

完善consumer-server的配置

server.port=8002

# 消费者去哪里拿服务。需要暴露自己的名字
dubbo.application.name=consumer-server
# 注册中心的地址
dubbo.registry.address=zookeeper://127.0.0.1:2181

在service目录下复制一份provider-serverTicketService接口。

完善UserService

@DubboService
public class UserService {
    @DubboReference //引用,获取provider-server提供的类
    TicketService ticketService;  //同目录下复制了同一份接口,用于引用
    
    public void butTicket(){
        String ticket=ticketService.getTicket();
        System.out.println("在注册中心拿到==>"+ticket);
    }
}

编写测试


步骤

前提:开启zookeeper服务

  1. 提供者提供服务
    1. 导入依赖
    2. 配置注册中心的地址,以及服务发现名、要扫描的包
    3. 在想要被注册的服务上,增加一个注解@DubboService
  2. 消费者如何消费
    1. 导入依赖
    2. 配置注册中心的地址,配置自己的服务名
    3. 从远程注入服务@DubboReference
      新建一个provider-server模块(spring),进入这个模块,端口配置为server.port=8001

新建接口service.TicketService

public interface TicketService {
    public String getTicket();
}

实现接口

public class TicketServiceImpl implements TicketService{
    @Override
    public String getTicket(){
        return "infinite";
    }
}

新建一个consumer-server模块(spring),进入这个模块,端口配置为server.port=8002

创建service.UserService


导入依赖(两个模块分别导入)

<dependency>
    <groupId>org.apache.dubbo</groupId>
    <artifactId>dubbo-spring-boot-starter</artifactId>
    <version>3.0.7</version>
</dependency>
<dependency>
    <groupId>com.github.sgroschupf</groupId>
    <artifactId>zkclient</artifactId>
    <version>0.1</version>
</dependency>

<!--引入zookeeper-->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.4.13</version>
    <!--排除slf4j-log4j12-->
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </exclusion>
    </exclusions>
</dependency>

完善provider-server的配置文件

server.port=8001

#服务应用名
dubbo.application.name=provider-server
#注册中心地址
dubbo.registry.address=zookeeper://127.0.0.1:2181
#哪些服务需要注册
dubbo.scan.base-packages=com.infinite.service

TicketServiceImpl添加dubbo的@DubboService注解,启动时可以被扫描到并注册

@DubboService
public class TicketServiceImpl implements TicketService{
    @Override
    public String getTicket(){
        return "infinite";
    }
}

给主启动类添加@EnableDubbo注解

启动zookeeper的服务端,再启动provider-server,测试是否注册成功

完善consumer-server的配置

server.port=8002

# 消费者去哪里拿服务。需要暴露自己的名字
dubbo.application.name=consumer-server
# 注册中心的地址
dubbo.registry.address=zookeeper://127.0.0.1:2181

在service目录下复制一份provider-serverTicketService接口。

完善UserService

@DubboService
public class UserService {
    @DubboReference //引用,获取provider-server提供的类
    TicketService ticketService;  //同目录下复制了同一份接口,用于引用
    
    public void butTicket(){
        String ticket=ticketService.getTicket();
        System.out.println("在注册中心拿到==>"+ticket);
    }
}

编写测试


步骤

前提:开启zookeeper服务

  1. 提供者提供服务
    1. 导入依赖
    2. 配置注册中心的地址,以及服务发现名、要扫描的包
    3. 在想要被注册的服务上,增加一个注解@DubboService
  2. 消费者如何消费
    1. 导入依赖
    2. 配置注册中心的地址,配置自己的服务名
    3. 从远程注入服务@DubboReference
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值