在当今的项目实践中,工程化的角色至关重要。实际上,没有工程化的支持,构建大型项目几乎是不可能的任务。本文旨在从技术小白的视角出发,逐步深入,采用自顶向下的方法,带领大家系统地学习工程化的基础知识。我们将通过易于理解的语言和实例,确保即使是刚入行的开发者也能够掌握工程化的核心概念和实践方法。
工程化引入
什么是工程化以及为什么要学习工程化
为什么要学习编程
- 实现各种各样的功能,解决各种各样的问题
为什么程序能实现各种各样的功能
- 程序可以模拟世间万物
如何模拟世间万物(程序的本质)
- 数据结构(物品)+ 算法(行为)= 程序
- 举个例子,我们常用的文件系统,就是n叉树这种典型的数据结构
如何更好的模拟世间万物,写出更优质的程序,解决更复杂的问题
- 建立秩序(工程化)
结论
- 学习程序(数据结构+算法)让我们拥有了快速解决各种问题的能力,但仅限于解决小规模的简单问题
- 学习工程化让我们拥有了更强的统筹管理能力,使我们能够解决各种大规模的复杂问题
引申
- 目前市面上大家所见的各种程序都用以解决非常复杂的问题,因此学习工程化是不可或缺的一环
工程化初步
粗略的阐述工程化的概念
实现工程化的几个方面
标准统一的范式
- 降低人和代码之间的耦合性 – 每个人都有自己的个性,如果不明确统一的范式,代码与代码的编写者是高度耦合的,就是只有作者自己能看懂自己写的代码,其他人难以理解
清晰易懂且松散的架构
- 降低各个功能方面的耦合性 – 功能与功能之间联系过强会导致牵一发而动全身,程序一处崩,处处崩,直接彻底崩盘
编码方式的最佳实践
- 降低代码间的耦合性 – 代码间耦合性如果过强,改动了一处代码,可能需要同时改动与其相关的很多处代码,一旦忘改某一处,bug就产生了,因此既麻烦又容易出错
程序质量的保证
- 降低程序质量与编码人员之间的耦合性 – 编码人员对于自己写的代码往往是高度认同,认为自己的代码非常优秀,从而不容易发现错误,影响程序质量,这就是编码人员与程序质量之间的耦合性
重复的流程自动化
- 降低重复性操作与人之间的耦合性 – 项目流程中有很多重复性操作,如果这些操作由人来执行,首先是只有操作的执行者知道该如何做,其他人对这些操作束手无策,其次,人类对于重复性手动操作往往有着很高的出错率,远比不上电脑的自动化操作
标准统一的范式
解决复杂的问题往往需要很多人一起合作,标准统一的范式是多人合作的基础(下面是部分例子)
- 对问题的描述清晰明确(需求明确)
- 编码风格规范统一(增强代码可读性)
- 特定问题的处理方式统一(比如出现异常,统一处理)
这个方面是需要长期积累,并且针对不同语言不同框架有不同范式,因此这个方面只做抽象层面的讲解
阿里的Java开发手册很能体现标准统一的范式的重要性,建议大家可以去看一看
清晰易懂且松散的架构(重点)
我们通过程序解决复杂问题,需要对程序进行一个整体结构上的设计,将其划分为很多模块和部分,这种设计就是架构
- 比如汽车,每辆车都不一样,但是其整体的设计(各个组成部分)大差不差
清晰易懂,降低学习成本和理解难度,减少了出错的可能性
松散指的是,尽量减少程序各个组成部分之间的联系,防止一个部分出问题,导致其他部分也出问题
- 比如汽车就是松散的架构,汽车的轮胎坏了并不会影响发动机的运转
编码方式的最佳实践(重点)
经典代码的复用(CV工程师)
- 经过这么多年的发展,很多问题都有前人总结出来的现成的解决方案,复用这些方案,让我们的编码更加轻松
- 这一点前端尤为突出(UI框架,典型的组件级复用)
善用设计模式
- 如果说代码复用是对某些具体问题解决方案的复用,那么设计模式就是对大量相似问题的抽象复用
- 设计模式实际上是一种前人总结出来的经验,让你的编码更加轻松且优质
- 优秀的设计模式在很大程度上降低了代码的耦合性
程序质量的保证
- 编码之后的测试环节(非常重要)
- 单元测试、(集成测试、系统测试、压力测试)-测试岗位
- 反馈途径
重复的流程自动化
CI/CD(持续集成/持续部署)
- 举例:发布一款安卓应用的流程 写代码->用工具将其打包成安装包->测试应用是否正常->上传到应用商店
- 除了第一步写代码,其他的步骤都是重复性步骤,因此我们完全可以写一个自动化程序让这些流程自动进行,一劳永逸
上述CI/CD只是一个例子,项目流程中的自动化流程还有很多
- Selenium:浏览器自动化测试工具
- 办公自动化 – word,excel
- …….
工程化方法
上面只是粗略的讲解了工程化的几个方面,具体该如何做呢
上面的五个方面中,标准统一的范式、程序质量的保证、重复的流程自动化这三方面,需要后期全面系统的深入学习,目前阶段只需要了解即可,因此接下来的内容主要围绕清晰易懂且松散的架构和编码方式的最佳实践
复杂问题的解决方法 – 关注点分离
- 学习工程化是为了快速有效的解决复杂问题,因此我们首先需要了解复杂问题的解决方法,实际上这里的解决方法就是程序架构设计的基础知识
分层思想
- 分层思想应用在生活中的方方面面,是一种典型的关注点分离思想,分层思想将复杂的问题划分成多个简单层次,以此来使复杂问题简单化
- 例子1:农作物/食材/菜品三层架构
- 例子2:计算机网络七层架构图(了解)
分块思想(拼积木)
汽车的制造
- 发动机制造、底盘和底架、车身制造、内饰装配、电气系统 ……
面向对象思想(引入)
- 将程序分成好多块,每一个块都是一个对象,每个对象拥有自己的属性(成员变量)和能做的事情(方法/函数)
- 例子:小明对象【 属性{姓名:小明;年龄:15;};方法{吃饭(食物参数);玩(游戏参数);};】
- 获取小明的年龄 小明.年龄
- 让小明吃面包 小明.吃饭(面包)
- 上面只是简单引入一下面向对象思想,简单来说就是把程序的各个部分都抽象成不同的对象,是典型的分块思想
分层和分块思想都属于关注点分离思想,就是将复杂的问题通过分层和分块的方法,分解成简单的问题,把关注点集中于一个个的简单问题,逐个击破,所有简单问题完成后,复杂的问题自然也就完成了
分层和分块思想并不矛盾,我们在设计一个架构的时候往往既考虑分层,又考虑分块,例子如下
前后端分离的分层架构(典型的分层架构)
前端架构
- 老架构(古董)
- 现代化,工程化的前端架构
后端工程化架构
一些关于后端工程化的小知识(了解即可)
- 对于实际情况会用到更多的方案进行更完全的解耦,比如IOC/DI(控制反转和依赖注入),通过IOC容器自动管理依赖项,大大的降低了代码的耦合度
- 对于一些特殊情况,比如打印日志,我们往往不会将日志写到代码逻辑中,而是通过AOP切面,实现零侵入的日志打印,使业务逻辑与日志逻辑完全解耦
- 对于异常捕获的情况,我们往往使用全局异常处理,通过拦截器,拦截业务逻辑中发生的所有异常并记录到日志中,使业务逻辑与异常处理逻辑完全解耦
- 上面这几个小知识的目的在于展示我们工程实践的至高目标 —— 解耦,遵从单一职责原则,不同的代码干不同的事,不要让代码既干这个又干那个,也不要让代码之间有过深的联系
数据层架构
- 数据层也有架构,实际项目中,我们往往需要多种存储方式,如Redis、MySQL、Milvus、Elasticsearch
- 不同的存储方式有不同的用途,比如Redis在内存中存储数据,读写速度极快,非常适合作为数据缓存以及临时数据存储
- MySQL十分的稳定高效,是持久存储结构化数据的不二选择
- Milvus是一款高效的向量数据库,是存储向量数据这种非结构性数据的首选
- Elasticsearch用于存储、搜索和分析大量数据,非常适合用来做搜索引擎的数据存储
- 我们针对不同的业务需求,选择不同的数据库,形成数据层架构
前后端分离项目整体架构
- 上面这张前后端分离的总架构图包含了前后端工程化的核心思想,但是实际上这只是一个简略的架构图,其中还有相当多的架构细节
- 上面架构图既运用到了分层思想,也运用到了分块思想,可以说前后端分离的项目是目前程序界的精化之作,其中蕴含相当珍贵的思想财富,但我们总结归纳一下,无非就是通过分层和分块的思想进行关注点分离,以将复杂问题简单化,这就是工程化中最为重要的部分!
至此,我们彻底搞懂了什么是清晰易懂且松散的架构,接下来我们学习编码方式的最佳实践
在编码方式的最佳实践这里,我的大纲中会有一些代码作为例子,这些例子在本节课中并不会讲解,本节课只讲工程化思想,不具体到代码层面,因此此处代码例子的目的是为了让想要深入研究的同学感受一下编码方式的最佳实践所带来的设计美学
例子1(模板方法模式)
某公司的员工分为5类,每类员工都有相应的封装类。
(1) Employee:这是所有员工总的父类。
① 属性:员工的姓名,员工的生日月份
② 方法:getSalary(int month) 根据参数月份来确定工资,如果该月员工过生日,则公司会额外奖励 100 元。
(2) SalariedEmployee:Employee 的子类,拿固定工资的员工。
① 属性:月薪。
(3)HourlyEmployee:Employee 的子类,按小时拿工资的员工,每月工作超出160 小时的部分按照1.5倍工资发放。
① 属性:每小时的工资、每月工作的小时数。
(4) SalesEmployee:Employee 的子类,销售,工资由月销售额和提成率决定。
① 属性:月销售额、提成率。
(5) BasePlusSalesEmployee:SalesEmployee 的子类,有固定底薪的销售人员,工资由底薪加上销售提成部分。
① 属性:底薪。
/**
* 员工
*/
public abstract class Employee {
private String name;
private int birthdayMonth;
public Employee(String name, int birthdayMonth) {
this.name = name;
this.birthdayMonth = birthdayMonth;
}
/**
* 获取对应月份的工资(模板方法)
* @param month 月份
* @return 对应月份的工资
*/
public float getSalary(int month) {
float salary = 0;
if (month == birthdayMonth) {
salary += 100;
}
salary += countSalary(); // 可变部分
return salary;
}
/**
* 计算工资(模板方法模式中的可变部分,子类只需要用不同逻辑实现此方法,就可以让getSalary模板方法展现出不同效果,这就是模板方法模式)
* @return 以对应职位的算法计算的工资
*/
public abstract float countSalary();
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getBirthdayMonth() {
return birthdayMonth;
}
public void setBirthdayMonth(int birthdayMonth) {
this.birthdayMonth = birthdayMonth;
}
}
/**
* 按月拿固定工资的员工
*/
public class SalariedEmployee extends Employee {
/**
* 月薪
*/
private float monthlySalary;
public SalariedEmployee(String name, int birthdayMonth, float monthlySalary) {
super(name, birthdayMonth);
this.monthlySalary = monthlySalary;
}
/**
* 实现父类模范方法可变部分(月薪工是按月拿固定工资)
* @return 以月薪计算的工资
*/
@Override
public float countSalary() {
return monthlySalary;
}
public float getMonthlySalary() {
return monthlySalary;
}
public void setMonthlySalary(float monthlySalary) {
this.monthlySalary = monthlySalary;
}
}
/**
* 小时工
*/
public class HourlyEmployee extends Employee {
/**
* 每小时工资
*/
private float hourlySalary;
/**
* 每月工作小时数
*/
private float hoursPerMonth;
public HourlyEmployee(String name, int birthdayMonth, float hourlySalary, float hoursPerMonth) {
super(name, birthdayMonth);
this.hourlySalary = hourlySalary;
this.hoursPerMonth = hoursPerMonth;
}
/**
* 实现父类模范方法可变部分(小时工是按小时拿工资,超出160h的部分发1.5倍工资)
* @return 以月薪计算的工资
*/
@Override
public float countSalary() {
float salary = 0;
if (hoursPerMonth > 160) {
salary += (float) ((hoursPerMonth - 160) * hourlySalary * 1.5);
salary += 160 * hourlySalary;
} else {
salary += hoursPerMonth * hourlySalary;
}
return salary;
}
public float getHourlySalary() {
return hourlySalary;
}
public void setHourlySalary(float hourlySalary) {
this.hourlySalary = hourlySalary;
}
public float getHoursPerMonth() {
return hoursPerMonth;
}
public void setHoursPerMonth(float hoursPerMonth) {
this.hoursPerMonth = hoursPerMonth;
}
}
/**
* 销售员
*/
public class SalesEmployee extends Employee {
/**
* 月销售额
*/
private float monthlySales;
/**
* 提成率
*/
private float commissionRate;
public SalesEmployee(String name, int birthdayMonth, float monthlySales, float commissionRate) {
super(name, birthdayMonth);
this.monthlySales = monthlySales;
this.commissionRate = commissionRate;
}
/**
* 实现父类模范方法可变部分(销售员是按月销售额提成)
* @return 以月薪计算的工资
*/
@Override
public float countSalary() {
return monthlySales * commissionRate;
}
public float getMonthlySales() {
return monthlySales;
}
public void setMonthlySales(float monthlySales) {
this.monthlySales = monthlySales;
}
public float getCommissionRate() {
return commissionRate;
}
public void setCommissionRate(float commissionRate) {
this.commissionRate = commissionRate;
}
}
/**
* 有固定底薪的销售员
*/
public class BasePlusSalesEmployee extends SalesEmployee{
/**
* 底薪
*/
private float baseSalary;
public BasePlusSalesEmployee(String name, int birthdayMonth, float monthlySales, float commissionRate, float baseSalary) {
super(name, birthdayMonth, monthlySales, commissionRate);
this.baseSalary = baseSalary;
}
/**
* 实现父类模范方法可变部分(底薪销售员是按月销售额提成+底薪)
* @return 以月薪计算的工资
*/
@Override
public float countSalary() {
return super.countSalary() + baseSalary;
}
public float getBaseSalary() {
return baseSalary;
}
public void setBaseSalary(float baseSalary) {
this.baseSalary = baseSalary;
}
}
例子2(基于枚举类的线程安全单例)
public enum Singleton {
INSTANCE; // 这是枚举类的单例
// 可以在这里添加单例的属性和方法
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
- 在写作文的时候,我们往往会有一些固定的开头结尾模板,这就是一种最佳实践,用模板快速完成作文开头和结尾,细心打磨中间内容,就得到了很好的作文,写程序也一样,我们有很多前人总结的最佳实践,最典型的就是设计模式,应用这些最佳实践可以高效的写出优质代码
工程化补充
这里对进一步学习工程化做一些补充性说明
通过鱼皮的教程进一步理解项目的工程化开发流程:鱼皮学习路线
Apifox、亿图图示等应用是我们工程化项目开发的得力助手,大家可以学习如何用这些工具设计架构,设计接口
了解coding的各种功能
需求出现,想要开发一个项目
需求分析,分析各个部分功能的可行性,以及大致要用到哪些技术
初步架构设计,根据你的需求设计出初步的架构,这个架构抽象程度较高,只是为了确定一个更进一步方向
技术选型,研究架构的各个部分需要用到哪些技术,这个环节需要广泛了解各种技术以及其功能,尽量选择更主流的技术,以及与团队成员兼容度更高的技术
技术可行性验证,选出的技术是否真正能够完成我们的需求,这一点需要进行试验,不然在项目开始开发之后发现技术不合适,就会非常麻烦
详细架构设计,对项目的详细架构进行设计,包括整体架构,数据库架构(如何组织数据),其实就是各种图形化的架构描述,如E-R图
接口设计,有了详细架构,我们就可以进一步细化,设计接口,整理出一份接口文档
评审阶段,所有人对前面所有设计进行评审,进一步完善所有的设计
项目搭建,根据架构,将程序的框架搭建好
- 前端:vue,ui框架,网络请求框架
- 后端:Python的flask,java的springboot
- 数据库
开发阶段,这个阶段开发具体的业务逻辑
单元测试,开发好的代码以最小的单元(方法)进行单元测试,确保所有的逻辑符合预期
集成测试,将各个功能放在一起使用,看看会出会出现冲突性问题
推送到git,这里主要讲解推送到coding(类似于共享文档)
- 一般项目存在一个主分支,这个分支是正式版本的分支
- 开发之前,我们会将主分支中已有的代码拉取到本地,然后再基于此开发新版本
- 我们一般将刚开发新版本推送到自己个人的分支上,防止直接推送到主分支引发问题
- 推送到自己的分支后,coding自动触发代码扫描,扫描出代码中潜在的一些问题
- 修改代码中的问题,再次提交到自己的分支,直到代码扫描评分达到满分
- 向分支管理员申请将你的分支合并到主分支,分支管理员会检查你的代码是否存在问题,如果有问题会给你打回,直到你的版本没有问题,管理员会通过你的代码合并请求,你的新版本就会被更新到主分支中
- 在合并到主分支的过程中,可能会出现分支冲突问题,举个例子,小明和小红从主分支拉取了1.0版本的代码,小明对代码中的函数fuc进行了改动,发布了1.1版本,并将其合并到主分支,现在主分支是1.1版本了,但是小红手里的版本依然是1.0版本,此时小红对函数fuc进行了改动,发布新版本并合并到主分支,我们会发现小明和小红都基于1.0版本的代码修改了fuc函数,那么应该使用谁的改动呢,这就产生了冲突,需要我们手动选择一下是保留小明的改动还是小红的改动。
- 很显然我们合并时有可能遇到冲突问题,因此我们在推送代码之前,往往会先拉取主分支的代码,将在你开发新版本的过程中主分支发生的变化合并到新版本代码中,将所有的冲突解决后,再发起合并请求
- 当我们的代码成功合并到了主分支,主分支就更新为了新版本,主分支发生了改变,就会触发CI/CD,CI主要用于将你的代码自动打包成可以运行的程序,CD主要用于将打包好的程序自动部署到服务器上运行,实现线上版本自动更新
前端自动化测试,一般使用浏览器自动化测试工具编写前端的测试程序,从前端进行项目整体的测试,这种测试主要是模拟用户的各种操作,测试这些操作是否能符合我们的预期
改Bug阶段,虽然前面经过了很多次测试,但是进行前端测试的时候,往往又会发现新的bug
所有之前设计好的内容全部开发完成之后,需要对本次项目的开发整体过程进行总结,发现问题,全部罗列出来,并讨论出解决方案
一轮一轮不断迭代,甚至重构以及技术变更,使项目越来越完美
上面就是具体到每一步的工程化项目开发流程,至此,我们便彻底学会如何解决复杂问题了!