Skip to content

框架设计的想法

Updated: at 06:46,Created: at 00:46

如何开发一个框架,或者如何搭建,如何设计一个框架,很难教会一个新人,本文记录一些能用文字写的方法

本文我写了很久但是我发现很难用文字来描述,如何搭建一个框架,有什么套路。在阅读本文之前,需要说明是本文不适合新手,至少需要对整个语言和开发过程有一定的了解才能继续阅读,同时也推荐只有在熟悉编程基础知识之后才进行框架的开发

减少轮子

在开始之前,需要先了解一下,是否市面上也有这样的轮子。如果有,那么新造的轮子要解决什么问题,或者对比现有的轮子有什么优势。是否可以加入其他轮子的开发

在全局和细节的平衡

搭建框架可以两个方向,一个是从某个细节开始,逐步搭建出一个框架。另一个方向是开始就是大框架开始,然后写入到细节

两个方法都能达成,但是如果是框架开发新手,推荐是从大框架开始而不是从细节入手。如本文最后参考里面引用的文章,我使用里面的例子来告诉大家为什么推荐从大框架开始。不知道大家有没有看过网上美术大师画画的视频,如果看过的话你们会发现,他们经常喜欢从一个局部出发画完整幅画。然而这些是大师们才能干的活,如果咱在学画画时,还是在扣细节,多半会被老师摁在调色盘里,告诉你先构图,先起形,别抓着个眼睛细节在那儿可劲儿磨。画过画的人都知道,扣细节爽,一直扣,一直爽,扣到最后发现,两个眼睛单独看是挺好看,合到一起,咋是歪的呢?啊?咋办,擦了重来,扣两小时细节白搭

因此对于框架开发新手来说,不适合从细节开始搭框架。而如果完全投入到大框架的开发,将会忽略细节,开发出来的框架一般用起来都诡异。例如有一些细节部分将会影响到整个框架,如我后续发现想要支持依赖注入,需要一个全局的容器,我需要让整个框架都支持可注入等,这部分细节不能在完全搭建框架完成之后再去开发,需要一开始就埋入到框架里面。需要多去开发框架,同时看看框架的使用者是如何使用的,在大框架和细节取得平衡

方便扩展

如果一个框架里面涉及到扩展的功能,例如遇到数据 A 执行 FooA 功能,遇到数据 B 执行 FooB 功能,需要在框架设计的时候,方便业务开发者自行扩展,如加上遇到数据 C 执行 FooC 功能

此时的要点在于,如果新添加一个扩展,在业务端需要写的代码量有多少是业务无关的,接入框架需要的代码量以及需要变更的函数有多少。自然是越少越好

假定设计的框架要求业务开发者在扩展功能的时候,需要写半天的业务无关的用于接入框架的代码,同时需要修改很多个不同项目的很多个函数的代码,求业务开发者心理阴影面积

方便静态阅读

静态代码阅读是对于比运行调试阅读代码的方法,通过静态代码可以了解含义以及业务逻辑是比较友好的。这个原则是用来限制新框架开发者使用大量的抽象定义,进行过度设计。假定所有的方法调用等,都进入框架绕一圈出来,所有的传入和传出的参数或执行函数都是抽象的接口定义,或者充满委托,那此时静态代码阅读基本阵亡。如果静态代码阅读不能理解逻辑含义,这就意味着在新增功能或者变更逻辑的时候,将会修改不全面,或者作出了不符合框架预期的行为

也会让新入手此项目的开发者需要投入很多的资源才能开始开发

静态代码阅读具体和框架的抽象设计相关,抽象设计等级越高,意味着通用性更强,也意味着静态阅读难度越高,理解难度越高。但如果有清晰的文档,那上文的问题将可以解决

方便调试

如果一个框架没有开放任何调试的入口或调试的方法,那么在使用这个框架的时候,遇到任何的坑都需要将框架参与进入构建来进行调试。这样将会让业务开发者也需要去了解框架本身的逻辑,或者让框架开发者自身需要不断救火

方便调试的实际做法可以是开放一些调试方法,或者有开关进入调试模式,以及输出信息来辅助调试。输出信息包括了异常输出以及日志输出等方式,输出信息是最简洁的方式方便调试。一个进入调试模式的例子是如用户将基于此框架开发一个做几何形状的数学计算库,如果有调试开关,可以打开可视化效果,那这对于开发的效率有提升

在框架里面需要加入一些调试入口,在传输的类型等需要附加一些调试相关信息。以上的调试信息其实更多指的是和用户业务无关的信息,例如读取 Xml 配置的时候,用户业务相关的是配置的内容本身,而调试信息就是如读取的内容对应在 Xml 的行号。如果有 Xml 的行号信息,在开发者遇到配置出错的时候,可以了解到是哪一行出错的,这一行的 Xml 内容是什么,就可以方便开发者进行调试

