网站数据统计(Redis:HyperLogLog、BitMap)
1.编写RedisUtil规范Key值
// UV (网站访问用户数量---根据Ip地址统计(包括没有登录的用户))
private static final String PREFIX_UV = "uv";
// DAU (活跃用户数量---根据userId)
private static final String PREFIX_DAU = "dau";
/**
* 存储单日ip访问数量(uv)--HyperLogLog ---k:时间 v:ip (HyperLogLog)
* 示例:uv:20220526 = ip1,ip2,ip3,...
*/
public static String getUVKey(String date) {
return PREFIX_UV + SPLIT + date;
}
/**
* 获取区间ip访问数量(uv)
* 示例:uv:20220525:20220526 = ip1,ip2,ip3,...
*/
public static String getUVKey(String startDate, String endDate) {
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}
/**
* 存储单日活跃用户(dau)--BitMap ---k:date v:userId索引下为true (BitMap)
* 示例:dau:20220526 = userId1索引--(true),userId2索引--(true),....
*/
public static String getDAUKey(String date) {
return PREFIX_DAU + SPLIT + date;
}
/**
* 获取区间活跃用户
* 示例:dau:20220526:20220526
*/
public static String getDAUKey(String startDate, String endDate) {
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
2.编写DataService业务层
@Autowired
private RedisTemplate redisTemplate;
// 将Date类型转化为String类型
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
/*********************** HypeLogLog*************************/
// 将指定ip计入UV---k:当前时间 v:ip
public void recordUV(String ip) {
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey, ip);
}
// 统计指定日期范围内的ip访问数UV
public long calculateUV(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空!");
}
if (start.after(end)) {
throw new IllegalArgumentException("请输入正确的时间段!");
}
// 整理该日期范围内的Key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
// 获取该日期范围内的每一天的Key存入集合
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
keyList.add(key);
// 日期+1(按照日历格式)
calendar.add(Calendar.DATE, 1);
}
// 合并日期范围内相同的ip
String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
// 获取keyList中的每一列key进行合并
redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
/*********************** BitMap *****************************/
// 将指定用户计入DAU --k:当前时间 v:userId
public void recordDAU(int userId) {
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey, userId, true);
}
// 统计指定日期范围内的DAU日活跃用户
public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空!");
}
if (start.before(end)) {
throw new IllegalArgumentException("请输入正确的时间段!");
}
// 整理该日期范围内的Key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
keyList.add(key.getBytes());
// 日期+1(按照日历格式)
calendar.add(Calendar.DATE, 1);
}
// 进行OR运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR, redisKey.getBytes(), keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});}
3.在DataInterceptor拦截器中调用Service(每次请求最开始调用)
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
// 在所有请求之前存用户访问数和日活跃人数
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求用户的ip地址,统计UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if (user != null) {
dataService.recordDAU(user.getId());
}
return true;
}
}
/*****************************注册拦截器*********************************/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private DataInterceptor dataInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/* */*.css", "/ **/ *.js", "/* */*.png", "/**/ *.jpg", "/* */*.jpeg");
}
}
4.编写DataController用以渲染模板
/**
* 统计页面
*/
@RequestMapping(value = "/data", method = {RequestMethod.GET, RequestMethod.POST})
public String getDataPage() {
return "/site/admin/data";
}
/**
* 统计网站UV(ip访问数量)
* @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 uv = dataService.calculateUV(start, end);
model.addAttribute("uvResult", uv);
model.addAttribute("uvStartDate", start);
model.addAttribute("uvEndDate", end);
// 转发到 /data请求
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.addAttribute("dauStartDate", start);
model.addAttribute("dauEndDate", end);
return "forward:/data";
}
5.编写SecurityConfig进行权限控制
.antMatchers(
"/discuss/delete",
"/data/* *"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
6.编写前端管理员专用页面(核心部分)
<!-- 网站UV (活跃用户类似)-->
<div>
<h6> 网站 访问人数</h6>
<form method="post" th:action="@{/data/uv}">
<input name="start" th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}" type="date"/>
<input name="end" th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}" type="date"/>
<button type="submit">开始统计</button>
</form>
<li>
统计结果
<span th:text="${uvResult}">访问人数</span>
</li>
</div>
热帖排行(Quartz线程池、Redis)
1.编写RedisUtil规范Key值
// 热帖分数 (把需要更新的帖子id存入Redis当作缓存)
private static final String PREFIX_POST = "post";
/**
* 帖子分数 (发布、点赞、加精、评论时放入)
*/
public static String getPostScore() {
return PREFIX_POST + SPLIT + "score";
}
2.处理发布、点赞、加精、评论时计算分数,将帖子id存入Key
2.1发布帖子时初始化分数
/**
* 计算帖子分数
* 将新发布的帖子id存入set去重的redis集合------addDiscussPost()
*/
String redisKey = RedisKeyUtil.getPostScore();
redisTemplate.opsForSet().add(redisKey, post.getId());
2.2点赞时计算帖子分数
/**
* 计算帖子分数
* 将点赞过的帖子id存入set去重的redis集合------like()
*/
if (entityType == ENTITY_TYPE_POST) {
String redisKey = RedisKeyUtil.getPostScore();
redisTemplate.opsForSet().add(redisKey, postId);
}
2.3评论时计算帖子分数
if (comment.getEntityType() == ENTITY_TYPE_POST) {
/**
* 计算帖子分数
* 将评论过的帖子id存入set去重的redis集合------addComment()
*/
String redisKey = RedisKeyUtil.getPostScore();
redisTemplate.opsForSet().add(redisKey, discussPostId);
}
2.4加精时计算帖子分数
/**
* 计算帖子分数
* 将加精的帖子id存入set去重的redis集合-------setWonderful()
*/
String redisKey = RedisKeyUtil.getPostScore();
redisTemplate.opsForSet().add(redisKey, id);
3.定义Quartz热帖排行Job
/**热帖排行定时刷新任务**/
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-10-22 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初始化时间失败!", e);
}
}
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
String redisKey = RedisKeyUtil.getPostScore();
// 处理每一个key
BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);
if (operations.size() == 0) {
logger.info("[任务取消] 没有需要刷新的帖子");
return;
}
logger.info("[任务开始] 正在刷新帖子分数" + operations.size());
while (operations.size() > 0) {
// 刷新每一个从set集合里弹出的postId
this.refresh((Integer)operations.pop());
}
logger.info("[任务结束] 帖子分数刷新完毕!");
}
// 从redis中取出每一个value:postId
private void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId);
if (post == null) {
logger.error("该帖子不存在:id = " + postId);
return;
}
if(post.getStatus() == 2){
logger.error("帖子已被删除");
return;
}
/**
* 帖子分数计算公式:[加精(75)+ 评论数* 10 + 点赞数* 2] + 距离天数
*/
// 是否加精帖子
boolean wonderful = post.getStatus() == 1;
// 点赞数量
long liketCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
// 评论数量
int commentCount = post.getCommentCount();
// 计算权重
double weight = (wonderful ? 75 : 0) + commentCount* 10 + liketCount* 2;
// 分数 = 取对数(帖子权重) + 距离天数
double score = Math.log10(Math.max(weight, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000* 3600* 24);
// 更新帖子分数
discussPostService.updateScore(postId, score);
// 同步搜索数据
post.setScore(score);
elasticsearchService.saveDiscussPost(post);
}
}
4.配置Quartz的PostScoreRefreshJob
@Bean
public JobDetailFactoryBean postScoreRefreshJobDetail() {
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(PostScoreRefreshJob.class);
factoryBean.setName("postScoreRefreshJob");
factoryBean.setGroup("communityGroup");
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(3000);
factoryBean.setJobDataMap(new JobDataMap());
return factoryBean;
}
5.修改主页帖子显示(Mapper、Service、Controller)
5.1 Mapper
// orderMode=0:最新 orderMode=1:最热
List<DiscussPost> selectDiscussPosts(@Param("userId") int userId, @Param("offset") int offset, @Param("limit") int limit,@Param("orderMode")int orderMode);
<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>
5.2 Service
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}
5.3 Controller
@RequestMapping(value = "/index", method = RequestMethod.GET)
// @RequestParam(name = "orderMode") 这是从前端传参数方法是:/index?xx 与Controller绑定
public String getIndexPage(Model model, Page page,@RequestParam(name = "orderMode",defaultValue = "0") int orderMode) {
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index?orderMode=" + orderMode);
List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit(), orderMode);
List<Map<String, Object>> discussPost = new ArrayList<>();
if (list!=null){
for(DiscussPost post:list) {
HashMap<String, Object> map = new HashMap<>();
map.put("post", post);
User user = userService.findUserById(post.getUserId());
map.put("user", user);
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
map.put("likeCount", likeCount);
discussPost.add(map);
}
}
model.addAttribute("discussPosts", discussPost);
model.addAttribute("orderMode", orderMode);
return "/index";
}
6编写前端页面实现切换最新/最热帖子显示
<!-- 切换最新/最热帖子 -->
<li class="nav-item">
<a th:class="|nav-link ${orderMode==0?'active':''}|" th:href="@{/index(orderMode=0)}">最新</a>
</li>
<li class="nav-item">
<a th:class="|nav-link ${orderMode==1?'active':''}|" th:href="@{/index(orderMode=1)}">最热</a>
</li>
文件上传至云服务器(七牛云服务器)
绑定云服务器
1.引入pom.xml
<!--七牛云服务器-->
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>7.2.28</version>
</dependency>
2.配置yml文件(服务器参数)
# qiniu
qiniu:
# 七牛云密钥(个人设置->密钥管理)
key:
access: 7Ia7E86E3B9XTQ9TrlA5l_E-_WBnkmXQhxoE3-_n
secret: 17Ab9TcKnyn_jw4-a0XyH6iD_acl0KaKGEi6_Hqc
bucket:
# 头像上传云服务器配置(七牛云对象存储)
header:
name: xmyheader
url: http://rcmsg2hwa.hb-bkt.clouddn.com
# 分享功能云服务器配置
share:
name: xmyshare
url: http://rcmscfkkw.hb-bkt.clouddn.com
将头像上传至云服务器
客户端上传:
—将客户端数据提交给云服务器,并等待其响应
—用户上传头像时,将表单数据提交给服务器
1.修改文件上传相应的Controller(这里是UserController)
@LoginRequired//自定义注解
@RequestMapping(value = "/setting", method = RequestMethod.GET)
public String getSettingPage(Model model) {
/**设置页面加载时就开始配置云服务器信息**/
// 上传随机文件名称
String fileName = CommunityUtil.generateUUID();
// 设置返回给云服务器的响应信息(规定用StringMap)
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";
}
/**
* 异步更新头像路径(云服务器异步返回Json,而不是返回页面,不然乱套)
*/
@RequestMapping(value = "/header/url", method = RequestMethod.POST)
@ResponseBody
public String updateHeaderUrl(String fileName) {
if (StringUtils.isBlank(fileName)) {
return CommunityUtil.getJSONString(1, "文件名不能为空!");
}
String url = headerBucketUrl + "/" + fileName;
// 将数据库头像url更换成云服务器图片url
userService.updateHeader(hostHolder.getUser().getId(), url);
return CommunityUtil.getJSONString(0);
}
2.编写更新头像路径时js异步ajax
// 上传到七牛云服务器的异步处理方法
$(function(){
$("#uploadForm").submit(upload);
});
function upload() {
// 表单异步提交文件不能用$.post--不能映射文件类型,所以用原生$.ajax
$.ajax({
// 七牛云华北地区上传地址
url: "http://upload-z1.qiniup.com",
method: "post",
// 不要把表单内容转为字符串(因为是上传图片文件)
processData: false,
// 不让JQuery设置上传类型(使用浏览器默认处理方法将二进制文件随机加边界字符串)
contentType: false,
// 传文件时需要这样传data
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.msg);
}
}
);
} else {
alert("上传失败!");
}
}
});
// <form>表单没写action,就必须返回false
return false;
}
将分享图片上传至云服务器
服务器直传:
—本地应用服务器将数据直接提交给云服务器,并等待其响应
—分享时,服务端将自动生成的图片,直接提交给云服务器
1.编写生成长图到本地Controller(使用消息队列处理并发)
/**
* wkhtmltopdf实现生成分享长图功能
*/
@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;
@Value("${qiniu.bucket.share.url}")
private String shareBucketUrl;
@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.fireMessage(event);
// 返回访问路径
Map<String, Object> map = new HashMap<>();
//map.put("shareUrl", domain + contextPath + "/share/image/" + fileName);
map.put("shareUrl", shareBucketUrl + "/" + fileName);
return CommunityUtil.getJSONString(0, null, map);
}
}
2.编写Kafka消费者—上传到云服务器
/**执行wk命令行的位置**/
@Value("${wk.image.command}")
private String wkImageCommand;
/**存储wk图片位置**/
@Value("${wk.image.storage}")
private String wkImageStorage;
/**
* 使用云服务器获取长图
*/
@Value("${qiniu.key.access}")
private String accessKey;
@Value("${qiniu.key.secret}")
private String secretKey;
@Value("${qiniu.bucket.share.name}")
private String shareBucketName;
/**定时器避免还没生成图片就上传服务器**/
@Autowired
private ThreadPoolTaskScheduler taskScheduler;
/**
* 消费wkhtmltopdf分享事件
*/
@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 htmlUrl = (String) event.getData().get("htmlUrl");
String fileName = (String) event.getData().get("fileName");
String suffix = (String) event.getData().get("suffix");
// 执行cmd d:/wkhtmltopdf/bin/wkhtmltoimage --quality 75 https://www.nowcoder.com d:/wkhtmltopdf/wk-images/2.png命令
String cmd = wkImageCommand + " --quality 75 "
+ htmlUrl + " " + wkImageStorage + "/" + fileName + suffix;
try {
Runtime.getRuntime().exec(cmd);
logger.info("生成长图成功: " + cmd);
} catch (IOException e) {
logger.error("生成长图失败: " + e.getMessage());
}
// 启用定时器,监视该图片,一旦生成了,则上传至七牛云.
UploadTask task = new UploadTask(fileName, suffix);
Future future = taskScheduler.scheduleAtFixedRate(task, 500);
task.setFuture(future);
}
class UploadTask implements Runnable {
// 文件名称
private String fileName;
// 文件后缀
private String suffix;
// 启动任务的返回值
private Future future;
// 开始时间
private long startTime;
// 上传次数
private int uploadTimes;
public UploadTask(String fileName, String suffix) {
this.fileName = fileName;
this.suffix = suffix;
this.startTime = System.currentTimeMillis();
}
public void setFuture(Future future) {
this.future = future;
}
@Override
public void run() {
// 生成失败
if (System.currentTimeMillis() - startTime > 30000) {
logger.error("执行时间过长,终止任务:" + fileName);
future.cancel(true);
return;
}
// 上传失败
if (uploadTimes >= 3) {
logger.error("上传次数过多,终止任务:" + fileName);
future.cancel(true);
return;
}
String path = wkImageStorage + "/" + fileName + suffix;
File file = new File(path);
if (file.exists()) {
logger.info(String.format("开始第%d次上传[%s].", ++uploadTimes, fileName));
// 设置响应信息
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));
// 生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(shareBucketName, fileName, 3600, policy);
// 指定上传机房
UploadManager manager = new UploadManager(new Configuration(Zone.zone1()));
try {
// 开始上传图片
Response response = manager.put(
path, fileName, uploadToken, null, "image/" + suffix, false);
// 处理响应结果
JSONObject json = JSONObject.parseObject(response.bodyString());
if (json == null || json.get("code") == null || !json.get("code").toString().equals("0")) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
} else {
logger.info(String.format("第%d次上传成功[%s].", uploadTimes, fileName));
future.cancel(true);
}
} catch (QiniuException e) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
}
} else {
logger.info("等待图片生成[" + fileName + "].");
}
}
}
使用Caffine本地缓存优化网站性能(缓存主页热门帖子)
1.缓存概念
2.引入caffine依赖项
<!--caffeine本地缓存优化热门帖子-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>
3.编写yml配置caffine全局变量
# caffeine本地缓存优化热门帖子
caffeine:
posts:
# 最大缓存15页
max-size: 15
expire-seconds: 180
4.修改DiscussPostService业务层分页查询方法
/**
* 使用caffine缓存热门帖子(可用Jmeter压力测试)
* QQ:260602448
* Caffeine核心接口: Cache, LoadingCache(常用同步), AsyncLoadingCache(异步)
*/
@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;
// 项目启动时初始化缓存
@PostConstruct
public void init() {
// 初始化帖子列表缓存
postListCache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
.build(new CacheLoader<String, List<DiscussPost>>() {
@Override
// load方法:当没有缓存时,查询数据库
public @Nullable 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]);
// 这里可用二级缓存:Redis -> mysql
logger.debug("正在从数据库中加载热门帖子!");
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("正在从数据库加载热门帖子总数!");
return discussPostMapper.selectDiscussRows(key);
}
});
}
/**
* 主页分页查询帖子(使用缓存查询热门帖子->即userId=0,orderMode=1)
*/
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
if (userId == 0 && orderMode ==1) {
logger.debug("正在从Caffeine缓存中加载热门帖子!");
return postListCache.get(offset + ":" + limit);
}
logger.debug("正在从数据库中加载热门帖子!");
return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
}
public int findDiscussPostRows(int userId) {
// userId=0:查询所有帖子
if (userId == 0) {
logger.debug("正在从Caffeine缓存中加载热门帖子!");
return postRowsCache.get(userId);
}
logger.debug("正在从数据库加载热门帖子总数!");
return discussPostMapper.selectDiscussRows(userId);
}
统一处理异常
1.将error/404.html或500.html放在templates
注意:springboot默认在templates资源路径下面新建error目录,添加404.html和500.html页面就会自动配置上错误页面自动跳转
2.定义一个控制器通知组件,处理所有Controller所发生的异常
@ControllerAdvice(annotations = Controller.class)
public class ExceptionAdvice {
private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);
public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {
logger.error("服务器发生异常: " + e.getMessage());
// 循环打印异常栈中的每一条错误信息并记录
for (StackTraceElement element : e.getStackTrace()) {
logger.error(element.toString());
}
// 判断异常返回的是HTML还是Json异步格式字符串
String xRequestedWith = request.getHeader("x-requested-with");
// XMLHttpRequest: Json格式字符串
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 页面响应普通plain字符串格式
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));
} else {
response.sendRedirect(request.getContextPath() + "/error");
}
}
}
@RequestMapping(value = "error", method = RequestMethod.GET)
public String getErrorPage(){
return "/error/500";
}
统一记录日志
1.AOP概念(面向切面编程)
常见的使用场景有:权限检查、记录日志、事务管理
Joinpoint:目标对象上织入代码的位置叫做joinpoint
Pointcut:是用来定义当前的横切逻辑准备织入到哪些连接点上 (如service所有方法)
Advice:用来定义横切逻辑,即在连接点上准备织入什么样的逻辑
Aspect:是一个用来封装切点和通知的组件
织入:就是将方面组件中定义的横切逻辑,织入到目标对象的连接点的过程
2.AOP切面编程Demo示例
2.1导入pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.6.6</version>
</dependency>
2.2编写Aspect类
@Component
@Aspect
public class DemoAspect {
/**
*第一个* :方法的任何返回值
* com.xmy.demonowcoder.service.*. *(..)) :service包下的所有类所有方法所有参数(..)
*/
@Pointcut("execution(* com.xmy.demonowcoder.service. *.*(..))")
public void pointcut(){}
/**切点方法之前执行(常用)**/
@Before("pointcut()")
public void before(){
System.out.println("before");
}
@After("pointcut()")
public void after(){
System.out.println("after");
}
/**返回值以后执行**/
@AfterReturning("pointcut()")
public void afterRetuning() {
System.out.println("afterRetuning");
}
/**抛出异常以后执行**/
@AfterThrowing("pointcut()")
public void afterThrowing() {
System.out.println("afterThrowing");
}
/**切点的前和后都可以执行**/
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
System.out.println("around before");
Object obj = joinPoint.proceed();
System.out.println("around after");
return obj;
}
}
3.AOP实现统一记录日志
实现需求 :用户ip地址[1.2.3.4],在[xxx],访问了[ com.nowcoder.community.service.xxx ()]业务.\
@Component
@Aspect
public class ServiceLogAspect {
private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);
@Pointcut("execution(* com.xmy.demonowcoder.service.*. *(..))")
public void pointcut(){}
@Before("pointcut()")
public void before(JoinPoint joinPoint){
// 用户ip[1.2.3.4],在[xxx],访问了[com.nowcoder.community.service.xxx()].
// 通过RequestContextHolder获取request
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 通过request.getRemoteHost获取当前用户ip
String ip = request.getRemoteHost();
String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
/**
* joinPoint.getSignature().getDeclaringTypeName()-->com.nowcoder.community.service
* joinPoint.getSignature().getName() -->方法名
*/
String target = joinPoint.getSignature().getDeclaringTypeName() + "." +joinPoint.getSignature().getName();
// String.format()加工字符串
logger.info(String.format("用户[%s],在[%s],访问了[%s]业务.", ip, time, target));
}
}
项目监控(Springboot actuator)
1.引入pom.xml依赖
<!-- actuator项目监控-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>2.7.0</version>
</dependency>
2.配置yml文件
# actuator项目监控
management:
endpoints:
web:
exposure:
include: beans,database,info,health
3.自定义监控id(database数据库监控)
/**
* QQ:260602448--xumingyu
* 自定义项目监控类
*/
@Component
@Endpoint(id = "database")
public class DatabaseEndpoint {
private static final Logger logger = LoggerFactory.getLogger(DatabaseEndpoint.class);
@Autowired
private DataSource dataSource;
// 相当于GET请求
@ReadOperation
public String checkConnection() {
try (
// 放到try这个位置就不用释放资源,底层自动释放
Connection conn = dataSource.getConnection();
) {
return CommunityUtil.getJSONString(0, "获取连接成功!");
} catch (SQLException e) {
logger.error("获取连接失败:" + e.getMessage());
return CommunityUtil.getJSONString(1, "获取连接失败!");
}
}}
4.使用SpringSecurity设置访问权限
.antMatchers(
"/discuss/delete",
"/data/* *",
"/actuator/* *"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)