课堂设计:什么是好?



DataArt解决方案架构师Denis Tsyplakov发布

多年来,我发现程序员有时会重复同样的错误。 不幸的是,关于发展的理论方面的书并不能避免它们:书通常没有具体的,实用的建议。 我什至猜想为什么...

例如,关于日志记录或类设计,想到的第一个建议非常简单:“不要胡说八道。” 但是经验表明,这绝对是不够的。 在这种情况下,仅课程的设计就是一个很好的例子-永恒的头痛源于每个人都以自己的方式看待这个问题。 因此,我决定在一篇文章中整理一些基本技巧,然后您将避免一些典型的问题,最重要的是,可以避免与您的同事共事。 如果某些原则对您而言是平庸的(因为它们确实是平庸的!)-那么,它们已经在您的子皮质中解决了,可以祝贺您的团队。

我会保留一下,实际上,我们将仅出于简化目的而专注于类。 关于应用程序的功能或任何其他构造块,可以说几乎相同。
如果应用程序可以工作并执行任务,则其设计很好。 还是不行 取决于应用程序的目标功能; 对于需要在展览会上展示一次的移动应用程序而言,什么完全适合可能不适合任何银行已经开发了多年的交易平台。 在某种程度上,可以将SOLID原则称为该问题的答案,但这太笼统了-我希望可以在与同事的对话中参考一些更具体的说明。

目标应用


由于没有统一的答案,我建议缩小范围。 假设我们正在编写一个标准的业务应用程序,该应用程序通过HTTP或其他接口接受请求,在它们之上实现一些逻辑,然后向链中的下一个服务发出请求或将接收到的数据存储在某个地方。 为简单起见,我们假设我们使用的是Spring IoC框架,因为它现在非常普遍,其余框架与之非常相似。 对于这样的应用程序我们能说些什么?

  • 处理器花费在处理一个请求上的时间很重要,但并不重要-天气增加0.1%不会。
  • 我们没有可用的TB内存,但是如果应用程序占用了额外的50-100 KB内存,这不会是灾难。
  • 当然,开始时间越短越好。 但是6秒和5.9秒之间没有根本差异。

优化标准


在这种情况下对我们来说重要的是什么?

该项目代码可能会被企业使用数年,甚至可能超过十年。

几个不熟悉的开发人员将在不同的时间修改代码。
几年后,开发人员可能会希望使用新的LibXYZ库或FrABC框架。

在某些时候,部分代码或整个项目可以与另一个项目的代码库合并。

在管理人员中间,人们普遍接受使用文档解决此类问题。 当然,该文档是有用且有用的,因为当您开始一个项目时,它是如此之好,以至于您身上挂着五张待售票,项目经理询问您的进展情况,并且您需要阅读(并记住)大约150张远非杰出作家撰写的文字页面。 当然,您需要花费几天甚至几周的时间来投入该项目,但是,如果使用简单的算术,一方面需要5,000,000字节的代码,另一方面需要50个工作小时。 事实证明,平均每小时必须注入100 Kb的代码。 在这里,一切都很大程度上取决于代码的质量。 如果很干净:易于组装,结构合理且可预测,那么投入到项目中似乎会减少很多痛苦。 类设计并不是最后一个角色。 不是最后一个。

我们要从课堂设计中得到什么


从以上所有内容中,可以得出有关通用体系结构,技术堆栈,开发过程等的许多有趣结论。但是从一开始,我们就决定讨论类设计,让我们看看我们可以从前面的内容中学到什么有用的东西。

  • 我希望对应用程序代码不太熟悉的开发人员在查看类时能够理解该类在做什么。 反之亦然-查看功能或非功能需求,我可以快速猜出应用程序在负责该类的类中的位置。 好吧,希望需求的实现不在整个应用程序中“分散”,而是集中在一个类或一组紧凑的类中。 让我用一个例子来解释我的意思是哪种反模式。 假设我们需要验证某个类型的10个请求只能由其帐户中拥有20分以上的用户执行(无论这意味着什么)。 实现这种要求的一种不好的方法是在每个请求的开始处插入一个检查。 然后,逻辑将分布在不同控制器中的10种方法中。 一个好的方法是创建一个过滤器或WebRequestInterceptor并在一个地方检查所有内容。
  • 我希望一个班级的变更不会影响班级合同,也不会影响(或者说是现实的!)至少不会对其他班级产生太大影响。 换句话说,我想封装类合同的实现。
  • 我希望有可能,当更改类协定时,通过遍历调用链并查找用法来查找此更改影响的类。 也就是说,我希望类没有间接依赖关系。
  • 如果可能的话,我希望看到由几个单级步骤组成的请求处理过程不会被多个类的代码所抹去,而是在相同的级别上进行描述。 如果描述这种处理过程的代码适合一个名字清晰的方法内部的一个屏幕上,那是很好的。 例如,我们需要查找一行中的所有单词,为每个单词调用第三方服务,获取单词的描述,对描述应用格式并将结果保存在数据库中。 这是四个步骤中的一个动作序列。 当有一种方法可以使这些步骤一个接一个地进行时,很容易理解代码并更改其逻辑。
  • 我确实希望代码中的相同内容以相同的方式实现。 例如,如果我们立即从控制器访问数据库,则最好在任何地方执行此操作(尽管我不会将这种设计称为良好实践)。 而且,如果我们已经输入了服务和存储库级别,那么最好不要直接从控制器联系数据库。
  • 我希望不直接负责功能和非功能需求的类/接口的数量不要太大。 使用一个项目,在该项目中每个类都有两个逻辑接口,一个复杂的继承自五个类的继承层次结构(一个类工厂和一个抽象类工厂)非常困难。

