第七章
Spring Security
对身份认证和授权提供全面、可拓展的支持,防止会话、点击劫持、csrf攻击等
权限控制
对所有请求分配访问权限、绕过认证流程
配置SecurityConfig
@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","/dicuss/add", "/comment/add/**","/letter/**","/notice/**","/like","/follow","/unfollow") .hasAnyAuthority( AUTHORITY_ADMIN, AUTHORITY_USER, AUTHORITY_MODERATOR ) .antMatchers("/discuss/top","/discuss/wonderful").hasAnyAuthority( AUTHORITY_MODERATOR ) .antMatchers("/discuss/delete", "/data/**").hasAnyAuthority( AUTHORITY_ADMIN ) .anyRequest().permitAll() .and().csrf().disable(); //权限不够的处理 http.exceptionHandling() .authenticationEntryPoint(new AuthenticationEntryPoint() { //没有登录 @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)){ response.setContentType("application/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(CommunityUtil.getJSONString(403,"你还没有登录")); }else { response.sendRedirect(request.getContextPath()+"/login"); } } }) .accessDeniedHandler(new AccessDeniedHandler() { //权限不足 @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)){ response.setContentType("application/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(CommunityUtil.getJSONString(403,"你没有访问此功能的权限")); }else { response.sendRedirect(request.getContextPath()+"/denied"); } } }); //security底层默认会拦截/logout请求,进行退出处理 //覆盖它默认的逻辑,才能执行我们自己的退出代码 http.logout().logoutUrl("/securitylogout"); } }
配置Service
public Collection<? extends GrantedAuthority> getAuthorities(int userId) { User user = this.findUserById(userId); List<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new GrantedAuthority() { @Override public String getAuthority() { switch (user.getType()) { case 1: return AUTHORITY_ADMIN; case 2: return AUTHORITY_MODERATOR; default: return AUTHORITY_USER; } } }); return authorities; }
配置LoginTicketInterceptor
//构建用户认证结果 并存入SecurityContext,以便于Security进行授权 Authentication authentication = new UsernamePasswordAuthenticationToken (user, user.getPassword(), userService.getAuthorities(user.getId())); SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
置顶、加精、删除
版主可以执行指定、加精
管理员可以进行删除
(修改帖子状态)
//置顶 @RequestMapping(path = "/top", method = RequestMethod.POST) @ResponseBody public String setTop(int id) { discussPostService.updateType(id, 1); //触发发帖事件 Event event = new Event().setTopic(TOPIC_PUBLISH).setUserId(hostHolder.getUser().getId()) .setEntityType(ENTITY_TYPE_POST).setEntityId(id); eventProducer.fireEvent(event); return CommunityUtil.getJSONString(0); }
配置权限SecurityConfig
.antMatchers("/discuss/top","/discuss/wonderful").hasAnyAuthority( AUTHORITY_MODERATOR ) .antMatchers("/discuss/delete", "/data/**").hasAnyAuthority( AUTHORITY_ADMIN )
还可以引入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>
异步调用:
-
@ResponseBody
-
在js里编写
(function (){ $("#topBtn").click(setTop); }); //置顶 function setTop(){ $.post( CONTEXT_PATH + "/discuss/top", {"id":$("#postId").val()}, function (data){ data = $.parseJSON(data); if (data.code == 0){ $("#topBtn").attr("disabled","disabled"); }else { alert(data.msg); } } ); }
Redis高级数据类型
UV-用户IP,每次访问都要统计--HeperLogLog:采用基数算法,完成独立总数的统计,不准确的统计算法
@Test public void testHyperLogLogUnion(){ String rediskey = "test:hll:01"; for (int i = 0; i < 10000; i++) { redisTemplate.opsForHyperLogLog().add(rediskey, i); } String rediskey1 = "test:hll:01"; for (int i = 5001; i < 15000; i++) { redisTemplate.opsForHyperLogLog().add(rediskey1, i); } String rediskey2 = "test:hll:01"; for (int i = 10001; i < 20000; i++) { redisTemplate.opsForHyperLogLog().add(rediskey2, i); } String rediskeyUnionKey = "test:hll:union"; for (int i = 0; i < 10000; i++) { redisTemplate.opsForHyperLogLog().union(rediskeyUnionKey, rediskey, rediskey1, rediskey2); } long size = redisTemplate.opsForHyperLogLog().size(rediskeyUnionKey); System.out.println(size); }
DAU-日活跃用户,访问一次就为活跃,Bitmap:按位存储,可看作byte数组
@Test public void testBitMap(){ String redisKey = "test:bm:01"; 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 redisConnection) throws DataAccessException { return redisConnection.bitCount(redisKey.getBytes()); } }); System.out.println(obj); }
管理员通过命令直接访问 xxxx/commuity/data
任务执行和调度
//JDK普通线程池 private ExecutorService executorService = Executors.newFixedThreadPool(5); //JDK普通线程池 @Test public void testExectorService() throws InterruptedException { Runnable task = new Runnable() { @Override public void run() { logger.debug("hello testExectorService"); } }; for (int i = 0; i < 10; i++) { executorService.submit(task); } sleep(10000); }
//JDK可执行定时任务的线程池 private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5); @Test public void testScheduleExectorService() throws InterruptedException { Runnable task = new Runnable() { @Override public void run() { logger.debug("hello testScheduleExectorService"); } }; scheduledExecutorService.scheduleAtFixedRate( task, 10000, 1000, TimeUnit.SECONDS); sleep(30000); }
//Spring普通线程池 @Autowired private ThreadPoolTaskExecutor taskExecutor; //spring可执行任务线程池 @Autowired private ThreadPoolTaskScheduler taskScheduler; //spring普通线程池 @Test public void testThreadPoolTaskExecutor() throws InterruptedException { Runnable task = new Runnable() { @Override public void run() { logger.debug("hello ThreadPoolTaskExecutor"); } }; for (int i = 0; i < 10; i++) { taskExecutor.submit(task); } sleep(10000); } //spring定时任务线程池 @Test public void testThreadPoolTaskScheduler() throws InterruptedException { 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); }
定义任务
配置quartz 中 PostScoreRefreshJob implements Job, CommunityConstant
@Override public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { String redisKey = RedisKeyUtil.getPostScoreKey(); BoundSetOperations operations = redisTemplate.boundSetOps(redisKey); if (operations.size() == 0){ logger.info("任务取消,没有需要刷新的帖子!"); return; } logger.info("任务开始 正在刷新帖子分数:" + operations.size()); while (operations.size() > 0){ this.refresh((Integer) operations.pop()); } logger.info("任务结束 帖子分数刷新完毕"); } private void refresh(int postId) { DiscussPost discussPost = discussPostService.findDiscussPostById(postId); if (discussPost == null) { logger.info("该帖子不存在:id" + postId); } //是否精华 boolean wonderful = discussPost.getStatus() == 1; //评论数量 int CommentCount = discussPost.getCommentCount(); //点赞数量 long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId); //计算权重 double w = (wonderful ? 75 : 0) + CommentCount * 10 + likeCount * 2; //分数 double score = Math.log10(Math.max(w, 1)) + (discussPost.getCreateTime().getTime() - epoch.getTime()) / (3600 * 1000 * 24); //跟新帖子分数 discussPostService.updateScore(postId, score); //同步搜索数据 elasticsearchService.saveDiscusspost(discussPost); }
配置 JobDetail和Trigger
config - QuartzConfig
@Configuration public class QuartzConfig { //更新帖子分数任务 @Bean public JobDetailFactoryBean postScorefreshJobDetail(){ 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); factoryBean.setName("postScoreRefreshTrigger"); factoryBean.setGroup("communityTriggerGroup"); factoryBean.setRepeatInterval(1000 * 60 * 5); factoryBean.setJobDataMap(new JobDataMap()); return factoryBean; } }
生产长图
工具:wkhtmltopdf
wkhtmltoimage --quality 75 牛客网 - 找工作神器|笔试题库|面试经验|实习招聘内推,求职就业一站解决_牛客网 e:/Study/workspace/data/wk-images/2.png
定义controller
@RequestMapping(path = "/share", method = RequestMethod.GET) @ResponseBody public String share(String htmlUrl){ //文件名 String fileName = CommunityUtil.generateUUID(); //异步生成长图 Event event = new Event().setTopic(TOPIC_SHARE) .setData("htmlUrl", htmlUrl) .setData("fileName", fileName) .setData("suffix", ".png"); eventProducer.fireEvent(event); Map<String, Object> map = new HashMap<>(); map.put("shareUrl", domain + contextPath + "/share/image" + fileName); //返回访问路径 return CommunityUtil.getJSONString(0, null, map); } //获取长图 @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.info("获取长图失败" + e.getMessage()); } }
消费者
//消费分享事件 @KafkaListener(topics = TOPIC_SHARE) public void handleShareMessage(ConsumerRecord record){ if (record == null && record.value() == null){ logger.error("消息内容不能为空"); return; } Event event = JSONObject.parseObject(record.value().toString(), Event.class); if (event == null){ logger.error("消息格式错误" ); return; } String htmUrl = (String) event.getData().get("htmlUrl"); String fileName = (String) event.getData().get("fileName"); String suffix = (String) event.getData().get("suffix"); String cmd = wkImageCommand + " --quality 75 " + htmUrl + " " + wkImageStorage + "/" + fileName +suffix; try { Runtime.getRuntime().exec(cmd); logger.info("生成长图成功" + cmd); } catch (IOException e) { logger.info("生成长图失败" + e.getMessage()); } }
云服务器
头像上传至七牛云
qiniu.key.access=ktu4EncQ52sVKYA4MKv96PcsVe5wHJWkolyFCfYr qiniu.key.secret=yIFPUKcVWIbU77z-qPthZLYASwK23rgFXgeaNbmr
@LoginRequired @RequestMapping(path = "/setting", method = RequestMethod.GET) public String getSettingPage(Model model) { //上传文件名称 String fileName = CommunityUtil.generateUUID(); //设置响应信息 StringMap policy = new StringMap(); policy.put("returnBody",CommunityUtil.getJSONString(0)); //生成上传凭证 Auth auth = Auth.create(accessKey, secretKey); String uploadToken = auth.uploadToken(headerBucketName, fileName, 3600, policy); model.addAttribute("uploadToken", uploadToken); model.addAttribute("fileName", fileName); return "/site/setting"; } //更新头像的路径 @RequestMapping(path = "/header/url",method = RequestMethod.POST) @ResponseBody public String updateHeaderUrl(String fileName){ if (StringUtils.isBlank(fileName)){ return CommunityUtil.getJSONString(1, "文件名不能为空"); } String url = headerBucketUrl + "/" + fileName; userService.updateHeader(hostHolder.getUser().getId(), url); return CommunityUtil.getJSONString(0); }
$(function (){ $("#uploadForm").submit(upload); }); function upload(){ $.ajax({ url:"http://upload-z1.qiniup.com", method:"post", processData:false, contentType:false, data: new FormData($("#uploadForm")[0]), success:function (data){ if (data && data.code == 0){ //更新头像访问路径 $.post( CONTEXT_PATH + "/user/header/url", {"fileName":$("input[name='key']").val()}, function (data){ data = $.parseJSON(data); if (data.code == 0){ window.location.reload(); }else { alert(data.mag); } } ); }else { alert("上传失败!"); } } }); return false; }
性能优化
本地缓存:数据缓存到服务器上,常用工具Caffeine
分布式缓存:缓存到NoSQL数据库上,如Redis
多级缓存:一级(本地) > 二级(分布式) > DB
优化service方法
Caffeine
//caffeine核心接口:Cache, loadingCache, AsyncLoadingCache //帖子列表缓存 private LoadingCache<String, List<DiscussPost>> postListCache; //帖子总数缓存 private LoadingCache<Integer, Integer> postRowsCache; @PostConstruct public void init(){ //初始化帖子列表缓存 postListCache = Caffeine.newBuilder() .maximumSize(maxSize) .expireAfterWrite(expiredSeconds, 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]); //二级缓存 return discussPostMapper.selectDiscussPosts(0, offset, limit, 1); } }); //初始化帖子总数缓存 postRowsCache = Caffeine.newBuilder() .maximumSize(maxSize) .expireAfterWrite(expiredSeconds, TimeUnit.SECONDS) .build(new CacheLoader<Integer, Integer>(){ @Nullable @Override public Integer load(@NonNull Integer key) throws Exception{ logger.debug("load post rows from DB"); return discussPostMapper.selectDiscussPostRows(key); } }); } public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode){ 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){ if (userId == 0){ return postRowsCache.get(userId); } logger.debug("load post rows from DB"); return discussPostMapper.selectDiscussPostRows(userId); }
压力测试
JMeter 点击xxx.bat
模拟100个线程
吞吐量:9.5/sec -> 188/sec