记得刚接手那个电商App重构项目时,我在凌晨三点的办公室里盯着屏幕上的404错误发呆。那时候团队里Java老员工和Kotlin新晋开发者吵得不可开交,后端说“我的DTO你接收不到”,前端说“你的字段命名坑死我了”。今天我就把这段血泪史转化为经验,手把手带你走完这条前后端分离的实战之路。
为什么前后端分离是必然选择
三年前我们还在用MVC老架构,Android直接调用Java Servlet,每次后端改个接口,前端就得重新发版。记得那次大促前,后端临时加了个字段promo_id,结果线上App直接崩了,客服电话被打爆。前后端分离不只是为了“酷”,而是为了解耦和迭代速度。
后端架构:Spring Boot 2.7+ 最佳实践
项目结构分层
src/main/java/com/example/ecommerce/
├── controller/ # 接口层,只负责接收请求和返回响应
├── service/ # 业务逻辑层
├── repository/ # 数据访问层
├── model/ # 数据库实体
├── dto/ # 数据传输对象(重点!)
├── config/ # 配置类
└── exception/ # 全局异常处理
很多新手会直接把Entity返回给前端,这是大忌!我见过太多人这么干,结果数据库加了字段,前端直接报错。
DTO设计哲学
package com.example.ecommerce.dto;
import lombok.Data;
import lombok.Builder;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import javax.validation.constraints.Email;
import java.math.BigDecimal;
import java.util.List;
/**
* 商品创建请求DTO
* 注意:这里不暴露内部实现细节,只暴露前端需要的字段
*/
@Data
@Builder
public class ProductCreateRequestDTO {
@NotBlank(message = "商品名称不能为空")
@Size(min = 2, max = 100, message = "商品名称长度在2-100字符之间")
private String productName;
@NotBlank(message = "商品描述不能为空")
private String description;
@NotBlank(message = "商品价格不能为空")
// 用字符串接收,避免精度丢失,前端传"99.99"而不是99.99
private String price;
private Integer stock;
@Size(max = 10, message = "图片最多10张")
private List<String> images;
// 敏感字段绝不暴露给前端
// private Long categoryId; // 这个由后端根据用户权限处理
}
关键点:
- 金额用String不用BigDecimal:JSON序列化精度问题,前端显示“99.99”而不是“99.98999999999999”
- 验证注解:让Spring自动校验,减少防御性代码
- Builder模式:字段多时创建对象更清晰
统一响应格式
package com.example.ecommerce.config;
import lombok.Data;
import java.util.Map;
/**
* 全局统一响应结构
* 前端只需要解析data字段,不用关心code和message
*/
@Data
public class ApiResponse<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
// 成功响应
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
response.setTimestamp(System.currentTimeMillis());
return response;
}
// 失败响应
public static <T> ApiResponse<T> error(Integer code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
response.setTimestamp(System.currentTimeMillis());
return response;
}
// 带分页的成功响应(电商必备)
public static <T> ApiResponse<PageResult<T>> successPage(List<T> data,
long total,
int page,
int size) {
PageResult<T> pageResult = PageResult.<T>builder()
.list(data)
.total(total)
.page(page)
.size(size)
.build();
return success(pageResult);
}
}
@Data
@Builder
class PageResult<T> {
private List<T> list;
private long total;
private int page;
private int size;
private int totalPages;
@Builder.Default
private boolean hasNext = false;
@Builder.Default
private boolean hasPrevious = false;
}
前端拿到这种结构,直接response.data就能用,不用每个接口都写解析逻辑。
Controller层最佳实践
package com.example.ecommerce.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import com.example.ecommerce.service.ProductService;
import com.example.ecommerce.dto.ProductCreateRequestDTO;
import com.example.ecommerce.dto.ProductResponseDTO;
import com.example.ecommerce.config.ApiResponse;
import javax.validation.Valid;
@Slf4j
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
/**
* 创建商品
* 注意:用@PostMapping,返回201 Created而不是200 OK
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<ProductResponseDTO> createProduct(
@Valid @RequestBody ProductCreateRequestDTO requestDTO) {
log.info("收到创建商品请求: productName={}", requestDTO.getProductName());
// 调用Service,Controller只负责参数校验和返回结果
ProductResponseDTO result = productService.createProduct(requestDTO);
return ApiResponse.success(result);
}
/**
* 分页查询商品
* 分页参数:page从0开始,size每页数量
*/
@GetMapping
public ApiResponse<PageResult<ProductResponseDTO>> listProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createTime,desc") String sortBy) {
// 解析排序参数
String[] sortParams = sortBy.split(",");
Sort sort = Sort.by(Sort.Direction.fromString(
sortParams.length > 1 ? sortParams[1] : "desc"
), sortParams[0]);
Page<ProductResponseDTO> productPage = productService.listProducts(
PageRequest.of(page, size, sort)
);
return ApiResponse.successPage(
productPage.getContent(),
productPage.getTotalElements(),
productPage.getNumber(),
productPage.getSize()
);
}
/**
* 查询单个商品详情
*/
@GetMapping("/{id}")
public ApiResponse<ProductResponseDTO> getProduct(
@PathVariable Long id) {
// 参数合法性校验
if (id <= 0) {
return ApiResponse.error(400, "无效的商品ID");
}
ProductResponseDTO product = productService.getProductById(id);
return ApiResponse.success(product);
}
}
前端架构:Kotlin + Retrofit2 + OkHttp3
项目依赖配置
// app/build.gradle.kts
dependencies {
// Retrofit核心
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Gson转换器(比Jackson轻量,适合Android)
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Kotlin协程支持
implementation("com.squareup.retrofit2:adapter-rxjava2:2.9.0")
implementation("io.reactivex.rxjava2:rxjava:2.2.21")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
// OkHttp日志拦截器(调试必备)
debugImplementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
// Gson配置(处理时间格式)
implementation("com.google.code.gson:gson:2.10.1")
}
Retrofit单例配置
package com.example.ecommerce.network
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private const val BASE_URL = "https://api.yourdomain.com"
// 日志拦截器,debug包才有日志
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// OkHttp客户端,统一配置
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor) // 调试日志
.addInterceptor(HeaderInterceptor()) // 统一添加请求头
.addInterceptor(ErrorInterceptor()) // 统一处理错误
.build()
// Retrofit实例
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
// 自定义Gson,处理时间格式
private val gson = com.google.gson.GsonBuilder()
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
.serializeNulls() // 允许null值序列化
.create()
// 暴露API接口
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}
API接口定义
package com.example.ecommerce.network
import com.example.ecommerce.model.Product
import com.example.ecommerce.model.PaginationResponse
import retrofit2.http.*
interface ApiService {
/**
* 创建商品
* POST请求,@Body传递JSON
*/
@POST("api/v1/products")
suspend fun createProduct(
@Body productRequest: ProductRequest
): BaseResponse<Product>
/**
* 分页查询商品列表
* GET请求,参数用@Query注解
*/
@GET("api/v1/products")
suspend fun listProducts(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20,
@Query("sortBy") sortBy: String = "createTime,desc"
): BaseResponse<PaginationResponse<Product>>
/**
* 查询单个商品详情
*/
@GET("api/v1/products/{id}")
suspend fun getProduct(
@Path("id") productId: Long
): BaseResponse<Product>
/**
* 更新商品
*/
@PUT("api/v1/products/{id}")
suspend fun updateProduct(
@Path("id") productId: Long,
@Body productRequest: ProductRequest
): BaseResponse<Product>
/**
* 删除商品
*/
@DELETE("api/v1/products/{id}")
suspend fun deleteProduct(
@Path("id") productId: Long
): BaseResponse<Unit>
}
数据模型设计
package com.example.ecommerce.model
import com.google.gson.annotations.SerializedName
// 统一响应结构,对应后端的ApiResponse<T>
data class BaseResponse<T>(
@SerializedName("code") val code: Int,
@SerializedName("message") val message: String,
@SerializedName("data") val data: T?,
@SerializedName("timestamp") val timestamp: Long
)
// 分页响应结构
data class PaginationResponse<T>(
@SerializedName("list") val list: List<T>,
@SerializedName("total") val total: Long,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("totalPages") val totalPages: Int,
@SerializedName("hasNext") val hasNext: Boolean,
@SerializedName("hasPrevious") val hasPrevious: Boolean
)
// 商品请求DTO,对应后端的ProductCreateRequestDTO
data class ProductRequest(
@SerializedName("productName") val productName: String,
@SerializedName("description") val description: String,
@SerializedName("price") val price: String, // 用String避免精度问题
@SerializedName("stock") val stock: Int? = null,
@SerializedName("images") val images: List<String>? = null
)
// 商品响应DTO
data class Product(
@SerializedName("id") val id: Long,
@SerializedName("productName") val productName: String,
@SerializedName("description") val description: String,
@SerializedName("price") val price: String,
@SerializedName("stock") val stock: Int,
@SerializedName("images") val images: List<String>,
@SerializedName("createTime") val createTime: String,
@SerializedName("updateTime") val updateTime: String
)
关键点:
- @SerializedName:确保字段名和后端一致,即使Kotlin改了命名风格也不会出问题
- 时间用String:避免时区和格式问题,前端统一格式化显示
- 可选字段用
?:Kotlin的空安全让代码更健壮
网络层封装:Repository模式
package com.example.ecommerce.repository
import com.example.ecommerce.network.RetrofitClient
import com.example.ecommerce.model.Product
import com.example.ecommerce.model.ProductRequest
import com.example.ecommerce.model.BaseResponse
import com.example.ecommerce.model.PaginationResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProductRepository {
private val apiService = RetrofitClient.apiService
/**
* 创建商品
* 在IO线程执行网络请求
*/
suspend fun createProduct(request: ProductRequest): Result<Product> = withContext(Dispatchers.IO) {
try {
val response = apiService.createProduct(request)
if (response.code == 200 && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception("服务器错误: ${response.message}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 分页查询商品
*/
suspend fun listProducts(
page: Int = 0,
size: Int = 20
): Result<PaginationResponse<Product>> = withContext(Dispatchers.IO) {
try {
val response = apiService.listProducts(page, size)
if (response.code == 200 && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception("服务器错误: ${response.message}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 查询单个商品
*/
suspend fun getProduct(productId: Long): Result<Product> = withContext(Dispatchers.IO) {
try {
val response = apiService.getProduct(productId)
if (response.code == 200 && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception("商品不存在或服务器错误"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
ViewModel层:业务逻辑与UI分离
”`kotlin package com.example.ecommerce.viewmodel
import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.ecommerce.model.Product import com.example.ecommerce.model.PaginationResponse import com.example.ecommerce.model.ProductRequest import com.example.ecommerce.repository.ProductRepository import kotlinx.coroutines.launch
class ProductViewModel : ViewModel() {
private val repository = ProductRepository()
// UI状态
private val _products = MutableLiveData<PaginationResponse<Product>>()
val products: LiveData<PaginationResponse<Product>> get() = _products
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> get() = _isLoading
private val _errorMessage = MutableLiveData<String>()
val errorMessage: LiveData<String> get() = _errorMessage
private val _createSuccess = MutableLiveData<Boolean>()
val createSuccess: LiveData<Boolean> get() = _createSuccess
var currentPage = 0
val pageSize = 20
/**
* 加载商品列表
*/
fun loadProducts() {
_isLoading.postValue(true)
_errorMessage.postValue(null)
viewModelScope.launch {
repository.listProducts(page = currentPage, size = pageSize)
.onSuccess { result ->
_products.postValue(result)
_isLoading.postValue(false)
}
.onFailure { exception ->
_isLoading.postValue(false)
_errorMessage.postValue(exception.message ?: "加载失败")
}
}
}
/**
* 创建商品
*/
fun createProduct(request: ProductRequest) {
viewModelScope.launch {
repository.createProduct(request)
.onSuccess { product ->
_createSuccess.postValue(true)
// 创建成功后刷新列表
currentPage = 0
loadProducts()
