第七章Spring Security

项目进阶,构建安全高效的企业服务

Spring Security

image-20211224154310845

Spring Security底层利用filter(许多专门登录、权限、退出。。。),利用javaee的规范,对整个请求进行拦截。对权限的控制比较靠前,权限不行的话到不了DispatcherServlet,更到不了controller

image-20211224155334301

image-20211224155617890

导包

导过包后会自动对项目进行安全管理,自带登陆页面,控制台有密码,用户名为user

image-20211224161347773 image-20211224161327127

怎么把它的登陆页面换成自己的?用自己数据库里的数据进行登录?

认证授权在业务层进行处理,当前用户的权限怎么体现?可以建立角色表,user的type字段(0普通,1管理员,2版主),用Security做授权时需要一个明确权限的字符串?

1、让user实体类实现UserDetails接口,实现里面的方法。

public class User implements UserDetails {
    
//true:账号未过期
@Override
public boolean isAccountNonExpired() {
    return true;
}

//true:账号未锁定
@Override
public boolean isAccountNonLocked() {
    return true;
}
//true:凭证未过期
@Override
public boolean isCredentialsNonExpired() {
    return true;
}
//true:账号可用
@Override
public boolean isEnabled() {
    return true;
}
//返回用户所具有的权限
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> list=new ArrayList<>();
        list.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {//每个这个方法封装一个权限(多个封装多个权限)
                switch (type){
                    case 1:
                        return "ADMIN";
                    default:
                        return "USER";
                }
            }
        });
        return list;
    }

2、让userservice实现UserDetailsService接口

根据用户名查用户,自动判断账号密码对不对

public class UserService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return this.findUserByName(s);
    }
}

3、利用security对项目进行授权

在配置类里面进行配置,继承WebSecurityConfigurerAdapter,重写父类方法

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService userService;


    @Override
    public void configure(WebSecurity web) throws Exception {
        //忽略resource下的所有静态资源
        web.ignoring().antMatchers("/resources/**");
    }

    //处理认证
    //AuthenticationManager:认证的核心接口
    //AuthenticationManagerBuilder:用于构建AuthenticationManager对象的工具
    //ProviderManager:AuthenticationManager接口默认实现类
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       //内置的认证规则
        // auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));//原密码+12345加密

        //自定义认证规则
        //AuthenticationProvider:ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证
        //委托模式:ProviderManager将认证委托给AuthenticationProvider
        auth.authenticationProvider(new AuthenticationProvider() {
            //Authentication:用于封装认证信息的接口,不同的实现类代表不同类型的认证信息
            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                //获得账号密码进行核实
                String username = authentication.getName();
                String password = (String) authentication.getCredentials();

                User user = userService.findUserByName(username);
                if (user==null){
                    throw new UsernameNotFoundException("账号不存在!");
                }

                password = CommunityUtil.md5(password + user.getSalt());
                if(!user.getPassword().equals(password)){
                    throw new BadCredentialsException("密码不正确!");
                }

                //principal:主要信息,  credentials:证书 ,authorities:权限
                return new UsernamePasswordAuthenticationToken(user,user.getPassword(),user.getAuthorities());

            }

            //当前的AuthenticationProvider支持哪种类型的认证
            @Override
            public boolean supports(Class<?> aClass) {
                //使用账号密码认证
                return UsernamePasswordAuthenticationToken.class.equals(aClass);
            }
        });

    }

    //授权
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //避开默认的登录页面
        //登录相关配置
        http.formLogin()
                .loginPage("/loginpage")//告诉登陆页面是谁
                .loginProcessingUrl("/login")//登录提交表单时的路径,拦截
                .successHandler(new AuthenticationSuccessHandler() {//成功处理器,成功时的处理
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        //重定向到首页
                        response.sendRedirect(request.getContextPath()+"/index");
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {//失败处理器,失败时的处理
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        //回到首页(不能重定向,重定向会让客户端发一个新的请求,请求变了,无法向下一个传参)
                        //将请求绑定到request里,将请求转发(在同一个请求之内,可以通过req传参)到首页
                        request.setAttribute("error",e.getMessage());
                        request.getRequestDispatcher("/loginpage").forward(request,response);
                    }
                });

        //退出相关配置
        http.logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.sendRedirect(request.getContextPath()+"/index");//退出成功重定向到首页
                    }
                });

        //授权配置(权限与路径的对应关系)
        http.authorizeRequests()
                .antMatchers("/letter").hasAnyAuthority("USER","ADMIN")
                .antMatchers("/admin").hasAnyAuthority("ADMIN")
                .and().exceptionHandling().accessDeniedPage("/denied");
        //没有权限的时候访问路径


        //增加Filter,处理验证码
        http.addFilterBefore(new Filter() {
            @Override
            public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                HttpServletRequest request=(HttpServletRequest)servletRequest;
                HttpServletResponse response=(HttpServletResponse) servletResponse;
                if(request.getServletPath().equals("/login")){//请求路径是登录
                    String verifyCode = request.getParameter("verifyCode");
                    if(verifyCode==null || !verifyCode.equalsIgnoreCase("1234")){
                        request.setAttribute("error","验证码错误!");
                        request.getRequestDispatcher("/loginpage").forward(request,response);
                        return;
                    }
                }
                //让请求继续向下执行
                filterChain.doFilter(request,response);
            }
        }, UsernamePasswordAuthenticationFilter.class);

        //记住我
        http.rememberMe()//默认将记住我放在内存里
                .tokenRepository(new InMemoryTokenRepositoryImpl())
                .tokenValiditySeconds(3600*24)//过期时间
                .userDetailsService(userService);//下次登录根据id查出用户信息
    
    
    }
}