对 API 进行分层

一个大的框架需要有很多层的 API 定义,按照简单和高级用法分为不同的访问方式,对于简单的使用,可以通过一层的 API 进行调用,如 Foo.F1() 等方式。对于高级的用法需要放在里层,如 Foo.F2.F3() 的方式,这样可以减少开发者用错

大部分的功能,简单的功能都是对高级的用法的封装,这就意味着对于用户来说,如果绕过调用简单的用法,而使用高级的用法,也是能达到预期效果的。然而很多高级的用法都有细节,对于不熟悉框架的用户,绕过简单的方法,调用高级用法,也许会忽略某些细节,从而在某些分支效果不符预期。例如有 Foo.F1 方法,此方法本质是调用高级的 Foo.F2.F3 方法,反而在 Foo.F1 方法里面做了一些封装,如判断了一些属性以及预先调用一些初始化方法。尽管大多数的时候,都不需要调用初始化,如以下代码

class Foo
{
public void F1()
{
if (还没有初始化)
{
初始化();
}
F2.F3();
}
// 忽略代码
}

假定上面的代码里面,基本上 还没有初始化 属性都是 false 也就是对象已初始化。于是用户如果绕过调用 F1 而调用 F2.F3 方法,将会和调用 F1 行为相同。然而在某些分支,此时需要进行初始化,那么以上行为将和调用 F2.F3 有所不同

对 API 的分层,其实就是分开用户的等级,对于不熟悉的用户,大部分的简单用法都能让用户符合预期,同时也没有什么坑。但是用户如果期望进行更多定制,那么将在框架开发者指导下,或者用户熟悉框架,调用高级的方法

遵守默认约定

如 API 的命名和行为,可以参照 dotnet 的框架的方式,这样开发者用起来就会顺手。如关闭某个功能,如果叫 Close 那就是比较通用的,而如果叫 Exit 尽管能达意,但是开发者也许找不到此方法。如果团队里面成员英文水平不高,那更建议使用接地气的命名法而不是专业的英文,对于特别的算法或难以表述的,可采用中文

如果有大量的 API 从命名上,不符合用户的习惯,将会让开发者用户在不熟悉时,不断找不到期望调用的 API 而需要不断询问框架开发者,从而让框架开发者成为 API 客服

另外,默认的编码规范也是需要遵守的,如果框架开发者自身都对基础编程知识不了解,那我还是劝退。如在框架里面没有特殊理由而公开字段,例如没有特殊理由公开了读写权限的委托,例如在里层方法大量使用 Thread 线程

统一性设计

统一性的含义是抽象的意思,指的是整个框架的整体是有统一的设计,属于比较难已描述的部分,我尝试在下文使用多个例子来告诉大家什么是统一性设计。维护好统一性设计的优势在于减少理解成本,无论是框架的维护者还是框架的使用者都可以使用更少的知识进行维护和使用框架

上一条 “遵守默认约定” 的实质其实也是为了统一性设计,默认的约定就是一个统一,所有的框架和系统等都遵守默认的约定,这就意味着所有的代码写起来的逻辑都是相近的,无论是重新阅读代码还是新写代码,都可以复用原有的知识。可以很大的降低新手加入项目的恐慌,也可以解决长时间不维护的项目的重新上手成本的问题

举一个反面的例子,假定有一个框架在设计上是如此的:对于 A 业务资源的定义上,要求使用 Id 作为唯一标识符;对于 B 业务资源的定义上,要求使用 Name 作为唯一标识符;对于 C 业务资源的定义上,要求使用 Key 作为唯一标识符

以上的设计将会让开发者在对接不同的业务的时候需要学习更多的知识,如开发者对接完成了 A 业务,学习到了资源需要使用 Id 作为唯一标识符。而当此开发者对接到 B 业务时,将会发现原有的知识,资源需要使用 Id 作为唯一标识符,已失效,需要重新学习新的知识,对于业务 B 资源需要使用 Name 作为唯一标识符,同时也需要学习到资源的定义对业务 A 和 B 的唯一标识符是不同的知识。相当于在对接到业务 B 的时候,开发者在资源的唯一标识符模块需要学习 3 条知识。再想想,如果此开发者还需要对接到 C 业务,那他需要学习多少条知识呢?是否写着写着开发者自己混乱了,用混了

对于一套相似的逻辑,推荐在设计的时候,采用相似的约束、相似的规范、相似的命名。正如对于支持集合类型的模块,如加上Xx的业务上,采用 AddXx 的方式命名。如所有类似 Stream 的业务,都采用 Dispose 方法释放

如在 dotnet 里面,特性采用 Attribute 后缀,如异常使用 Exception 后缀,事件参数使用 EventArgs 后缀等。这样的设计就体现了设计的统一性

