Перейти к основному содержимому

Java Spring Boot руководство

Полное руководство по разработке приложений на Spring Boot.

Создание проекта

Spring Initializr

<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

Основной класс приложения

// Application.java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

Конфигурация

application.yml

# application.yml
spring:
application:
name: demo-app

datasource:
url: jdbc:mysql://localhost:3306/demo_db
username: ${DB_USERNAME:demo_user}
password: ${DB_PASSWORD:demo_password}
driver-class-name: com.mysql.cj.jdbc.Driver

jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true

security:
jwt:
secret: ${JWT_SECRET:mySecretKey}
expiration: 86400

server:
port: 8080
servlet:
context-path: /api

logging:
level:
com.example.demo: DEBUG
org.springframework.security: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file:
name: logs/application.log

management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always

Entity и Repository

Entity класс

// User.java
package com.example.demo.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotBlank
@Size(max = 20)
private String username;

@NotBlank
@Size(max = 50)
@Email
private String email;

@NotBlank
@Size(max = 120)
private String password;

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();

@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;

@UpdateTimestamp
private LocalDateTime updatedAt;

// Конструкторы
public User() {}

public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}

// Геттеры и сеттеры
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }

public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }

public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }

public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }

public Set<Role> getRoles() { return roles; }
public void setRoles(Set<Role> roles) { this.roles = roles; }

public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}

Repository

// UserRepository.java
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByUsername(String username);

Optional<User> findByEmail(String email);

Boolean existsByUsername(String username);

Boolean existsByEmail(String email);

Page<User> findByUsernameContaining(String username, Pageable pageable);

@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findUserByEmail(@Param("email") String email);

@Query("SELECT u FROM User u JOIN u.roles r WHERE r.name = :roleName")
List<User> findUsersByRoleName(@Param("roleName") String roleName);

@Query(value = "SELECT * FROM users WHERE created_at >= :date", nativeQuery = true)
List<User> findUsersCreatedAfter(@Param("date") String date);
}

REST Controller

Основной контроллер

// UserController.java
package com.example.demo.controller;

import com.example.demo.dto.UserDto;
import com.example.demo.service.UserService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/users")
@CrossOrigin(origins = "*", maxAge = 3600)
public class UserController {

@Autowired
private UserService userService;

@GetMapping
public ResponseEntity<Page<UserDto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir) {

Sort sort = sortDir.equalsIgnoreCase("desc") ?
Sort.by(sortBy).descending() : Sort.by(sortBy).ascending();

Pageable pageable = PageRequest.of(page, size, sort);
Page<UserDto> users = userService.getAllUsers(pageable);

return ResponseEntity.ok(users);
}

@GetMapping("/{id}")
public ResponseEntity<UserDto> getUserById(@PathVariable Long id) {
UserDto user = userService.getUserById(id);
return ResponseEntity.ok(user);
}

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserDto userDto) {
UserDto createdUser = userService.createUser(userDto);
return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
}

@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserDto userDto) {
UserDto updatedUser = userService.updateUser(id, userDto);
return ResponseEntity.ok(updatedUser);
}

@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}

@GetMapping("/search")
public ResponseEntity<List<UserDto>> searchUsers(
@RequestParam String username) {
List<UserDto> users = userService.searchUsersByUsername(username);
return ResponseEntity.ok(users);
}
}

Service слой

UserService

// UserService.java
package com.example.demo.service;

import com.example.demo.dto.UserDto;
import com.example.demo.entity.User;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

public Page<UserDto> getAllUsers(Pageable pageable) {
Page<User> users = userRepository.findAll(pageable);
return users.map(this::convertToDto);
}

public UserDto getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
return convertToDto(user);
}

public UserDto createUser(UserDto userDto) {
if (userRepository.existsByUsername(userDto.getUsername())) {
throw new RuntimeException("Username is already taken!");
}

if (userRepository.existsByEmail(userDto.getEmail())) {
throw new RuntimeException("Email is already in use!");
}

User user = new User(userDto.getUsername(),
userDto.getEmail(),
passwordEncoder.encode(userDto.getPassword()));

User savedUser = userRepository.save(user);
return convertToDto(savedUser);
}

public UserDto updateUser(Long id, UserDto userDto) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));

user.setUsername(userDto.getUsername());
user.setEmail(userDto.getEmail());

if (userDto.getPassword() != null && !userDto.getPassword().isEmpty()) {
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
}

User updatedUser = userRepository.save(user);
return convertToDto(updatedUser);
}

public void deleteUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
userRepository.delete(user);
}

public List<UserDto> searchUsersByUsername(String username) {
List<User> users = userRepository.findByUsernameContaining(username, Pageable.unpaged())
.getContent();
return users.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}

private UserDto convertToDto(User user) {
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
dto.setCreatedAt(user.getCreatedAt());
return dto;
}
}

DTO классы

UserDto

// UserDto.java
package com.example.demo.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.time.LocalDateTime;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDto {

private Long id;

@NotBlank(message = "Username is required")
@Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters")
private String username;

@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
private String email;

@Size(min = 6, max = 40, message = "Password must be between 6 and 40 characters")
private String password;

private LocalDateTime createdAt;

// Конструкторы
public UserDto() {}

public UserDto(String username, String email) {
this.username = username;
this.email = email;
}

// Геттеры и сеттеры
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }

public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }

public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }

public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }

public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Security Configuration

JWT Token Provider

// JwtTokenProvider.java
package com.example.demo.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtTokenProvider {

@Value("${spring.security.jwt.secret}")
private String jwtSecret;

@Value("${spring.security.jwt.expiration}")
private int jwtExpirationInMs;

private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecret.getBytes());
}

public String generateToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationInMs);

return Jwts.builder()
.setSubject(Long.toString(userPrincipal.getId()))
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}

public Long getUserIdFromJWT(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();

return Long.parseLong(claims.getSubject());
}

public boolean validateToken(String authToken) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(authToken);
return true;
} catch (SecurityException ex) {
System.err.println("Invalid JWT signature");
} catch (MalformedJwtException ex) {
System.err.println("Invalid JWT token");
} catch (ExpiredJwtException ex) {
System.err.println("Expired JWT token");
} catch (UnsupportedJwtException ex) {
System.err.println("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
System.err.println("JWT claims string is empty");
}
return false;
}
}

Security Configuration

// SecurityConfig.java
package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationEntryPoint;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
);

http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}

Exception Handling

Global Exception Handler

// GlobalExceptionHandler.java
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {

ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
request.getDescription(false)
);

return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex) {

Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});

ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
"Input validation failed",
errors.toString()
);

return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(
RuntimeException ex, WebRequest request) {

ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
ex.getMessage(),
request.getDescription(false)
);

return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

Testing

Unit Tests

// UserServiceTest.java
package com.example.demo.service;

import com.example.demo.dto.UserDto;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

@Mock
private UserRepository userRepository;

@Mock
private PasswordEncoder passwordEncoder;

@InjectMocks
private UserService userService;

private User user;
private UserDto userDto;

@BeforeEach
void setUp() {
user = new User("testuser", "test@example.com", "password");
user.setId(1L);

userDto = new UserDto();
userDto.setUsername("testuser");
userDto.setEmail("test@example.com");
userDto.setPassword("password");
}

@Test
void testGetUserById_Success() {
when(userRepository.findById(1L)).thenReturn(Optional.of(user));

UserDto result = userService.getUserById(1L);

assertNotNull(result);
assertEquals("testuser", result.getUsername());
assertEquals("test@example.com", result.getEmail());
}

@Test
void testCreateUser_Success() {
when(userRepository.existsByUsername("testuser")).thenReturn(false);
when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
when(passwordEncoder.encode("password")).thenReturn("encodedPassword");
when(userRepository.save(any(User.class))).thenReturn(user);

UserDto result = userService.createUser(userDto);

assertNotNull(result);
assertEquals("testuser", result.getUsername());
verify(userRepository).save(any(User.class));
}
}

Integration Tests

// UserControllerIntegrationTest.java
package com.example.demo.controller;

import com.example.demo.dto.UserDto;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureWebMvc
@TestPropertySource(locations = "classpath:application-test.properties")
@Transactional
class UserControllerIntegrationTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private UserRepository userRepository;

@Autowired
private ObjectMapper objectMapper;

@BeforeEach
void setUp() {
userRepository.deleteAll();
}

@Test
void testCreateUser() throws Exception {
UserDto userDto = new UserDto();
userDto.setUsername("testuser");
userDto.setEmail("test@example.com");
userDto.setPassword("password123");

mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("testuser"))
.andExpect(jsonPath("$.email").value("test@example.com"));
}

@Test
void testGetAllUsers() throws Exception {
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
}