实用建议


提出愿望后,我们可以概述将使我们实现目标的具体步骤。

静态方法


作为热身,我将从一个相对简单的规则开始。 除非对使用的一种库的操作是必需的,否则不应创建静态方法(例如,您需要为数据类型创建序列化器)。

原则上,使用静态方法没有错。 如果方法的行为完全取决于其参数,为什么不真正使其静态。 但是您需要考虑到我们使用Spring IoC的事实,Spring IoC用来绑定应用程序的组件。 Spring IoC处理Bean的概念及其范围。 这种方法可以与分组为类的静态方法混合使用,但是要理解这样的应用程序甚至更改其中的某些内容(例如,如果您需要将某个全局参数传递给方法或类)可能会非常困难。

同时,与IoC容器相比,静态方法在方法调用速度上的优势微不足道。 至此,优势就终结了。

如果您不构建需要在不同类之间进行大量超快速调用的业务功能,则最好不要使用静态方法。

在这里,读者可能会问:“但是,StringUtils和IOUtils类呢?” 确实,Java世界已经形成了一种传统-将用于处理字符串和输入输出流的辅助函数放入静态方法,并将其收集在SomethingUtils类的保护之下。 但是在我看来,这种传统很生苔。 如果您遵循它,那么当然不会造成很大的伤害-所有Java程序员都习惯了它。 但是,这种仪式行动毫无意义。 一方面,为什么不使StringUtils bean,另一方面,如果不使bean和所有辅助方法变为静态,则让我们已经使静态伞类StockTradingUtils和BlockChainUtils成为可能。 开始将逻辑放入静态方法中,划定边界并停止是困难的。 我建议你不要开始。

最后,不要忘记,通过Java 11,数十年来一直困扰开发人员从一个项目到另一个项目的许多辅助方法,要么成为标准库的一部分,要么合并到例如Google Guava中的库中。

原子合同


有一条简单的规则适用于任何软件系统的开发。 上任何一堂课,您都应该能够迅速而紧凑地进行教学,而无需进行长时间的挖掘,请解释一下该课的目的。 如果无法在一个段落中放入相应的说明(不过,用一句话表示,则没有必要),则可能值得考虑并将该类分为几个原子类。 例如,类“在磁盘上查找文本文件并计算每个字母中的字母Z的数目”-分解“在磁盘上搜索” +“计数字母的数目”的良好候选者。

另一方面,不要制作太小的类,每个类都是为一个动作而设计的。 但是,班级应该是多少? 基本规则如下:

  • 理想情况下,当类别合同与业务功能(或子功能)的描述匹配时,取决于需求的排列方式。 这并非总是可能的:如果尝试遵守此规则导致创建繁琐且不明显的代码,则最好将类分成较小的部分。
  • 评估集体合同质量的一个好的指标是其内部复杂度与合同复杂度之比。 例如,一个很好的(虽然很棒)的班级合同可能看起来像这样:“班级有一种方法,在输入时会收到一行用俄语对主题进行描述的行,从而撰写出高质量的故事,甚至是关于给定主题的故事。” 在这里,合同是简单的并且通常被理解。 它的实现极其复杂,但是复杂性隐藏在类内部。

为什么这个规则很重要?

  • 首先,能够清楚地向自己解释每个类所做的事情总是有用的。 不幸的是,并非每个项目开发人员都能做到这一点。 您经常会听到类似的声音:“嗯,这就是Path类的包装,我们以某种方式制作了它,有时甚至用它代​​替Path。 她还有一个可以使所有File.separator路径加倍的方法-将报告保存到云时,我们需要此方法,由于某种原因,它最终出现在Path类中。”
  • 人脑能够同时操作不超过五到十个对象。 大多数人只有不超过七个。 因此,如果开发人员需要处理七个以上的对象才能解决问题,那么他要么会错过某些东西,要么会被迫将多个对象打包在一个逻辑“伞”下。 而且,如果您仍然必须打包,为什么不自觉地立即打包,并给这个雨伞起一个有意义的名称和明确的合同。