转发和重定向的区别

重定向:

浏览器去访问A组件,但是A组件没有什么信息反馈给浏览器(例如删除),删除之后想去查询页面,但是两者又没有关系,两个独立的组件,这个时候适合重定向。地址栏变化

B的访问是浏览器自己去访问的,A只是给一个建议和路径,低耦合跳转。但是如果A想给B带个信息就不行了,因为开启了一个新的请求request(两个请求想共享数据就需要cookie或者session)。

image-20211224172817103

转发:

浏览器去访问A,但是A只能处理请求的一部分,另一部分需要B去处理,(A,B共同合作),整个过程是一个请求,A可以把数据存在request里面,B再取出来。地址栏不变

image-20211224173244559

总之,服务端有两个组件想要跳转,看一下两个组件是协作合作还是独立,选择转发还是重定向

例如:A是登录提交的表单,B是登录失败的页面,A失败后将请求转给B(我们在项目中直接是return模板),这样B可以复用A的代码逻辑,可以再添加其他的逻辑

image-20211224173649976

Security要求退出必须是post请求

<li>
    <form method="post" th:action="@{/logout}">
        <!--推出提交表单,用js实现-->
        <a href="javascript:document.forms[0].submit();">退出</a>
    </form>
</li>
@RequestMapping(path = "/index", method = RequestMethod.GET)
public String getIndexPage(Model model) {
    //认证成功后,结果会通过SecurityContextHolder存入SecurityContext中
    Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if(obj instanceof User){
        model.addAttribute("loginUser",obj);
    }

    return "/index";
}

认证成功后,结果会通过SecurityContextHolder存入SecurityContext中

权限控制

image-20211224203325380

1、将登陆的拦截器注掉

2、编写配置类,继承父类WebSecurityConfigurerAdapter

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {

    @Override
    public void configure(WebSecurity web) throws Exception {
        //忽略静态资源的拦截
        web.ignoring().antMatchers("/resources/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置授权(哪些路径必须登录才能访问,拥有什么身份能访问)
        http.authorizeRequests()
                .antMatchers(
                        "/user/setting",
                        "/user/upload",
                        "/discuss/add",
                        "/comment/add/**",
                        "/letter/**",
                        "/notice/**",
                        "/like",
                        "/follow",
                        "/unfollow"
                )
                .hasAnyAuthority(
                    //任意一个权限都可访问
                        AUTHORITY_USER,
                        AUTHORITY_ADMIN,
                        AUTHORITY_MODERATOR
                )
                .anyRequest().permitAll();//除了这些请求之外的其他请求都可以直接访问
    }
}

什么C是SRF

当你浏览器里面存有身份标识的cookie,之后你再去访问服务器想让其返回一个表单,服务器返回之后,你得到表单并没有提交,去访问了另外一个不安全的网站B,网站B含有病毒可以窃取你的cookie信息,对服务器进行表单提交,如果这是个银行账户系统,这个操作具有很大的危害性

image-20211225143246558

总结:某网站利用cookie仿造你的身份去访问服务器提交表单,来达到某种目的

Security在返回表单时会隐藏一个数据token凭证(随机生成的),某网站可以窃取你的cookie,但是获取不到token,当它去提交的时候,服务器会对比cookie和token

image-20211225143527158

但是异步请求会有安全隐患,因为异步请求没有表单,没法生成csrf凭证

处理发帖时异步请求

当我们访问这个页面的时候,就会生成csrf的key和value(在发异步请求时将其值取到即可)

<!--访问该页面时,在此生成CSRF令牌-->
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">

异步请求(js)修改

//发送AJAX请求之前,将CSRF令牌设置到请求的消息头中
var token=$("meta[name='_csrf']").attr("content");
var header=$("meta[name='_csrf_header']").attr("content");
$(document).ajaxSend(function (e,xhr,options) {
   xhr.setRequestHeader(header,token);
});
image-20211225150015043

你这样写了之后每一个异步请求都要处理下,不然security不通过,因为项目中有许多异步请求,不按个处理了,就注掉了。

在security里面禁用csrf,不让其走这个逻辑即可(在授权处禁用)

置顶、加精、删除

image-20211225161351837数据层编写sql修改语句。

异步请求,局部刷新,所以需要加上@ResponseBody

image-20211225161745751

将帖子同步到es,触发一下发帖事件,之后再返回一个提示信息

设置删帖事件,之后在EventConsumer里消费该事件

//消费删帖事件(删除帖子之后,也删除es里面的帖子)
@KafkaListener(topics = TOPIC_DELETE)
public void handleDeleteMessage(ConsumerRecord record){
    //发了一个空消息
    if(record==null || record.value()==null){
        logger.error("发送消息为空!");
        return;
    }
    //将json消息转为对象,指定字符串对应的具体类型
    Event event = JSONObject.parseObject(record.value().toString(), Event.class);
    //转为对象之后再判断
    if(event==null){
        logger.error("消息格式错误!");
        return;
    }
    
    elasticsearchService.deleteDiscussPost(event.getEntityId());
}

在SecurityConfig里面增加配置置顶、加精、删除的权限(后台处理)

版主:置顶、加精

管理员:删除

.antMatchers(
        "/discuss/top",
        "/discuss/wonderful"
)
.hasAnyAuthority(
        AUTHORITY_MODERATOR
)
.antMatchers(
        "/discuss/delete"
)
.hasAnyAuthority(
        AUTHORITY_ADMIN
)
image-20211225170919758

实现该用户有什么权限就只能看到什么功能?(前端处理)

先导包,再在对应页面加命名空间

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"

<button type="button" class="btn btn-danger btn-sm" id="topBtn"
								th:disabled="${post.type==1}" sec:authorize="hasAnyAuthority('moderator')">置顶</button>
						<button type="button" class="btn btn-danger btn-sm" id="wonderfulBtn"
								th:disabled="${post.status==1}" sec:authorize="hasAnyAuthority('moderator')">加精</button>
						<button type="button" class="btn btn-danger btn-sm" id="deleteBtn"
								th:disabled="${post.status==2}" sec:authorize="hasAnyAuthority('admin')">删除</button>

Redis的高级数据类型

image-20211225190158140

HyperLogLog:可以用来统计某个网站的访问量(一个用户多次访问算一次),自动去重

Bitmap:可以用来统计一年内你每天是否上班(0,1)

// 统计20万个重复数据的独立总数.
    @Test
    public void testHyperLogLog() {
        String redisKey = "test:hll:01";

        for (int i = 1; i <= 100000; i++) {
            redisTemplate.opsForHyperLogLog().add(redisKey, i);
        }

        long size = redisTemplate.opsForHyperLogLog().size(redisKey);
        System.out.println(size);
    }


// 统计一组数据的布尔值
@Test
public void testBitMap() {
    String redisKey = "test:bm:02";

    // 记录
    redisTemplate.opsForValue().setBit(redisKey, 1, true);
    redisTemplate.opsForValue().setBit(redisKey, 4, true);
    redisTemplate.opsForValue().setBit(redisKey, 7, true);

    // 查询
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));

    // 统计
    Object obj = redisTemplate.execute(new RedisCallback() {
        @Override
        public Object doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.bitCount(redisKey.getBytes());
        }
    });

    System.out.println(obj);
}


