基于Vue2.6和.NetCore3.1的工业互联网CPS系统多租户架构实践
1. 面对工业现场的千奇百怪先聊聊这套CPS系统的由来工业互联网喊了好几年真正落到车间里你会发现绝大多数项目根本不是技术不够花哨而是“软件形态”压根没跟上现场节奏。有大厂直接从云端给你一个SaaS账号说你们工厂接上去就行——结果现场网络不稳定、数据不出厂的要求卡在那儿、车间里既有Windows工控机又有Linux网关老板还想要一套系统所有部门共用。最后项目要么烂尾要么成了PPT上的摆设。我这两年做的一套基于Vue2.6和.NetCore3.1的工业互联网CPS系统就是冲着这些真实痛点来的。CPS这个概念听着玄乎其实就是信息物理系统——把物理世界的设备、产线、传感器跟数字世界的业务系统打通形成“感知-分析-决策-执行”的闭环。我们这套软件定位是工业应用软件底座核心解决三件事跨平台部署、多租户共用、多业务模块按需组合。先说跨平台这是个被很多人低估的需求。一套系统如果只能装在Windows上那工业现场一半的场景你就覆盖不了——很多边缘网关是Linux很多老的工控机又只能是Windows 7还有一些客户为了安全要求必须部署在内网服务器上。而.NetCore3.1天生跨平台前端Vue又跑在浏览器里这套组合天然就能做到“写一次到处部署”。再说多租户这个在纯互联网SaaS里很常见但放到工业场景里会复杂得多。同一个集团下面可能有多个工厂每个工厂有自己的用户、设备、工艺数据但又需要集团层面汇总分析。传统做法是给每个工厂上一套独立系统结果运维成本翻几倍数据标准还不统一。多租户就是要解决“一套系统、多组织共用、数据互相隔离、权限独立控制”的问题。至于多业务需求工业软件最怕的就是做成“大而全”的怪物。今天要设备管理明天要工单系统后天又要质量管理每加一个模块就改一遍主体代码最后没人敢动。我们把业务拆成可插拔的模块像搭积木一样按客户需求组合这个思路在服务多个行业客户时特别管用。这篇文章不聊虚的直接拆解这套系统的架构设计、落地方案和踩过的坑。如果你是做工业软件、IoT平台或者SaaS系统开发的尤其正在纠结技术选型和多租户方案的可以认真看看。2. 整体架构拆解为什么是Vue2.6和.NetCore3.1而不是其他组合2.1 后端选型.NetCore3.1在工业场景里的优势先说说为什么选.NetCore3.1。当时团队评估过Java Spring Cloud、Go、甚至Python最后还是定了.NetCore3.1原因很实际。第一性能上有天然优势。.NetCore的运行时性能在TechEmpower的基准测试里常年排在第一梯队同样的硬件配置处理高并发设备数据上报的业务场景比Java节省不少机器。工业现场的设备数据特点是“高频小包”一个车间几百台设备每台设备几秒钟上报一次状态数据吞吐量要求比普通企业应用高一个量级。第二生态对工业协议的支持够用。工业领域最常见的Modbus、OPC UA、S7协议在.Net生态里都有成熟的类库像IoT Gateway这种开源项目就是基于.Net写的直接拿来改改就能对接设备层。如果用Java有些事情就得自己造轮子。第三部署方式灵活。发布成单文件、自包含部署包扔到Linux服务器上直接跑不依赖外部运行时。这在工业现场很关键——很多工厂的服务器是不能连外网的你不能现场装环境、装依赖自包含部署解压就能跑。2.2 前端选型Vue2.6在工业系统里的生命力前端选了Vue2.6而不是Vue3或者React这个决策当时也有争议。但Vue2.6在国内工业软件圈的渗透率实在太高了尤其是老项目维护和招人成本这两个维度Vue2.6的优势无可比拟。工业管理系统的前端特点就是表格多、表单多、实时数据刷新频繁这些场景Vue2.6的响应式模型处理起来很成熟。再加上Element UI这套组件库工业后台管理页面的开发效率极高。我们现在看Vue3的生态系统已经非常完善了但如果你接手的是既有项目或者团队里全是Vue2的老手Vue2.6在2025年的今天完全够用关键在于架构设计得当。代码层面我们用了Vuex做状态管理vue-router做前端路由。组件库选了Element UI配合ECharts做工业可视化大屏。前端只做交互和展示所有业务逻辑全部走后端API这样前端代码的复杂度可控。2.3 CPS分层架构从设备到业务的完整链路整个系统的架构分层大概是这样的设备接入层负责对接各类工业设备通过Modbus TCP、OPC UA、MQTT等协议采集数据。这一层我们开发了独立的边缘网关程序部署在车间现场负责协议解析和数据清洗然后将标准化后的数据推送到平台。平台服务层这是整个CPS系统的核心包含设备管理、数据存储、规则引擎、报警中心、权限管理等基础能力。所有的业务模块都构建在这一层之上。业务应用层面向不同角色提供的具体功能模块比如设备运维人员的点检保养模块、生产管理人员的工单排产模块、质量部门的质量追溯模块。展示层Vue2.6构建的Web端包含管理后台、车间看板、领导驾驶舱大屏三种形态。这套分层的好处是每一层都可以独立部署、独立扩展。设备接入层可以单独部署到边缘节点平台服务层可以横向扩展业务应用层按需装载。客户只需要基础平台那我们就只交付设备管理和报警中心客户需要生产和质量模块再把对应的业务模块部署上去。这种可裁剪性正是多业务需求落地的关键。3. 多租户落地方案一套系统怎么服务多个工厂、多个客户3.1 三种租户隔离策略我们应该怎么选多租户的核心问题是隔离也就是不同的租户之间数据不能互相串。隔离策略通常有三种我做了个对比隔离方案隔离级别成本适用场景独立数据库最高高大型客户、数据敏感行业共享数据库独立Schema中中多数SaaS场景共享表租户ID区分低低租户间信任度高的场景我们最终选了“共享数据库 独立Schema”的方案原因很现实。独立数据库方案的成本太高每个租户一套数据库实例光是数据库连接数、备份任务、监控告警就够运维喝一壶的。而共享表方案虽然成本最低但在工业场景下有隐患——一旦代码里某个查询漏掉了租户条件A工厂的设备数据就会出现在B工厂的界面上这种事故在工业项目里是绝对不能发生的。万一出了事故责任划分也非常麻烦。独立Schema的方案是把数据库里的数据按Schema隔开同一个数据库实例每个租户一个Schema数据表结构一样但数据物理隔离。查询的时候只要指定Schema天然就带上了租户隔离条件就算代码里忘了加“where tenant_id xxx”数据也串不了。这个方案在隔离级别和运维成本之间取得了平衡是我们权衡之后的选择。3.2 租户上下文解析中间件怎么识别“你是谁”多租户要解决的第一件事就是用户的每一次请求系统得知道这个请求属于哪个租户。我们的实现方案是通过JWT令牌携带租户信息而不是每个请求都传输租户ID。具体做法是用户登录的时候后端根据用户名找到对应的租户把租户ID写进JWT的Claim里。之后每次请求网关或API层从JWT里解析出租户ID然后通过一个自定义中间件把租户上下文存入当前请求的线程安全容器里。public class TenantMiddleware { private readonly RequestDelegate _next; public TenantMiddleware(RequestDelegate next) { _next next; } public async Task InvokeAsync(HttpContext context) { var tenantId context.User?.FindFirst(tenant_id)?.Value; if (!string.IsNullOrEmpty(tenantId)) { var tenantContext context.RequestServices.GetServiceTenantContext(); tenantContext.TenantId Guid.Parse(tenantId); } await _next(context); } }这里有个重要的细节租户上下文不能存到静态变量里因为多租户系统是并发的A租户的请求和B租户的请求可能同时在服务器上处理静态变量存租户ID会造成数据串。我们用的是Scoped生命周期的依赖注入每个HTTP请求创建独立的TenantContext实例请求结束自动释放这样才能保证租户上下文不串。租户上下文建立之后数据库访问层会在每次创建数据库连接时根据租户ID自动切换Schema。我们的DbContext是动态创建的根据TenantContext里的租户ID拼接连接字符串里的Initial Catalog或Schema参数。3.3 租户级别的功能开关与个性化配置光有数据隔离还不够不同租户对功能的需求也不一样。有的工厂要设备点检有的要能耗管理有的要计件工资多租户系统必须支持“租户级功能开关”。我们设计了FeatureFlag机制每个租户在开通时都会分配一套功能清单清单里包含了这个租户能访问哪些业务模块、每个模块的按钮权限有哪些。前端根据租户的功能清单动态渲染菜单和按钮后端在API层也做同样的权限校验双重保障。这个机制带来的一个好处是系统内核可以不断扩展新功能但不会影响到存量租户。比如我们研发了新的“设备预测性维护”模块测试通过后先给试点租户开通验证没问题再推给其他租户。这跟传统软件“一升级所有客户全变”的模式完全不同。4. 多业务需求模块化从设备管理到生产执行的不停扩展4.1 核心业务模块清单工业互联网到底要管什么工业互联网CPS系统承载的业务远不止“设备连上网”这么简单。围绕设备全生命周期管理我们沉淀了一套基础业务模块设备管理设备台账、设备档案、备品备件管理、设备维修记录。这个模块是工业系统的地基所有设备相关的业务都从这里开始。生产工单管理生产任务下达、工单进度跟踪、工序流转记录。把生产计划拆解成可执行的工单再分配到具体设备和人。质量追溯生产过程中的质量数据采集、不良品记录、批次追溯。一旦产品出现质量问题能快速定位到是哪台设备、哪个批次、哪个操作工。报警中心设备异常报警、参数超限报警、报警处理闭环。支持微信、短信、邮件多种通知方式。点检保养设备点检计划、保养计划自动生成、点检记录上报。这个模块最琐碎但客户最需要。能源管理水、电、气的分项计量、能耗趋势分析、能耗异常报警。报表中心各类管理报表的自动生成包括设备OEE报表、稼动率报表、质量统计报表等。4.2 模块化开发的边界划分和接口约定这么多业务模块放在一起如果没有清晰的边界很快就会变成意大利面条式的代码。我们通过几个原则来维持模块独立每一个业务模块都做成了独立的后端项目通过项目引用或NuGet包的方式引用公共类库。模块之间不允许直接引用彼此的业务代码只允许通过事件或API进行通信。比如设备管理模块触发“设备维修完成”事件后生产模块监听这个事件自动恢复对应的工单排程。数据库层面每个模块有自己的数据表前缀比如设备相关表统一前缀“equ_”工单模块统一前缀“pro_”互不干扰。这样做的好处是数据库结构一目了然新接手的开发也能快速定位到某个功能涉及的是哪些表。前端代码也按模块拆分每个模块一个目录路由和菜单配置单独维护。新增一个业务模块只需要在“模块注册中心”登记一下前端会自动生成菜单入口后端挂载对应API路由就能让租户看到新功能。4.3 一个完整的业务流设备报警怎么驱动维修工单光说模块化有点抽象我讲一个具体场景设备报警如何自动驱动维修工单的生成感受一下各个模块怎么协作。第一步设备接入层的网关程序采集到设备数据发现主轴温度超过85度触发了预设的报警规则。报警信息通过MQTT推送到平台服务层报警中心模块存储报警记录同时通过实时通道推送到前端页面。第二步报警中心的规则引擎判断这个设备属于哪个租户然后查找该设备关联的维修策略发现该设备启用了“报警自动创建维修工单”的配置。规则引擎调用生产工单模块的API自动生成一张待处理的维修工单工单里自动带上了报警详情设备编号、报警时间、报警参数、位置信息。第三步设备管理模块更新该设备的状态为“异常”同时通过消息通知机制把维修任务指派给负责该设备的维修工。维修工的手机或电脑上实时看板弹出提醒。第四步维修工到现场处理完后在系统里填写维修记录和处理结果工单状态变为“已完成”。报警中心根据处理结果自动关闭报警设备状态恢复为“正常”。这个流程涉及了报警中心、生产工单、设备管理三个模块的联动但各个模块代码之间完全没有直接引用全靠事件驱动和API调用。这就是模块化设计的价值——业务流程可以跨模块编排但代码边界始终清晰。5. Vue2.6前端架构设计动态路由、权限控制与工业可视化5.1 动态路由和菜单不同租户看到不同功能前端最核心的机制就是动态路由。传统后台管理系统的路由是写死的所有用户进来看到的菜单都一样。但在多租户系统里不同租户的功能清单可能完全不同所以菜单和路由必须根据当前登录用户的权限动态生成。我在Vue2.6里这样实现的用户登录后拿到JWT令牌前端调用“获取用户信息”接口返回该用户可见的菜单列表和按钮权限标识。然后通过router.addRoutes方法把这些菜单对应的路由动态注册到Vue Router实例里。同时用Vuex保存菜单列表侧边栏组件根据菜单列表递归渲染。// 登录成功后动态添加路由 const routeMap { /device: () import(/views/device/index.vue), /workorder: () import(/views/workorder/index.vue), /quality: () import(/views/quality/index.vue), /energy: () import(/views/energy/index.vue) }; function generateRoutes(menus) { const routes []; menus.forEach(menu { if (routeMap[menu.path]) { routes.push({ path: menu.path, name: menu.name, component: routeMap[menu.path], meta: { title: menu.title, icon: menu.icon } }); } }); return routes; }按钮级权限用自定义指令实现。我们在Vue里注册了一个“v-permission”指令按钮元素上加上这个指令并传入权限标识如果当前用户没有这个权限标识指令就把这个DOM元素移除。用指令的好处是模板里不需要写太多v-if判断代码干净很多。Vue.directive(permission, { inserted(el, binding) { const requiredPermission binding.value; const hasPermission store.getters.permissions.includes(requiredPermission); if (!hasPermission) { el.parentNode.removeChild(el); } } });5.2 工业大屏和实时数据告别浏览器卡死的刷新方式工业系统跟普通管理系统的最大区别就是有大量的实时数据展示。车间看板大屏要显示设备状态、产量、报警信息数据几秒钟就要刷新一次。第一个版本我们用了“定时器每3秒请求一次API”的粗暴方案结果用户数一多浏览器窗口一多服务器压力山大界面还经常闪烁跳动。后来我们把所有实时数据通道全部改为WebSocket长连接通过SignalR.NetCore自带的实时通信库服务端主动推送数据。前端只负责订阅和渲染不再反复发起HTTP请求。设备状态变化、报警产生、产量更新全部通过WebSocket通道实时推送到前端前端用Vuex统一管理推送过来的数据界面组件用computed属性做依赖更新。这里有个Vue2.6的性能优化细节大屏上的数据点非常多如果每个数据变化都立刻触发DOM更新浏览器根本扛不住。我们给高频变化的数据加了一个缓存层前端收到推送后先更新到Vuex里然后用requestAnimationFrame或setTimeout做批量更新把一秒钟内收到的几十条数据合并成一次DOM渲染。实测下来200个设备同时在线、每秒变化1000个数据点的大屏浏览器帧率能稳定在50fps以上。5.3 大屏可视化布局和图表选型工业大屏的可视化组件选型和布局同样重要。我们的方案是ECharts做图表 DataV的边框装饰效果再加上CSS Grid做宫格布局。ECharts的图表种类多、社区生态好在工业场景里无论是折线图、柱状图、环形图还是地图都能找到现成的案例。大屏布局我们一般遵循“从上到下、从左到右”的信息流逻辑。顶部是标题和时间左侧放设备综合状态和报警统计中间放设备拓扑图或生产事件流右侧放产量趋势和能耗曲线。16:9的页面用Grid栅格切成6列4行每个图表组件占一定的跨度和高度这样在大屏和小屏上都能自适应缩放。关于图表性能工业大屏要特别注意ECharts的“细粒度更新”而不是“整图重绘”。我们用setOption方法传入新的data并且把notMerge参数设为true让ECharts只更新变化的系列而不是整个图表全部重绘。如果一个页面同时有10个图表整图重绘会明显卡顿但细粒度更新几乎无感。6. .NetCore3.1跨平台部署实战Docker、Linux和Windows全兼容6.1 用Docker抹平环境差异跨平台说起来简单真正落地的时候坑特别多。最大的坑就是环境依赖不一致Windows上的IIS部署、CentOS上的守护进程方式、Ubuntu上的systemd配置各不相同每次部署都要写一套新的运维文档。我们的解决办法是全面容器化。发布后的.NetCore3.1应用打成一个Docker镜像前端Vue项目构建后的静态文件用Nginx镜像承载数据库用的SQL Server容器版或PostgreSQL容器版。不管底层的宿主机是Windows Server、CentOS还是Ubuntu部署命令都是一样的“docker-compose up -d”彻底抹平了平台差异。version: 3.8 services: api: image: cps-api:latest container_name: cps-api ports: - 8080:8080 environment: - ConnectionStrings__DefaultServerdb;DatabaseCPS_Main;User Idsa;Password******** - TZAsia/Shanghai depends_on: - db restart: always web: image: nginx:alpine container_name: cps-web ports: - 80:80 volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./dist:/usr/share/nginx/html:ro depends_on: - api restart: alwaysDocker不仅解决了跨平台部署问题还解决了“多租户快速交付”的问题。客户现场要新装一套系统以前得派实施人员跑现场安装配置现在直接把镜像拷贝到客户服务器上一条命令就能起一套环境。6.2 Linux部署和Windows部署的差异清单虽然Docker抹平了大部分差异但有些底层细节还是要注意尤其是如果你的客户坚持不用Docker要在物理机上直接跑。我整理了几个容易踩坑的点路径分隔符Windows用反斜杠“\”Linux用正斜杠“/”。代码里所有文件路径拼接统一用Path.Combine而不是手动拼字符串否则Linux下必炸。文件权限Linux对文件权限很敏感应用需要的写目录比如日志目录、临时文件目录要在部署时创建好并设置好权限否则运行时报“Access denied”很难排查。数据库连接如果数据库在另一台服务器上Windows机器连SQL Server可能默认走Windows身份认证而Linux下必须用SQL Server身份认证或配置文件中的用户名密码这个连接字符串配置要提前处理好。时区问题工业场景里时间戳极其重要设备上报的数据里时间错一个小时报警和统计就全错了。.NetCore跨平台部署默认时区是UTC我们在所有部署环境里都通过环境变量指定了Asia/Shanghai时区并且数据库连接字符串里也加了“TrustServerCertificateTrue”这样避免证书校验问题。6.3 自包含部署没有运行时的服务器也能跑还有一个工业现场常见的情况服务器不能连外网不能安装.NetCore运行时。这种情况用自包含部署模式self-contained deployment发布应用会带一份完整的运行时解压即可运行不依赖系统环境。发布命令很简单dotnet publish -c Release -r linux-x64 --self-contained true发布完成后一个目录里包含了可执行文件和所有依赖把它拷到Linux服务器上直接执行。需要注意自包含部署的包体积会比框架依赖部署大很多大约80MB到150MB而且要为每个目标平台单独发布一个版本。比如客户用Linux x64你就发布linux-x64版本客户用Windows Server 2019你就发布win-x64版本。所以我们会提前问清楚客户现场的操作系统版本再决定发布哪个目标。7. 常见问题与排查技巧多租户和跨平台场景的实战避坑7.1 多租户数据串号的排查方法多租户系统最怕的就是A租户看到B租户的数据。我们上线初期就出过一次事故客户A的领导在大屏上看到了客户B的产线数据场面极其尴尬。后来我们总结了一套排查方案现在分享出来。第一确认租户上下文是否传递正确。在中间件里打日志输出每次请求的租户ID对比实际访问的租户数据看有没有不对齐的。第二确认数据库连接字符串是否正确。我们遇到过一个问题多租户DbContext在创建时从TenantContext读取租户ID但服务注册时误把TenantContext注册成了Singleton生命周期导致所有请求复用一个TenantContext实例租户ID被后登录的用户覆盖前面的请求全部串号。这个问题的排查方法是在日志里打印TenantContext的实例ID如果多个请求的实例ID一样就是生命周期配置错误。第三检查有没有绕过租户中间件的API。比如某些内部API或者后台任务不走HTTP请求管道就没有租户上下文。这种API里如果直接查询数据库就会查出所有租户的数据。解决方案是这类API强制要求显式传入租户ID参数不允许缺省。7.2 缓存穿透导致的多租户数据不一致多租户系统里缓存用不好也会出问题。我们早期把设备信息缓存到Redis但是缓存Key只用了设备ID没用租户ID。结果A租户改了设备名称B租户看到的设备名称也跟着变了。排查这种问题的技巧是检查所有缓存Key的生成规则是否包含租户ID。正确的做法是缓存Key用“租户ID:业务对象类型:业务ID”的格式比如“tenant_123:device:456”。同时任何涉及租户隔离的数据一律不允许做全局缓存这是多租户系统的铁律。7.3 .NetCore3.1跨平台部署的常见坑坑一Linux下读取文件路径异常。客户现场用Windows部署一切正常换到Linux就报“找不到配置文件”。原因是代码里用了硬编码的反斜杠路径。排查技巧是在代码里全局搜索“\”把所有硬编码路径全部替换为Path.Combine。坑二中文乱码。Linux下默认编码是UTF-8Windows下默认可能是GBK。如果对接的老设备系统返回的是GBK编码的数据在Linux下解析就会乱码。解决方案是在代码里显式指定编码Encoding.GetEncoding(GBK)并且确保Linux系统安装了对应的编码支持。坑三端口被占用。Linux上默认的HTTP端口是80和443都是特权端口普通用户不能直接监听。如果直接用非root用户运行应用并监听80端口启动就会失败。一般我们会让应用监听8080端口然后通过Nginx反向代理到80端口顺带解决了HTTPS证书配置的问题。7.4 Vue2.6性能问题的定位手段Vue2.6在数千个DOM节点的大型页面上性能问题主要集中在两次一次是首次渲染一次是数据频繁更新。定位首次渲染慢的问题用Vue Devtools的Performance标签能看到每个组件的渲染时间分布。通常罪魁祸首是某些组件在mounted钩子里同步请求了过多数据阻塞了渲染进程。优化手段包括把不重要的组件改为异步组件通过import()动态加载、首屏只渲染核心区域、数据请求改为并行发出而不是串行。定位频繁更新卡的问题看Data里的数据有没有“过度响应化”。Vue2.6的响应式系统会对data里每个属性做getter/setter代理如果一个对象有几万个属性初始化响应式的过程中就会卡顿。我们遇到过一个场景设备明细数据一次性从后端取了10万条前端用v-for渲染浏览器直接崩溃。后来改成服务端分页 前端虚拟滚动组件才解决了问题。8. 最后分享两个我在实际项目里觉得最值钱的经验这套系统从0到1、从1到N折腾了快两年交付给十几个不同类型的工业客户后我有两个体会特别深。第一个体会是多租户架构一定要在项目一开始就定好后期再改成本极其昂贵。我们最早给第一个客户定制开发时压根没考虑多租户代码里全是单租户假设。后来第二个客户进来光是改造数据隔离就重构了整整一个半月还差点耽误客户上线。如果你预料到未来可能有多个工厂、多个客户共用系统第一版就要按多租户架构设计这个不能省。第二个体会是工业现场的技术栈选择稳定比新潮重要一百倍。我见过太多团队为了炫技选了最新的框架、最前沿的架构结果客户现场的硬件环境和团队技术水平根本跟不上。Vue2.6 .NetCore3.1这个组合在工业互联网圈子里属于“老成持重”的搭配性能足够、生态成熟、招人容易、踩坑文档多。技术选型不是选最好的而是选最不容易出错的这句话在工业软件领域尤其成立。如果你正在规划类似的工业互联网平台希望这篇文章能帮你少走点弯路。有具体的架构问题或者多租户实现细节想聊的欢迎在评论区交流我看到都会回复。