ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot的美食分享交流平台从零到部署全流程实战

基于SpringBoot的美食分享交流平台从零到部署全流程实战 去年我把一个做了两个多月的美食分享交流平台从零搭到上线期间断断续续有人问我这项目该从哪下手表怎么建图片传哪前端怎么合进来部署有没有坑说实话这种项目本身就是典型的SpringBoot练手场景——用户注册登录、发布美食图文、点赞评论收藏、搜索和榜单功能听起来都是老四样但真要把每个细节都做得能上线、能演示、能写进简历里面值得琢磨的点比想象中多得多。这篇文章就直接拿这个“基于SpringBoot的美食分享交流平台”当例子把从需求拆解、数据模型、核心功能实现、踩坑记录、Vue联调到服务器部署的完整链路过一遍。适合正在做毕业设计、准备SpringBoot面试、或者想独立接一个前后端分离小项目练手的读者。我会尽量说人话能上代码的上代码能讲原理的讲原理尤其是那些文档里不会明说、但实际开发一定会碰到的坑都会单独拿出来聊。1. 项目定位与选型为什么美食社区要用SpringBoot而不是SSM1.1 先把“美食分享交流平台”拆成功能清单拿到这种标题很多人第一反应是开一个SpringBoot项目然后照着网上的CRUD模板开始建表写接口。这是最容易翻车的地方。做项目的第一步不是敲代码而是把产品需求拆成足够细的功能点。我的做法是用一张脑图把整个平台分成五大模块用户模块注册、登录、个人主页、头像修改、关注与粉丝。内容模块发布美食图文帖、编辑、删除、按分类或标签筛选、草稿功能。互动模块点赞、收藏、评论、楼中楼回复、站内通知。发现模块热门榜单、关键词搜索、标签聚合、随机推荐。管理模块内容审核、违规处理、数据统计、用户禁用。这个平台我最终只做了用户、内容、互动、发现四个模块后台管理做了个极简版用的是同一个SpringBoot工程的/admin路径没单独拆后台前端。原因很简单单人开发能砍的需求一定要砍先把核心闭环跑通比堆功能有意义。1.2 为什么选SpringBoot而不是继续用SSM很多教材到现在还在教SSH或SSM但实际工作里SpringBoot基本已经是Java后端的默认起点。我选择SpringBoot主要是这几点起步依赖省心引入spring-boot-starter-web就把Web环境、内嵌Tomcat、JSON转换全带进来了不用再手动拼spring-web、spring-webmvc、jackson那一串依赖。自动配置省事数据源、MyBatis、Redis、定时任务都有一键starter配置项收敛到application.yml不用再写一堆Bean式XML。微服务友好虽然这个项目是单体但SpringBoot的模块化骨架让后续拆库拆服务都容易。而且现在面试聊微服务几乎所有公司都默认你懂SpringBoot。说实话SSM做这个项目也能做但你需要多花两三倍的精力在配置上。SpringBoot把“配置地狱”变成了“约定优于配置”让开发者把时间花在业务逻辑上这件事对于这种中型练手项目尤其重要。1.3 工程目录怎么规划才不像玩具项目我见过太多新手项目controller、service、mapper全堆在一个包里几百个文件平铺。这种结构跑通Demo没问题但后面想加功能、排查问题都难受。我当时用的是Maven多模块结构food-platform ├── food-common // 公共工具类、统一返回体、异常定义 ├── food-framework // 配置类、拦截器、Redis工具、分布式ID ├── food-api // 主应用controller/service/mapper都在这 │ ├── controller │ ├── dto │ ├── entity │ ├── mapper │ └── service └── food-admin // 预留后台管理模块包名从上到下是com.example.food.common、com.example.food.framework、com.example.food.api这样。每个模块内部再按业务拆分比如api.controller.user、api.controller.post、api.service.interact。这样分的好处是别人拿到你的代码扫一遍目录就知道每个类是干什么的而不是点开十几个文件去猜。2. 数据模型设计一张美食帖背后到底要建几张表这章是整个项目的地基也是面试官最喜欢追问的点。表设计得好不好后续功能开发会差别很大。美食分享平台的核心表我数了数一共七张用户表、帖子表、图片表、评论表、点赞表、收藏表、关注表再加上标签和帖子标签关联两张表一共九张。2.1 用户表别只存用户名和密码用户表看起来最简单但最容易踩坑。我当时设计的核心字段是CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50) DEFAULT , avatar VARCHAR(255) DEFAULT , bio VARCHAR(255) DEFAULT , status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );几个值得注意的设计点密码不能存明文我用的是BCrypt加密Spring Security加密工具可以直接调用比MD5加盐安全得多。nickname和username分开。用户名是登录用的不允许改昵称是展示用的用户可以随便换。很多新手只用一个字段后面做个人主页修改就尴尬。status字段留着做禁用逻辑。用户违规了直接改状态而不是删数据这样能保留历史言论用于审核。2.2 帖子表冗余字段要大胆加美食分享的核心是帖子帖子表承载了标题、正文、封面、标签、互动数据。我当时的建表结构大致如下CREATE TABLE post ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL, content TEXT, cover_image VARCHAR(255), status TINYINT DEFAULT 1, like_count INT DEFAULT 0, favorite_count INT DEFAULT 0, comment_count INT DEFAULT 0, view_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );我把点赞数、收藏数、评论数、浏览量直接冗余在帖子表里而不是每次都用COUNT去统计。为什么因为列表页、详情页、个人主页、搜索页都要展示这些数字用COUNT实时算单表几万条数据还能忍数据一多、并发一高就会拖慢页面。冗余字段的代价是写操作时要多维护这几个数字但可以用事务或者异步消息来保证最终一致。status字段除了表示“正常/禁用”我后面还扩展了“草稿/待审核/已发布/已删除”几个状态。用数字代替字符串枚举配合代码里的枚举类统一管理前端拿到的是数字后端再翻译成含义避免歧义。2.3 互动关系表唯一索引防止重复点赞点赞、收藏、关注这三张表的逻辑很相似核心字段都是“谁对什么操作”。以点赞表为例CREATE TABLE post_like ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, post_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_post (user_id, post_id) );关键就在那个唯一索引(user_id, post_id)联合唯一。这样即使用户连续快速点两次请求数据库层面也能拦住重复插入。点赞和取消赞用逻辑删除保留一条关系记录用status标记是否有效这样个人主页的“我赞过的”随时能查。评论表我用了parent_id来做楼中楼CREATE TABLE comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, post_id BIGINT NOT NULL, user_id BIGINT NOT NULL, parent_id BIGINT DEFAULT 0, reply_user_id BIGINT DEFAULT 0, content VARCHAR(500) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );parent_id为0表示顶级评论否则指向父评论。reply_user_id记录回复的目标用户前端可以直接拼出“某某”的效果。查楼中楼时按parent_id分组查询性能完全够用。2.4 标签与搜索中间表比逗号分隔靠谱刚开始我图省事在post表里加了一个tags字段用逗号分隔存字符串比如“红烧肉,下饭菜,家常菜”。后来做标签聚合筛选时痛苦到了极点只能用LIKE去模糊匹配数据多了根本不敢这么查。所以老老实实拆成三张表CREATE TABLE tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL UNIQUE ); CREATE TABLE post_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, post_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, UNIQUE KEY uk_post_tag (post_id, tag_id) );标签表存唯一的标签名称帖子标签关联表存多对多关系。筛选某个标签下的帖子时直接JOIN关联表即可。日期、索引、唯一约束这些东西看着基础但几乎所有接口的性能问题都出在这几张表上。3. 核心功能实现发帖、传图、搜索、榜单这四件事表结构定下来以后剩下的事情就是往里面填功能。这一章我挑项目里最核心、也最容易被问到的四个功能点展开说。3.1 发布美食帖一个接口搞定图片和内容前端用表单同时提交文本和图片后端接口用MultipartFile接收文件。接口签名大概是这样的PostMapping(/api/post) public ResultLong createPost(RequestParam(title) String title, RequestParam(content) String content, RequestParam(images) MultipartFile[] images) { // 1. 保存封面和图片 // 2. 插入post记录 // 3. 处理标签 // 4. 返回帖子ID }这个接口有几个隐藏细节图片不能直接存数据库要把文件保存到服务器磁盘或者对象存储数据库里存访问URL。文案长度要做校验前端做一次、后端必须再做一次。我直接用NotBlank、Size注解配合全局异常处理省得每个接口手写判断。发帖成功后要刷新用户发帖数这一步可以用AOP或者事务事件回调但不能塞在发帖主流程里硬编码。3.2 图片上传本地存储还是对象存储图片存储方案上我一开始图简单用本地磁盘存储。配置文件里加一个自定义属性file: upload-dir: /data/food/images上传时把图片写到这个目录然后用Nginx把/images/**映射到该目录这样图片URL就是一个纯静态地址。具体处理逻辑String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) suffix; String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); File dest new File(uploadDir / dateDir / fileName); file.transferTo(dest); String url /images/ dateDir / fileName;为什么用UUID重命名因为用户上传的文件名可能是“美食照片.jpg”“我的拿手菜.png”直接存磁盘会出现重名覆盖、特殊字符乱码等问题。按日期分目录是为了避免单个目录文件过多Linux文件系统在目录内文件超过几千个时访问效率会明显下降。如果以后要上云直接把存储层抽象成一个接口本地实现和OSS实现互相替换。头像、帖子图片这些不经常变的数据OSS成本也不高但开发阶段完全没必要为这个多花钱。3.3 关键词搜索从LIKE到HanLP分词搜索功能一开始我用的是MySQL的LIKESELECT * FROM post WHERE title LIKE CONCAT(%, #{keyword}, %)这个方案在小数据量下没毛病但有两个问题一是%keyword%无法命中索引全表扫描二是搜索“红烧肉的家常做法”期望能匹配到“红烧肉怎么做才好吃”这种标题但LIKE做不到语义匹配。我后来引入了HanLP分词库这也是我在项目里做过的最提升体验的一件事。思路是发帖时把标题和正文用HanLP分词将分词结果存进一个post_keyword表搜索时先分词再根据分词结果去匹配帖子。这样搜“红烧肉”就能匹配标题中包含“红烧”“肉”或“红烧肉”的帖子。HanLP在SpringBoot里的集成很简单引入依赖后核心就一行ListTerm termList HanLP.segment(text);分词后的词语进行去重、停用词过滤然后批量插入关键词关联表。如果你不想引入搜索引擎这个方案是中小型项目里性价比最高的“伪全文检索”。3.4 定时任务刷新热门榜单美食平台需要一个“今日热门”榜单不然内容再多也没有曝光入口。热门榜不能实时算那样太耗资源我用的是SpringBoot自带定时任务每十分钟刷新一次。最开始的写法Component public class HotRankTask { Scheduled(cron 0 */10 * * * ?) public void refreshHotRank() { // 计算热度并写入Redis } }这里有个坑SpringBoot的Scheduled默认是单线程执行的。如果定时任务里有耗时的数据库操作后一个任务必须等前一个任务跑完才开始到了高峰期可能任务堆积。解决办法是实现SchedulingConfigurer把任务线程池调大Configuration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }热度计算我用了一个简洁的公式热度 浏览量 * 1 点赞数 * 5 收藏数 * 10 评论数 * 15再乘一个时间衰减系数最近发布的帖子分数高老帖子权重逐渐下降。这样榜单不会永远被老帖子霸占新内容有机会浮上来。4. 那些SpringBoot的坑自动装配失效、版本太高、异步消息选型这是我最想写的一章。项目做得越久越发现SpringBoot的麻烦不是写代码而是版本、依赖、自动装配这些“隐性问题”。这一章全程都是真实踩坑记录。4.1 自动装配原理与配置失效排查SpringBoot的自动装配很多同学面试能背出来实际出问题时不会排查。核心就三句话EnableAutoConfiguration会扫描所有依赖里的自动配置类自动配置类根据Conditional系列注解决定是否生效生效的配置会通过starter的AutoConfiguration.imports文件注册。我遇到过一次自定义starter不生效的问题。排了一下午最后发现是resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports这个文件路径写错了。SpringBoot 2.7之后自动配置的注册文件从spring.factories迁移到AutoConfiguration.imports如果你用的老写法在新版本里就是静默不生效。排查这类问题的固定套路启动时加--debug参数看Positive matches和Negative matches输出确认你的自动配置类为什么没匹配上。检查ConditionalOnProperty的配置项是否漏写。检查ConditionalOnClass依赖的类是否真的在classpath里。检查AutoConfiguration.imports文件里的类名是否写全限定名。4.2 SpringBoot版本太高引发的连锁问题我最初的开发环境是SpringBoot 2.7.18项目进行到一半想升级到SpringBoot 3.2尝尝鲜。结果升级完项目都有点跑不起来的味道。SpringBoot 3.x最大的变化是强制要求Java 17以上而且把javax.*改名成了jakarta.*。这意味着你的旧代码里import javax.servlet.*、import javax.validation.*全部要改。更麻烦的是很多二方库还没有跟上MyBatis-Plus要从3.5.3才能兼容SpringBoot 3.x老版本会直接报类找不到。knife4j要升级到4.x才支持OpenAPI 3和SpringBoot 3。一些自己封装的公共模块里全用的是javax注解排查起来非常痛苦。我最终的选择是回到SpringBoot 2.7.18稳定地把项目上线。这里给大家一个建议如果是毕业设计或者长期维护的项目不要盲目追求最新大版本等周边生态跟上了再升不迟。如果是新项目且技术栈和版本都是自己可控的可以直接上SpringBoot 3.x毕竟Java 17的ZGC、封印类这些特性还是很香的。4.3 异步消息选型ActiveMQ够用就好美食平台的另一个需求是点赞和评论后给用户发站内通知。一开始我想着直接上Kafka后来冷静想了想这个平台日活撑死几百人Kafka的重分发、持久化、分区机制全是多余的成本。为了学而学不是理由项目要解决实际问题。最后我选了ActiveMQ一方面SpringBoot有官方的starter支持另一方面JMS规范学一遍以后接触RabbitMQ也能快速迁移。配置非常简单spring: activemq: broker-url: tcp://localhost:61616 user: admin password: admin发送通知的核心用了一个队列和一个监听器Component public class NotifyProducer { Autowired private JmsTemplate jmsTemplate; public void sendLikeNotice(Long targetUserId, Long postId, Long fromUserId) { NotificationMessage message new NotificationMessage(); message.setTargetUserId(targetUserId); message.setPostId(postId); message.setFromUserId(fromUserId); jmsTemplate.convertAndSend(queue.notify, message); } }Component public class NotifyConsumer { JmsListener(destination queue.notify) public void onMessage(NotificationMessage message) { // 保存到数据库通知表 // 如果目标是离线用户下次登录再拉取 } }这样做的好处是点赞接口里不用再写插入通知表的逻辑只往队列塞一条消息就返回吞吐量上去了削峰填谷也实现了。对于这种小项目ActiveMQ完全够用。别看到“消息队列”三个字就非得上Kafka合适的才是最好的。4.4 两个提升开发幸福感的小工具项目里我还用了Spring Boot Banner生成器在启动时打印一个“美食分享平台”的ASCII艺术字纯属开发仪式感但团队里看着确实提神。另外如果你网上下载的插件或工作台模板想集成到项目里直接把对应的jar引入依赖、再通过maven插件打包装进运行环境即可别把下载的jar随便扔进JDK的lib目录那样会把全局环境搞乱。5. Vue打包进SpringBoot前后端联调与生产部署这个平台前端用的Vue 2 Element UI属于经典的前后端分离组合。开发阶段各跑各的但部署时为了减少服务器成本我选择把Vue打包产物直接放进SpringBoot工程里一个SpringBoot进程搞定前后端。5.1 开发环境下的跨域代理开发时Vue跑在localhost:8081后端跑在localhost:8080。前端请求/api接口会遇到跨域解决方案不是在后端疯狂写CrossOrigin而是在Vue的vue.config.js里配置代理module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端只认相对路径/api/user/login到了dev server再代理给后端浏览器看到的是同源请求不会触发CORS。后端依然不需要开放跨域干净利落。5.2 生产环境把dist放进SpringBootVue项目构建后会生成dist目录里面有index.html、static/js、static/css等资源。很多人在这一步直接把dist内容拷贝到src/main/resources/static下面结果部署测试发现页面404、刷新404。问题出在Vue Router的路由模式。如果用的是history模式刷新/detail/123这个地址时SpringBoot的请求分发会去找detail/123这个Controller结果当然404。解决办法有两个把Vue Router改成hash模式URL会变成/#/detail/123刷新不会产生真实请求简单省事但丑一点。保持history模式SpringBoot做个转发把非接口路径统一转发到/index.htmlController public class PageForwardController { RequestMapping(value {/detail/**, /user/**, /search/**, /login, /register}) public String forwardToIndex() { return forward:/index.html; } }我个人更推荐第二种URL美观更贴近实际项目。写的时候注意图片等静态资源路径不要被这条规则覆盖。5.3 登录态与JWT拦截前后端分离后登录态不能再依赖Session因为Vue发起的请求和浏览器打开的页面不共享SessionId。我用的方案是JWT登录成功后返回一个token前端存在localStorage之后每次请求在请求头里带Authorization: Bearer xxx。后端的拦截器代码模式非常固定Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); // 解析token失败则返回401 // 成功则把userId放入request attribute return true; } }注册拦截器时注意放行静态资源和登录接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register, /api/post/list, /images/**); } }6. 宝塔Docker部署上线从jar包到跑起来的完整过程本地跑得再好不上线等于白做。我部署用的是云服务器 宝塔面板 Docker整体很顺利就是有一些细节得注意。6.1 打包与服务器准备后端在项目根目录执行mvn clean package -DskipTests打包完成后的jar包在food-api/target/food-api.jar。服务器上我安装了宝塔面板然后在面板里装好Docker和Docker Compose再在服务器上创建好目录/data/food-platform用来放jar包、Dockerfile、配置文件和数据目录。6.2 Dockerfile与docker-compose配置我用的镜像基于Java 8因为SpringBoot 2.7默认兼容JDK 8。Dockerfile很简单FROM openjdk:8-jdk-alpine MAINTAINER yourname COPY food-api.jar /app/food-api.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT [java, -jar, /app/food-api.jar]然后写了一个docker-compose.yml把MySQL、Redis、后端服务一起编排起来。这是最省心的方式version: 3.8 services: mysql: image: mysql:5.7 container_name: food-mysql environment: MYSQL_ROOT_PASSWORD: yourpassword MYSQL_DATABASE: food_platform volumes: - /data/food-platform/mysql:/var/lib/mysql ports: - 3306:3306 restart: always redis: image: redis:6-alpine container_name: food-redis ports: - 6379:6379 restart: always food-api: image: food-api:1.0 container_name: food-api build: . depends_on: - mysql - redis ports: - 8080:8080 volumes: - /data/food-platform/images:/data/food/images - /data/food-platform/logs:/data/food/logs restart: always重点说一下数据卷挂载图片目录和日志目录一定要挂载到宿主机否则容器一旦重建用户上传的图片和运行日志全没了。我第一次部署时就没挂载图片目录升级完容器后用户头像全部变成空白排查时发现图片文件全被容器重绘给清理了那是实实在在的教训。6.3 上线后的基础加固服务跑起来只是开始上线后我做了几件小事避免后续出问题JVM参数启动命令加上-Xms512m -Xmx1024m避免容器内存无限增长把整台服务器挤爆。内存充足时可以按-Xmx为物理内存的一半来设置。日志切割SpringBoot默认的日志文件不会自动切割我引入logback配置按天滚动并压缩appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/data/food/logs/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/data/food/logs/app.%d{yyyy-MM-dd}.log.gz/fileNamePattern /rollingPolicy /appender数据库备份写了一个简单的shell脚本每天凌晨用mysqldump全量备份保留最近7天。脚本丢到crontab里执行成本几乎为零。最后说一个关于图片压缩的小技巧美食平台的图片大多从手机上传一张图动辄两三MB直接展示很卡。我在上传接口里加了thumbnailator做压缩帖子列表页用的缩略图压缩到宽度800px详情页用原图访问速度和磁盘占用都好了很多。这个小改动用户可能感知不到但服务器压力和页面加载速度差得很明显。项目上线后我也一直在维护后面如果要做搜索热词、同城美食推荐现在的数据模型和模块边界都能扩展这是当初多花两天做设计换来的底气。
返回列表