// 统计3组数据的布尔值, 并对这3组数据做OR运算.
 String redisKey = "test:bm:or";
        Object obj = redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                connection.bitOp(RedisStringCommands.BitOperation.OR,
                        redisKey.getBytes(), redisKey2.getBytes(), redisKey3.getBytes(), redisKey4.getBytes());
                return connection.bitCount(redisKey.getBytes());
            }
        });

网站数据统计

image-20211225194104925

UV关注的是访问量,而不是是否注册或登录,游客也需要记录,所以使用IP统计,而不是用户id。每次访问都记录,之后再去重。

DAU统计的是活跃用户,使用用户id统计,userid为整数将其作为索引,1表示活跃,0表示不活跃。它也是比较节约空间的。

在这里插入图片描述

记录是以天为单位,查询的时候可以合并查询一周的

redis的key设置两种,一种是单天的,一种是在某个区间的

在这里插入图片描述

在这里插入图片描述

redis不需要写dao层,直接写service,直接调redisTemplate

service

@Service
public class DataService {

    @Autowired
    private RedisTemplate redisTemplate;

    //格式化日期
    private SimpleDateFormat df=new SimpleDateFormat("yyyyMMdd");

    //统计数据(1、记录数据 2、能够查询到)记、查

    /*
    * 统计UV
    * */
    //1、将指定IP计入UV
    public void recordUV(String ip){
        //先得到key
        String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
        redisTemplate.opsForHyperLogLog().add(redisKey,ip);//存进redis
    }

    //统计指定日期范围内的UV
    public long calculateUV(Date start,Date end){
        //先判断日期是否为空
        if(start==null || end==null){
            throw new IllegalArgumentException("参数不能为空!");
        }

        //将该范围内每一天的key合并整理到一个集合里
        List<String> keyList=new ArrayList<>();
        //利用calendar对日期做运算
        Calendar calendar=Calendar.getInstance();//实例化抽象类对象
        calendar.setTime(start);

        //时间<=end才循环
        while (!calendar.getTime().after(end)){
            //得到key
            String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
            //将key加到集合里
            keyList.add(key);
            //calendar加一天
            calendar.add(Calendar.DATE,1);
        }

        //合并这些数据,存放合并后的值
        String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));//得到合并后的key
        redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());//合并存到redis

        //返回统计的结果
        return redisTemplate.opsForHyperLogLog().size(redisKey);


    }

    /*
    * 统计DAU
    * */

    //统计单日的dau
    public void recordDAU(int userId){
        //得到key
        String rediskey = RedisKeyUtil.getDAUKey(df.format(new Date()));
        //存入redis
        redisTemplate.opsForValue().setBit(rediskey,userId,true);
    }

    //统计某个区间的dau(在该区间内某一天登录了就算是活跃,所以要用or运算)
    public long  calculateDAU(Date start,Date end){
        //先判断日期是否为空
        if(start==null || end==null){
            throw new IllegalArgumentException("参数不能为空!");
        }

        //将该范围内每一天的key合并整理到一个集合里
        //bitmap运算需要数组,所以list集合里面存byte数组
        List<byte[]> keyList=new ArrayList<>();
        //利用calendar对日期做运算
        Calendar calendar=Calendar.getInstance();//实例化抽象类对象
        calendar.setTime(start);

        //时间<=end才循环
        while (!calendar.getTime().after(end)){
            //得到key
            String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
            //将key加到集合里
            keyList.add(key.getBytes());
            //calendar加一天
            calendar.add(Calendar.DATE,1);
        }

        //得到合并的key
        String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
        //将合并的or运算结果存入redis
         return (long) redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                connection.bitOp(RedisStringCommands.BitOperation.OR,
                        redisKey.getBytes(),keyList.toArray(new byte[0][0]));
                return connection.bitCount(redisKey.getBytes());
            }
        });

    }

}

