
簡介Java實現的數據采集系統項目壓縮包面向有一定Java基礎的數據采集、爬蟲或大數據入門開發者用于快速上手一個完整的數據采集應用。包內共169個文件含39個jar依賴庫、35個class編譯文件、35個java源碼、20個jsp頁面以及18個xml配置等體積約22.54MB目錄結構完整便于對照學習。項目圍繞數據采集核心流程展開涉及HttpURLConnection發送請求、多線程并發抓取、Jsoup解析HTML、正則與Stream清洗數據、JDBC持久化存儲、日志記錄等關鍵技術點并包含前端展示頁面可幫助理解數據從獲取到存儲的完整鏈路。當前已有332人學習下載適合作為課程設計、畢業設計或自學數據采集的參考資料。1. Gather-master 里的 Survey 類暴露了數據采集系統的另一種常見形態很多人一提到 Java 數據采集系統第一反應是爬蟲框架、代理 IP、分布式抓取。實際上真正跑在業務系統里的數據采集大量是“業務對象采集”通過頁面問卷、表單、API 接口把用戶產生的結構化數據收回來再清洗入庫。這份 java實現的數據采集系統.zip 里Question.class、Survey.class、SurveyServiceImpl.class、BaseDaoImpl.class就是一套典型的 Java Web 前后臺采集閉環。Gather-master 這個目錄名加上 MoveOrCopyPageAction、QuestionAction 這類 Struts2 風格 Action 類說明它大概率是一套老項目但分層思想至今仍適用。想快速搭問卷評測采集或者準備 Java 面試時拿真實類名反推架構都可以從這份資源入手。2. 從類名反推 Java 數據采集的經典分層結構拿到這份壓縮包時我第一時間沒去解壓運行而是先把 class 文件名抄了一遍。這習慣來自調試別人老項目的經驗類名就是最簡單的架構文檔。這里出現的 Survey、Question、SurveyServiceImpl、BaseDaoImpl、BaseServiceImpl、SurveyAction、QuestionAction、MoveOrCopyPageAction已經足夠畫出一條完整調用鏈頁面或接口請求進入 ActionAction 調用 ServiceService 做采集和校驗ServiceImpl 繼承 BaseServiceImplBaseDaoImpl 統一負責 Hibernate/JPA 持久化。這就是 Java 數據采集系統最常見的分層實現也是面試里常被追問“分層之后事務邊界在哪里”的答案。2.1 Action、Service、Dao 三層架構在采集任務里的映射先看最底的 BaseDaoImpl。老項目里通常不會有幾十個 Dao 類只有少數幾個需要自定義查詢的 Dao 會繼承它多數實體直接復用 BaseDaoImpl 的泛型 CRUD。落到采集場景主表、明細表、答案表都可以通過同一條路徑落庫。// BaseDaoImpl 把公共的增刪改查收攏采集任務只關心具體實體 public class BaseDaoImplT implements BaseDaoT { private ClassT entityClass; private HibernateTemplate hibernateTemplate; public void save(T entity) { hibernateTemplate.save(entity); } public T get(Long id) { return hibernateTemplate.get(entityClass, id); } }save 方法接收任意采集對象hibernateTemplate 內部幫我們管理 SessionSession 何時 flush 由事務決定。參數上 entityClass 是泛型運行期識別HibernateTemplate 通過它拼 HQL 和主鍵查詢。如果你換成 MyBatis這類模板類通常被 SqlSessionTemplate 取代但分層位置不變。Service 層是采集邏輯的核心。以 SurveyServiceImpl 為例它不只做一個問卷的保存而是把“接收答卷、逐題校驗、寫明細表、統計完成狀態”這幾步串成事務public class SurveyServiceImpl extends BaseServiceImplSurvey implements SurveyService { private QuestionDao questionDao; Override Transactional public void collectSurvey(Survey survey, MapString, String answers) { validate(survey); save(survey); // 先落主表 ListQuestion questions questionDao.listValidQuestions(survey.getId()); for (Question q : questions) { saveAnswer(q, answers.get(q.getFieldName())); } } }Transactional 保證同一個問卷下的所有回答要么全部入庫要么全部回滾避免主表寫成功、答案表丟一半。參數上可以不寫 rollbackFor但 Spring 默認只回滾 RuntimeException如果你的事務實現里拋的是受檢異常需要顯式聲明 rollbackFor Exception.class。老項目常用 HibernateTemplate 的 Session 由 OpenSessionInView 管理事務結束時如果還有懶加載對象訪問會在頁面上拋異常這與采集任務的異步處理沖突。2.2 從 HTTP 請求到領域對象的采集轉換Struts2 的 Action 不像 Spring MVC 那樣按方法參數解析。SurveyAction 一般實現 ModelDriven把 request 里的參數自動綁定到 Survey 對象QuestionAction 則處理單選、多選、排序等題目維護。放到數據采集場景這就是把 HTTP 請求體采集到內存對象的第一步。public class SurveyAction extends ActionSupport implements ModelDrivenSurvey { private Survey survey new Survey(); private MapString, String answerMap new HashMap(); public String execute() { surveyService.collectSurvey(survey, answerMap); return SUCCESS; } Override public Survey getModel() { return survey; } }getModel 返回的 survey 對象會被 Struts2 參數攔截器自動填充表單里 namequestion_001 的字段會對應到 answerMap 的 key。參數綁定有兩個坑一是老項目里 Entity 直接當 Model 用字段過多時容易被惡意參數污染比如多傳一個 id 就把已有問卷覆蓋掉二是嵌套屬性要用 survey.questions[0].content 這種路徑字段名寫錯會導致采集值全部為 null。我一般會在 Service 層手動封裝一個 CollectCommand 對象Entity 不直接暴露給 Web 層。2.3 多線程采集與事務邊界采集系統只要涉及多渠道、多表單單線程寫庫就是瓶頸。老項目常見做法是給每個采集任務分配一個線程用 ExecutorService 控制并發。這里要特別注意事務邊界不能放在 Action 層否則每個線程各自開 SessionHibernate 懶加載會直接拋 LazyInitializationException。Spring 的事務傳播行為中REQUIRED 和 REQUIRES_NEW 最常用傳播行為當前無事務當前有事務REQUIRED新建事務加入當前事務REQUIRES_NEW新建事務掛起當前事務新建獨立事務NESTED新建事務基于保存點嵌套事務并行采集時我習慣用異步任務封裝事務方法ExecutorService pool Executors.newFixedThreadPool(4); for (String sourceUrl : sourceUrls) { pool.submit(() - { ListQuestion questions fetchQuestions(sourceUrl); surveyService.saveQuestions(survey.getId(), questions); }); } pool.shutdown();fixedThreadPool 的 4 表示同時最多 4 個采集線程超出任務排隊。shutdown 不是立刻停止是等待已提交任務執行完。老項目容易忽略的是DataSource 的連接池大小必須大于線程池大小否則線程池并發上去c3p0 連接全被占滿日志里會出現 Connection is not available。所以我的默認經驗是連接池最大連接數至少是線程池任務數的兩倍。3. 數據采集核心鏈路從接口抓取到解析落庫class 列表里沒有爬蟲相關類但一個完整的數據采集系統大概率要對接第三方接口。下面按抓取、解析、清洗三步展開。這里的代碼是我在實際項目里常用的版本不依賴 Struts 老代碼。3.1 選 HttpClient 還是 HttpURLConnection老項目里很多采集功能直接用 HttpURLConnection因為不用引第三方依賴。但我做真實項目時基本不用它連接超時和讀取超時設置繁瑣重定向要手動處理連接池還得自己寫。Apache HttpClient 4.x 或者 OkHttp 更實際。如果你要抓 JSON 接口用 HttpClient 的典型代碼是RequestConfig config RequestConfig.custom() .setConnectTimeout(3000) // 連接目標地址的超時 .setSocketTimeout(5000) // 服務端返回數據的最大等待時間 .setConnectionRequestTimeout(1000) // 從連接池拿連接的超時 .build(); try (CloseableHttpClient client HttpClients.custom() .setDefaultRequestConfig(config) .setMaxConnPerRoute(20) // 每個路由最大并發連接數 .setMaxConnTotal(100) // 整個客戶端總連接數 .build()) { HttpGet get new HttpGet(https://api.example.com/surveys?page1); get.addHeader(Authorization, Bearer token); try (CloseableHttpResponse resp client.execute(get)) { String body EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); // 響應體已經采到 body 字符串后續交給解析 } }setMaxConnPerRoute 和 setMaxConnTotal 是針對同一個 Host 的并發上限。響應體必須用 EntityUtils.toString 并指定 UTF-8否則中文亂碼。每次執行后通過 try-with-resources 關閉 response避免連接泄漏。如果采集頻率高client 要聲明成單例不能每次 new否則 TIME_WAIT 連接會占滿本地端口。另外請求頭里加一個 User-Agent 能減少被對方風控誤傷的概率。3.2 解析 HTML 與 JSON 的取舍如果采集目標是 HTML 頁面Jsoup 是首選。它把頁面解析成 DOM用 CSS 選擇器定位元素適合抓取問卷題目、答案選項、表格行。碰上 JS 動態渲染的頁面Jsoup 拿不到數據那就要換 HtmlUnit 或者 Selenium但成本高一個量級所以老項目通常讓后端接口同時輸出 JSON。Jsoup 的常見寫法Document doc Jsoup.connect(https://www.example.com/survey) .timeout(3000) .userAgent(Mozilla/5.0) .get(); Elements items doc.select(.question-item); // 選擇器定位到每條題目 for (Element item : items) { String id item.attr(data-id); String title item.select(.question-title).first().text(); String rawType item.select(.q-type).text(); // 清洗后可繼續轉換 }Jsoup.connect 返回的 Document 已經完成解析select 語法跟 CSS 選擇器一致。.question-item 表示 class 為 question-item 的元素.attr(data-id) 取屬性值.text() 提取純文本并自動剝離標簽。如果頁面結構是表格可以用 tr/td 選擇器逐行取數。這里的 timeout 只控制響應的讀取解析本身很快。JSON 接口用 Gson 解析老項目里 Gson 比 Jackson 少配置Gson gson new GsonBuilder().setDateFormat(yyyy-MM-dd HH:mm:ss).create(); JsonObject root JsonParser.parseString(body).getAsJsonObject(); JsonArray arr root.getAsJsonArray(data); for (JsonElement el : arr) { JsonObject obj el.getAsJsonObject(); if (!obj.has(title)) { continue; // 字段缺失時跳過 } Question q new Question(); q.setTitle(obj.get(title).getAsString()); q.setType(obj.get(type).getAsInt()); }setDateFormat 會把時間字符串映射到 Date 類型。obj.get(title).getAsString() 在字段缺失時會拋異常所以先用 has() 判斷。采集接口如果返回列表還要處理分頁建議把 page/pageSize 放入循環條件直到返回空列表為止。3.3 數據清洗與格式校驗抓下來的數據很少能直接用。常見問題包括首尾空格、全角數字、空字符串代表未作答、手機號格式不統一。老項目首選正則加 String.trim()新一點的代碼可以混用 Stream API。清洗邏輯要放在 Service 層不要放進 Action方便用 JUnit 直接測。ListAnswer answers rawList.stream() .filter(a - a.getValue() ! null !a.getValue().trim().isEmpty()) .map(a - { a.setValue(a.getValue().trim().replaceAll(\\s, )); return a; }) .collect(Collectors.toList());filter 先排除空值map 再統一把連續空白壓縮成單個空格。這種變換每加一個規則就多一個方法盡量保持方法簽名只有 String 入參和 String 返回值方便復用和單測。手機號校驗用正則if (phone ! null phone.matches(^1[3-9]\\d{9}$)) { answer.setPhone(phone); } else { log.warn(跳過非法手機號: {}, phone); }matches 要求整個字符串匹配所以正則前后不需要再加 ^ 和 $寫了也不影響。清洗結果可以直接寫進 answer 表也可以在清洗過程中記錄一條 dirty_data 日志為后續排查留下依據。如果采集量特別大清洗后的數據可以用 List batch 提交一次 500 條再用 Hibernate 的 saveAll 或 JDBC batch避免每條 insert 都獨立提交。4. 從 Survey 與 Question 類看采集數據表設計壓縮包里的 Survey 和 Question 類直接對應關系型數據庫的問卷表和題目表。表設計決定采集結果能不能低成本復用。下面從建表、冪等、查詢安全三個角度展開。4.1 一對多關系建模Survey 與 Question 是父子關系一個問卷下面掛多道題目題目編號 sort_no 控制展示順序。老項目用 Hibernate 時關系寫在 Entity 注解里Entity Table(name survey) public class Survey { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name title, nullable false) private String title; OneToMany(mappedBy survey, cascade CascadeType.ALL, orphanRemoval true) private ListQuestion questions new ArrayList(); }對應的建表 SQLCREATE TABLE survey ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-草稿 1-已發布, created_at DATETIME NOT NULL ); CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, survey_id BIGINT NOT NULL, field_name VARCHAR(50) NOT NULL COMMENT 提交表單的字段名, question_type TINYINT NOT NULL COMMENT 1-單選 2-多選 3-填空, content TEXT NOT NULL, sort_no INT NOT NULL DEFAULT 0, CONSTRAINT fk_question_survey FOREIGN KEY (survey_id) REFERENCES survey(id) );field_name 是采集結果提交時的參數名比如 question_001。question_type 用數字枚舉比字符串更省空間但應用層要維護映射關系。cascade 和 orphanRemoval 的作用是通過 survey 對象保存時子題目能級聯寫入。代價是批量導入題目時容易產生 N1 查詢采集大數據量時我一般不用級聯而是直接調 questionDao.batchInsert先存主表再循環批量插子表。4.2 采集數據的冪等與去重重復采集在老項目里幾乎每天碰到接口超時重試、前端重復點擊、消息隊列重復投遞。最穩妥的辦法是在數據庫層加唯一約束再把插入改成冪等寫入ALTER TABLE answer ADD UNIQUE KEY uk_survey_question_user (survey_id, question_id, user_token);String sql INSERT IGNORE INTO answer (survey_id, question_id, user_token, answer_value) VALUES (?, ?, ?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, surveyId); ps.setLong(2, questionId); ps.setString(3, token); ps.setString(4, value); int rows ps.executeUpdate(); if (rows 0) { log.info(重復采集跳過: survey{}, question{}, surveyId, questionId); } }INSERT IGNORE 在命中唯一鍵時返回影響行數 0程序不需要先在內存里做 Set 判斷天然冪等。參數 user_token 是用戶標識或游客標識老項目經常會缺失這一列這時只能先查再插但并發高會有競態條件所以最合理的做法是補列并用唯一索引兜底。如果用的是 MySQL 8.0也可以把 ignore 改成 ON DUPLICATE KEY UPDATE但要注意 answer_value 更新策略。4.3 參數化查詢與 SQL 注入防護數據采集系統的輸入來自外部SQL 注入風險高。無論 JDBC 還是 Hibernate必須用參數占位符不能拼字符串。Hibernate 的寫法String fieldName request.getParameter(fieldName); ListQuestion list session.createQuery( from Question q where q.fieldName :fieldName, Question.class) .setParameter(fieldName, fieldName) .list();setParameter 最終走 JDBC PreparedStatement特殊字符不會被當作 SQL 執行。這里常見的坑是模糊查詢時有人寫 q.content like %content%這種寫法在 Hibernate 里一開始是報錯的要寫成 concat(%, :content, %)。另外采集系統里經常出現批量更新的需求如果用了 Hibernate 的 hql 拼接 set 子句一定要驗證每一段來自外部不要把請求參數直接拼進 HQL否則等于把注入面從 SQL 擴展到了 HQL。5. 沒源碼時從 class 文件反查采集邏輯的三個技巧壓縮包里沒有 .java 源碼只有編譯后的 .class。這種場景在接手老系統時很常見用三個技巧可以在不導入 IDE 的情況下快速定位采集邏輯。5.1 javap 反編譯看方法簽名javap -p -c SurveyServiceImpl.class-p 顯示私有方法-c 輸出字節碼。雖然看不到變量原名但常量池里的字符串會暴露 HQL、SQL、Jsoup 選擇器和日志模板。比如出現 from Survey where status 1就能確認采集查詢用的 Hibernate出現 .question-item 這樣的字符串就說明某處有 HTML 選擇器。這個方法速度比反編譯工具快適合線上環境臨時排查。5.2 從日志定位采集失敗老項目日志默認 INFO采集失敗信息會被淹沒。我會臨時調整 log4j 配置log4j.logger.com.example.collectDEBUG log4j.logger.org.hibernateERROR只把采集包調成 DEBUGHibernate 保持 ERROR這樣能看到每一條采集 SQL 和傳入參數又不會被 HQL 刷屏。用 Log4j2 或 Logback 時還要把 pattern 里的線程名輸出出來并行采集時線程名能幫我們確定是哪條任務失敗。5.3 把問卷批量采集改成并行任務ExecutorService pool Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); ListFutureInteger futures questions.stream() .map(q - pool.submit(() - collectAnswer(q))) .collect(Collectors.toList()); for (FutureInteger f : futures) { f.get(10, TimeUnit.SECONDS); // 單題采集最多等 10 秒 } pool.shutdown();f.get 帶超時能防止某道題卡死拖垮整個任務。線程數給到核數兩倍以上是因為采集操作是 IO 密集等待網絡響應時 CPU 可以切到別的線程但如果是寫數據庫還要看連接池上限。這個參數在 Java 面試里常拿“線程池參數如何設置”來問脫離采集場景談參數都是理論真實落地時還要不斷壓測。本文還有配套的精品資源點擊獲取