ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从Survey类看Java数据采集系统分层架构与实战技巧

从Survey类看Java数据采集系统分层架构与实战技巧 简介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 面试里常拿“线程池参数如何设置”来问脱离采集场景谈参数都是理论真实落地时还要不断压测。本文还有配套的精品资源点击获取
返回列表