表现层分为两步记录和查看,每次请求都要记录,所以要再拦截器里面实现。

@Component
public class DataInterceptor implements HandlerInterceptor {

    @Autowired
    private DataService dataService;

    @Autowired
    private HostHolder hostHolder;

    //在请求之前拦截,只是记录数据,所以要返回true,让其继续向下执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //统计单日的UV
        //通过request得到ip
        String ip = request.getRemoteHost();
        dataService.recordUV(ip);//调用service统计
        
        //统计单日的DAU
        //得到当前用户,并且判断登陆后才记录
        User user = hostHolder.getUser();
        if (user!=null){
            dataService.recordDAU(user.getId());
        }
        return true;
    }
}

注入拦截器,使其生效

registry.addInterceptor(dataInterceptor)
        .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");

控制器

@Controller
public class DataController {

    @Autowired
    private DataService  dataService;

    //打开统计页面的方法,get,post请求都可处理
    @RequestMapping(path = "/data",method ={RequestMethod.GET,RequestMethod.POST} )
    public String getDatePage(){
        return "/site/admin/data";
    }


    //页面上传的是日期的字符串,告诉服务器字符串的格式,他就可以帮你转化,
    // 利用注解@DateTimeFormat
    @RequestMapping(path = "/data/uv",method = RequestMethod.POST)
    public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd")Date end, Model model){
        long calculateUV = dataService.calculateUV(start, end);
        model.addAttribute("uvResult",calculateUV);//将统计结果存到model
        //将表单的日期也存到model里面,跳转后便于页面显示
        model.addAttribute("uvStartDate",start);
        model.addAttribute("uvEndDate",end);

        return "forward:/data";//转发(这个请求只能完成一部分,下面的部分交给这个请求去完成,即上面那个请求)
    }

    //统计DAU
    @RequestMapping(path = "/data/dau",method = RequestMethod.POST)
    public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                        @DateTimeFormat(pattern = "yyyy-MM-dd")Date end, Model model){
        long dau = dataService.calculateDAU(start, end);
        model.addAttribute("dauResult",dau);//将统计结果存到model
        //将表单的日期也存到model里面,跳转后便于页面显示
        model.addAttribute("dauStartDate",start);
        model.addAttribute("dauEndDate",end);

        return "forward:/data";//转发(这个请求只能完成一部分,下面的部分交给这个请求去完成,即上面那个请求)
    }




}

添加这个功能只能管理员访问

.antMatchers(
        "/discuss/delete",
        "/data/**"
)
.hasAnyAuthority(
        AUTHORITY_ADMIN
)

entity是实体,可以对帖子进行评论也可以对帖子的评论进行评论。(1代表帖子,2代表评论,3代表用户)
entityid,某个类型的具体目标。
targetid,对某个评论进行回复(具有指向性)
status,0表示正常,1表示删除禁用

任务执行和调度

image-20211226132729268

有些任务并不是我们访问服务器才启动的,例如:每隔一个小时计算帖子的分数,每隔半个小时清理服务器上存的文件。

在分布式下为什么使用jdk线程池和Spring线程池会出现问题?需要使用分布式定时任务?

分布式(多个服务器,一个集群),浏览器发给Nginx请求,根据策略Nginx发给服务器,两个服务器代码都一样,对于普通代码没问题,但是对于定时任务,两个同时执行就可能出现问题。

image-20211226133548040

对于QuartZ怎么解决问题呢?

jdk、spring定时任务组件是基于内存的,配置参数在内存里,即服务器1和服务器2没法进行数据共享。QuarZ的定时任务参数保存在数据库里,即便两个服务器同时执行定时任务,会通过数据库加锁的方式抢锁,只有一个线程可以访问,下个线程去访问时,先看一下任务参数是否被修改,修改了,那他就不做了。

image-20211226134027531

使用spring带有的线程池时,首先需要先配置下

#spring普通线程池配置
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=15
spring.task.execution.pool.queue-capacity=100

#spring定时任务的线程池配置
spring.task.scheduling.pool.size=5

另外还需要一个配置类,加上相关注解,配置类、能定时执行、能异步调用

image-20211226140021810

在这里插入图片描述

在这里插入图片描述

该方法被调用后多长时间被执行,每隔多长时间执行。

在这里插入图片描述

只要有程序在跑,他就会被执行。