如何检查一切是否足够细? 请同事给您5(五)分钟。 参与当前正在创建的应用程序的一部分。 对于每个班级,请向同事解释该班级的确切功能。 如果您在5分钟内不适应,或者同事无法理解为什么需要此类课程,那么您应该进行一些更改。 好吧,还是不要与另一位同事再次更改并进行实验。

类依赖


假设我们需要为打包在ZIP存档中的PDF文件选择大于100字节的链接文本部分,并将其保存到数据库中。 在这种情况下,流行的反模式如下所示:

  • 有一个类可打开ZIP存档,在其中查找PDF文件,并将其作为InputStream返回。
  • 该类具有指向在PDF文本段落中搜索的类的链接。
  • 反过来,与PDF一起使用的类具有指向将数据存储在数据库中的类的链接。

一方面,一切看起来都很合乎逻辑:接收到的数据直接称为链中的下一个类。 但是同时,链顶部的类的合同也混入了其后链中所有类的合同和依赖关系。 使这些类相互独立并且相互独立,并通过将这三个类相互连接来创建实际上实现处理逻辑的另一个类,这是更正确的做法。

如何不这样做:



怎么了 使用ZIP文件的类将数据传递给处理PDF的类,然后将数据传递给与数据库一起使用的类。 因此,由于某些原因,与ZIP一起使用的类取决于与数据库一起使用的类。 另外,处理逻辑分布在三个类中,要理解它,我们必须遍历所有三个类。 如果您需要从PDF获取的文本段落通过REST调用传递给第三方服务,该怎么办? 您将需要更改适用于PDF的类,并在其中进行绘制,使其也适用于REST。

怎么做:



这里有四个类:

  • 一个仅与ZIP存档一起使用并返回PDF文件列表的类(可以争论-返回文件不好-它们很大,会破坏应用程序。但是在这种情况下,让我们广泛地阅读“ returns”一词。例如,它从InputStream返回Stream )
  • 第二类负责使用PDF。
  • 第三类不知道,除了在数据库中保存段落外,什么也不能做。
  • 第四类实际上由几行代码组成,包含了适合放在一个屏幕上的所有业务逻辑。

我再次强调,2019年在Java中至少有两个好(并且更少
好的),这样就不会传输文件和所有段落的完整列表作为内存中的对象。 这是:

  1. Java Stream API
  2. 回呼 也就是说,具有业务功能的类不会直接传输数据,而是会显示ZIP Extractor:这是给您的回调,在ZIP文件中查找PDF文件,为每个文件创建InputStream并调用传输的回调。

内隐行为


当我们不试图解决一个以前从未被任何人解决过的全新问题,而是做其他开发人员之前已经做过数百(或数十万)次的事情时,所有团队成员都对解决方案的循环复杂性和资源强度有一些期望。 例如,如果我们需要在文件中找到所有以字母z开头的单词,则这是一次从磁盘上按块顺序读取文件。 也就是说,如果您专注于https://gist.github.com/jboner/2841832 ,那么这种操作每1 MB会花费几微秒,也许,这取决于编程环境和系统负载,可能是几十甚至一百微秒,但是一点也不。 这将需要几十个千字节的内存(我们不考虑结果如何处理,这是另一类的问题),并且代码很可能会占用一个屏幕。 同时,我们希望不会使用其他系统资源。 也就是说,该代码将不会创建线程,不会将数据写入磁盘,不会通过网络发送数据包以及将数据保存在数据库中。

这是方法调用的通常期望:

zWordFinder.findZWords(inputStream). ... 

如果您的类的代码出于某种合理的原因而不能满足这些要求,例如,将一个单词分类为z而不是z,则需要每次都调用REST方法(我不知道为什么这是必要的,但让我们想象一下)必须在类协定中非常仔细地编写,并且如果方法的名称指示该方法正在某个地方运行以供参考,则非常好。

如果您没有合理的理由暗示其行为,请重写该类。

如何理解该方法的复杂性和资源强度的期望? 您需要采用以下简单方法之一:

  1. 凭借经验,可以获得相当广阔的视野。
  2. 问一个同事-这总是可以做到的。
  3. 在开始开发之前,请与团队成员讨论实施计划。
  4. 问自己一个问题:“但是,在这种方法中我是否不使用过多的冗余资源?” 通常这足够了。

您也无需参与优化-在100,000类中使用时节省100个字节对于大多数应用程序来说意义不大。

