Java开发入门:从零开始构建第一个RESTAPI
你打开IDE,心里默念:我要用Java写一个REST API。这个念头可能是来自产品经理的紧迫需求,或是你想从CRUD工程师跃迁为全栈开发者的第一步。别被“REST”这个词吓到——它本质上就是一对规则,告诉你的API如何用HTTP动词去拥抱资源。资源就是数据,比如用户、订单、文章;HTTP动词就是GET、POST、PUT、DELETE。Java生态里有Spring Boot这个黑魔法工具,它帮你省掉配置Tomcat的麻烦,让你把注意力聚焦在业务逻辑上。今天我们就从零开始,不跳过任何细节,构建一个能真正运行在浏览器或Postman里的REST API。
项目骨架:用Spring Initializr一键生成
到start.spring.io去,这是你的神殿。选择Maven Project、Java 17或21、Spring Boot 3.x。填入Group:com.example,Artifact:demo。依赖搜索框里至少要勾选:Spring Web(提供REST支持)、Spring Boot DevTools(热部署)、Lombok(减少样板代码)。点Generate,下载zip后解压,用IntelliJ或VS Code打开。你会看到一个DemoApplication.java,里面有个main方法。别碰它,它是启动入口。你真正的战场在com.example.demo包下。
你的第一个任务是让程序跑起来。右键运行DemoApplication,控制台输出Spring的Banner,并看到Tomcat started on port 8080。恭喜,已经有一个空壳的Web应用在运行。现在访问http://localhost:8080,会返回一个Whitelabel Error Page——正常,因为你还没建立任何控制器。
第一个端点:用@RestController宣示主权
新建一个包controller,在里面创建HelloController.java。粘贴如下代码:
package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping("/api/hello") public String sayHello() { return "Hello, World!"; } }
关键点:@RestController告诉Spring:这个类的所有方法返回值都直接写入HTTP响应体,而不是跳转到视图模板。加上@GetMapping("/api/hello"),就定义了一个GET端点。重启应用,访问http://localhost:8080/api/hello,浏览器里会显示“Hello, World!”。你刚刚完成了零到一的突破。
现在开始不要满足于字符串。REST API的核心是操作资源,资源通常以JSON格式呈现。我们需要让API返回Java对象,Spring会自动将其序列化为JSON。这是Spring Boot默认在classpath里包含Jackson依赖的功劳,你不必手动配置任何json库。
定义资源:从简单POJO开始
资源模型应该放在model包下。新建User.java:
package com.example.demo.model; import lombok.Data; @Data public class User { private Long id; private String name; private String email; }
@Data是Lombok的注解,自动生成getter、setter、toString、equals等。没有它你需要手写几十行代码。现在修改HelloController,返回一个用户列表:
@GetMapping("/api/users") public List<User> getUsers() { User user = new User(); user.setId(1L); user.setName("Alice"); user.setEmail("alice@example.com"); return Collections.singletonList(user); }
访问/api/users,你会看到JSON数组。看,这就是REST的启蒙:HTTP GET /api/users 返回用户集合,资源通过URL路径标识,数据通过JSON交换。但每次硬编码创建对象显然不靠谱。你需要一个数据层,但本环节先不用数据库,用内存里的静态列表模拟。
CRUD的骨架:GET、POST、PUT、DELETE
新建service包,创建UserService.java,这个服务类负责管理一个ConcurrentHashMap作为持久化存储。同时让控制器注入这个服务。
package com.example.demo.service; import com.example.demo.model.User; import org.springframework.stereotype.Service; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @Service public class UserService { private final Map<Long, User> store = new ConcurrentHashMap<>(); private final AtomicLong idCounter = new AtomicLong(1); public User createUser(User user) { long id = idCounter.getAndIncrement(); user.setId(id); store.put(id, user); return user; } public User getUser(Long id) { return store.get(id); } public List<User> getAllUsers() { return new ArrayList<>(store.values()); } public User updateUser(Long id, User user) { if (!store.containsKey(id)) return null; user.setId(id); store.put(id, user); return user; } public boolean deleteUser(Long id) { return store.remove(id) != null; } }
然后控制器扩展为:
@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public List<User> getAll() { return userService.getAllUsers(); } @GetMapping("/{id}") public User getById(@PathVariable Long id) { return userService.getUser(id); } @PostMapping public User create(@RequestBody User user) { return userService.createUser(user); } @PutMapping("/{id}") public User update(@PathVariable Long id, @RequestBody User user) { return userService.updateUser(id, user); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id) { userService.deleteUser(id); } }
注意几个要点:@RequestMapping("/api/users")在类级别定义了根路径,省去每个方法重复写路径。@PathVariable绑定URL里的占位符,@RequestBody把请求体JSON反序列化为User对象。现在用Postman测试:POST到/api/users,body为{"name":"Bob","email":"bob@test.com"},返回201状态码(Spring默认201,因为@PostMapping返回201 Created)。GET /api/users/1返回那个用户。PUT /api/users/1修改email。DELETE /api/users/1删除。
但你发现没有:如果请求的ID不存在,API返回了null,或者状态码是200但body为空。这不是规范的REST响应。规范的做法是:GET单个资源不存在时返回404,POST创建返回201,PUT更新返回200或204,DELETE成功返回204。我们需要引入HTTP状态码的显式控制。
让错误更优雅:ResponseEntity与全局异常处理
修改控制器的getById方法:
@GetMapping("/{id}") public ResponseEntity<User> getById(@PathVariable Long id) { User user = userService.getUser(id); if (user == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(user); }
delete方法也改一下:
@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { boolean deleted = userService.deleteUser(id); if (!deleted) { return ResponseEntity.notFound().build(); } return ResponseEntity.noContent().build(); }
但每个方法都写if判断会很笨重。更好的方式是使用全局异常处理。自定义一个ResourceNotFoundException,然后加上@ControllerAdvice。
@ResponseStatus(HttpStatus.NOT_FOUND) public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); } }
在getById中直接:return userService.getUser(id).orElseThrow(() -> new ResourceNotFoundException("User not found"));但因为我们之前用null返回,现改为Optional。重新设计service的getUser返回Optional 。然后在controller中:
@GetMapping("/{id}") public User getById(@PathVariable Long id) { return userService.getUser(id) .orElseThrow(() -> new ResourceNotFoundException("User with id " + id + " not found")); }
然后创建GlobalExceptionHandler:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Map<String, String> handleNotFound(ResourceNotFoundException ex) { return Map.of("error", ex.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> handleValidation(MethodArgumentNotValidException ex) { // 提取字段验证错误 String message = ex.getBindingResult().getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.joining(", ")); return Map.of("error", message); } }
全局异常处理让你把错误逻辑从控制器中剥离,REST API变得整洁且一致。每个异常类型对应一个HTTP状态码和统一格式的错误响应体。这是专业API的标配。
数据验证:用Jakarta Validation保驾护航
用户提交的数据必须校验。加入spring-boot-starter-validation(旧版本需要手动加,Spring Boot 3.x已经包含)。在User类字段上加注解:
@Data public class User { private Long id; @NotBlank(message = "Name cannot be blank") private String name; @Email(message = "Email must be valid") @NotBlank(message = "Email cannot be blank") private String email; }
然后在控制器中的@RequestBody前加@Valid:
@PostMapping public ResponseEntity<User> create(@Valid @RequestBody User user) { User created = userService.createUser(user); return ResponseEntity.status(HttpStatus.CREATED).body(created); }
当你发送空name或非法email时,Spring会自动抛出MethodArgumentNotValidException,被我们的全局异常处理器捕获,返回400和错误信息。不要信任任何客户端输入,后端校验是安全的最后一道防线。
让API可发现:HATEOAS?(不,先做好基础分页和过滤)
HATEOAS是REST成熟度模型第三级,但多数生产API只用到第二级(资源加动词)。更实际的是你API需要支持分页、排序、按字段过滤。Spring Data提供了Pageable接口,但你目前没有数据库。我们可以模拟分页:在service中根据参数返回子列表。但更佳实践是直接引入Spring Data JPA并连接一个H2内存数据库,让数据持久化天然支持分页。这是很多教程跳过的步骤,但我们不跳。
在pom.xml里添加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>
删除之前的UserService和内存存储,创建User实体和JpaRepository:
@Entity @Table(name = "users") @Data public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank private String name; @Email @NotBlank private String email; }
UserRepository:
public interface UserRepository extends JpaRepository<User, Long> { List<User> findByNameContainingIgnoreCase(String name); }
控制器修改为直接注入Repository,但为了分层,还是创建service:
@Service @Transactional public class UserService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } public User createUser(User user) { return repository.save(user); } public Optional<User> getUser(Long id) { return repository.findById(id); } public Page<User> getAllUsers(Pageable pageable) { return repository.findAll(pageable); } public Optional<User> updateUser(Long id, User newData) { return repository.findById(id).map(existing -> { existing.setName(newData.getName()); existing.setEmail(newData.getEmail()); return repository.save(existing); }); } public boolean deleteUser(Long id) { if (repository.existsById(id)) { repository.deleteById(id); return true; } return false; } }
控制器getAll接收Pageable参数:
@GetMapping public Page<User> getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "id") String sortBy, @RequestParam(defaultValue = "asc") String sortDir) { Sort sort = sortDir.equalsIgnoreCase("asc") ? Sort.by(sortBy).ascending() : Sort.by(sortBy).descending(); Pageable pageable = PageRequest.of(page, size, sort); return userService.getAllUsers(pageable); }
当你请求GET /api/users?page=0&size=5&sortBy=name&sortDir=desc,Spring Data JPA自动生成SQL查询并返回分页后的JSON,包含totalPages、totalElements、content等元数据。分页是任何CURD API的必修课,直接返回整个表是反模式。
测试你的API:不只是手工Postman
集成测试用Spring Boot的@WebMvcTest。新建test类:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void shouldReturnUserWhenExists() throws Exception { User user = new User(); user.setId(1L); user.setName("Test"); user.setEmail("test@example.com"); given(userService.getUser(1L)).willReturn(Optional.of(user)); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("Test")); } @Test void shouldReturn404WhenNotFound() throws Exception { given(userService.getUser(99L)).willReturn(Optional.empty()); mockMvc.perform(get("/api/users/99")) .andExpect(status().isNotFound()); } }
自动化测试能让你在重构时立即发现破坏性变更。别偷懒,每一层都写点测试。除了Web层,还应写service层单元测试(使用@ExtendWith(MockitoExtension.class))和Repository层测试(@DataJpaTest)。
安全第一位:添加简单的API Key验证
虽然完整认证要用Spring Security + JWT,但入门阶段可以尝一个简单的filter来验证请求头中的API Key。实现一个OncePerRequestFilter:
@Component public class ApiKeyFilter extends OncePerRequestFilter { private static final String API_KEY = "my-secret-key-123"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String apiKey = request.getHeader("X-API-KEY"); if (apiKey == null || !apiKey.equals(API_KEY)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"error\":\"Invalid API key\"}"); return; } filterChain.doFilter(request, response); } }
然后配置FilterRegistrationBean在控制器之前执行:
@Bean public FilterRegistrationBean<ApiKeyFilter> apiKeyFilterRegistration(ApiKeyFilter filter) { FilterRegistrationBean<ApiKeyFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(filter); registration.addUrlPatterns("/api/"); registration.setOrder(1); return registration; }
现在你的API受到硬编码密钥保护。当然生产环境绝不会这么干,但理解过滤器机制是学习Spring Security的前奏。
文档自动生成:Swagger/OpenAPI
配合springdoc-openapi-starter-webmvc-ui(Spring Boot 3版本),在pom.xml添加:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.3.0</version> </dependency>
启动后访问http://localhost:8080/swagger-ui.html,你会看到交互式API文档。你可以为控制器和方法添加@Operation和@ApiResponse注解描述业务含义。好的API文档不止是曲线救赎,更是团队协作的基础设施。
进阶:输入验证、自定义序列化、多版本共存
你还可以做很多:使用@JsonView控制序列化字段暴露;通过@ControllerAdvice统一包装响应格式(例如总是返回{status, message, data}结构);利用Spring的Content Negotiation支持XML或自定义格式;使用版本控制(路径版/v1/users,头部版Accept: application/vnd.myapp.v2+json)。
记住,REST API的终极奥义是使客户端与服务端解耦。每个端点应当幂等(GET、PUT、DELETE符合,POST不一定),URL应该命名复数名词,使用复数而不是单数(/users而不是/user),不要出现动词(/getUsers这种错误)。HTTP状态码是沟通语言,务必正确使用:201表示创建成功,204表示无内容,400表示客户端错误,401表示未认证,403表示未授权,404表示资源不存在,500表示服务器内部错误。
把项目打包成可交付制品
在pom.xml里配置Spring Boot Maven插件,然后用命令行mvn clean package,生成一个fat jar文件。在服务器上用java -jar target/demo-0.0.1-SNAPSHOT.jar运行,你的REST API就能在任意环境启动。你还可以在application.properties里设置server.port=80,以及数据库连接、日志级别等。
回过头看看,你从Hello World走到了一个分页、校验、异常处理、认证、文档齐全的REST API。这个过程不是背语法,而是建立一种思维模型:资源经过URL暴露,操作通过HTTP动词映射,数据通过JSON流动,错误通过状态码和格式传递。你的第一个API可能只有几百行代码,但它承载着REST全貌。将来面对更复杂的微服务、事件驱动架构时,今天打下的地基永远不会浪费。
现在,去构建更多接口吧。把这段代码提交到GitHub,叫它“first-rest-api”,然后告诉世界:你跨过了Java Web开发的门槛。