@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class ThreadPoolTests {

    private static final Logger logger = LoggerFactory.getLogger(ThreadPoolTests.class);

    // JDK普通线程池
    private ExecutorService executorService = Executors.newFixedThreadPool(5);

    // JDK可执行定时任务的线程池
    private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);

    // Spring普通线程池
    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;

    // Spring可执行定时任务的线程池
    @Autowired
    private ThreadPoolTaskScheduler taskScheduler;

    @Autowired
    private AlphaService alphaService;

    private void sleep(long m) {
        try {
            Thread.sleep(m);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 1.JDK普通线程池
    @Test
    public void testExecutorService() {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("Hello ExecutorService");
            }
        };

        for (int i = 0; i < 10; i++) {
            executorService.submit(task);
        }

        sleep(10000);
    }

    // 2.JDK定时任务线程池
    @Test
    public void testScheduledExecutorService() {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("Hello ScheduledExecutorService");
            }
        };

        scheduledExecutorService.scheduleAtFixedRate(task, 10000, 1000, TimeUnit.MILLISECONDS);

        sleep(30000);
    }

    // 3.Spring普通线程池
    @Test
    public void testThreadPoolTaskExecutor() {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("Hello ThreadPoolTaskExecutor");
            }
        };

        for (int i = 0; i < 10; i++) {
            taskExecutor.submit(task);
        }

        sleep(10000);
    }

    // 4.Spring定时任务线程池
    @Test
    public void testThreadPoolTaskScheduler() {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                logger.debug("Hello ThreadPoolTaskScheduler");
            }
        };

        Date startTime = new Date(System.currentTimeMillis() + 10000);
        taskScheduler.scheduleAtFixedRate(task, startTime, 1000);

        sleep(30000);
    }

    // 5.Spring普通线程池(简化)
    @Test
    public void testThreadPoolTaskExecutorSimple() {
        for (int i = 0; i < 10; i++) {
            alphaService.execute1();
        }

        sleep(10000);
    }

    // 6.Spring定时任务线程池(简化)
    @Test
    public void testThreadPoolTaskSchedulerSimple() {
        sleep(30000);
    }

}

使用QuartZ

先需要先初始化表

image-20211226141615188

2、导包

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

3、

# QuartzProperties 将配置放到数据库里
spring.quartz.job-store-type=jdbc
#调度器的名字
spring.quartz.scheduler-name=communityScheduler
#调度器id自动生成
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
#是否采用集群
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
spring.quartz.properties.org.quartz.threadPool.threadCount=5

首先定义一个任务(Job接口execute声明我要做什么),具体做什么需要配置(JobDetail名字,组对job进行配置)(Trigger触发器,Job什么时候运行,以什么频率运行),将读取到的配置初始化到数据库,以后直接访问数据库读取配置。

BeanFactory是容器的顶层接口,

FactoryBean可简化Bean的实例化过程:
1、通过FactoryBean封装Bean的实例化过程。
2、将FactoryBean装配到Spring容器里。
3、将FactoryBean注入给其他的Bean。
4、该Bean得到的是FactoryBean所管理的对象实例

例如:

//只有第一次有用,将配置读取到数据库中,以后直接从数据库中读
@Configuration
public class QuartzConfig {

    @Bean  //将JobDetailFactoryBean装配到容器里
    public JobDetailFactoryBean alphaJobDetail(){
        return null;
    }

    @Bean  //我这里的参数需要JobDetail,我将上面bean的方法名传进来,这里得到的是JobDetailFactoryBean管理的对象
    public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail){
        return null;
    }
}

使用用例

1、



public class AlphaJob implements Job {
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        System.out.println(Thread.currentThread().getName()+":execute a quartz job.");
    }
}

2、



//只有第一次有用,将配置读取到数据库中,以后直接从数据库中读
@Configuration
public class QuartzConfig {

    //配置JobDetail
    @Bean  //将JobDetailFactoryBean装配到容器里
    public JobDetailFactoryBean alphaJobDetail(){
        JobDetailFactoryBean factoryBean=new JobDetailFactoryBean();
        factoryBean.setJobClass(AlphaJob.class);
        factoryBean.setName("alphajob");
        factoryBean.setGroup("alphaJobGroup");
        factoryBean.setDurability(true);//任务不在运行,触发器没有了也不用删,留着
        factoryBean.setRequestsRecovery(true);//任务是不是可恢复的
        return factoryBean;
    }

    //配置触发器
    @Bean  //我这里的参数需要JobDetail,我将上面bean的方法名传进来,这里得到的是JobDetailFactoryBean管理的对象
    public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail){
        SimpleTriggerFactoryBean factoryBean=new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(alphaJobDetail);//参数名与bean名一致
        factoryBean.setName("alphaTrigger");
        factoryBean.setGroup("alphsTriggerGroup");
        factoryBean.setRepeatInterval(3000);//执行频率
        factoryBean.setJobDataMap(new JobDataMap());//指定那个对象来存状态
        return factoryBean;
    }
}

启动后,配置就会传到数据库里面,每三秒一次

image-20211226150237315

删除任务

@Test
public void testDeleteJob(){
    boolean b = false;
    try {
        b = scheduler.deleteJob(new JobKey("alphajob", "alphaJobGroup"));
        System.out.println(b);
    } catch (SchedulerException e) {
        e.printStackTrace();
    }

}

热帖排行

image-20211226155100530

评论、点赞、加精之后立马计算,效率比较低,启动定时任务去计算,再根据分数排列显示。

思路:

评论、点赞、加精之后不立马计算,将其丢到缓存redis里,定时计算变化的帖子,不变的不算

在新添加的帖子后面也计算分数,并将其存到redis里面

image-20211226160908966

置顶直接放在最顶上,所以不用计算分数。

在加精处也计算将贴子放到redis里

image-20211226161037313

评论:对帖子评论才将其帖子放到redis里

image-20211226161146407

点赞:先判断是对帖子点赞才将贴子放到redis里面

image-20211226161310549

写定时任务(帖子刷新)

1、Job(记录日志)

查帖子,将计算后的帖子同步到es搜索引擎中

声明常量(只需要初始化一次,所以在静态块里面初始化),方便计算