该规则打开了一个进入过度工程世界的窗口,隐藏了诸如“为什么您不应该花一个月的时间在需要10 GB的应用程序中节省10个字节的内存”之类的问题的答案。 但是我不会在这里讨论这个话题。 她值得一读。

隐式方法名称


在Java编程中,当前有一些关于类名及其行为的隐式约定。 它们并不多,但是最好不要破坏它们。 我将尝试列出我想到的那些:

  • 构造函数-创建该类的实例,它可以创建一些相当分支的数据结构,但它不适用于数据库,不写入磁盘,不通过网络发送数据(我会说,内置的记录器可以完成所有这些操作,但这是另一回事无论如何,这取决于日志记录配置程序的良心。
  • Getter-getSomething()-从对象的深处返回某种内存结构。 同样,它不写入磁盘,不执行复杂的计算,不通过网络发送数据,不与数据库一起使用(除非这是一个懒惰的ORM字段,这只是应谨慎使用懒惰字段的原因之一) 。
  • Setter-setSomething(某些东西)-设置数据结构的值,不执行复杂的计算,不通过网络发送数据,不与数据库一起使用。 通常,不希望设置程序隐式行为或任何重要计算资源的消耗。
  • equals()和hashcode()-除了简单的计算和比较(其数量线性地取决于数据结构的大小)之外,什么都没有。 即,如果我们为三个基本字段的对象调用哈希码,则预期将执行N * 3个简单的计算指令。
  • toSomething()-还有望成为一种将一种数据类型转换为另一种数据类型的方法,并且进行转换时,它仅需要与结构大小相当的内存量,而处理器时间则线性地取决于结构的大小。 在此应该注意,类型转换不能总是线性完成,例如,将像素图像转换为SVG格式可能是非常重要的事情,但是在这种情况下,最好用不同的方式命名该方法。 例如,名称computeAndConvertToSVG()看起来有些尴尬,但立即表明内部正在进行一些重要的计算。

我举一个例子。 我最近进行了应用程序审核。 通过工作逻辑,我知道代码中某处的应用程序订阅了RabbitMQ队列。 我正在浏览代码-我找不到这个地方。 我直接在寻求对Rabbit的吸引力,我开始攀登,我将转到业务流程中实际发生订阅的位置-我开始发誓。 它在代码中的外观:

  1. 调用service.getQueueListener(tickerName)方法-忽略返回的结果。 这可能会引起警告,但是这种忽略方法结果的代码并不是应用程序中唯一的代码。
  2. 在内部,检查tickerName是否为null,然后调用另一个getQueueListenerByName(tickerName)方法。
  3. 在其中,通过代码的名称从哈希中获取QueueListener类的实例(如果没有,则创建它),并在其上调用getSubscription()方法。
  4. 现在,在getSubscription()方法内部,实际发生了订阅。 它发生在一个方法的中间,大小为三个屏幕。

坦率地说,我没有遍及整个链,也没有阅读一堆细心的代码屏幕,所以猜测预订在哪里是不现实的。 如果该方法称为subscribeToQueueByTicker(tickerName),它将为我节省大量时间。

实用程序类


有一本很棒的书《设计模式:可重用的面向对象软件的元素》(1994年),它通常被称为GOF(按作者数量划分的四人制)。 本书的主要优点在于,它为来自不同国家/地区的开发人员提供了一种用于描述类设计模式的语言。 现在,我们可以说“单身”,而不是“保证该类仅在一个实例中存在并且具有静态访问点”。 同一本书对脆弱的心灵造成了明显的损害。 其中一个论坛的名言很好地描述了这种危害:“同事,我需要创建一个网上商店,告诉我我需要从哪个模板开始。” 换句话说,一些程序员倾向于滥用设计模式,并且无论您在哪里使用一个类进行管理,有时他们一次创建五个或六个-以防万一,“为了更大的灵活性”。

如何确定是否需要抽象类工厂(或比接口更复杂的其他模式)? 有一些简单的注意事项:

  1. 如果您在Spring中编写应用程序,则不需要99%的时间。 Spring为您提供了更高层次的构建块,请使用它们。 您可能会发现有用的最大值是一个抽象类。
  2. 如果第1点仍然没有给您明确的答案-请记住,每个模板为+1000,表示应用程序的复杂性。 仔细分析使用模板的好处是否大于其带来的危害。 谈到一个隐喻,请记住,每种药物不仅可以治愈,而且还会造成轻微伤害。 不要一次喝所有药。


不需要的一个很好的例子,您可以在这里看到。

结论


总而言之,我想指出,我列出了最基本的建议。 我什至不会以文章的形式列出它们-它们是如此明显。 但是在过去的一年中,我经常遇到一些违反了许多建议的应用程序。 让我们编写易于阅读和维护的简单代码。

Source: https://habr.com/ru/post/zh-CN443756/


All Articles