资讯详情

资讯详情

Winform通用开发框架实战:多语言、多数据库与模块化设计

做Winform开发这些年我最怕的不是业务逻辑本身有多复杂而是每接一个新项目都得把那些跟业务八竿子打不着的底层东西重新搭一遍——登录窗口、用户权限、数据访问、日志异常处理、菜单管理、操作审计这些活最磨人还特别影响项目初期的推进节奏。后来我学乖了从手头几个项目里慢慢沉淀出一套通用的Winform开发框架把多语言、多数据库、自动更新、模块化这些能力都揉进去再开新项目时直接拿它打底效率提升非常明显。这篇文章就把这套框架的设计思路、核心实现和踩过的坑完整记录下来给也想自建框架的朋友做个参考。这套框架不是那种大而全的商用平台而是一个偏实战的脚手架工具箱形态。它解决的核心问题很简单让开发人员从进项目的第一天就能把精力放在业务代码上而不是花两周时间纠结数据库访问层怎么写、国际化资源怎么组织、更新程序怎么部署。接下来我会按模块拆开讲每个部分都会说清楚当时为什么这么设计、具体怎么实现、有哪些细节容易踩坑。1. 框架设计的出发点1.1 什么时候需要一套通用框架先说个判断标准如果公司里同时维护着三五个Winform项目或者未来半年内还有两三个新项目要启动那投入精力做一套内部通用框架就是划算的。我当时是从实际问题出发的。手头几个项目表面上是不同行业的系统但底层需求高度重合都需要登录和权限校验都需要连数据库做增删改查都需要给客户分中英文界面都需要在部署之后能远程升级。每个项目都把同样一套代码换个写法重写一遍浪费的不仅是编码时间还有后续维护的隐性成本——同样的bug在每个项目里修一遍滋味非常难受。那为什么不直接选一个成熟的第三方框架市面上的商业框架和开源组件我也评估过。问题在于商业框架往往拖着很多用不上的功能定制改造成本高开源组件则通常只覆盖单点能力比如只做ORM、只做插件化集成起来仍然需要自己缝缝补补。所以最终决定自研一套轻量框架只保留最核心的公共能力然后把扩展点留给各个业务项目自己去填。1.2 框架的整体分层思路这套框架的物理结构我按三个层次组织基础组件层、业务服务层、表现应用层。基础组件层负责通用技术能力包括多语言管理器、数据库方言适配器、自动更新客户端、日志与异常处理组件、通用Winform基类库。这一层不依赖任何具体业务属于被引用的底层工具箱。业务服务层负责与业务相关的公共逻辑比如用户认证、权限校验、菜单与功能授权、操作审计记录。这部分会调用基础组件层同时暴露给上层窗体使用。不同项目之间在这层的改动通常不大做项目时只需要做少量扩展。表现应用层就是具体的窗体和用户控件业务开发人员日常主要在这一层写代码。框架会提供一批基类窗体例如带权限控制的主窗体、带语言切换的BaseForm、带数据绑定规范的列表页从基类继承后自动获得框架的大部分能力。三个层次之间的工程引用关系是单向的表现层可以引用服务层和组件层服务层只能引用组件层组件层不依赖任何上层。这样从架构上杜绝循环依赖也是后来能让模块化插件顺利拆分的必要前提。2. 多语言模块换语言不重启才算好用2.1 三种实现方案的取舍Winform多语言方案我先后试过三种传统资源文件Resx、运行时控件遍历切换、基于语言配置文件动态赋值。三种方案各有适用场景但最终我选择了语言配置化控件遍历组合方案。资源文件方案是微软官方推荐的做法把每个窗体的文字分别放进不同语言的resx文件里。这个方案有个致命问题如果项目里窗体数量多每新增一种语言就需要编辑几十上百个resx文件累人不说还容易漏掉某些窗体。我曾经在给客户做三语版本时被坑过一次某个功能窗体的法文标签没翻译客户直接截图投诉。运行时控件遍历方案的核心思路是所有窗体的文本控件不硬编码文字而是设置一个语言Key存放在Tag属性或者专门的路由表启动时或切换语言时统一遍历窗体上的所有控件根据Key从全局语言字典中取出对应语言的文本并赋值。这套方案的好处是语言包集中管理新增语言只改一份文件完全不用碰窗体代码。两种方案的关键对比可以用一张表说清楚对比维度资源文件方案控件遍历方案新增语言成本为每个窗体单独处理只维护一份语言包运行时切换难以支持天然支持漏翻译风险高低缺Key能集中排查静态文本替换支持支持动态拼接文本不方便通过格式化函数处理控件遍历方案也不是没有缺点比如窗体打开后再动态创建的控件需要手动处理一些第三方控件库的复杂结构也遍历不到。但整体收益明显大于成本实际用下来是值得的。2.2 语言包的文件格式与热加载语言包我设计成JSON文件每个语言一个独立文件放在应用的Languages目录下。文件内容长这样{ langName: 简体中文, langCode: zh-CN, items: { Login_Title: 用户登录, Login_UserName: 账号, Login_Password: 密码, Menu_System: 系统管理, Menu_UserMgr: 用户管理, Msg_LoginFailed: 用户名或密码错误, Msg_ConfirmExit: 确定要退出系统吗 } }使用JSON而不是resx的一个额外好处是非开发人员也能用文本编辑器直接翻译不需要装Visual Studio。当客户要新增一种语言时我只需要复制一份JSON然后把值翻译掉放到目录里重启或切换语言即可。语言包的加载逻辑放在LangManager单例里public class LangManager { private static readonly Dictionarystring, Dictionarystring, string _languages new Dictionarystring, Dictionarystring, string(); public static string CurrentLangCode { get; private set; } zh-CN; public static void LoadLanguages(string baseDir) { string langDir Path.Combine(baseDir, Languages); if (!Directory.Exists(langDir)) return; foreach (string file in Directory.GetFiles(langDir, *.json)) { string json File.ReadAllText(file, Encoding.UTF8); var doc JsonDocument.Parse(json); string code doc.RootElement.GetProperty(langCode).GetString(); var items new Dictionarystring, string(); foreach (var prop in doc.RootElement.GetProperty(items).EnumerateObject()) { items[prop.Name] prop.Value.GetString(); } _languages[code] items; } } public static string GetText(string key, string langCode null) { string code langCode ?? CurrentLangCode; if (_languages.TryGetValue(code, out var dict) dict.TryGetValue(key, out var text)) return text; return key; } }这里有几个容易忽略的细节。首先是编码问题语言包文件必须用UTF-8编码保存否则中文会乱码。其次是缺Key的降级策略我特意设计成找不到就直接显示Key本身这样在开发阶段漏翻译能一眼看出来而不是显示空字符串让用户和开发都不知道发生了什么。动态文本拼接也是一个需要提前设计的地方。多语言环境里不能简单用今天 count 条记录这种字符串拼接因为不同语言的语序完全不同。我统一用格式化占位符来处理语言包里的值写成今天共{0}条记录已审核{1}条代码里调用LangManager.GetText(Msg_RecordCount, login, count, approved)来填充这样每种语言可以自己定义语句结构。2.3 窗体控件遍历与字体回退细节语言切换时最核心的操作是遍历窗体所有控件并重新赋值文本。我写了一个基类BaseForm里面封装了递归遍历方法protected void ApplyLanguageToControls(Control parent) { foreach (Control ctrl in parent.Controls) { string key ctrl.Tag as string; if (!string.IsNullOrEmpty(key)) { string text LangManager.GetText(key); if (!string.IsNullOrEmpty(text)) { ctrl.Text text; } } if (ctrl is DataGridView dgv) { foreach (DataGridViewColumn col in dgv.Columns) { if (!string.IsNullOrEmpty(col.Tag as string)) col.HeaderText LangManager.GetText(col.Tag as string); } } ApplyLanguageToControls(ctrl); } }窗体的控件嵌套层级通常比较深比如SplitContainer里套PanelPanel里再套GroupBox所以递归遍历是必需品而不是可有可无的技巧。DataGridView的列头需要单独处理因为列不是Control控件不在Controls集合里。字体回退是另一个特别值得讲的细节。Winform控件默认字体是微软雅黑或者宋体换成英文、法文基本上没问题但如果切到一些特殊语言比如俄语、阿拉伯语中文字体里根本不包含对应字形界面会出现大量方块乱码。我在语言包里增加了一个fontFamily字段切换语言时会先从当前语言配置中读取推荐字体如果目标字体可用就动态设置到控件上。实现方式是在ApplyLanguageToControls里额外做一次字体赋值if (_currentLangDef.ContainsKey(fontFamily)) { string fontName _currentLangDef[fontFamily]; if (FontHelper.IsFontInstalled(fontName)) ctrl.Font new Font(fontName, ctrl.Font.Size, ctrl.Font.Style); }逐控件重置字体其实开销不小所以只对当前激活窗体做字体刷新而不是全程序所有窗口同步处理。这里也踩过坑后面踩坑实录里会详细说。3. 多数据库适配换库像换连接串一样简单3.1 数据访问层的接口抽象多数据库支持是所有企业级系统都躲不开的需求。有些客户在采购时指定要用SQL Server有的要求MySQL还有的为了省钱要用国产数据库甚至同一个项目在交付阶段从SQL Server换到PostgreSQL。如果不做适配抽象这种切换会变成一场灾难。我的设计思路是业务层只依赖一组接口具体数据库由接口实现来适配。先用一个简单示例说明这层抽象长什么样public interface IDataProvider { IDbConnection CreateConnection(string connectionString); IDbDataParameter CreateParameter(string name, object value); string BuildPagingSql(string sql, int pageIndex, int pageSize, string orderBy); string GetLastInsertIdSql(); }框架内置了针对不同数据库的实现类例如SqlServerProvider、MySqlProvider、PostgreSqlProvider。每个Provider只做一件事让自己的方言语法正确。业务层在拼SQL时完全面对接口不直接使用任何数据库特有的语法。具体项目的使用方式是在应用配置文件里指定providerName框架启动时根据原名反射加载对应的Provider实现// 配置示例 { db: { provider: Framework.Data.PostgreSqlProvider, connectionString: Host...;Database...;Username...;Password... } }有人可能会问既然要做到数据库无关为什么不直接用ORM框架事实上框架设计时也确实把ORM作为可选组件但在底层之上仍然保留了这一层轻量接口抽象。原因是ORM解决的是对象映射问题不能解决方言问题比如分页语法、取自增ID的方式、日期截断函数这些仍然需要针对每种数据库分别实现。而且有些项目数据库环境很特殊不允许引入重型ORM有一层轻量接口兜底灵活性会高很多。3.2 方言层与分页处理的隐蔽坑数据库方言是多数据库支持里最考验细节的部分。举几个具体例子一是分页差异。SQL Server传统分页写法是OFFSET...FETCH NEXTMySQL是LIMIT ... OFFSETOracle 12c以前是ROWNUM子查询PostgreSQL虽然支持LIMIT但处理大偏移量时性能也不一样。我在BuildPagingSql里针对每种数据库做了特化处理public override string BuildPagingSql(string sql, int pageIndex, int pageSize, string orderBy) { int offset (pageIndex - 1) * pageSize; return ${sql} {orderBy} OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY; }这里有个隐蔽问题OFFSET语法要求必须配套ORDER BY有些业务SQL本身不含排序时拼接分页就会报错。所以框架在分页前会做一次校验如果SQL没有ORDER BY就自动追加一个按主键排序的默认顺序。二是自增主键的获取方式。SQL Server可以用SELECT SCOPE_IDENTITY()MySQL可以用SELECT LAST_INSERT_ID()PostgreSQL则是INSERT ... RETURNING id。如果在INSERT之后统一走GetLastInsertIdSql获取新ID就能屏蔽这些差异。三是模糊查询的通配符。SQL Server默认不区分中文大小写但区分中文全半角MySQL的LIKE对大小写敏感度取决于排序规则PostgreSQL的LIKE和ILIKE又有差别。框架里专门封装了一个MatchAny方法在生成查询条件时统一转为参数化方式避免字符串拼接导致的SQL注入风险和大小写误差。3.3 连接字符串的安全管理连接字符串直接写在部署配置文件里是很多内部系统的通病一旦源码泄露或者有人拿到桌面端文件数据库账号就完全暴露了。我在框架里做了一层简单但有效的保护连接字符串只保存密文密钥部署在客户端机器的Windows凭据存储里。实际操作时由管理员或者首次启动向导输入明文连接框架使用本机密钥加密后写入配置文件public static string EncryptConnectionString(string plainText) { byte[] entropy Encoding.UTF8.GetBytes(Framework.Key.Entropy); byte[] data ProtectedData.Protect(Encoding.UTF8.GetBytes(plainText), entropy, DataProtectionScope.CurrentUser); return Convert.ToBase64String(data); } public static string DecryptConnectionString(string encryptedText) { byte[] entropy Encoding.UTF8.GetBytes(Framework.Key.Entropy); byte[] data Convert.FromBase64String(encryptedText); return Encoding.UTF8.GetString(ProtectedData.Unprotect(data, entropy, DataProtectionScope.CurrentUser)); }这个方案的好处是即使有人拿到了配置文件在其他机器上也解不开因为加密数据绑定当前Windows用户。缺点也很明显——换一台机器部署时必须重新生成配置文件。我通常在打包部署工具里自动处理这个过程首次启动时如果发现解不开就弹出配置向导让管理员重新输入。4. 自动更新从手动替换到一键升级4.1 整体更新流程Winform程序部署在客户内网环境里最原始的升级方式是运维拿着U盘挨个办公室去覆盖文件稍微正规一点的是共享文件夹手动替换。这两种方式在客户现场都出现过问题版本混淆、文件缺失、DLL占用导致覆盖失败、回滚没有备份。后来我认真设计了一套轻量级自动更新机制运行效果非常稳定。更新流程整体分五步第一步是版本检查。客户端启动时异步请求更新服务器携带当前版本号。更新服务器是一个简单的HTTP站点服务端对比版本号后返回更新包元数据。这里不搞复杂的WebService一个WebAPI接口就够了。第二步是下载更新包。服务器把更新包文件按目录结构打成ZIP压缩包客户端下载到本地临时目录。第三步是校验和前置检查。客户端解压后计算每个文件的SHA256哈希与服务器返回的哈希列表比对不一致的文件直接判定更新失败防止下载损坏或者传输劫持。第四步是应用更新。程序把当前运行的进程信息写入暂停标记然后启动独立的更新器进程Updater.exe主程序退出更新器负责替换文件、清理临时目录、执行升级SQL脚本。第五步是回滚保护。更新器替换文件前先将被替换的文件备份到Backup目录如果更新过程中出现致命错误可以依据备份恢复到上一个可用版本。这个流程里最关键的决策是更新器独立进程这个设计。一开始我想过主程序自我复制文件但Windows下正在运行的exe是锁定的即使把更新文件先放到临时目录也无法原地覆盖。用独立更新器进程主程序退出后锁自然消失更新器就能毫无阻碍地完成文件替换。4.2 清单文件与哈希校验更新服务器上除了ZIP包还有一个update.json清单文件内容包括版本号、更新类型、文件列表等。下面是一个典型的清单片段{ version: 1.4.2, minSupportedVersion: 1.3.0, updateType: incremental, releaseNote: 修复订单导出并发异常新增库存预警模块, files: [ { path: Modules/OrderModule.dll, size: 351232, sha256: a1b2c3d4e5f6... }, { path: Assets/icon_new.png, size: 2048, sha256: ef6d3c4b5a77... } ] }增量更新模式里只有发生变化的文件会出现在清单里这种方式相比全量更新大大降低了大客户内网带宽压力。但是增量更新对版本管理要求很高——必须保证客户端当前版本与上一个版本绝对一致否则增量包会应用到错误的版本上。所以我在客户端检查时增加了minSupportedVersion判断如果当前版本过低就直接回退到全量下载模式。SHA256哈希校验代码很简单public static string ComputeSha256(string filePath) { using (var stream File.OpenRead(filePath)) using (var sha SHA256.Create()) { byte[] hashBytes sha.ComputeHash(stream); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } }客户端下载完成后逐文件比对哈希只要有一个文件不一致就中止更新绝不带着半损坏的状态继续跑。我在实战里遇到过内网流量不稳定导致的ZIP解压异常哈希校验就是在这种场景下把更新事故拦截在发生之前的。4.3 更新过程的异常与回滚自动更新看起来不复杂但它在客户现场的表现直接决定运维团队对框架的信任度。做了两年多更新系统我遇到的异常主要集中在三块第一块是文件占用。客户开着主程序或者杀毒软件正在扫描某个DLL更新器替换文件时就会遇到文件正在被另一个进程使用的异常。解决思路分两层更新器尝试替换如果失败就等待并重试连续三次失败则进入维护模式提示用户关闭相关程序后重新操作。第二块是数据库升级冲突。有些版本改动不只是换程序集还涉及数据库表结构变更。这类变更如果放在应用启动时自动执行在多客户端同时打开的情况下就会互相竞争。我的方案是引入一个数据库锁表机制执行升级脚本前先申请一把锁拿到锁的客户端负责执行变更其他客户端等待锁释放后再继续启动流程。第三块是回滚保护。每次更新前更新器都会把被替换文件的原始版本复制到Backup目录同时记录一条回滚清单。一旦主程序启动时发现自身的版本号与预期不符或者异常崩溃就弹窗询问用户是否回滚到上一版本。回滚操作其实就是把Backup目录里的文件复制回去配合数据库脚本的反向补偿语句如果有必要的话。这套机制虽然增加了少量磁盘消耗但在客户现场防住了几次严重升级事故觉得非常值。5. 模块化设计功能插件化的关键接口5.1 插件接口与模块契约模块化是这套框架里最有技术含量也最容易被过度设计的一块。我的观点很明确模块化不是为了炫技而是为了物理隔离和团队并行开发。比如订单模块和库存模块可以由两个不同小组同时开发互不干扰交付时也只集成客户买了的功能不需要把整个产品线全部装上。模块化体制的核心是接口契约。我定义了一个IModule接口public interface IModule { string ModuleId { get; } string ModuleName { get; } string Description { get; } int ModuleVersion { get; } void OnLoad(IModuleHost host); void OnUnload(); }每个功能模块以独立程序集DLL存在DLL里有一个实现IModule的入口类。框架启动时扫描Modules目录反射加载所有模块程序集然后调用OnLoad方法让模块完成初始化。这里有个经验不要在接口里塞太多方法。第一版接口我放了十几个方法包括权限初始化、菜单注册、窗体获取、数据迁移等结果每个模块都要实现一堆空方法。后来按接口隔离原则拆成多个小接口模块按需实现比如IPermissionProvider、IMenuProvider、IWorkformProvider主程序通过类型判断来调用可选能力。5.2 扫描加载与菜单自动装配模块DLL放在程序运行目录的Modules子目录下框架启动时通过AssemblyLoadContext加载。这里有个关键点不能直接Assembly.LoadFrom然后立即使用因为依赖同一个第三方C#库的不同版本时可能会发生程序集加载冲突。扫描加载的相关逻辑简化后如下var moduleHost new ModuleHost(); foreach (string dllPath in Directory.GetFiles(moduleDir, *.dll)) { var assembly AssemblyLoadContext.Default.LoadFromAssemblyPath(dllPath); foreach (Type type in assembly.GetTypes()) { if (typeof(IModule).IsAssignableFrom(type) !type.IsAbstract) { var module (IModule)Activator.CreateInstance(type); module.OnLoad(moduleHost); _modules.Add(module); } } }每个模块在OnLoad阶段向ModuleHost注册自己的菜单项、窗体类型和权限点。主窗体启动时从ModuleHost中读取所有注册的菜单项按顺序组装成菜单栏。这样的好处是主程序不需要硬编码任何与具体业务模块相关的代码新增一个功能模块只需要把DLL丢进Modules目录配置好权限即可。为了给模块提供运行期服务访问能力ModuleHost还会暴露一个轻量级的服务定位器。模块通过它获取日志服务、用户会话信息、数据访问器等而不是直接依赖某个具体类型。服务定位器本质是一个类型字典实现很轻量但让模块与主程序之间的耦合度明显下降了。5.3 模块间通信与权限联动模块之间偶尔需要交互典型的例子是订单模块创建订单后通知库存模块锁定库存数量。两个模块如果互相引用程序集就破坏了模块独立性。我采用事件总线EventBus模式来解决这个问题。模块可以向事件总线订阅特定主题也可以发布事件。发布方不关心谁会处理自己的消息订阅方也不关心事件从哪个模块来。这种松耦合方式让模块可以独立升级。事件总线实现其实很短public sealed class EventBus { private readonly Dictionarystring, ListFuncobject, Task _subscriptions new(); public void Subscribe(string topic, Funcobject, Task handler) { if (!_subscriptions.TryGetValue(topic, out var list)) { list new ListFuncobject, Task(); _subscriptions[topic] list; } list.Add(handler); } public async Task PublishAsync(string topic, object payload) { if (!_subscriptions.TryGetValue(topic, out var list)) return; foreach (var handler in list.ToList()) { try { await handler(payload); } catch (Exception ex) { Log.Error(ex, $Event handler failed: {topic}); } } } }权限联动这块也值得一提。每个模块可以定义自己的权限点比如订单审批、库存调拨。模块在OnLoad阶段把权限点注册到框架的权限仓储里管理员在角色配置界面就能看到并分配这些权限点。运行期窗体打开时会拦截权限检查没有权限的用户会看到菜单但点进去被拒绝访问。我遇到过一个坑模块A注册权限点如果模块B在模块A之前加载模块B的某个操作依赖模块A的权限点就会因为权限点不存在而报错。后来我在模块加载顺序上做了一次拓扑排序按照模块依赖声明决定先后顺序这个问题才算彻底解决。6. 踩坑实录六条值得记住的教训6.1 多语言切换后控件不会自动刷新第一版多语言切换是主窗体设置的动态语言但打开的子窗体不会自动刷新必须重新打开才生效。客户当时就吐槽你们这个切换语言怎么还要重启后来我把语言变更做成了事件驱动模式。LangManager里增加一个LanguageChanged事件所有已经打开的窗体订阅这个事件收到通知后调用ApplyLanguageToControls刷新自己。同时刷新时强制调用了窗体的SuspendLayout和ResumeLayout避免每个控件单独刷新导致界面闪烁。处理下来的体会是多语言系统要做就做全局热切换半吊子的刷新机制还不如直接要求重启程序来得省心。6.2 数据库兼容性测试要覆盖多个维度多数据库支持不能只停留在代码层面测试必须覆盖真实场景。我在切换数据库时踩过这种坑在SQL Server上一切正常切到PostgreSQL后某些模糊查询变慢明明走的还是同一个抽象接口。所以后来我建了一个数据库兼容性测试清单每换一次支持的数据库类型就完整跑一遍这些用例测试场景涉及功能模块分页查询含大数据量列表页所有分页逻辑自增主键插入后获取ID所有新增操作日期函数与时间范围过滤报表、统计、日历页模糊查询中文大小写搜索框、筛选器并行事务与死锁处理库存、审批、订单并发特殊字符存储表情符号备注、附件说明这套清单后来不仅用于框架适配测试也被业务项目组的测试团队直接用作为验收数据库兼容性的标准用例。6.3 自动更新最怕遇到的是文件占用文件占用的问题前面提到了再补一个更隐蔽的案例。客户使用的杀毒软件会把新下载的DLL先隔离扫描扫描期间文件被锁定更新器去覆盖时就会报错。更麻烦的是有些杀毒软件扫描一次可能持续几十秒更新器重试三次也等不到解锁。我的处理方式是更新器在文件操作失败时识别错误类型——如果是共享违规IOException就进入等待循环最长等待90秒每次间隔5秒重试。同时更新器界面上明确提示用户当前正在等待杀毒软件扫描完成不让用户误以为卡死了。这个等待策略上线后更新失败率从百分之几降到了接近于零。6.4 插件程序集的版本冲突模块化之后遇到一个比较头疼的问题多个模块依赖同一个第三方C#库的不同版本比如订单模块用了某个组件1.2版本库存模块用了1.3版本运行时会因为程序集绑定失败而崩溃。解决的方案是调整模块加载策略不再从模块目录直接反射加载所有DLL而是按模块为单元建立独立的AssemblyLoadContext每个上下文可以加载自己依赖的库版本。这个改动让模块之间的依赖彻底隔离但代价是模块间无法共享同一类型实例跨模块传输数据时只能通过事件总线传递简单对象或自定义DTO。在多数项目里这种隔离是值得的。模块化本来就是控制复杂度的手段不要为了让模块之间能自由调用而破坏物理隔离。6.5 框架过度设计的边界框架设计过程中最大的风险不是功能不足而是过度设计。我第一版框架什么都想管引入了复杂的工作流引擎、动态表单设计器、规则引擎结果框架自身的复杂度远超业务复杂度项目组根本用不起来甚至出现为了用框架而用框架的现象。后来我做了一次大瘦身保持框架层只做通用性最强的五件事——多语言、多数据库、自动更新、模块化、审计日志。业务相关的特殊性需求工作流、报表、审批流全部放到业务层去实现不再试图塞进通用框架。这次瘦身后团队的接受度高了很多新项目也愿意主动基于这个框架来搭。6.6 日志与监控的迟到部署框架初期没有统一的日志各项目组自己用NLog写日志格式五花八门排查问题时基本靠人肉翻文件。后来我把日志统一整合到框架里支持按级别输出到文本文件和数据库表关键操作登录、增删改、权限变更自动写入审计日志。日志模块要遵守一个原则业务日志与系统日志分离。系统日志记录异常、性能、启动关闭等运行时状态输出到本地文本文件便于开发排查业务日志记录敏感操作与业务关键事件写入审计数据表便于合规追溯。两类日志的保存周期和权限设计完全不同混在一起会把没有权限查看系统日志的人也暴露给业务日志信息审计上很有问题。7. 持续打磨的方向框架做到现在这个程度解决了团队百分之八十的问题但我知道还有很多值得补充的方向。比如目前的数据访问还是以SQL拼接加轻量ORM为主下一步打算在方言层之上再加一个查询表达式解析器让调用方可以用类似LINQ的写法来描述条件由方言层翻译成目标数据库的语法这样业务代码里就更少出现数据库特有关键字了。自动更新方面计划把更新包灰度加进去允许只对一部分客户端先推送测试版本观察稳定之后再全量推送。对客户现场特别多的项目来说这个能力能有效降低升级事故的影响面。不过新增了这个机制后更新服务器的清单接口要相应扩展支持渠道和灰度比例的参数工作量不算小。我在实际做框架的过程中还有一个感受比较深通用框架最容易失败的场景不是技术难而是不接地气。框架作者必须真正理解业务项目的痛点和团队开发习惯设计出来的东西才有生命力。框架写完之后最好先拿一个真实项目去试运行让框架在真实业务里被锤打一遍才知道哪些设计是合理好用的哪些只是自我感动。如果您也在做类似的自研框架我的建议是从最小的闭环开始先只做数据库适配和统一异常处理确认这个结构稳定了再加多语言和自动更新最后再考虑模块化拆分。框架是一步步长出来的不是一次写出来的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →