⭐️基礎連結導航⭐️
伺服器 → ☁️ 阿里雲活動地址
看樣例 → 🐟 摸魚小網站地址
學程式碼 → 💻 原始碼庫地址
一、前言
大家好呀,我是summo,最近發生了些事情(被裁員了,在找工作中)導致斷更了,非常抱歉。剛被裁的時候還是有些難受,而且我還有房貸要還,有些壓力,不過休息了一段時間,心態也平復了一些,打算一邊找工作一邊寫文,如果有和我一樣經歷的同學,大家共勉!
《花100塊做個摸魚小網站! 》這個系列的前六篇已經大概把整體的流程寫完了,從這篇起我會補充一些細節和元件,讓我們的小網站更加豐富一些。這一篇呢我會介紹如何將使用者的訪問記錄留下來,看著自己做的網站被別人訪問是一件很有意思和很有成就感的事情。
對應的元件也就是我用紅框標出來的那個,如下圖:
解釋下PV和UV的意思,如下:
- PV:頁面訪問量,即PageView,使用者每次對網站的訪問均被記錄,使用者對同一頁面的多次訪問,訪問量累計。
- UV:獨立訪問使用者數:即UniqueVisitor,訪問網站的一臺電腦客戶端為一個訪客。
二、使用者身份標識
用於表明一個使用者身份最好的做法是做登入註冊,但是一旦加了這樣的邏輯,就會有很多麻煩的問題要處理,比如如何做人機驗證啦、介面防刷啦,等等,這些問題不處理的話網站很容易被攻擊。像我們這樣的小網站,我覺得這個功能沒必要,我們只需要知道有多少人訪問過我們的網站就可以了。
對於這樣的需求,最簡單的做法是根據使用者的訪問IP作為標識,然後根據IP解析一下地域資訊,這樣就已經很不錯了。而目前最常用的IP解析工具就是:ip2region,如何使用這個元件我之前寫過一篇文章進行介紹了,文章連結:SpringBoot整合ip2region實現使用ip監控使用者訪問城市。
核心程式碼是這段:
package com.example.springbootip.util;
import org.apache.commons.io.FileUtils;
import org.lionsoul.ip2region.xdb.Searcher;
import java.io.File;
import java.text.MessageFormat;
import java.util.Objects;
public class AddressUtil {
/**
* 當前記錄地址的本地DB
*/
private static final String TEMP_FILE_DIR = "/home/admin/app/";
/**
* 根據IP地址查詢登入來源
*
* @param ip
* @return
*/
public static String getCityInfo(String ip) {
try {
// 獲取當前記錄地址位置的檔案
String dbPath = Objects.requireNonNull(AddressUtil.class.getResource("/ip2region/ip2region.xdb")).getPath();
File file = new File(dbPath);
//如果當前檔案不存在,則從快取中複製一份
if (!file.exists()) {
dbPath = TEMP_FILE_DIR + "ip.db";
System.out.println(MessageFormat.format("當前目錄為:[{0}]", dbPath));
file = new File(dbPath);
FileUtils.copyInputStreamToFile(Objects.requireNonNull(AddressUtil.class.getClassLoader().getResourceAsStream("classpath:ip2region/ip2region.xdb")), file);
}
//建立查詢物件
Searcher searcher = Searcher.newWithFileOnly(dbPath);
//開始查詢
return searcher.searchByStr(ip);
} catch (Exception e) {
e.printStackTrace();
}
//預設返回空字串
return "";
}
public static void main(String[] args) {
System.out.println(getCityInfo("1.2.3.4"));
}
}
三、功能實現
為了解耦邏輯,我使用了一個註解:@VisitLog
,只要將該註解放在IndexController的index方法上即可。同時為了統計使用者的訪問資料,我們需要設計一張訪問記錄表將資料存下來,並設計一個小元件用來展示這些資料,具體流程如下文。
1. 後端部分
(1)訪問記錄表設計
建表語句
-- `summo-sbmy`.t_sbmy_visit_log definition
CREATE TABLE `t_sbmy_visit_log` (
`id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '物理主鍵',
`device_type` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '裝置型別,手機還是電腦',
`ip` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '訪問',
`address` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'IP地址',
`time` int DEFAULT NULL COMMENT '耗時',
`method` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '呼叫方法',
`params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '引數',
`gmt_create` datetime DEFAULT NULL COMMENT '建立時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '建立人',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=oDEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DO、Mapper、Repository等檔案
還記得我在第三篇介紹的那個DO生成外掛嗎,在config.properties改下表名和DO名,雙擊mybatis-generator:generate就可以生成對應的DO、Mapper、xml了。
(2)VisitLog註解
VisitLog.java
package com.summo.sbmy.aspect;
/**
* 訪問標識註解
*/
public @interface VisitLog {
}
VisitLogAspect.java
package com.summo.sbmy.aspect.visit;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.summo.sbmy.common.util.AddressUtil;
import com.summo.sbmy.common.util.HttpContextUtil;
import com.summo.sbmy.common.util.IpUtil;
import com.summo.sbmy.dao.entity.SbmyVisitLogDO;
import com.summo.sbmy.dao.repository.SbmyVisitLogRepository;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import static com.summo.sbmy.common.util.DeviceUtil.isFromMobile;
@Slf4j
@Aspect
@Component
public class VisitLogAspect {
@Autowired
private SbmyVisitLogRepository sbmyVisitLogRepository;
@Pointcut("@annotation(com.summo.sbmy.aspect.visit.Log)")
public void pointcut() {
// do nothing
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//獲取request
HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
// 請求的類名
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
String className = joinPoint.getTarget().getClass().getName();
// 請求的方法名
String methodName = signature.getName();
String ip = IpUtil.getIpAddr(request);
String address = AddressUtil.getAddress(ip);
SbmyVisitLogDO sbmyVisitLogDO = SbmyVisitLogDO.builder().deviceType(isFromMobile(request) ? "手機" : "電腦").method(
className + "." + methodName + "()").ip(ip).address(AddressUtil.getAddress(address)).build();
// 請求的方法引數值
Object[] args = joinPoint.getArgs();
// 請求的方法引數名稱
LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
String[] paramNames = u.getParameterNames(method);
if (args != null && paramNames != null) {
// 建立 key-value 對映用於生成 JSON 字串
Map<String, Object> paramMap = new LinkedHashMap<>();
for (int i = 0; i < paramNames.length; i++) {
if (args[i] instanceof HttpServletRequest || args[i] instanceof HttpServletResponse) {
continue;
}
paramMap.put(paramNames[i], args[i]);
}
// 使用 Fastjson 將引數對映轉換為 JSON 字串
String paramsJson = JSON.toJSONString(paramMap);
sbmyVisitLogDO.setParams(paramsJson);
}
long beginTime = System.currentTimeMillis();
Object proceed = joinPoint.proceed();
long end = System.currentTimeMillis();
sbmyVisitLogDO.setTime((int)(end - beginTime));
sbmyVisitLogRepository.save(sbmyVisitLogDO);
return proceed;
}
/**
* 引數構造器¬
*
* @param params
* @param args
* @param paramNames
* @return
* @throws JsonProcessingException
*/
private StringBuilder handleParams(StringBuilder params, Object[] args, List paramNames)
throws JsonProcessingException {
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof Map) {
Set set = ((Map)args[i]).keySet();
List<Object> list = new ArrayList<>();
List<Object> paramList = new ArrayList<>();
for (Object key : set) {
list.add(((Map)args[i]).get(key));
paramList.add(key);
}
return handleParams(params, list.toArray(), paramList);
} else {
if (args[i] instanceof Serializable) {
Class<?> aClass = args[i].getClass();
try {
aClass.getDeclaredMethod("toString", new Class[] {null});
// 如果不丟擲 NoSuchMethodException 異常則存在 toString 方法 ,安全的 writeValueAsString ,否則 走 Object的
// toString方法
params.append(" ").append(paramNames.get(i)).append(": ").append(
JSONObject.toJSONString(args[i]));
} catch (NoSuchMethodException e) {
params.append(" ").append(paramNames.get(i)).append(": ").append(
JSONObject.toJSONString(args[i].toString()));
}
} else if (args[i] instanceof MultipartFile) {
MultipartFile file = (MultipartFile)args[i];
params.append(" ").append(paramNames.get(i)).append(": ").append(file.getName());
} else {
params.append(" ").append(paramNames.get(i)).append(": ").append(args[i]);
}
}
}
return params;
}
}
這裡使用到了一些工具類,程式碼我已經上傳到倉庫了,大家直接down下來就行。
(3)註解使用
在IndexController.java中加入該註解即可
package com.summo.sbmy.web.controller;
import com.summo.sbmy.aspect.visit.VisitLog;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping("/")
@VisitLog
public String index(){
return "index";
}
}
2. 前端部分
(1)新建VisitorLog元件
程式碼如下:
<template>
<div class="stats-card-container">
<el-card class="stats-card-main">
<!-- 突出顯示的今日 PV -->
<div class="stats-section">
<div class="stats-value-main">{{ statsData.todayPv }}</div>
<div class="stats-label-main">今日 PV</div>
</div>
<!-- 其他統計資料,以更緊湊的形式顯示 -->
<div class="stats-section stats-others">
<div class="stats-item">
<div class="stats-value-small">{{ statsData.todayUv }}</div>
<div class="stats-label-small">今日 UV</div>
</div>
<div class="stats-item">
<div class="stats-value-small">{{ statsData.allPv }}</div>
<div class="stats-label-small">總 PV</div>
</div>
<div class="stats-item">
<div class="stats-value-small">{{ statsData.allUv }}</div>
<div class="stats-label-small">總 UV</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import apiService from "@/config/apiService.js";
export default {
name: "VisitorLog",
data() {
return {
statsData: {
todayPv: 0,
todayUv: 0,
allPv: 0,
allUv: 0,
},
};
},
created() {
this.fetchVisitorCount(); // 元件建立時立即呼叫一次
this.startPolling(); // 啟動定時器
},
beforeDestroy() {
this.stopPolling(); // 在元件銷燬前清理定時器
},
methods: {
fetchVisitorCount() {
apiService
.get("/welcome/queryVisitorCount")
.then((res) => {
// 處理響應資料
this.statsData = res.data.data;
})
.catch((error) => {
// 處理錯誤情況
console.error(error);
});
},
startPolling() {
// 定義一個方法來啟動週期性的定時器
this.polling = setInterval(() => {
this.fetchVisitorCount();
}, 1000 * 60 * 60); // 每60000毫秒(1分鐘)呼叫一次
},
stopPolling() {
// 定義一個方法來停止定時器
clearInterval(this.polling);
},
},
};
</script>
<style scoped>
>>> .el-card__body{
padding: 10px;
}
.stats-card-container {
max-width: 400px;
margin-bottom: 10px;
}
.stats-card-main {
padding: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stats-section {
text-align: center;
}
.stats-value-main {
font-size: 24px;
font-weight: bold;
color: #0A74DA;
margin-bottom: 4px;
}
.stats-label-main {
font-size: 14px;
color: #333;
}
.stats-others {
display: flex;
justify-content: space-between;
margin-top: 12px;
}
.stats-item {
text-align: center;
}
.stats-value-small, .stats-label-small {
font-size: 12px; /* 減小字型尺寸以實現更緊湊的佈局 */
}
.stats-value-small {
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.stats-label-small {
color: #666;
}
@media (max-width: 400px) {
.stats-others {
flex-direction: column;
align-items: center;
}
.stats-item {
margin-bottom: 8px;
}
}
</style>
(2)元件使用
在App.vue元件中引入VisitorLog元件,順便將佈局重新分一下,程式碼如下:
<template>
<div id="app">
<el-row :gutter="10">
<el-col :span="20">
<el-row :gutter="10">
<el-col :span="6" v-for="(board, index) in hotBoards" :key="index">
<hot-search-board
:title="board.title"
:icon="board.icon"
:fetch-url="board.fetchUrl"
:type="board.type"
/>
</el-col>
</el-row>
</el-col>
<el-col :span="4">
<visitor-log />
</el-col>
</el-row>
</div>
</template>
<script>
import HotSearchBoard from "@/components/HotSearchBoard.vue";
import VisitorLog from "@/components/VisitorLog.vue";
export default {
name: "App",
components: {
HotSearchBoard,
VisitorLog,
},
data() {
return {
hotBoards: [
{
title: "百度",
icon: require("@/assets/icons/baidu-icon.svg"),
type: "baidu",
},
{
title: "抖音",
icon: require("@/assets/icons/douyin-icon.svg"),
type: "douyin",
},
{
title: "知乎",
icon: require("@/assets/icons/zhihu-icon.svg"),
type: "zhihu",
},
{
title: "B站",
icon: require("@/assets/icons/bilibili-icon.svg"),
type: "bilibili",
},
{
title: "搜狗",
icon: require("@/assets/icons/sougou-icon.svg"),
type: "sougou",
},
],
};
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
background: #f8f9fa; /* 提供一個柔和的背景色 */
min-height: 100vh; /* 使用視口高度確保填滿整個螢幕 */
padding: 0; /* 保持整體佈局緊湊,無額外內邊距 */
}
</style>
咱們做出來的效果就是這樣的,如下:
這個小元件做起來還是簡單的,主要就是監控了別人的訪問IP,然後透過IP反解析出所屬地域,最後將其存到資料庫中。
番外:搜狗熱搜爬蟲
1. 爬蟲方案評估
搜狗的熱搜介面返回的一串JSON格式資料,這就很簡單了,省的我們去解析dom,訪問連結是:https://go.ie.sogou.com/hot_ranks
2. 網頁解析程式碼
SougouHotSearchJob.java
package com.summo.sbmy.job.sougou;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Lists;
import com.summo.sbmy.common.model.dto.HotSearchDetailDTO;
import com.summo.sbmy.dao.entity.SbmyHotSearchDO;
import com.summo.sbmy.service.SbmyHotSearchService;
import com.summo.sbmy.service.convert.HotSearchConvert;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Calendar;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import java.util.stream.Collectors;
import static com.summo.sbmy.common.cache.SbmyHotSearchCache.CACHE_MAP;
import static com.summo.sbmy.common.enums.HotSearchEnum.DOUYIN;
import static com.summo.sbmy.common.enums.HotSearchEnum.SOUGOU;
/**
* @author summo
* @version SougouHotSearchJob.java, 1.0.0
* @description 搜狗熱搜Java爬蟲程式碼
* @date 2024年08月09
*/
@Component
@Slf4j
public class SougouHotSearchJob {
@Autowired
private SbmyHotSearchService sbmyHotSearchService;
@XxlJob("sougouHotSearchJob")
public ReturnT<String> hotSearch(String param) throws IOException {
log.info("搜狗熱搜爬蟲任務開始");
try {
//查詢搜狗熱搜資料
OkHttpClient client = new OkHttpClient().newBuilder().build();
Request request = new Request.Builder().url("https://go.ie.sogou.com/hot_ranks").method("GET", null)
.build();
Response response = client.newCall(request).execute();
JSONObject jsonObject = JSONObject.parseObject(response.body().string());
JSONArray array = jsonObject.getJSONArray("data");
List<SbmyHotSearchDO> sbmyHotSearchDOList = Lists.newArrayList();
for (int i = 0, len = array.size(); i < len; i++) {
//獲取知乎熱搜資訊
JSONObject object = (JSONObject)array.get(i);
//構建熱搜資訊榜
SbmyHotSearchDO sbmyHotSearchDO = SbmyHotSearchDO.builder().hotSearchResource(SOUGOU.getCode()).build();
//設定知乎三方ID
sbmyHotSearchDO.setHotSearchId(object.getString("id"));
//設定文章標題
sbmyHotSearchDO.setHotSearchTitle(object.getJSONObject("attributes").getString("title"));
//設定文章連線
sbmyHotSearchDO.setHotSearchUrl(
"https://www.sogou.com/web?ie=utf8&query=" + sbmyHotSearchDO.getHotSearchTitle());
//設定熱搜熱度
sbmyHotSearchDO.setHotSearchHeat(object.getJSONObject("attributes").getString("num"));
//按順序排名
sbmyHotSearchDO.setHotSearchOrder(i + 1);
sbmyHotSearchDOList.add(sbmyHotSearchDO);
}
if (CollectionUtils.isEmpty(sbmyHotSearchDOList)) {
return ReturnT.SUCCESS;
}
//資料加到快取中
CACHE_MAP.put(SOUGOU.getCode(), HotSearchDetailDTO.builder()
//熱搜資料
.hotSearchDTOList(
sbmyHotSearchDOList.stream().map(HotSearchConvert::toDTOWhenQuery).collect(Collectors.toList()))
//更新時間
.updateTime(Calendar.getInstance().getTime()).build());
//資料持久化
sbmyHotSearchService.saveCache2DB(sbmyHotSearchDOList);
log.info("搜狗熱搜爬蟲任務結束");
} catch (IOException e) {
log.error("獲取搜狗資料異常", e);
}
return ReturnT.SUCCESS;
}
}