一提起“Java Web应用开发”,很多刚学完Java基础的人第一反应是:语法看懂了、集合会用了,可真让自己做一个能在浏览器里访问并操作数据的系统,脑子里还是一片空白。这也是我这些年回答过最多的入门问题之一。市面上的“任务式教程”很多,但不少做成了“照着敲一遍”,敲完还是不会举一反三。这篇内容我打算换一种讲法——按一个完整任务的推进过程,把从环境搭建、技术选型、代码组织,到数据库接入、部署上线、常见翻车点全部串起来,给出一条能直接照做的路径。
这篇内容适合两类人:一类是刚学完Java基础、准备进入Web方向的学生;另一类是已经用IDE拖拽式创建过项目、但始终没搞明白为什么这样配置、为什么报错的在职新人。下面所有步骤我尽量按实际开发中会遇到的顺序来写,不会跳步,也不会光给结论不给理由。
1. 先解决“Hello World都跑不起来”的环境问题
能走到“任务式开发”这一步的人,多半已经卡在环境配置上一段时间了。我见过太多人不是因为代码难,而是因为IDEA配置、Maven依赖、Tomcat端口这些环境问题直接劝退。所以第一步不是写功能,而是先把开发环境折腾到“能稳定跑通一个空项目”。
1.1 版本选择:不要为了新而新
先说结论:JDK选17或者21,Maven选3.8以后的稳定版本,IDEA选2024社区版或旗舰版都行,Tomcat如果你用Spring Boot内嵌服务器则根本不需要单独安装。
为什么是17或21?因为现在大多数教学和企业项目已经迁移到17,21则是新的长期支持版本。如果你刚开始学,直接上17最稳妥,遇到网上资料提问时,别人给你的答案也更容易适配。JDK 8虽然老当益壮,但很多新特性(比如var、switch表达式增强、records)会让代码简洁很多,真不建议新项目再踩回JDK 8。
Maven的坑在于默认中央仓库在国外,第一次下载依赖时速度极慢,甚至卡死在下载界面。解决办法很简单:在settings.xml里配置阿里云镜像。这一步没有技术难度,但99%的初学教程不会提前告诉你,于是很多人卡在“创建项目后一直下载依赖”这一步。
<mirror> <id>aliyunmaven</id> <mirrorOf>*</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>1.2 IDEA 2024创建Web项目的两种方式
在2024版的IDEA里创建Web项目,其实有两条路,很多人不知道区别:
- 第一种:用
Spring Initializr创建,这是目前主流做法,生成的就是一个Maven结构下的Spring Boot工程。好处是起步快,内嵌Tomcat,不依赖外部容器。 - 第二种:手动创建普通Maven项目,然后手动引入
spring-boot-starter-web依赖,再把目录结构补齐。好处是你能看清每一个依赖是做什么的,适合理解原理。
我比较推荐新手用第二种方式至少做一次。不是因为第一种不好,而是因为如果你一开始就用IDE自动生成,很可能连pom.xml里那几行依赖代表什么都说不清楚,后面遇到依赖冲突时更会一头雾水。
手动创建时,最常报的错是Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin,本质上是编译插件版本和JDK版本不匹配。解决办法是在pom.xml里显式声明编译插件版本,或者直接右键项目→Maven→Reload Project,把Maven配置刷新一遍。
1.3 本地第一个能跑的验证动作
环境配好后,不要急着写业务代码。我给自己和学生定了一个“三个验证”标准:
- 启动一个Spring Boot项目,浏览器访问
localhost:8080能看到一个页面(哪怕是Whitelabel错误页也算启动成功); - 能通过
mvn clean package打出可执行jar包; - 写出一个带参数的GET接口,通过浏览器或Postman调用返回JSON。
这三步全过了,环境才叫“稳了”。很多人环境其实已经能用了,但因为不知道“验证什么”,总觉得自己配得不对,反复重装重配,白白浪费几个小时。记住:开发工具的目标是让你专注写逻辑,不是每天伺候它。
2. Servlet、JSP与Spring Boot:这条学习路线该怎么走
很多教材还在从Servlet和JSP开始讲Java Web,而企业实际开发基本已经在用Spring Boot。这就让初学者很纠结:学旧的怕落伍,直接学新的又不知道底层原理。
我的观点很明确:Servlet的“生命周期”和“请求处理模型”必须理解,但不建议在JSP上花太多时间。JSP的写法在现在的企业项目里已经相当少了,前后端分离之后,绝大部分后端最常做的工作变成了“写接口、返回JSON”。但如果你不理解Servlet容器是怎么接收HTTP请求并调用Java方法的,那么Spring Boot里的DispatcherServlet对你来说就永远是黑盒。
2.1 Servlet到底做了什么类比
可以把Servlet想象成一个“接电话的接线员”。浏览器发起一个HTTP请求到服务器,Tomcat这样的容器先把请求拿过来,按照URL找到对应的Servlet,然后把”电话内容“(请求参数、请求头)递过去。Servlet处理完,再把回应塞给你(浏览器)。
手工注册Servlet的代码你能看懂即可:
@WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/plain;charset=UTF-8"); resp.getWriter().write("Hello from Servlet"); } }这里有两个信息量:HttpServletRequest封装了请求数据,HttpServletResponse封装了响应数据。Servlet生命周期中的init、service、destroy三个方法,对应的是“接线员上岗、接电话、下岗”。Spring Boot并没有推翻这套模型,它只是在你和Servlet之间加了一层“自动接线”的封装,让你不用手写web.xml配置。
2.2 Spring Boot的“自动配置”是怎么回事
Spring Boot最让人觉得神奇的地方是一个只有几行的启动类:
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这个注解干了什么?可以这样理解:启动时Spring Boot扫一遍你依赖里的所有starter,发现你引入了spring-boot-starter-web,就自动把Tomcat内嵌服务器、JSON转换器、DispatcherServlet全部配好。你引入spring-boot-starter-data-jpa,它又自动帮你配好数据库连接池。这种模式叫“约定优于配置”——你要做的不是重复配置一切,而是只在需要偏离默认行为时去覆盖。
对于初学者,最容易犯的错误是觉得“Spring Boot太黑盒了,我要先学完Spring原理再动手”。有这个敬畏心是好事,但请不要等。正确姿势是:先拿Spring Boot把接口写出来,等程序跑通了、逻辑理顺了,再回头研究@Autowired是怎么注入的、AOP是怎么代理的。反向学习,效率更高。
2.3 我推荐的学习顺序
我给学生安排的学习路径是“倒梯形”:
- 第一阶段:会看Servlet代码,能说清请求-响应模型;
- 第二阶段:用Spring Boot写CRUD接口,不纠结原理;
- 第三阶段:遇到问题后带着疑问去查源码,研究Spring IoC/DI如何生效;
- 第四阶段:再回头对照Servlet理解Spring MVC的请求分发过程。
这条路径的好处是每阶段都有产出,且阶段之间有明确的“为什么”驱动。很多人的问题不是笨,而是“顺序反了”——一开始扎进Spring源码,被Bean生命周期、依赖注入流程绕晕,还没跑到写接口那天就放弃了。
3. 以“员工信息管理系统”为例的任务拆解与实现
任务式学习最关键的一点是:不要用“功能清单”代替“任务”。真正的任务是有场景、有用户、有约束的。下面我用一个练手几乎必做的“员工信息管理系统”走一遍任务拆解逻辑,你把这个流程跑通,换任何管理系统都能照做。
3.1 先把任务描述成“人能听懂的话”
正常情况下,你接到的需求不会是“实现员工的增删改查”,而是一句生活化的描述:部门主管要能登录系统,录入新入职员工的信息,修改员工岗位和薪资,离职了要能删除记录,还要能看到所有员工的列表。
拆解时要抓住三个问题:谁在用?要做什么事?操作前后有什么数据变化?
这个任务的用户是“部门主管”,要做的事是登录、管理员工信息,涉及的数据就是员工记录。围绕这三个问题,后续的页面、接口、数据库表才能有据可依。不拆需求直接开写代码,是新手最容易犯的错误。
3.2 数据库表设计:别等代码写完了再改表
员工表我建议至少包含这些字段:主键ID、工号、姓名、性别、所属部门、岗位、入职日期、薪资、状态、创建时间、更新时间。
CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工号', name VARCHAR(64) NOT NULL COMMENT '姓名', gender TINYINT COMMENT '1-男 2-女', dept VARCHAR(64) COMMENT '部门', position VARCHAR(64) COMMENT '岗位', hire_date DATE COMMENT '入职日期', salary DECIMAL(10,2) COMMENT '薪资', status TINYINT DEFAULT 1 COMMENT '1-在职 0-离职', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工信息表';emp_no加上唯一约束,避免重复工号;update_time使用ON UPDATE CURRENT_TIMESTAMP,方便以后排查数据变更时间。如果你在建表时省掉这些细节,后面写业务代码时就会不断打补丁。
3.3 登录与会话管理:别做表面登录
初级项目的登录最容易做成“只要密码对了就返回成功”,但真正的登录还要考虑三件事:密码不能明文存储、登录状态如何保持、未登录用户如何拦截。
密码存储最少也要做哈希加盐。实际项目中一般会用BCryptPasswordEncoder,Spring Security里自带了这套工具。登录状态有两种方案:Session(传统单体架构)和JWT(前后端分离场景)。练手项目用Session更简单,启动时配置好拦截器或过滤器,把未登录请求拦下来即可。
这里提供一个非常小的拦截器示例:
@Configuration public class LoginInterceptor implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HandlerInterceptor() { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute("userId") == null) { response.sendRedirect("/login"); return false; } return true; } }).addPathPatterns("/**") .excludePathPatterns("/login", "/css/**", "/js/**"); } }这个拦截器的逻辑非常直白:会话里有用户就放行,没有就打回登录页。别小看这段代码,它是所有管理后台权限控制的地基。
3.4 列表、新增、修改、删除的完整链路
有了表和登录,剩下的CRUD就是“机械填充”了吗?不完全是。列表页要注意分页,新增要校验参数,修改要防止把不该改的字段也改了,删除建议用逻辑删除而不是物理删除,也就是把status置为0。
Spring Boot里写一个员工查询接口,核心不过十行代码:
@RestController @RequestMapping("/employee") public class EmployeeController { @Autowired private EmployeeMapper employeeMapper; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { PageHelper.startPage(page, size); List<Employee> employees = employeeMapper.selectByCondition(keyword); return Result.success(new PageInfo<>(employees)); } }但注意,真正的问题是employeeMapper.selectByCondition(keyword)这行背后的SQL怎么写。这里我强烈建议你把条件查询的SQL手写一遍,理解动态SQL的拼接逻辑,再让MyBatis的<if>标签帮你省事:
<select id="selectByCondition" resultType="com.example.entity.Employee"> SELECT * FROM employee <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR emp_no LIKE CONCAT('%', #{keyword}, '%') OR dept LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY id DESC </select>这段SQL里的<where>会自动去掉多余的AND,<if>实现可选条件。很多教程直接给你成品,但你在写之前最好先用原生SQL在数据库客户端里跑一遍,确定查询逻辑正确,再搬到Mapper里。这一步能帮你避开80%的“接口查不出数据”问题。
4. 数据库接入层选型:JDBC、MyBatis与Spring Data JPA的取舍
不少人在做完上述CRUD后,会冒出一个疑问:我用MyBatis写SQL,感觉和JDBC也差不多,为什么还要框架?这个问题问得很好,因为它直接牵出数据库接入层的设计逻辑。
4.1 三条路线的核心差异
| 方案 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|
| JDBC手写 | 完全可控、理解底层 | 代码重复、易错 | 学习原理 |
| MyBatis | SQL可控、灵活(动态SQL、复杂查询) | XML/注解和Java代码分离,需要维护映射关系 | 企业级业务系统、SQL复杂 |
| Spring Data JPA | 开发效率高,告别大量SQL | 复杂查询学习曲线陡,SQL调优空间受限 | 简单CRUD较多、快速开发 |
如果你是做学习项目,我建议至少把JDBC手写一遍(比如用PreparedStatement执行SQL、遍历ResultSet),再切换到MyBatis。多说一句,PreparedStatement能防SQL注入是因为它把参数和SQL模板分开了,而不是简单拼字符串,这个原理面试常问。
4.2 事务与@Transactional的坑
“Java怎么保证数据一致性”是热搜里出现频率很高的问题,落点往往就在事务上。事务的本质是“要么全做,要么全不做”。比如员工入职时要同时完成“新增员工”和“部门人数加一”两步操作,中间任何一步失败,都不能留下半截数据。
Spring里最省事的是@Transactional注解:
@Transactional(rollbackFor = Exception.class) public void addEmployeeAndUpdateDept(Employee employee) { employeeMapper.insert(employee); deptMapper.increaseCount(employee.getDeptId()); }看起来简单,实际有坑。
先看rollbackFor = Exception.class,这一行很关键。Spring默认只对RuntimeException回滚事务,如果你不写明把Exception全部纳入回滚范围,代码抛出自定义Exception(受检异常)时事务可能不回滚,数据就“悄悄写错”了。
再看事务失效问题,这是新手最容易踩的雷:@Transactional加在private方法上无效;从同类里用this调用带@Transactional的方法无效;自己new出来的对象调用方法无效。原因统一说就是:Spring的事务是通过AOP代理实现的,代理才能在你调用前开后开事务,直接内部调用的那个不是代理。
我排查过很多“数据怎么没回滚”的问题,最后发现都是事务没生效。建议你在写完事务方法后,故意让第二步抛一个异常,确认第一步的数据被回滚了,再继续往下写。
4.3 数据一致性不能只靠事务
事务保证的是“单次操作的一致性”,但高并发下还需要别的工具。举一个具体场景:两个管理员同时把同一个员工的薪资改成不同金额,最终结果以谁为准?如果只是简单UPDATE,后提交的会覆盖先提交的,用户很可能就被“悄悄改错”。
解决思路通常是:
- 乐观锁:在员工表加一个
version字段,更新时SET salary = #{newSalary}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion},受影响行数为0则说明数据已被别人改了; - 悲观锁:查询时
SELECT ... FOR UPDATE,把这一行锁住再修改。
学习阶段你不需要把两部全部实践一遍,但要能说清各自的适用场景。面试官问“数据一致性”时,他不只听你背出“事务”两个字,更想听你有没有真的处理过“并发覆盖”这个具体问题。
5. 部署上线前最容易翻车的五个细节
很多人在本地上一切正常,一发布到服务器就各种“灵异事件”。我按真实踩坑频率排个序,这五件事是在部署前值得先自查一遍的。
5.1 编码问题:UTF-8的隐形战斗
最常见的症状是:数据库里显示中文正常,网页上却是???;或者本地上传的文件名到服务器变成乱码。
排查三处:
- 数据库连接串上加
characterEncoding=utf8; - 建表时统一用
utf8mb4(MySQL 8.0默认已经是,老库需要手动确认); - 应用层接收POST请求时配置HTTP请求编码过滤器。
Spring Boot里配置一行即可:
spring.datasource.url=jdbc:mysql://localhost:3306/employee_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai注意serverTimezone=Asia/Shanghai。不加这个,服务器和数据库时区不一致时,时间字段会凭空差8个小时,这是新手最爱犯的“玄学”问题。
5.2 端口占用和进程管理
Tomcat默认8080,但服务器上经常被别的进程占着。用netstat -tlnp | grep 8080看一下是谁占的,不要盲目换个端口就完事,要搞清楚为什么被占。
Spring Boot打出来的jar包启动方式:
nohup java -jar employee-system.jar --server.port=8080 > app.log 2>&1 &这条命令里nohup表示不挂断,> app.log把日志写到文件,2>&1把错误也合并进去。新手最爱犯的错误是关掉终端进程就没了——原因就是少写了nohup和&。
5.3 数据库连接池配置
Spring Boot 2.X之后默认的数据库连接池是HikariCP,性能很强,但默认值在你本地够用,上线不够用。很多人第一次上线就遇到“连接数耗尽”,是因为没配置最大连接数。一个基础的配置:
spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=3000020个连接对于学生项目和企业常见的后台管理系统基本够用。如果并发量再上去,就别只看这个参数了,先去做慢查询优化和缓存。
5.4 Web安全的基本功:这几个洞一定要堵
热搜词里“web服务器安全”“web安全”出现频率很高,说明这是大家实际关心的问题。后端开发至少要堵住三个最基本的漏洞:
- SQL注入:用
PreparedStatement、MyBatis的#{}传参,不要用${}直接拼接; - XSS跨站脚本攻击:不要直接在前端用
v-html渲染用户输入,后端也要做基本输出编码; - CORS跨域配置:如果前后端分离,
Access-Control-Allow-Origin不要配成*,要指定明确的域名白名单。
千万别觉得“我这是学习项目,没人攻击”。我以前也这么想,直到看到自己的学习项目日志里被人用' OR '1'='1这种字符串疯狂探测。安全不是一个模块,而是一种写代码的默认习惯。
5.5 PDF打印这个需求为什么会翻车
“web页面pdf打印”是热搜里的一个具体场景,很多管理后台都需要打印员工表或者报表。实现方式一般是前端用浏览器的打印功能,或者后端用组件生成PDF。
后端生成PDF,中文乱码是最典型的问题——很多PDF库默认字体不支持中文。解决办法是指定系统里存在的中文支持字体,比如Windows下的宋体、微软雅黑,Linux下的noto-sans-cjk。如果你部署到Linux服务器,需要在服务器上确认字体包存在:
fc-list | grep -i noto没有就安装:
yum install -y noto-sans-cjk-fonts这个坑特别隐蔽,本地Windows生成正常,上线Linux就乱码,原因就是服务器没字体。
6. 从完成任务到面试过关:Java Web技术栈的进阶脉络
任务做完只是第一步,能把项目说清楚才是面试阶段的核心能力。不少热搜词指向“java面试题”“java开发工程师面试题”,我结合Java Web方向给一些点拨。
6.1 面试官看你这个项目,会问什么
如果你简历上写“开发了员工信息管理系统”,技术栈写了Spring Boot + MyBatis + MySQL,面试官大概率会顺着这几条线问:
- 你在这个项目里遇到的最大问题是什么,怎么解决的;
- MyBatis的
#{}和${}有什么区别,动态SQL是怎么执行的; - Spring Boot的自动配置原理是什么,自定义starter怎么做;
- 如何优化一个查询很慢的接口(这里会从SQL索引衔接到缓存和数据库优化);
- 一个请求从浏览器地址栏到接口返回数据,完整经过了哪些环节。
前三个问题是命中率最高的。我面试别人的时候,最怕听到的回答是“这个项目很简单,就是增删改查”。你辛辛苦苦写了登录拦截、做了数据校验、配了事务管理,这些细节在候选人嘴里消失了,面试官只会判断你是“照着教程敲了一遍”。
6.2 怎么把项目讲出亮点
讲项目的核心方法是“问题驱动”:不要按功能清单念,要按“你遇到了什么问题→怎么排查的→为什么选择这个方案→最后怎么验证的”来讲。
我举一个例子,同样是员工列表接口:初级说法是“我写了一个分页查询接口”。加分说法是“最初的接口不分页,数据到几千条后前端卡顿。我排查后发现是查询把所有记录一次性返回,后来引入了PageHelper分页,并给dept字段建了索引。在测试环境实测,接口响应从1.8秒降到了220毫秒左右。”
这就是“任务式”的价值——当年你把这些坎都趟过一遍,项目才真正属于你自己。
6.3 还可以往哪些方向扩展
一个员工管理系统做扎实之后,后续扩展非常方便:
- 加上部门管理和岗位管理,涉及父子级联,你会学到树的处理;
- 加上工资流水,你会接触到多表事务、定时任务;
- 加上审批流程,你会理解状态机的转移和权限控制;
- 加上多角色登录,你会从Session方案切换到Spring Security + JWT,并理解认证与授权的区别;
- 加上文件上传,你会接触对象存储、文件扫描和静态资源代理。
每条扩展方向背后都是一组新问题。用同样的“拆任务→定方案→实现→复盘”流程去做,你的技术深度会明显拉开单纯刷题的人。
我个人一直主张,学Java Web应用开发最忌讳的就是顺着教程从第一章老三样看到最后一章,最后发现自己什么都没做过。找一个自己日常工作或生活中真正用得上、哪怕很小的系统,一点一点补功能,遇到不会的再查、再问、再记,这种学习方式下的每一行代码都有意义。等你能把一个带登录、权限、数据查询、部署上线的系统完整讲给别人听,再回看当初那个“不知道从哪儿下手”的自己,你会感谢这些被掰开揉碎、踏踏实实趟过的任务。