在 dotnet 里面,类的设计上,可以采用接口约束来实现统一性。这是基础的面向对象的封装,但是这里需要说明的是,足够抽象的底层接口,可以让更多的上层框架采用此接口的元素,从而实现上层框架的统一

不同的逻辑截然不同

和上文的统一性设计相对的是,如果两个不能混淆的逻辑放在一起,那么将这两个逻辑设计为截然不同的风格可以让业务开发者不会混淆

例如有业务是简单从内存移除某个文件的记录,另一个业务是从本地磁盘永久删除某个文件。那么对于此两个不同的等级的方法的设计上,就可以采用不同的命名方式,甚至要求传入不同的参数来进行区分

不同的逻辑截然不同指的是那些好混淆的逻辑,而不是让每个模块各自为政

开发时做好防逗比

按照优先级,最高的是构建不通过,其次是运行时抛调试异常,其次是写注释,其次是写文档。如切页 API 只许传入正数,可以让传入参数是正数,而让开发者写不出传入负数的代码。运行时抛出调试异常,可以告诉开发者用法用错了,同时在异常信息里面告诉正确的用法

如果能做到构建不通过,那就是最符合预期的。假定给你选择,实现一个解析某个类型文件的函数,请问此函数的参数里面如果要传入文件,那参数的类型是字符串还是 FileInfo 类型好?如果传入的是字符串,小心开发将用户名当成文件名传入。在实现框架时,尽可能给定单位,比如我期望画出一个 10 像素的矩形,我在调用某个函数进行画矩形,这个函数里面的参数是 int 值,请问我是否应该传入 10 进去?如果我期望画出的是一个 10 厘米的矩形呢?如果画矩形的方法没有告诉我参数的单位,那么只能按照经验进行猜测,而如果画矩形函数有明确给定参数类型是 Pixel 类型,那么我自然就了解需要传入的是像素。关于给定单位请看 程序猿修养 给属性一个单位 博客

下面来做一道题,假定你要设计一个函数,这个函数里面有一个参数期望表示线程等待时间,请问此参数的类型推荐是什么

最好的方法就是让开发者写不出不符合预期的逻辑。通过类型进行限制是很好的做法。而如果无法通过语法层面解决问题,可以从运行时抛出调试异常告诉开发者用户,或者非运行时抛出异常的方式。推荐先是考虑运行时抛出调试异常,因为运行时的调试异常不会影响发布版本,也就是不会影响到软件实际的软件用户,而且调试异常可以加上更多的调试信息,让开发者用户了解更多内容。下面来聊聊调试异常或者出错信息

告诉开发者用户出错信息,可以加入更多详细信息,如在异常里面的 Data 属性,添加更多的辅助调试的信息。如在调试日志里面,输出更多细节信息。对于调试部分的运行时输出,无论是异常还是日志,都推荐输出是更多的信息。例如开发者用户调用了某个方法,此时方法告诉说失败,返回某个错误码,请问为什么失败?开发者用户需要去查阅文档,了解到错误码对应的信息,然后猜测为什么出错。以上是古老的使用方法,大量的 Win32 函数都采用此方法。然而当前是现代,不妨修改为抛出异常,给出大量的内部细节,告诉开发者用户为什么出错了,此时开发者用户可以省去查阅文档,了解错误码对应的信息的工作

区分运行时抛出的调试异常和非调试的异常,简单的方法是通过宏来决定,如下面代码

#if DEBUG
throw new 调试异常();
#else
throw new 非调试异常();
#endif

然而如果有太多的输出,用于输出调试细节,也会影响到开发者用户,解决方法请看 C# 如何写 DEBUG 输出 博客

如果无法从构建不通过以及运行时抛异常方法告诉开发者用户,那么只能通过文档的方法

文档最好是跟随代码的,或者放在代码仓库,如果放在其他,那么大多数的开发者用户将很少去关注

我遇到冬哥(不是这个冬哥)写过的代码如下

[Obsolete("此方法只有冬哥才能调用")]
public void Foo()
{
}

当然也有文档写在注释

/// <summary>
/// 在调用此方法之前,需要了解 xx 以及 xx 坑
/// </summary>
public void Foo()
{
}

有大量的开发者用户,其实是不看文档的。如果遇到有开发者用户不看文档就问问题,一个推荐的做法是不回答他的问题,而是给他文档链接或文档截图,这样可以培养开发者用户去阅读文档。而且可以让自己被问到没有文档的问题时,会去写文档

更多文档

更多设计相关的文档请看

参考


知识共享许可协议

原文链接: http://blog.lindexi.com/post/%E6%A1%86%E6%9E%B6%E8%AE%BE%E8%AE%A1%E7%9A%84%E6%83%B3%E6%B3%95

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。 欢迎转载、使用、重新发布,但务必保留文章署名 林德熙 (包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我 联系