private static final Date epoch;
static {
    try {
        epoch=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
    } catch (ParseException e) {
        throw new RuntimeException("初始化牛客纪元失败!",e);
    }
}

public class PostScoreRefreshJob  implements Job, CommunityConstant{

    private static final Logger logger= LoggerFactory.getLogger(PostScoreRefreshJob.class);

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private LikeService likeService;

    @Autowired
    private ElasticsearchService elasticsearchService;

    private static final Date epoch;
    static {
        try {
            epoch=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
        } catch (ParseException e) {
            throw new RuntimeException("初始化牛客纪元失败!",e);
        }
    }


    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        //从redis里面取值(先得到key),每一个key都要算一下,反复的操作,所以用BoundSetOperation
        String redisKey = RedisKeyUtil.getPostScoreKey();
        BoundSetOperations operations=redisTemplate.boundSetOps(redisKey);
        //先判断一下缓存中有没有数据,没有变化就不做操作
        if(operations.size()==0){
            logger.info("[任务取消] 没有要刷新的帖子!");
            return;
        }
        //使用日志记录时间中间过程
        logger.info("[任务开始] 正在刷新帖子分数: "+operations.size());
        while (operations.size()>0){//只要redis里面有数据就算
            //集合中弹出一个值
            this.refresh((Integer)operations.pop());
        }
    }

    private void refresh(int postId) {
        //先将贴子查出来
        DiscussPost post = discussPostService.findDiscussDetail(postId);
        //空值判断(帖子被人点赞,但是后来被管理删除)
        if(post==null){
            logger.error("帖子不存在: id= "+ postId);//日志记录错误提示
            return;
        }


        //计算帖子分值(加精-1、评论数、点赞数)
        boolean wonderful = post.getStatus() == 1;
        int commentCount = post.getCommentCount();
        long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
        //先求权重
        double w=(wonderful? 75 : 0) + commentCount*10 + likeCount * 2;
        //分数=帖子权重+距离天数
        //为了不得到负数,在权重和1之间取最大值。将时间得到的毫秒在换算为天
        double score=Math.log10(Math.max(w,1)+
                (post.getCreateTime().getTime()-epoch.getTime())/(1000 * 3600 * 24));

        //更新帖子的分数
         discussPostService.updateDiscussScore(postId, score);
        //同步搜索对应帖子的数据(先重设帖子的分数,再保存到es)
        post.setScore(score);
        elasticsearchService.saveDiscussPost(post);

    }
}

写好了定时任务还要去配置别忘了

//刷新帖子分数任务
    @Bean  //将JobDetailFactoryBean装配到容器里
    public JobDetailFactoryBean postScoreRefreshJobDetail(){
        JobDetailFactoryBean factoryBean=new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");
        factoryBean.setGroup("communityJobGroup");
        factoryBean.setDurability(true);//任务不在运行,触发器没有了也不用删,留着
        factoryBean.setRequestsRecovery(true);//任务是不是可恢复的
        return factoryBean;
    }

    @Bean
    public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail){
        SimpleTriggerFactoryBean factoryBean=new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(postScoreRefreshJobDetail);//参数名与bean名一致
        factoryBean.setName("postScoreRefreshTrigger");
        factoryBean.setGroup("communityJobGroup");
        factoryBean.setRepeatInterval(1000 * 60 *5);//执行频率
        factoryBean.setJobDataMap(new JobDataMap());//指定那个对象来存状态
        return factoryBean;
    }

五分钟更新一次

在这里插入图片描述

重构之前查找帖子的mapper

//查找帖子分页显示(userId是动态需要条件,0表示不拼接,其余拼接)
List<DiscussPost> selectDiscussPosts(int userId,int offset,int limit,int orderMode);
<!--orderMode为0表示按照正常显示,为1表示按照热度成绩排序,先置顶,再根据分数,其次是时间-->
<select id="selectDiscussPosts" resultType="DiscussPost">
    select <include refid="selectFields"></include>
    from discuss_post
    where status!=2
    <if test="userId!=0">
        and user_id=#{userId}
    </if>
    <if test="orderMode==0">
        order by type desc,create_time desc
    </if>
    <if test="orderMode==1">
        order by type desc,score desc, create_time desc
    </if>
    limit #{offset},#{limit}
</select>
image-20211226174058268

image-20211226193116959

image-20211226193140027

image-20211226174513190

生成长图

image-20211226194048303

命令:1、将模板的内容生成pdf,2、将网页生成图片

wkhtmltopdf https://www.nowcoder.com
C:\nowcoder_community\data\wk-pdfs/1.pdf

wkhtmltoimage https://www.nowcoder.com C:\nowcoder_community\data\wk-images/1.png

压缩图片75%

wkhtmltoimage --quality 75 https://www.nowcoder.com C:\nowcoder_community\data\wk-images/2.png

在这里插入图片描述

java中使用生成长图

package com.nowcoder.community;

import java.io.IOException;

