ARTICLE DETAIL

资讯详情

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

Spring Cloud药品管理系统微服务实战:从拆分到部署

Spring Cloud药品管理系统微服务实战:从拆分到部署 简介一份基于Spring Cloud微服务架构的综合药品管理系统毕业设计源码面向计算机相关专业毕业生及微服务学习者用于理解药品进销存、供应商与销售管理等业务场景下的系统拆分与实现。包内共1027个文件以375个Java源文件、167个JavaScript脚本、77个Vue组件及99张PNG图片为主另有SQL数据库脚本、YML配置和前端样式文件覆盖后端服务、管理界面与数据初始化等完整工程结构压缩包仅7.23MB核心目录medicine-master便于直接导入与查阅。目前已有207人学习适合作为毕业设计选题参考或Spring Cloud实战项目进行二次开发。通过源码可学习Eureka服务注册发现、API网关路由、Ribbon/Feign调用、Spring Security权限控制及前后端分离开发同时可参考其数据库设计与部署思路是一套能帮助快速搭建药品管理微服务项目的完整蓝本。1. 从毕业设计到生产级药品管理系统为什么需要Spring Cloud综合药品管理系统听起来只是一个“管理药品信息”的CRUD系统但当你真正开始设计数据模型和接口时会发现业务远比想象中复杂药品有多规格、多批次、多供应商库存要支持近效期预警和批次追溯采购、销售、处方审核之间还有强一致性的库存扣减需求。用单体应用撑着倒也能跑可一旦你需要在毕业设计答辩里讲清楚“系统为什么这么拆”“模块之间怎么协作”Spring Cloud就成了解答这些问题的关键线索。本文假设你已经掌握Spring Boot的基本用法带你从零构建一个可运行的药品管理微服务系统内容包括微服务拆分方法、Nacos注册与配置中心、OpenFeign调用、网关路由、分布式事务和缓存策略。每一段代码都来自我实际搭建时的做法你可以直接复制到自己的项目里改造。整个系统跑通后你再去看网上那份“毕业设计源代码”会更容易读懂它为什么那么设计。2. 药品管理系统的微服务拆分领域模型与Spring Cloud组件选型2.1 先划业务域药品管理系统需要几个服务我在动手写代码前会先把业务名词列出来用户、角色、药品、药品批次、供应商、采购单、入库单、出库单、库存记录、处方、销售订单。这些名词可以自然聚合成几个高内聚的领域。常见的保守拆分方案如下服务名称核心实体主要接口user-service用户、角色、权限登录、用户CRUD、权限校验drug-service药品基础信息、厂家、分类药品查询、药品详情stock-service批次、库存、入库单、出库单库存查询、入库、出库、批次近效期列表order-service处方、销售订单、订单明细创建订单、扣库存、订单查询这个数量级对于毕业设计正合适。四个服务拆得太少体现不出Spring Cloud价值拆得太多你光维护服务间调用关系就要花掉大量时间。2.2 注册中心与配置中心选择Nacos而不是Eureka过去Spring Cloud的标配是Eureka和Spring Cloud Config但最近几年新项目基本都转向Nacos。Nacos同时承担注册中心和配置中心两个角色这意味着你不需要再单独维护一个config-server。我当前的依赖版本选择如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties spring-cloud.version2021.0.8/spring-cloud.version spring-cloud-alibaba.version2021.0.5.0/spring-cloud-alibaba.version /properties在bootstrap.yml里需要同时配置注册中心地址和配置中心数据源spring: application: name: stock-service cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: public group: DEFAULT_GROUP config: file-extension: yaml namespace: public group: DEFAULT_GROUP shared-configs: ->dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency网关路由配置spring: cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix2 - id: drug-route uri: lb://drug-service predicates: - Path/api/drug/** filters: - StripPrefix2 - id: stock-route uri: lb://stock-service predicates: - Path/api/stock/** filters: - StripPrefix2这里的关键是uri: lb://stock-serviceGateway会根据服务名从Nacos拿到实际实例列表再通过LoadBalancer做轮询或负载均衡。StripPrefix2表示在转发前丢弃前两段路径也就是去掉/api/stock这两个段位让target服务直接收到业务路径。3. 从零搭建库存与药品服务的代码实现数据库设计、REST API与调用链3.1 数据库设计批次与库存分开表药品库存系统的核心是“批次”和“库存”分开建模。一张药品表只存通用信息一张批次表存每批药品的批号、生产日期、有效期一张库存表存“某个批次在某仓库的数量”。近效期预警只需对批次表做失效日期查询不需要在药品明细里维护复杂的库存状态。我用MySQL数据库核心建表脚本如下CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL UNIQUE, drug_name VARCHAR(128) NOT NULL, category VARCHAR(64), spec VARCHAR(64), unit VARCHAR(16), manufacturer VARCHAR(128), approval_number VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 ); CREATE TABLE stock_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) NOT NULL, production_date DATE, expire_date DATE NOT NULL, supplier_id BIGINT, purchase_price DECIMAL(10,2), sale_price DECIMAL(10,2), KEY idx_drug_batch (drug_id, batch_no), KEY idx_expire (expire_date) ); CREATE TABLE stock_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, KEY idx_batch (batch_id) );stock_balance表里我加了一个version字段这是为后面做乐观锁扣库存准备的具体逻辑在第四章展开。3.2 药品服务的基础CRUD用MyBatis-Plus还是JPA在这个项目里我推荐MyBatis-Plus因为毕业设计通常需要展示SQL可控性而MyBatis-Plus的LambdaQueryWrapper写起来比原生XML快很多还内置了分页插件。下面是一个药品查询的Mapper示例import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; Mapper public interface DrugInfoMapper extends BaseMapperDrugInfo { }对应的Service层用IServiceimport com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.springframework.stereotype.Service; Service public class DrugInfoService extends ServiceImplDrugInfoMapper, DrugInfo { public DrugInfo findDrugByCode(String drugCode) { LambdaQueryWrapperDrugInfo wrapper new LambdaQueryWrapper(); wrapper.eq(DrugInfo::getDrugCode, drugCode) .eq(DrugInfo::getDeleted, 0); return this.getOne(wrapper); } public PageDrugInfo pageSearch(String keyword, int pageNo, int pageSize) { LambdaQueryWrapperDrugInfo wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), DrugInfo::getDrugName, keyword) .or() .like(StringUtils.hasText(keyword), DrugInfo::getDrugCode, keyword); return this.page(new Page(pageNo, pageSize), wrapper); } }代码说明LambdaQueryWrapper可以避免手写字符串列名编译期就能发现字段名拼写问题。or()方法要与前面的like条件逗号分隔如果你写成like().or().eq()SQL会变成where drug_name LIKE ? OR drug_code ?条件解析完全不同这里很容易踩坑。Controller层直接返回统一响应体RestController RequestMapping(/drug) public class DrugInfoController { Resource private DrugInfoService drugInfoService; GetMapping(/page) public RPageDrugInfo page( RequestParam(required false) String keyword, RequestParam(defaultValue 1) int pageNo, RequestParam(defaultValue 10) int pageSize) { return R.ok(drugInfoService.pageSearch(keyword, pageNo, pageSize)); } }参数说明required false表示前端可以不带keyword不传时直接查全量分页参数默认值写死在注解里避免每次调用都要显式传值。3.3 服务间调用从订单服务调库存服务现在走一条完整链路order-service创建订单时需要同步检查并扣减库存。这里选择使用OpenFeign调用库存服务。在order-service中引入依赖并定义Feign接口dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-openfeign/artifactId /dependencyFeign接口必须与库存服务Controller的请求映射保持一致import com.medsys.common.R; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; FeignClient(name stock-service, path /stock) public interface StockFeignClient { PostMapping(/deduct) RVoid deductStock(RequestBody StockDeductDTO dto); GetMapping(/near-expire) RListBatchVO nearExpire(RequestParam(days) int days); }主启动类上必须加上EnableFeignClientsSpringBootApplication EnableFeignClients public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }调用链说明订单服务启动后Feign会基于服务名从Nacos获取库存服务的实例列表然后通过LoadBalancer选择一个实例发起HTTP调用。这个过程中你不必手工管理URL地址库存服务再扩展一个副本时Feign会自动感知。3.4 接口防重用Redis做一个简单幂等键药品订单接口面临重复提交的问题尤其是前端超时重试场景。常见做法是用Redis的SETNX做幂等键。我在库存扣减接口中这样实现PostMapping(/deduct) public RVoid deductStock(RequestBody StockDeductDTO dto) { String idempotentKey deduct: dto.getOrderNo(); Boolean success redisTemplate.opsForValue() .setIfAbsent(idempotentKey, processing, Duration.ofSeconds(30)); if (Boolean.FALSE.equals(success)) { return R.fail(重复请求订单正在处理中); } try { stockService.deduct(dto); } finally { // 处理完成后删除key保证同一订单后续重试能够覆盖 redisTemplate.delete(idempotentKey); } return R.ok(); }代码说明setIfAbsent就是SETNXRedis集群下也能保证原子性。锁超时时间设为30秒考虑的是扣库存逻辑正常执行不超过1秒给足余量但也不会让锁堆积太久。4. 综合业务落地事务一致性、分布式锁与缓存策略4.1 本地事务与分布式事务边界在微服务架构里订单服务和库存服务各自拥有独立数据库。一个创建订单的操作会先写订单表再调用库存服务扣库存。这两个动作单纯靠Spring的Transactional无法保证原子性因为事务边界只存在于单个数据库连接内。这时候有两种常见方案基于可靠消息的最终一致性订单创建成功后将消息写入本地消息表通过MQ异步通知库存服务使用Seata AT模式在业务方法上标注GlobalTransactionalSeata框架通过全局锁和undo_log表实现分布式事务。毕业设计我更推荐使用Seata AT模式因为这不需要改造业务SQL只需在每个服务数据库中增加一张undo_log表。依赖配置如下dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency在order-service的方法上添加注解GlobalTransactional(name create-order, timeoutMills 10000) public void createOrder(OrderCreateDTO dto) { // 本地事务写入订单主表和明细表 orderMapper.insert(OrderDO.builder().orderNo(dto.getOrderNo()).build()); // 远程事务扣减库存 stockFeignClient.deductStock(dto.toDeductDTO()); }注意GlobalTransactional的timeoutMills要设置合理一点常见坑是设置太短导致库存服务响应稍慢就报全局事务超时。建议设置10秒以上。4.2 库存扣减的并发陷阱乐观锁 Redis锁库存扣减是药品系统的核心高并发场景。单纯用update stock_balance set quantity quantity - 1 where id ? and quantity 1在单库条件下没问题但服务间调用时会出现超卖风险。我选择在库存服务本地使用乐观锁。UPDATE stock_balance SET quantity quantity - #{deductCount}, version version 1 WHERE id #{balanceId} AND quantity #{deductCount} AND version #{version}对应MyBatis-Plus的UpdateWrapperUpdateWrapperStockBalance wrapper new UpdateWrapper(); wrapper.eq(id, dto.getBalanceId()) .eq(version, dto.getVersion()) .ge(quantity, dto.getDeductCount()) .setSql(quantity quantity - dto.getDeductCount() , version version 1); int rows this.update(wrapper); if (rows 0) { throw new BusinessException(库存不足或版本冲突); }代码说明setSql使用原生SQL片段这是MyBatis-Plus允许的逃生舱。rows 0表示update条件没有命中可能是版本号被其他线程改了也可能是库存不够。先返回失败让上层重试或提示用户。如果扣减操作还涉及多个批次扣减顺序我会再加一层Redis分布式锁保证同一药品批次的操作串行化String lockKey stock:lock: dto.getBatchNo(); String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BusinessException(系统繁忙请稍后重试); } try { doDeduct(dto); } finally { String currentValue (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } }最后一个assert判断是为了防止误删别的线程设置的锁。锁超时时间只有5秒如果业务的扣减在那段时间内没有完成锁会自动释放其他请求会同时进入。像扣减库存这种轻操作5秒足够完成。4.3 缓存策略药品详情的多级缓存药品基础信息被读多写少适合缓存。常见做法是先用Redis缓存再用本地Caffeine作为一级缓存。这里有一个容易忽略的点药品信息修改后必须先更新数据库再删除缓存而不是直接更新缓存否则并发更新会导致缓存里残留旧值。我通常写一段简单的缓存工具类Component public class DrugCacheService { Resource private RedisTemplateString, Object redisTemplate; private final CacheString, DrugInfo localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public DrugInfo getDrug(Long drugId) { // 先查本地缓存 DrugInfo drugInfo localCache.getIfPresent(drugId.toString()); if (drugInfo ! null) { return drugInfo; } // 再查Redis String redisKey drug:info: drugId; DrugInfo redisValue (DrugInfo) redisTemplate.opsForValue().get(redisKey); if (redisValue null) { drugInfo drugInfoMapper.selectById(drugId); redisTemplate.opsForValue().set(redisKey, drugInfo, Duration.ofHours(2)); } else { drugInfo redisValue; } // 回填本地缓存 localCache.put(drugId.toString(), drugInfo); return drugInfo; } public void evictCache(Long drugId) { redisTemplate.delete(drug:info: drugId); localCache.invalidate(drugId.toString()); } }代码说明本地缓存最大容量10000条超过后Caffeine会按照W-TinyLFU算法淘汰。先更新数据库再evictCache确保下一次读取不会拿到旧值。5. 打包部署与调试细节源代码该如何改造成自己的毕业设计拿到一份可运行的Spring Cloud药品管理系统源代码你最迫切的是让它能在本地跑起来。第一步是确认Nacos和Redis版本我通常用Nacos 2.2.0Redis 6.x以上。启动Nacos服务后把配置中心里common.yaml里的数据库地址改成你的本机MySQL地址。接着用Maven的打包命令mvn clean package -DskipTests每个子模块都会生成独立jar包进入gateway目录执行nohup java -jar gateway-service.jar --spring.profiles.activedev gateway.log 21 启动顺序建议是先启动user-service、drug-service、stock-service这类的纯业务服务最后启动order-service和gateway。实际排错时有个技巧用浏览器直接访问一个业务服务的随机端口比如http://localhost:52001/drug/page看能不能返回JSON。直接访问业务服务能隔离出问题是出在网关路由还是服务本身。调试OpenFeign调用时建议在订单服务本地开启Feign日志logging: level: com.medsys.order.feign: DEBUG feign: client: config: default: loggerLevel: FULLloggerLevel: FULL会打印请求头、请求体、响应体和元数据。常见的400错误通常是DTO字段名对不上后端接收不到参数500错误一般是库存服务内部异常看看目标服务的日志即可。另一个常见坑是当前不会命中断点。这通常不是代码问题而是因为你的IDE调试的服务实例和访问的服务实例不是同一个。比如你同时启动了两个库存服务实例网关负载均衡轮流转发请求但你的断点只打在其中一个实例上。解决方法是给每个服务设置独一无二的端口或者关闭LoadBalancer的重试策略让请求固定走到带断点的实例。最终的毕业设计演示我建议准备一份验证脚本按顺序调用这几个接口登录获取token、创建药品、录入批次、入库、创建订单扣库存、查询近效期列表。确认每个接口响应时间都在500毫秒以内答辩时展示Nacos上四个服务注册成功的截图再贴一段Feign调用的日志整个项目的完整度和技术深度就都够了。本文还有配套的精品资源点击获取
返回列表