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. 正确使用代码助手,以及何时不应该使用

代码助手是好工具。避免使用它不会让你进步更快。重要的是你保持决策者的角色。合理的流程包括四个步骤:

  1. 先自己写需求和主要测试,如第 4 节。
  2. 请工具建议实现部分。
  3. 逐行阅读。如果你无法解释某一行,不要合并它。
  4. 运行测试,为你在审查期间想到的边界情况添加测试。

不应该将工作交给代码助手,或者必须特别谨慎的情况包括:

  • 安全代码:认证、授权、加密、token 处理。这里的小错误可能导致大问题,而且错误代码通常看起来很像正确代码。
  • 学习新概念时。如果工具代你写第一个并发练习,你会错过学习中最重要的部分。
  • 敏感数据:不要将 secret、客户信息或内部代码粘贴到公司未授权的工具中。
  • 特定领域业务工具无法理解的,如税务规则或专有折扣政策。先询问了解业务的人。

警告: 一个危险的信号是你合并代码的速度快于理解它的速度。那时你不再是工程师了,只是在转发代码。

9. 为初级开发者推荐的学习路线

你不需要同时学习所有内容。合理的顺序如下:

  • 语言基础:数据类型、集合、equals/hashCode、异常、基本并发。
  • 测试:JUnit 5、AssertJ、按行为命名测试、边界情况测试。
  • 框架内部:依赖注入、bean 生命周期、代理、事务。
  • 数据库:真实 SQL、索引、事务隔离、使用 JPA 时的 N+1 查询问题。
  • 运维:阅读日志、指标、健康检查、在类生产环境中调试。
  • 沟通:把模糊的需求转化为清晰的验收标准,写易于审查的拉取请求描述。

最后一项技能经常被轻视。懂得提出正确问题的人,比如“空订单怎么办?“或”两个请求同时到达怎么办?“,无论代码由谁或什么工具编写,都将永远需要。

10. 总结

本文中,我们看到初级开发者的价值从写代码的速度转向了评估、验证和理解系统的能力。例子说明了“看起来没问题”的代码仍然犯的错误:用 double 处理货币的舍入误差、单例 bean 中的 race condition,以及由于自调用导致 @Transactional 被忽略。你也看到了如何使用测试和 Actuator 自己验证,而不是猜测。

像对待一个写得快但容易出错的同事一样使用代码助手。你可以利用它的速度,但在合并之前始终要重新阅读、编写测试并理解每一行。这样你才不会被替代,成为团队离不开的人。

careerjunior-developeraijavatesting