AI时代的初级开发者:学什么才不会被替代
1. 概述
代码生成工具现在可以在几秒内写出 controller、entity 或测试样板。因此很多初入行的开发者担心初级职位会消失。本文不是在预测未来。文章着眼于一个更实际的问题:当写代码不再是难点时,你应该练习什么技能?
读完本文,你将获得一份需要优先学习的技能清单。每项技能都配有一个可以立即运行的 Java 示例。文章还会明确指出什么时候不应该把工作交给代码助手。
2. 为什么这个问题值得关注
从前,初级开发者通常被分配重复性工作,比如写 CRUD、映射 DTO 或修复小 bug。这也是他们学习的方式。现在大部分这类工作都可以自动生成。创建代码的成本大大降低。
但出错的成本还是老样子。生成的代码可能能编译通过、看起来合理、甚至通过简单的测试,但在细微处出错:货币、并发、事务、安全。代码上线到生产环境时,责任还是在你身上。
因此价值从“能写代码”转向了三项工作:
- 评估代码是否正确及原因。
- 验证行为(通过测试)。
- 理解 runtime、framework 和代码运行其中的系统。
接下来的各部分将逐一介绍每项技能。
3. 读和审查代码好过快速写代码
第一项技能是看似“没问题”的代码中找出错误。假设你请代码助手写一个计算订单总额的函数,得到如下代码:
import java.util.List;
record OrderLine(String product, double price, int quantity) {}
record Order(List<OrderLine> lines) {
double total() {
return lines.stream()
.mapToDouble(l -> l.price() * l.quantity())
.sum();
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(
new OrderLine("笔", 0.1, 1),
new OrderLine("本子", 0.2, 1)));
System.out.println(order.total());
}
}
代码简洁易读,但结果是:
0.30000000000000004
double 按二进制浮点数形式表示。因此 0.1 和 0.2 不能精确存储。对于货币,这个误差在数千笔交易中累积会变成对账错误。改进方案使用 BigDecimal:
import java.math.BigDecimal;
import java.util.List;
record OrderLine(String product, BigDecimal price, int quantity) {
BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
record Order(List<OrderLine> lines) {
BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
有三点需要注意:
price的类型是 BigDecimal。应该用new BigDecimal("0.1")创建值,不要用new BigDecimal(0.1)。subtotal()将价格乘以数量,而不经过 double。reduce从BigDecimal.ZERO开始,所以空订单仍然返回 0 而不是出错。
这就是工具还无法代替你的技能。你必须知道提问:关于数据类型、边界值和空值的问题。在审查代码时,问自己:“什么样的输入会导致这个函数出错?”
4. 用测试当作一份规格说明
没有测试,你就不知道收到的代码是否正确。测试也是最清楚地描述你想要什么的方式。描述越清楚,生成的代码或同事写的代码就越容易验证。
使用 Spring Boot,只需一个依赖就能拥有 JUnit 5 和 AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
上面 Order 类的测试:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderTest {
@Test
@DisplayName("总价准确相加小数")
void totalIsExactForDecimals() {
var order = new Order(List.of(
new OrderLine("笔", new BigDecimal("0.1"), 1),
new OrderLine("本子", new BigDecimal("0.2"), 1)));
assertThat(order.total()).isEqualByComparingTo("0.3");
}
@Test
@DisplayName("空订单的总价为0")
void emptyOrderTotalIsZero() {
var order = new Order(List.of());
assertThat(order.total()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("总价按数量正确相乘")
void totalMultipliesByQuantity() {
var order = new Order(List.of(
new OrderLine("书", new BigDecimal("12.50"), 3)));
assertThat(order.total()).isEqualByComparingTo("37.50");
}
}
值得学习的几个细节:
@DisplayName用可读的语言记录业务规则。读测试报告就能理解系统应该做什么。isEqualByComparingTo按值比较。new BigDecimal("0.3").equals(new BigDecimal("0.30"))返回 false,因为 scale 不同。这是很常见的错误。- 每个测试恰好检查一种行为:常见情况、空值情况、乘以数量的情况。
注意: 不要让工具同时生成代码和测试,然后看到绿色通过就安心。如果两者都误解了需求,测试仍然会通过。你应该至少自己写关键业务规则的测试。
5. 理解 runtime:并发不能靠猜
大多数难以捕捉的 bug 只在多个 thread 同时运行时出现。在 Spring 中,每个 bean 默认是单例的,意味着所有请求共用一个实例。以下代码看起来无害:
import java.util.concurrent.Executors;
class ReportService {
private int processedCount = 0;
void process() {
processedCount++;
}
int processedCount() {
return processedCount;
}
}
public class CounterDemo {
public static void main(String[] args) {
var service = new ReportService();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(service::process);
}
}
System.out.println("已处理: " + service.processedCount());
}
}
Java 21 的 Executors.newVirtualThreadPerTaskExecutor() 为每个任务创建一个虚拟线程。try-with-resources 块等待所有任务完成后再关闭。我们期望打印 10000,但实际上经常看到一个较小的数字,而且每次运行都不同:
已处理: 9871
原因是 processedCount++ 不是原子操作。它包括三个步骤:读、加、写。两个线程可能同时读取一个值,导致一次增加丢失。改进方案:
import java.util.concurrent.atomic.AtomicInteger;
class ReportService {
private final AtomicInteger processedCount = new AtomicInteger();
void process() {
processedCount.incrementAndGet();
}
int processedCount() {
return processedCount.get();
}
}
这次结果总是 10000。更广泛的教训是:单例 bean 不应该保持随请求而改变的状态。读 @Service 时,应该查找非 final 的字段。那通常是隐藏错误的地方。
你不需要记住 java.util.concurrent 的所有 API。但你需要理解 race condition、visibility 和 lock 等概念,以便在看到错误时认出它。
6. 理解框架:知道“魔法”怎样运作
Spring 通过注解让你写更少的代码,但代价是很多事情发生在幕后。如果不理解底层机制,你会被惊吓到。经典例子是 @Transactional 的自调用问题:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
public void payAll(List<Long> paymentIds) {
paymentIds.forEach(this::pay);
}
@Transactional
public void pay(Long paymentId) {
var payment = paymentRepository.findById(paymentId).orElseThrow();
payment.markPaid();
paymentRepository.save(payment);
}
}
乍一看,每次调用 pay 都会在事务中运行。实际上不会。Spring 通过包裹在 bean 外面的代理应用 @Transactional。当 payAll 调用 this.pay(...) 时,调用直接进入原始对象,绕过了代理。因此没有事务被打开。
有两种常见的修复方法:
- 将
@Transactional放在从外部调用的方法上,即payAll,如果你希望整个批次在一个事务中运行。 - 将
pay提取到另一个 bean,比如SinglePaymentProcessor,然后注入到PaymentService中,这样调用就会通过代理。
理解代理机制能帮助你解释许多其他现象:@Async 不异步运行、@Cacheable 不缓存,或 private 方法上的注解被忽略。这些基础知识能让你比任何建议工具更快地调试。
7. 观察系统而不是猜测
代码在你的机器上运行正确,不意味着在生产环境中也能正确运行。懂得阅读日志、指标和健康检查的初级开发者比只会写功能的许多人更有价值。使用 Spring Boot,可以从 Actuator 开始:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
然后配置所需的端点:
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
logging:
level:
root: INFO
org.springframework.transaction: DEBUG
各部分的含义:
exposure.include仅打开所需的三个端点。用"*"打开全部是常见的安全错误。show-details: when-authorized仅向已认证的用户显示健康详情。- 为
org.springframework.transaction启用 DEBUG 能让你在日志中看到事务何时打开和提交。这是直接验证第 6 节自调用错误的方法。
快速检查:
curl http://localhost:8080/actuator/health
{"status":"UP"}
这里需要培养的习惯是:有 bug 时,先从日志、指标和堆栈跟踪收集证据,而不是随机尝试代码。不要无目的地尝试直到 bug 自己消失。
8. 正确使用代码助手,以及何时不应该使用
代码助手是好工具。避免使用它不会让你进步更快。重要的是你保持决策者的角色。合理的流程包括四个步骤:
- 先自己写需求和主要测试,如第 4 节。
- 请工具建议实现部分。
- 逐行阅读。如果你无法解释某一行,不要合并它。
- 运行测试,为你在审查期间想到的边界情况添加测试。
不应该将工作交给代码助手,或者必须特别谨慎的情况包括:
- 安全代码:认证、授权、加密、token 处理。这里的小错误可能导致大问题,而且错误代码通常看起来很像正确代码。
- 学习新概念时。如果工具代你写第一个并发练习,你会错过学习中最重要的部分。
- 敏感数据:不要将 secret、客户信息或内部代码粘贴到公司未授权的工具中。
- 特定领域业务工具无法理解的,如税务规则或专有折扣政策。先询问了解业务的人。
警告: 一个危险的信号是你合并代码的速度快于理解它的速度。那时你不再是工程师了,只是在转发代码。
9. 为初级开发者推荐的学习路线
你不需要同时学习所有内容。合理的顺序如下:
- 语言基础:数据类型、集合、
equals/hashCode、异常、基本并发。 - 测试:JUnit 5、AssertJ、按行为命名测试、边界情况测试。
- 框架内部:依赖注入、bean 生命周期、代理、事务。
- 数据库:真实 SQL、索引、事务隔离、使用 JPA 时的 N+1 查询问题。
- 运维:阅读日志、指标、健康检查、在类生产环境中调试。
- 沟通:把模糊的需求转化为清晰的验收标准,写易于审查的拉取请求描述。
最后一项技能经常被轻视。懂得提出正确问题的人,比如“空订单怎么办?“或”两个请求同时到达怎么办?“,无论代码由谁或什么工具编写,都将永远需要。
10. 总结
本文中,我们看到初级开发者的价值从写代码的速度转向了评估、验证和理解系统的能力。例子说明了“看起来没问题”的代码仍然犯的错误:用 double 处理货币的舍入误差、单例 bean 中的 race condition,以及由于自调用导致 @Transactional 被忽略。你也看到了如何使用测试和 Actuator 自己验证,而不是猜测。
像对待一个写得快但容易出错的同事一样使用代码助手。你可以利用它的速度,但在合并之前始终要重新阅读、编写测试并理解每一行。这样你才不会被替代,成为团队离不开的人。