马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
从Servlet到SSM:一个乡镇自来水收费系统的Java Web架构演进与实现
本文导读
最近翻了一个老项目——乡镇自来水收费系统,用SSM(Spring+SpringMVC+MyBatis)+JSP写的,功能不复杂但很典型:用户管理、水表管理、抄表录入、费用计算、收费开票、报表统计。这种项目在三四线城市和乡镇特别常见,很多水务公司还在用类似的系统。这篇文章我从Java Web架构的演进讲起,以这个项目为例,把SSM三层架构的设计思路、核心代码、配置文件和部署踩坑都讲透,适合做Java Web方向毕设或者刚入行的后端同学参考。
一、Java Web架构演进简史
做Java Web开发,了解技术演进的脉络很重要——你现在用的框架,都是前人踩坑踩出来的。大致经历了这么几个阶段:
阶段 | 代表技术 | 特点 | 主要问题 |
第一阶段
|
Servlet
|
纯Java代码输出HTML,所有逻辑写在一个类里
|
代码臃肿,前后端混在一起,维护噩梦
|
第二阶段
|
JSP+JavaBean
|
JSP负责页面,JavaBean封装数据和业务
|
JSP里还是有大量Java代码,职责不清
|
第三阶段
|
Struts+JDBC
|
MVC模式开始普及,Struts管Controller
|
配置繁琐,JDBC手写SQL太痛苦,Action线程安全问题
|
第四阶段
|
SSH(Struts+Spring+Hibernate)
|
Spring管依赖注入,Hibernate做ORM
|
Hibernate学习曲线陡,复杂SQL优化难,Struts漏洞多
|
第五阶段
|
SSM(SpringMVC+Spring+MyBatis)
|
SpringMVC替代Struts,MyBatis替代Hibernate
|
配置文件多,部署打包麻烦
|
第六阶段
|
Spring Boot+MyBatis-Plus
|
约定优于配置,自动装配,起步依赖
|
对新手来说屏蔽了太多底层原理,不利于理解
|
这个自来水收费系统用的是第五阶段的SSM,在当时(大概2018-2020年)是企业级Java Web的主流方案。现在新项目一般直接上Spring Boot了,但SSM的项目存量还很大,而且理解SSM是理解Spring Boot的基础——Spring Boot本质上就是把SSM的配置自动化了。
二、为什么这个项目选SSM
做技术选型不能只看"什么最新用什么",要看项目需求和团队能力。这个项目选SSM的原因:
第一,功能需求明确。自来水收费系统是典型的CRUD+业务计算,用户量不大(乡镇级别,同时在线几十人),不需要微服务那套重量级架构,SSM单体应用完全够用。
第二,开发效率和可控性的平衡。MyBatis相比Hibernate,SQL是自己写的,优化起来直观,出了问题好排查;SpringMVC相比Struts,配置更简洁,注解驱动开发效率高。
第三,生态成熟。SSM的文档、教程、问题解决方案网上一搜一大把,遇到坑基本都能找到答案,对小团队来说很重要。
第四,部署简单。打个war包扔Tomcat里就能跑,乡镇的运维人员也能搞定,不需要Docker、K8s那套。
三、SSM三层架构详解
SSM的核心是分层——表现层、业务层、持久层,各层职责明确,通过Spring的依赖注入(DI)组装在一起。
图1 SSM框架三层架构图(SpringMVC表现层+Spring业务层+MyBatis持久层) 层次 | 框架 | 职责 | 关键组件 |
表现层
|
SpringMVC
|
接收请求、参数封装、调用业务层、返回视图/JSON
|
Controller、Interceptor、ViewResolver
|
业务层
|
Spring
|
事务管理、依赖注入、业务逻辑编排
|
Service、AOP、TransactionManager
|
持久层
|
MyBatis
|
SQL映射、数据库操作、结果集封装
|
Mapper接口、Mapper XML、SqlSessionFactory
|
请求的完整流程是:用户发请求→DispatcherServlet拦截→HandlerMapping找到对应的Controller→Controller调用Service→Service调用Mapper→Mapper通过MyBatis执行SQL→结果一层层返回→Controller把数据交给View渲染→响应给用户。整个流程里,对象的创建和依赖关系都由Spring容器管理,你不用自己new对象。
四、项目整体设计
4.1 功能模块
乡镇自来水收费系统的功能模块划分如下:
图2 自来水收费管理系统功能模块图 模块 | 核心功能 | 说明 |
系统管理
|
用户管理、角色权限、密码修改、操作日志
|
基于SpringMVC拦截器做权限控制
|
基础资料
|
用水类型、水价标准、区域管理
|
水价支持阶梯定价,居民/商业/工业不同价格
|
用户水表
|
用户信息、水表档案、过户、销户
|
一个用户可以有多块水表
|
抄表管理
|
抄表录入、历史读数、用水量计算
|
支持手工录入和批量导入
|
水费管理
|
费用计算、账单生成、滞纳金计算
|
按月生成账单,逾期按日加收滞纳金
|
收费管理
|
收费登记、票据打印、退费处理
|
支持现金、微信、支付宝等多种支付方式
|
统计查询
|
收费日报、月报、欠费统计、用水分析
|
导出Excel报表
|
4.2 数据库核心表
数据库用MySQL 5.7,核心表设计:
表名 | 核心字段 | 说明 |
sys_user
|
id, username, password, role_id, status
|
系统用户表,密码MD5加密
|
water_user
|
id, name, phone, address, area_id
|
用水户信息表
|
water_meter
|
id, meter_no, user_id, install_date, status
|
水表档案表,和用水户一对多
|
meter_reading
|
id, meter_id, read_date, last_num, current_num, usage
|
抄表记录表
|
price_config
|
id, user_type, base_price, ladder_price
|
水价配置表,支持阶梯水价
|
bill
|
id, bill_no, user_id, meter_id, amount, status, month
|
账单表,status: 0未付1已付
|
payment
|
id, bill_id, amount, pay_method, pay_time, operator
|
收费记录表
|
这里有个设计要点:账单表要冗余存储用水量和金额,不能只存水表ID和月份——因为水价可能会调整,历史账单的金额应该固定不变,不能跟着水价变。这个坑很多新手会踩,到时候对账对不上就麻烦了。
五、核心模块代码实现
5.1 用户登录与权限拦截
登录用SpringMVC的Controller处理,密码MD5加密后比对。权限控制用拦截器(Interceptor),检查Session里有没有登录信息:
// Controller层
@Controller
@RequestMapping("/login")
public class LoginController {
@Autowired
private SysUserService sysUserService;
@PostMapping("/doLogin")
public String doLogin(String username, String password,
HttpSession session, Model model) {
SysUser user = sysUserService.findByUsername(username);
if (user == null) {
model.addAttribute("msg", "用户不存在");
return "login";
}
String md5Pass = DigestUtils.md5DigestAsHex(password.getBytes());
if (!md5Pass.equals(user.getPassword())) {
model.addAttribute("msg", "密码错误");
return "login";
}
if (user.getStatus() == 0) {
model.addAttribute("msg", "账号已禁用");
return "login";
}
session.setAttribute("loginUser", user);
return "redirect:/index";
}
}
// 权限拦截器
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
Object user = request.getSession().getAttribute("loginUser");
if (user == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
return true;
}
}
生产环境建议把MD5换成BCrypt,MD5已经不安全了。但这个项目是内部系统,用户量小,MD5也能接受——不过能升级还是升级。
5.2 水表信息管理(MyBatis CRUD)
MyBatis的好处是SQL和Java代码分离,Mapper接口写方法,Mapper XML写SQL,灵活好优化:
// Mapper接口
public interface WaterMeterMapper {
List<WaterMeter> findByCondition(@Param("meterNo") String meterNo,
@Param("userId") Integer userId,
@Param("status") Integer status);
WaterMeter findById(Integer id);
int insert(WaterMeter meter);
int update(WaterMeter meter);
int deleteById(Integer id);
}
<!-- Mapper XML -->
<select id="findByCondition" resultType="WaterMeter">
SELECT * FROM water_meter
<where>
<if test="meterNo != null and meterNo != ''">
AND meter_no LIKE CONCAT('%', #{meterNo}, '%')
</if>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY id DESC
</select>
MyBatis的动态SQL(<where>、<if>)做条件查询特别方便,比JDBC拼字符串强太多了。这也是MyBatis比Hibernate灵活的地方——复杂查询自己写SQL,优化空间大。
5.3 抄表与费用计算(业务逻辑层)
费用计算是核心业务逻辑,放在Service层。阶梯水价的计算逻辑:
@Service
public class MeterReadingServiceImpl implements MeterReadingService {
@Autowired
private WaterMeterMapper meterMapper;
@Autowired
private MeterReadingMapper readingMapper;
@Autowired
private PriceConfigMapper priceMapper;
@Autowired
private BillMapper billMapper;
@Transactional
@Override
public void addReading(Integer meterId, Integer currentNum, String readDate) {
WaterMeter meter = meterMapper.findById(meterId);
if (meter == null) {
throw new RuntimeException("水表不存在");
}
// 查询上次抄表读数
MeterReading lastReading = readingMapper.findLastByMeterId(meterId);
int lastNum = lastReading != null ? lastReading.getCurrentNum() : 0;
int usage = currentNum - lastNum;
if (usage < 0) {
throw new RuntimeException("当前读数不能小于上次读数");
}
// 保存抄表记录
MeterReading reading = new MeterReading();
reading.setMeterId(meterId);
reading.setReadDate(readDate);
reading.setLastNum(lastNum);
reading.setCurrentNum(currentNum);
reading.setUsage(usage);
readingMapper.insert(reading);
// 计算费用(阶梯水价)
BigDecimal amount = calculateFee(meter.getUserId(), usage);
// 生成账单
Bill bill = new Bill();
bill.setBillNo(generateBillNo());
bill.setUserId(meter.getUserId());
bill.setMeterId(meterId);
bill.setAmount(amount);
bill.setStatus(0); // 未付款
bill.setMonth(readDate.substring(0, 7));
billMapper.insert(bill);
}
private BigDecimal calculateFee(Integer userId, int usage) {
// 根据用户类型查水价配置,简化版:三档阶梯
PriceConfig config = priceMapper.findByUserType(getUserType(userId));
BigDecimal amount = BigDecimal.ZERO;
if (usage <= 10) {
amount = config.getBasePrice().multiply(new BigDecimal(usage));
} else if (usage <= 20) {
amount = config.getBasePrice().multiply(new BigDecimal(10))
.add(config.getLadderPrice().multiply(new BigDecimal(usage - 10)));
} else {
amount = config.getBasePrice().multiply(new BigDecimal(10))
.add(config.getLadderPrice().multiply(new BigDecimal(10)))
.add(config.getLadderPrice().multiply(new BigDecimal(1.5))
.multiply(new BigDecimal(usage - 20)));
}
return amount.setScale(2, RoundingMode.HALF_UP);
}
}
注意抄表和生成账单要放在同一个事务里(@Transactional),要么都成功要么都失败,不能出现抄表记录存了但账单没生成的情况。生产环境这个事务很关键,对账全靠它。
5.4 收费与账单状态更新
@Service
public class PaymentServiceImpl implements PaymentService {
@Autowired
private BillMapper billMapper;
@Autowired
private PaymentMapper paymentMapper;
@Transactional
@Override
public void pay(Integer billId, String payMethod, Integer operatorId) {
Bill bill = billMapper.findById(billId);
if (bill == null) {
throw new RuntimeException("账单不存在");
}
if (bill.getStatus() == 1) {
throw new RuntimeException("账单已支付,请勿重复操作");
}
// 计算滞纳金(逾期30天以上按日0.05%加收)
BigDecimal lateFee = calculateLateFee(bill);
BigDecimal totalAmount = bill.getAmount().add(lateFee);
// 保存收费记录
Payment payment = new Payment();
payment.setBillId(billId);
payment.setAmount(totalAmount);
payment.setPayMethod(payMethod);
payment.setOperator(operatorId);
paymentMapper.insert(payment);
// 更新账单状态
bill.setStatus(1);
billMapper.update(bill);
}
}
六、关键配置文件解析
SSM项目配置文件多,这也是它被吐槽的地方。核心有四个:web.xml、applicationContext.xml(Spring配置)、spring-mvc.xml(SpringMVC配置)、mybatis-config.xml(MyBatis配置)。挑关键的看:
<!-- web.xml:核心配置,启动Spring容器和SpringMVC -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>springmvc</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>springmvc</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- 字符编码过滤器,解决中文乱码 -->
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<!-- applicationContext.xml:Spring核心配置,管数据源、事务、扫描Service -->
<context:component-scan base-package="com.water.service"/>
<!-- 数据源,用Druid连接池 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/water_fee?useUnicode=true&characterEncoding=utf-8"/>
<property name="username" value="root"/>
<property name="password" value="123456"/>
</bean>
<!-- SqlSessionFactory -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<property name="mapperLocations" value="classpath:mapper/*.xml"/>
</bean>
<!-- Mapper接口扫描 -->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="com.water.mapper"/>
</bean>
<!-- 事务管理 -->
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="txManager"/>
配置文件虽然多,但每一块职责很清晰:web.xml管Web容器启动,applicationContext.xml管业务层和持久层,spring-mvc.xml管表现层。理解了这个分工,配置就不复杂了。
七、部署与踩坑记录
这个项目部署到乡镇服务器上的时候踩了几个坑,给大家避避雷:
坑1:Tomcat启动报ClassNotFoundException
本地IDEA里跑好好的,打包扔Tomcat里启动就报ClassNotFoundException。原因是IDEA的Artifacts配置没把依赖jar包打进去,或者pom.xml里有些依赖scope设成了provided。解决方法:检查pom.xml,确保所有运行时需要的依赖scope是compile,然后用mvn clean package重新打包,解压war包看看WEB-INF/lib里jar包全不全。
坑2:数据库连接超时
系统跑一段时间后就报数据库连接超时,重启Tomcat就好,过一阵又犯。原因是MySQL默认8小时无操作自动断开连接,而连接池没有配置验证和重连。解决方法:Druid连接池加validationQuery、testWhileIdle、timeBetweenEvictionRunsMillis这几个配置,让连接池定期检测空闲连接,断开了自动重连。生产环境这个配置一定要加,不然半夜系统挂了都不知道。
坑3:中文乱码
页面提交中文到后台变成乱码,或者数据库存进去是问号。三个地方要统一编码:web.xml加CharacterEncodingFilter(UTF-8)、JSP页面pageEncoding设为UTF-8、MySQL连接URL加useUnicode=true&characterEncoding=utf-8。三处都对了才不会乱码,少一个都不行。
八、从SSM到Spring Boot的升级思路
如果要把这个项目升级到Spring Boot,思路是这样的:
1. 引入spring-boot-starter-web替代SpringMVC配置,spring-boot-starter替代Spring核心配置;
2. 引入mybatis-spring-boot-starter,Mapper XML位置在application.yml里配置,不用SqlSessionFactoryBean了;
3. 数据源用spring-boot-starter-jdbc+druid-spring-boot-starter,配置写在application.yml;
4. 事务管理自动配置,@Transactional直接用就行;
5. 打jar包用内置Tomcat启动,不用外部Tomcat了。
升级后最大的变化是配置文件从四五个XML变成一个application.yml,启动从扔Tomcat变成java -jar。但底层的架构思想没变——还是三层架构,还是Spring管依赖注入,还是MyBatis做ORM。所以说SSM学透了,Spring Boot上手很快。
九、常见问题FAQ
Q:现在学SSM还有必要吗?直接学Spring Boot不行吗?
A:如果只是做项目,直接学Spring Boot没问题。但如果想搞懂原理、排查深层问题,SSM的知识是基础。Spring Boot的自动装配本质上就是把SSM的那些XML配置用Java代码自动做了,你不懂SSM的话,出了问题都不知道从哪查。建议先花一周了解SSM的基本原理,再用Spring Boot做项目。
Q:MyBatis和JPA/Hibernate怎么选?
A:业务复杂、SQL多、需要精细优化的项目选MyBatis,比如这个收费系统;业务简单、CRUD为主、不想写SQL的项目选JPA。大厂里MyBatis用得更多,因为SQL可控、好优化、DBA好审查。
Q:SSM项目怎么做前后端分离?
A:Controller方法上加@ResponseBody,返回JSON而不是JSP视图;前端用Vue/React调用接口。SpringMVC本身就支持RESTful,改起来不难。这个收费系统如果要前后端分离,把所有Controller的返回值改成JSON,前端单独部署就行。
Q:这个项目做毕设够不够?
A:功能上完全够,甚至偏丰富了。毕设要注意:一是需求分析要写清楚,画用例图;二是数据库设计要规范,画ER图,说清楚表之间的关系;三是核心代码要讲明白,特别是费用计算和事务控制;四是测试部分要有测试用例和测试结果截图;五是可以加点亮点,比如Excel导入导出、图表统计、短信催费提醒。
十、总结与延伸学习路径
这个乡镇自来水收费系统虽然是个老项目,用的是SSM架构,但它涵盖了Java Web开发的核心知识点:MVC分层、依赖注入、ORM映射、事务管理、拦截器、连接池。把这个项目吃透,再学其他Java Web框架就触类旁通了。
如果想继续深入Java后端,可以按这个路径学习:
1. 先把SSM原理搞透,特别是Spring的IoC和AOP、MyBatis的SQL映射和缓存;
2. 学Spring Boot,理解自动装配原理,会用starter快速搭建项目;
3. 学Redis,做缓存和分布式Session,解决高并发场景的性能问题;
4. 学消息队列(RabbitMQ/Kafka),做异步处理和解耦;
5. 最后学Spring Cloud微服务,把单体应用拆成多个服务。
做后端开发,架构思维比具体技术更重要。从Servlet到SSM到Spring Boot到微服务,变的是工具和框架,不变的是分层、解耦、高内聚低耦合这些设计原则。理解了演进的逻辑,就不会被新技术牵着走。
—— 技术一直在变,但解决问题的思路是相通的。
有问题欢迎评论区交流,需要SSM完整配置文件模板可以关注后续更新~
|