学 Java 到一定程度,很多人会卡在同一道坎上:语法题能刷,八股能背,但一说“给前端提供一个接口”就不知道从哪里下手。这个坎,恰恰是后端开发真正的分水岭。如果你跟我走到这一篇,说明前面已经把 Java 的语法、集合、JDBC、MySQL 这些基础内容趟过了一遍,那接下来要做的事情,就不再是继续堆知识点,而是把碎片焊成一个能真实对外提供服务的后端项目。
所谓“后端提供接口”,说白了就是后端程序接收前端发来的 HTTP 请求,访问数据库,再把数据以 JSON 格式返回给浏览器或小程序。从 0 到资深工程师的进阶之路上,这个阶段最值得投入的不是背更多面试题,而是亲手从空目录开始,搭一套结构清楚、敢交给别人联调的后端服务。这一篇,我会围绕一个真实的小项目,把 Spring Boot 搭建、接口开发、统一返回、跨域、鉴权、日志、部署的完整链路讲清楚。
1. 别人背八股,你已经需要独立交付接口:本阶段的学习目标先划清楚
1.1 “接口是啥”这个问题,值得正面回答一次
很多初学者一听到“给前端提供接口”就发怵,因为“接口”这个词在 Java 里被用得太乱了。有人觉得它是 interface 关键字,有人觉得是 Controller 里的方法,还有人觉得是一份文档。站在后端开发的视角,我们口中常说的接口,一般指 HTTP 接口:前端通过 URL 发请求,后端根据请求路径、请求方式找到对应的方法,处理完业务后把结果返回给前端。
你可以把它理解成餐厅里的取餐口。顾客(前端)不会直接冲到后厨拿菜,而是通过取餐口递小票、取菜品;后端就是后厨,扫描小票上的菜单编号(URL),备菜、炒菜、打包,再把菜品递出去。前端不关心菜是怎么炒的,只需要知道取餐口长什么样、点餐规则是什么;后端也不关心顾客长什么样,只要在约定的格式下把菜做好、按时送出来就行。
所以这个阶段的学习目标非常明确:你能独立设计并实现一套 HTTP 接口,让别人通过请求拿到数据,或者把数据安全地写入数据库。与此同时,你还要让接口足够规范,比如入参有校验、出错时有明确提示、返回结构一致,让前端拿到之后不需要针对每个接口额外写一套解析逻辑。
1.2 动手做就做最小闭环:待办事项后端项目
如果要在实战中练手,不建议一上来就做秒杀系统或者电商项目,这种项目业务边界太大,很容易让人沉迷于“加功能”,而不是把基础链路吃透。更合适的做法是做一个小而完整的业务闭环,比如待办事项(Todo)后端。
选待办事项有几个原因。第一,实体简单,核心就是一条待办记录,包含标题、状态、创建时间、更新时间,不需要设计复杂的关系表;第二,增删改查齐全,刚好覆盖后端开发日常最高频的操作;第三,前后端做 CRUD 时的交互非常典型,列表查询、表单提交、状态更新都有;第四,后面想扩展登录鉴权、分页查询、软删除等功能时,都非常自然,不会因为业务复杂而干扰对框架本身的判断。
我建议你这套项目交付的内容是:一个能本地启动的后端工程,对外提供待办创建、分页查询、编辑、删除和状态筛选接口,接口返回格式统一,参数校验完善,异常信息能正确传回前端,最后打成 jar 包在服务器上跑起来。能做到这一步,就已经比很多只在简历上写“熟悉 Spring Boot”的候选人扎实得多。
1.3 为什么主线选 Spring Boot + MyBatis-Plus,而不是其他组合
很多初学者会在选型上反复纠结:到底用 Spring MVC + MyBatis 还是 Spring Boot + MyBatis-Plus?要不要顺便学 Spring Cloud?这里我把思路说清楚。
Spring Boot 的核心价值是“约定优于配置”,它帮你把 Web 容器、自动配置、依赖管理这些问题解决了,让你能更快地把精力放到业务代码上。你不需要自己配置一堆 XML 去描述 Bean,也不需要手工集成 Tomcat,它能直接启动一个可访问的服务。Spring Boot 不是和 Spring MVC 对立的框架,Spring MVC 是它 Web 模块底层的工作方式,只是 Boot 把整合过程简化了。
MyBatis-Plus 解决的问题也很直接,它能替你完成大部分单表 CRUD,让你不用写那些几乎一模一样的 insert、update、selectById。很多人觉得用 MyBatis-Plus 会让 SQL 能力退化,这个担心有一定道理,但那是“完全依赖它”才会出现的问题。正确姿势是:单表 CRUD 交给 Plus,复杂查询、多表联查、性能敏感的 SQL 自己手写 XML,两者结合才是实际项目最常见的状态。
至于 Spring Cloud,那是微服务阶段的事情。你的服务还只有一个模块的时候,引入注册中心、配置中心、网关实际上意义不大,而且会严重分散学习精力。先学会把单体应用做好,遇到分布式问题再学分布式,这是最稳妥的路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从空目录到能启动:Spring Boot 版本搭配与工程骨架设计
2.1 版本选型先定好,免得后面连环报错
我见过很多人项目起不来,不是代码写错,而是 Spring Boot 版本和 JDK 版本对不上。Spring Boot 3.x 默认要求 JDK 17 及以上,如果你还在用 JDK 8,就需要选择 Spring Boot 2.7.x。这个选择不是越新越好,而是要看你的目标环境。
如果你是纯粹学习,电脑上装的是 JDK 17,那直接用 Spring Boot 3.x 没毛病。但如果公司大量存量项目还跑在 JDK 8 上,你也最好把 Spring Boot 2.7 的写法摸熟,因为很多老项目短期内根本不会升级。下面的表格是常见搭配,方便你对照:
| 场景 | JDK 版本 | Spring Boot 版本 | MyBatis-Plus 相关依赖 |
|---|---|---|---|
| 老项目兼容、学习低成本 | JDK 8 | 2.7.x | mybatis-plus-boot-starter |
| 新项目、尝鲜新特性 | JDK 17 | 3.2.x 及以上 | mybatis-plus-spring-boot3-starter |
这里要特别提醒一个细节:IDEA 里的 Project SDK、Module SDK、Maven 使用的 JDK 必须一致。很多时候你明明在 pom 里写了 Java 版本,结果编译时还是报“源发行版 XX 需要目标发行版 XX”,就是因为 IDEA 的 Language Level 还指向旧版本。后面我会专门讲这个问题的排查过程。
2.2 从 IDEA 初始化到项目包结构落地
初始化项目最省事的方式是在 IDEA 中新建 Spring Initializr 项目,按自己选定的 Boot 版本创建,然后手动加入 MyBatis-Plus 依赖。起步依赖可以勾选这几个:
- spring-boot-starter-web:提供 Spring MVC 和内置 Tomcat
- spring-boot-starter-validation:提供参数校验注解,比如 @NotBlank、@Valid
- mysql-connector-j:MySQL 驱动
- lombok:简化 getter/setter,新手需要先适应一下
创建完成后的包结构不要随意堆。一个比较清晰的分层结构如下:
text复制com.example.todo
├── TodoApplication.java // 启动类
├── common/ // 通用返回体、常量、全局异常
│ ├── Result.java
│ └── GlobalExceptionHandler.java
├── config/ // 配置类,比如 CORS、拦截器、Knife4j
├── controller/ // Controller 层,只做参数接收和接口路由
├── service/ // Service 接口
│ └── impl/ // Service 实现类,写业务逻辑
├── mapper/ // MyBatis-Plus 的 Mapper 接口
├── entity/ // 数据库表对应的实体
└── dto/ // 前端入参对象,比如 TodoCreateDTO
很多公司会简化为 controller、service、mapper、entity、config 五层,这没问题。关键是职责要清楚:Controller 只负责接收和返回,Service 处理业务规则,Mapper 负责数据库访问。不要看着简单就把业务逻辑全堆在 Controller 里,那会给后续维护埋雷。
2.3 application.yml 里最容易写错的两个地方
配置文件是后端项目里不起眼却极其关键的部分。新建的 Spring Boot 项目默认带一个 application.properties,我习惯改成 application.yml,结构更直观。下面这份配置可以做模板:
yaml复制server:
port: 8080
servlet:
context-path: /
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/todo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
新手容易踩的坑有两个。第一个是 MySQL 连接串里的 serverTimezone 漏了,尤其是本地 MySQL 8.x,在某些时区设置下会报时间相关的异常;serverTimezone=Asia/Shanghai 是本地开发比较稳妥的设置。第二个是 MyBatis-Plus 的 mapUnderscoreToCamelCase,这个配置控制数据库字段下划线 id 和 Java 中驼峰 id 的自动映射,MyBatis-Plus 默认其实已经开启,但如果你想自定义 SQL 返回复杂对象,最好还是显式确认一遍。
还有一点,数据库密码不要写死提交到 Git 仓库,尤其是公司项目。本地练习可以模糊处理,但养成把敏感配置拆到环境变量或配置中心的习惯,越早越好。
3. 一个接口从请求到 JSON 回给前端,到底发生了什么
3.1 先建表还是先写实体?我的顺序
项目一开始,最重要的不是代码,是数据库表结构。我的习惯是先建表,再根据表结构生成实体。这不只是流程问题,而是防止 Java 实体和数据库字段脱节的有效手段。
以待办事项为例,建表语句可以这样写:
sql复制CREATE TABLE todo_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
title VARCHAR(100) NOT NULL COMMENT '待办标题',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-未完成,1-已完成',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待办事项表';
这里有几个设计细节值得注意。状态字段我用了 TINYINT 而不是字符串,因为字符串状态查询时必须带引号、可能出现大小写不一致,一旦拼错就是线上事故;用数字加注释来表示状态,再在 Java 层用枚举或常量定义,管理起来更干净。create_time 和 update_time 直接交给数据库维护默认值,插入和更新的时候 Java 层不用手动赋值。deleted 字段是为了以后做逻辑删除准备的,先留着,但要注意 MyBatis-Plus 里如果加了 @TableLogic,所有查询都会自动追加“deleted=0”条件,业务代码会少很多隐患。
3.2 实体类和 Mapper 层要尽量符合 MyBatis-Plus 的约定
有了表结构,实体类就比较好写了:
java复制@Data
@TableName("todo_item")
public class TodoItem {
@TableId(type = IdType.AUTO)
private Long id;
private String title;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
@TableName 用来指定数据库表名,@TableId(type = IdType.AUTO) 表示主键自增,@TableLogic 标记逻辑删除。实体字段命名遵循驼峰命名法,配合 map-underscore-to-camel-case 配置,就能自动映射到数据库的下划线字段。
Mapper 接口写起来非常轻,核心代码只有一行:
java复制@Mapper
public interface TodoItemMapper extends BaseMapper<TodoItem> {
}
继承 BaseMapper 之后,selectById、insert、updateById、deleteById 这些方法都已经存在了。很多新手会疑惑:这也能算写后端?是的,对于单表复杂度和体量都不高的 CRUD,MyBatis-Plus 就是这样帮你省样板代码的。真正需要手写 SQL 的场景是统计、多表关联和复杂条件查询,那些在后面进阶时你自然会遇到。
3.3 Service 和 Controller 之间的分工要理顺
Service 层负责承载业务规则。比如创建一个待办事项时,我们需要校验标题不是空字符串、初始化 status 为 0。这些规则不该出现在 Controller,因为 Controller 本质上只是 HTTP 协议和 Java 方法之间的翻译层。
Service 接口可以这样定义:
java复制public interface TodoService {
Long create(TodoCreateDTO dto);
Page<TodoItem> pageQuery(int page, int size, Integer status);
void updateStatus(Long id, Integer status);
void deleteById(Long id);
}
实现类里负责调用 Mapper、处理具体逻辑。这里我建议你在小项目里也要区分“接口”和“实现类”,虽然单体会感觉多了一层,但这种写法在 Spring 生态里很常见,能更方便地替换实现、做 AOP 代理、写单元测试。Spring 依赖注入时只用接口类型,具体实现由容器注入,后续如果想在实现上加缓存或事务,结构上不会乱。
Controller 只是把 HTTP 请求映射到 Service:
java复制@RestController
@RequestMapping("/api/todos")
@RequiredArgsConstructor
public class TodoController {
private final TodoService todoService;
@PostMapping
public Result<Long> create(@RequestBody @Valid TodoCreateDTO dto) {
return Result.ok(todoService.create(dto));
}
@GetMapping
public Result<Page<TodoItem>> page(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Integer status) {
return Result.ok(todoService.pageQuery(page, size, status));
}
}
这里你会看到几个后端接口的高频知识点。@PostMapping 对应新增请求,@GetMapping 对应查询请求;@RequestBody 表示前端把 JSON 放在请求体里,Spring 会帮你反序列化成 DTO;@RequestParam 表示参数来自 URL 的查询字符串,适合列表查询的分页参数。很多面试题会盯着这些注解问,但实际写一遍项目,你自然就理解它们各自该用在什么场景了。
4. 返回体、参数校验、全局异常:把接口从“能跑”打磨到“规范”
4.1 统一 Result 不只是为了让代码好看
有段时间我审代码特别在意一件事:项目里所有接口的返回结构是不是一致的。最让人崩溃的场景是,一个接口成功时返回的是对象,失败时却只返回一行字符串,前端写联调代码时不得不对每个接口单独写判断。所以从一开始,后端就应该定下一套返回协议。
最简单的协议是三个字段:code 表示业务状态码,message 表示提示信息,data 表示真正的数据。可以封装成下面的样子:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> ok(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}
返回协议一旦统一,前端就可以写一个统一的拦截器:如果 code 等于 200,直接取 data 渲染页面;如果不是 200,弹 message 给用户。所有接口的调用方体验都是一致的,不用各自碰撞。
需要提醒的是,这里的 200 并不一定等于 HTTP 状态码 200。HTTP 状态码是传输层的概念,业务状态码是应用层的概念。真实项目里有人喜欢 HTTP 200 + 业务 code 表示一切,有人喜欢严格区分 404、500 等 HTTP 状态。各自都有道理,但最怕的是既不统一、又没有文档。建议你从第一套接口开始,就先定一个内部规范,避免后面长了再换代。
4.2 用 @Valid 做参数校验,不要在 Controller 里写一长串 if
参数校验是后端接口非常基础但经常被忽略的环节。很多初期项目是这样写的:前端传一个 title 过来,后端直接拿 title 去插入数据库,等数据库因为空字符串或者超长而报错时,异常再抛给前端。这种开发方式的体验非常差,而且也不安全。
正确做法是在 DTO 字段上直接声明校验规则,然后在 Controller 方法参数前加上 @Valid:
java复制@Data
public class TodoCreateDTO {
@NotBlank(message = "待办标题不能为空")
@Size(max = 50, message = "待办标题不能超过50个字符")
private String title;
}
Controller 里的 @Valid 一旦生效,Spring 就会在进入方法前完成校验。如果 title 是空的,框架会抛出 MethodArgumentNotValidException;如果超长,会抛出 BindException。这些异常会落到全局异常处理器里,最终由你控制转换成什么形式的 Result 返回给前端。这种做法避免了在业务代码里到处写 if (xxx == null) 之类的判断。
4.3 用 @RestControllerAdvice 做全局异常时,别忘了最外层的兜底
全局异常处理是后端进阶工程师和新人之间的一个分水岭。没有全局异常处理时,每个 Controller 内部都会被 try-catch 包裹,代码又臭又长;有些异常一旦在某个分支里漏掉,前端收到的是完整的异常堆栈,甚至包括服务器路径、类名、数据库表名,这些信息非常危险。
用 Spring 的 @RestControllerAdvice 可以统一处理:
java复制@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.findFirst()
.orElse("参数校验失败");
return Result.error(400, message);
}
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException ex) {
log.warn("业务异常:{}", ex.getMessage());
return Result.error(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception ex) {
log.error("系统异常", ex);
return Result.error(500, "系统繁忙,请稍后重试");
}
}
这里有一个非常容易踩的坑:如果你只定义了 @ExceptionHandler(Exception.class) 这个兜底,那么参数校验异常也会被它捕获,返回“系统繁忙”,前端拿不到真正有用的错误提示。所以全局异常处理要把“具体异常”放在优先级更高的位置。Spring 会优先匹配最具体的异常处理器,然后才轮到兜底。建议你至少处理三个层级:业务异常、参数校验异常、未知异常。
5. 前后端一旦分开,跨域、鉴权、接口文档就成了后端躲不掉的三件事
5.1 跨域到底是谁的问题,又该怎么优雅解决
前后端分离项目里,跨域可以说是每个后端都会遇到的现象。你本地接口调试得好好的,Postman 一发就通,可前端页面一请求就报错,控制台写着“CORS policy”相关日志。为什么会这样?因为浏览器的同源策略默认禁止页面访问不同协议、域名或端口的接口资源。这里的核心是浏览器在拦截,不是后端服务没有响应。
解决跨域的主流方案是后端明确告诉浏览器“我允许这个前端来源访问”。Spring Boot 里可以用 CORS 全局配置,而不是在 Controller 上写一堆 @CrossOrigin。我推荐下面的写法:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
这里有个细节我要多说一句。很多人网上抄配置时写的是 allowedOrigins(""),同时 allowCredentials(true)。在 Spring 新版本里,这种组合会导致请求被直接拒绝,因为允许凭证(比如 Cookie、Authorization 头)时,不能再允许所有来源。allowedOriginPatterns("") 就是用来处理这种情况的,它表示允许任意来源,同时允许携带凭证。如果你们的接口不需要携带 Cookie 或认证头,直接 allowedOrigins("*") 也可以,但一旦涉及登录态,上面的写法更稳妥。
还要注意一点:如果后端配了拦截器或者 Spring Security,一定要放行 OPTIONS 预检请求。浏览器在 POST、PUT、DELETE 这类跨域请求前,往往会先发一个 OPTIONS 请求探路,如果你的拦截器没有放行它,请求就到不了真正的接口层。
5.2 JWT 登录态:一套能跑通的最小方案
前后端分离后,传统的 HttpSession 方案会变得别扭,因为 Session 依赖 Cookie,而跨域场景下 Cookie 的维护成本比较高,尤其是前端域名和后端域名不一致时。更常见的选择是 JWT,一种把用户身份信息加密后下发给前端的方案。
JWT 的核心结构是三段式:头部、载荷、签名。后端登录成功后,会生成一个包含用户 ID、过期时间等信息的 Token,把它返回给前端。前端在每次请求的请求头里带上 Authorization: Bearer xxx,后端用一个拦截器解析 Token,从而知道当前请求是谁发起的。
一个最小实现可以这样设计:
- 登录接口接收用户名和密码,校验通过后生成 Token
- 自定义一个拦截器,从请求头里取 Token,解析失败直接返回 401
- 在 WebMvcConfigurer 中注册拦截器,并放行登录接口和 swagger 文档路径
要注意,JWT 方案绝对不是万能的。它没法在后端主动让某个 Token 立刻失效,所以很多项目实际会结合 Redis 黑名单来做。但作为学习阶段,先把 JWT 的基本流程跑通,后面再深入权限模型,会让你对“无状态认证”的理解更具体。
5.3 接口文档:别让前端靠猜来调你的接口
很多初学者做接口时,习惯在聊天窗口里把参数发给前端:“你传一个 title,还有 userId,注意是 JSON 格式……”这种协作方式在小项目里能忍,但一旦接口多了,文档绝对会变成灾难。
现在比较流行的做法是接入 Knife4j,它是 Swagger 文档的增强版本,界面更友好。Spring Boot 3 项目需要引入 knife4j-openapi3-jakarta-spring-boot-starter,然后在 Controller 上写对应的注解说明。
关键不是文档工具本身,而是你要形成一种意识:一个接口的定义,至少应该包括接口路径、请求方式、请求参数、返回值结构、异常情况。写文档时,让前端关注的应该是如何调用,而不是后端内部怎么实现。
6. 从本地跑通到部署可用:日志、多环境、打包与真实运维经验
6.1 把 System.out.println 换成日志,能帮你少熬夜
开发阶段很多人习惯用 System.out.println 打印调试信息,这在本地小 Demo 里没什么问题,但放到真实项目里会很尴尬。System.out 要么打到控制台就行,要么在不该出现的地方泄露信息,而且无法区分不同日志级别,也没法配置输出到文件。
Spring Boot 项目里可以用 Lombok 的 @Slf4j 注解,然后在类里直接使用 log 对象:
java复制@Slf4j
@Service
public class TodoServiceImpl implements TodoService {
// 业务代码里用 log.info / log.warn / log.error
}
在写日志时有一个经验:入参、出参、耗时这类关键信息应该在开发或生产环境留下记录,但密码、Token、身份证号这类敏感信息绝对不能在日志里出现。我见过线上事故就是因为日志把用户密码打进去了,排查日志时还要先清理敏感内容,非常被动。另外,日志是给运维和排查的人看的,不是写给自己欣赏的,所以日志信息要能说明是谁在什么上下文里调用了什么方法,而不是只打印“进来了”“出去了”。
6.2 多环境配置:dev、prod 不要在同一个文件里反复改
开发一套配置,测试一套配置,生产一套配置,这是后端工程师迟早要面对的事情。如果你每部署一次就要手动改 application.yml 里的数据库地址,迟早会出问题。
Spring Boot 支持多配置文件方案。你可以拆分成三份:
- application.yml:公共配置,比如应用名、活跃环境配置
- application-dev.yml:本地开发环境配置,数据源指向本地
- application-prod.yml:生产环境配置,数据源指向线上数据库
在 application.yml 里通过 spring.profiles.active 指定激活哪个环境。打包部署时,如果生产机器对应的是 prod,可以启动时用参数指定:java -jar app.jar --spring.profiles.active=prod。这样不同环境的差异就被隔离了,不会出现开发环境连着生产数据库的惨案。
6.3 打包部署时,最常被忽略的三个环节
当你的项目在本地能跑通,下一步就是打包部署到服务器。在 IDEA 里执行 mvn clean package -DskipTests,如果一切顺利,target 目录下会生成一个可执行的 jar 包。把 jar 包上传到服务器后,在你习惯的目录下执行:
bash复制nohup java -jar todo-backend.jar --spring.profiles.active=prod > app.log 2>&1 &
nohup 表示即使窗口关闭,进程也继续跑;> app.log 2>&