public class WKTests {
    public static void main(String[] args) {
        String cmd="C:/user/soft/wk/wkhtmltopdf/bin/wkhtmltoimage --quality  75  https://www.nowcoder.com  C:/nowcoder_community/data/wk-images/3.png";
        try {
            Runtime.getRuntime().exec(cmd);
            System.out.println("ok!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

操作系统执行命令和程序执行是并发、异步的。

配置一下路径和图片保存的文件路径

#wk
wk.image.command=C:/user/soft/wk/wkhtmltopdf/bin/wkhtmltoimage
wk.image.storage=C:/nowcoder_community/data/wk-images

路径是否存在,验证文件是否可以自动创建

先删除之前创建的文件夹,测试能否成功。

在服务启动时,创建一个目录。

服务器启动时,先去实例化配置类,自动调@PostConstruct,初始化一次

@Configuration
public class WKConfig {
    private static final Logger logger= LoggerFactory.getLogger(WKConfig.class);

    //注入路径
    @Value("${wk.image.storage}")
    private String wkImageStorage;

    @PostConstruct
    public void init(){
        //创建wk图片目录
        File file=new File(wkImageStorage);
        if(!file.exists()){
            file.mkdir();
            logger.info("创建wk图片目录:" +wkImageStorage);
        }
    }
}

处理前端的请求(生成图片,生成一个请求允许你访问图片)

生成图片时间比较长,采用异步的方式,将事件丢给Kafka即可,不需要一直等着它去处理。。

注入域名,项目名,图片存放的位置

//消费分享事件
@KafkaListener(topics = TOPIC_SHARE)
public void handleShareMessage(ConsumerRecord record){
    //发了一个空消息
    if(record==null || record.value()==null){
        logger.error("发送消息为空!");
        return;
    }
    //将json消息转为对象,指定字符串对应的具体类型
    Event event = JSONObject.parseObject(record.value().toString(), Event.class);
    //转为对象之后再判断
    if(event==null){
        logger.error("消息格式错误!");
        return;
    }

    //得到htmlUrl、文件名字、后缀
    String htmlUrl = (String) event.getData().get("htmlUrl");
    String fileName = (String) event.getData().get("fileName");
    String  suffix = (String) event.getData().get("suffix");
    //拼命令
    String cmd= wkImageCommand + " --quality  75 "
            +htmlUrl+" "+wkImageStorage +"/" +fileName +suffix;
    //执行命令,成功和失败都要记录日志
    try {
        Runtime.getRuntime().exec(cmd);
        logger.info("生成长图成功:"+cmd);
    } catch (IOException e) {
        e.printStackTrace();
        logger.error("生成长图失败:"+ e);
    }


}
@Controller
public class ShareController implements CommunityConstant {
    private static final Logger logger= LoggerFactory.getLogger(ShareController.class);

    @Autowired
    private EventProducer eventProducer;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Value("${wk.image.storage}")
    private String wkImageStorage;

    //分享的请求(异步的返回json,将要分享的功能路径传过来)
    @RequestMapping(path = "/share",method = RequestMethod.GET)
    @ResponseBody
    public String share(String htmlUrl){
        //随机生成图片的文件名
        String fileName = CommunityUtil.generateUUID();

        //异步生成长图    构建事件(主题:分享,携带参数存到map里,htmlUrl,文件名,后缀,)
        Event event=new Event();
        event.setTopic(TOPIC_SHARE)
                .setData("htmlUrl",htmlUrl)
                .setData("fileName",fileName)
                .setData("suffix",".png");
        //触发事件(处理异步事件别忘,消费事件)
        eventProducer.fireEvent(event);

        //给客户端返回访问图片的访问路径
        //将路径存到map里
        Map<String,Object> map=new HashMap<>();
        map.put("shareUrl",domain+contextPath +"/share/image/"+fileName);

        return CommunityUtil.getJSIONString(0,null,map);

    }

    //获得长图
    //返回一个图片用response处理
    @RequestMapping(path = "/share/image/{fileName}",method = RequestMethod.GET)
    public void getShareImage(@PathVariable("fileName")String fileName, HttpServletResponse response){
        //先判断参数空值
        if(StringUtils.isBlank(fileName)){
            throw new IllegalArgumentException("文件名不能为空!");
        }
        //声明输出的是什么(图片/格式)
        response.setContentType("image/png");
        //实例化文件,图片存放的路径
        File file = new File(wkImageStorage + "/" + fileName + ".png");
        // 图片是字节,所以获取输出字节流
        try {
            OutputStream os=response.getOutputStream();//输出,就是写入其他文件
            FileInputStream fis = new FileInputStream(file);//进入,就是进去读取
            //一边读取文件,一边向外输出(读取缓冲区,游标)
            byte[] buffer=new byte[1024];
            int b=0;
            while ((b=fis.read(buffer))!=-1){
                os.write(buffer,0,b);
            }
        } catch (IOException e) {
            logger.error("获取长图失败: "+e.getMessage());
        }

    }


}

image-20211227104602137

将文件上传至云服务器

image-20211227135807223

1、导包

2、配置key、桶名和对应的url

在这里插入图片描述

3、注入在配置文件配置的信息,废弃头像上传的upload和xx

4、在setting里面写代码,成功的时候返回json字符串,code:0,只要不是返回这个就认为是失败。

在这里插入图片描述

新增方法,返回成功后,在user表的url更新下,异步

更新数据,要穿数据进去所以是post,异步的所以是@ResponseBody

//先判断一下参数空值

//拼接url(空间的url+文件名)

image-20211227144544292

找到表单setting,将之前的注掉,

异步上传,获得id

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

点击提交触发表单提交事件,return false事件到此为此,不再向下,因为没有action,

不要把表单的内容转换为字符串。不让jquery去设置上传的类型,浏览器会自动配置。

异步更新头像路径,从表单里面取文件名

在这里插入图片描述

优化网站性能

image-20211228110443705

两级缓存,

一级缓存就是服务器的缓存,存在本地内存。本地缓存中没有就去访问DB,在更新到缓存中。

用户第一次访问落在了服务器1上,生成用户信息缓存(可能是是否登录的状态)。如果用户第二次访问服务器2,里面没有用户信息,就不会去访问DB,而直接认为你没有登陆,所以和用户相关的信息缓存到本地缓存不合适。

但是本地缓存可以放一些热门的帖子,第一次访问服务器1,没有就去数据库,同步到缓存。第二次请求如果落在了服务器2上,进行和服务器1一样的操作,以后的请求不管落在哪一个服务器上,就都有了缓存。

image-20211228111448026

Redis缓存可以放用户相关联的数据。应用看redis里面没有数据,就去访问DB,并缓存到redis里面。下一次用户在访问服务器2,同样先去redis里面看有没有数据,有就直接返回。

Redis可以跨服务器。分布式缓存。本地缓存比redis缓存快。

image-20211228111928438

使用两级缓存:

先去访问本地缓存、再去redis缓存、没有再去访问DB。之后再将数据更新到本地缓存和redis里面。我们要设置缓存的过期时间,提高了访问速度。

image-20211228112259738

缓存基于时间和大小有一定的淘汰策略,

优化热门的帖子列表顺序缓存(数据变化的频率低,分值隔一段时间才更新一下,能保证一段时间不变),

本地缓存使用Caffeine,spring整合它是使用一个缓存管理器管理所有缓存(统一的淘汰、过期时间),每个缓存业务不同,缓存时间不同,不用spring去整合。

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.7.0</version>
</dependency>

设置参数,自定义参数,缓存帖子列表是,缓存能存多少帖子(15页),缓存过期的时间(3分钟),

主动淘汰(帖子数据发生变化,清掉缓存),自动淘汰(定时)缓存的是一页数据,如果因为一页中某一个帖子的变化将整页数据都淘汰不合适,所以不设置主动淘汰。

在这里插入图片描述

优化业务方法(service)DiscussPostService

初始化logger,注入刚才配置的参数
在这里插入图片描述

缓存帖子的总行数,调用比较多

使用LoadingCache,一个缓存帖子列表,一个缓存帖子总行数。先声明,在初始化。缓存按照key-value来存值。

在这里插入图片描述

缓存只需要在服务启动或者首次调service初始化就可以了。唯一调用一次。

只缓存热门帖子,只缓存首页,首页用户没登陆,userId为0,缓存一页数据,key就有offset和limit有关。

缓存的是帖子列表,当用户查询自己的帖子时传入userId,这个时候是不走缓存的。当userId为0,才走缓存。因为一定要有key,所以就将userId作为key吧,虽然一定为null。

在这里插入图片描述

对缓存进行初始化

最大页数,过期时间,让配置生效build(匿名实现),怎么查询数据库得到数据(缓存怎么来的)

@Value("${caffeine.posts.max-size}")
private int maxSize;

@Value("${caffeine.posts.expire-seconds}")
private int expireSeconds;

//帖子列表缓存
private LoadingCache<String,List<DiscussPost>> postListCache;

//帖子总数缓存
private LoadingCache<Integer,Integer> postRowsCache;


public List<DiscussPost> findDiscussPosts(int userId,int offset,int limit,int orderMode){
    //只缓存热门帖子,只缓存首页,首页用户没登陆,userId为0,缓存一页数据,key就有offset和limit有关。
    if(userId==0 && orderMode==1){
        return postListCache.get(offset+":"+limit);
    }
    logger.debug("load post list from DB.");
    return discussPostMapper.selectDiscussPosts(userId,offset,limit,orderMode);
}

public int findDiscussPostRows(int userId){
    //缓存的是帖子列表,当用户查询自己的帖子时传入userId,这个时候是不走缓存的。当userId为0,才走缓存。
    if(userId==0){
        return postRowsCache.get(userId);
    }
    logger.debug("load post list from DB.");
    return discussPostMapper.selectDiscussPostRows(userId);
}

//初始化热门帖子、帖子总数缓存
@PostConstruct
public void init(){
    //初始化帖子列表缓存
    postListCache= Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
            .build(new CacheLoader<String, List<DiscussPost>>() {
                @Nullable
                @Override
                public List<DiscussPost> load(@NonNull String key) throws Exception {
                    if(key==null || key.length()==0){
                        throw new IllegalArgumentException("参数错误!");
                    }
                    //解析数据
                    String[] params = key.split(":");
                    //判断解析数据(切割得到的是不是两个)
                    if(params==null || params.length!=2){
                        throw new IllegalArgumentException("参数错误!");
                    }
                    //有了参数,查数据(缓存)
                    int offset = Integer.valueOf(params[0]);
                    int limit = Integer.valueOf(params[1]);

                    logger.debug("load post list from DB.");
                    return discussPostMapper.selectDiscussPosts(0,offset,limit,1);
                }

            });

    //初始化帖子总数缓存
    postRowsCache=Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(expireSeconds,TimeUnit.SECONDS)
            .build(new CacheLoader<Integer, Integer>() {
                @Nullable
                @Override
                public Integer load(@NonNull Integer key) throws Exception {
                    logger.debug("load post list from DB.");
                    return discussPostMapper.selectDiscussPostRows(key);
                }
            });
}

先将其注掉,进行压力测试100个请求

在这里插入图片描述

image-20211228151454760

优化后:大概是原来的1.5倍

image-20211228151817726

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值