继承 & 模块化
模块化
在开发一个项目时,通常会进行模块化拆包,如下:
blog (parent)
- blog-controller
- blog-service
- blog-entity
- blog-util
...
根据模块的名称可以看出,业务是比较单一的。若是一个企业级的后台项目,业务模块是很多的,那么在拆分模块是又是另一种方式,以业务维度进行拆分:
project (parent)
- business1-controller
- business1-service
- business2-controller
- business2-service
...
无论那种方式,目的都在于解耦。根据代码体量的不同,应用不同的方式。当然也并不是说,越复杂的拆分越好,根据实际情况而定。比如仅仅是一个工具型的项目,
过度的拆分反而适得其反。
那么为什么一定要拆分为多个module,而不是在一个项目下,通过业务包名进行拆分呢?以我个人经验来说,项目会变得很臃肿、代码无法得到充分复用。
以我当前所在项目为例(泪),N个系统对应N个项目,但是操作同一个数据库。将每个系统作为一个独立的module,内部根据业务包名的方式进行划分,
一并集成在一个父项目中(实在弄不懂当初搭架子的人为什么这么做)。那么问题来了:
- 有些项目的业务繁多(超过50个,即50多个业务包),单个 module 包、代码量过多。
- 代码熟悉成本高。
- 当A项目想要使用B项目中的Service中的业务代码时,只能进行代码Copy。运气不好,需要将Copy代码中用到的所有依赖类都Copy一次。那如果依赖的类又依赖了其他类呢?
如果通过业务模块拆分的形式进行拆分,就可以避免这些问题。但是也不能所有模块都进行拆分这样的拆分,不然会导致modules的数量过多,业务量多的模块可以这么做。
模块继承
那么 parent & modules 中是如何进行关联的?
Parent:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>xxx.xxx</groupId>
<artifactId>Parent</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- Here -->
<packaging>pom</packaging>
<modules>
<module>business1-controller</module>
<module>business1-service</module>
<module>business2-controller</module>
<module>business2-service</module>
...
</modules>
<dependencyManagement>
<dependencies>
...
</dependencies>
</dependencyManagement>
</project>
Modules:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>xxx.xxx</groupId>
<artifactId>business1-controller</artifactId>
<version>0.0.1-SNAPSHOT</version>
<!-- Here -->
<parent>
<groupId>xxx.xxx</groupId>
<artifactId>Parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<dependencies>
...
</dependencies>
</project>
这里有个小问题需要注意:改动父工程时,先将 modules 注释,自身install一下。否则子工程依赖的父工程依旧是旧的,现象则是父工程中的改动没有生效。
聚合
如同设计模式中的合成复用原则:优先组合,其次继承。若想使用其他类中的功能时,应引用该类的实例,而非继承该类。目的在于解耦。
在 Maven 中也存在这样的概念,最常见的例子:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
在父 POM 工程中,使用 SpringBoot 框架,通过 import 的方式引入其他 POM 中定义好的一套依赖,即引入依赖中定义的 <dependencyManagement>
。而非通过继承的方式引入:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.3.7.RELEASE</version>
</parent>
依赖范围
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.10</version>
<!-- Here -->
<scope>compile</scope>
</dependency>
默认:compile(不指定默认为compile)
可选:test、provided、runtime、system、import
在讨论它们的具体作用前,我们应先说明一个事:Maven 项目在不同阶段引入到Classpath中的依赖是不相同的。可以分为:
- 编译阶段
- 运行阶段
- 测试阶段
1、test:测试阶段存在,编译、运行阶段不存在。编译和运行主代码时,无法找到该依赖。例如:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.7</version>
<scope>test</scope>
</dependency>
junit 依赖就是一个典型例子,在项目实际运行时是不需要它的,仅是在开发阶段测试代码时会用到,所以并不会被打包。
如果你是使用idea开发,可以在src/test/java
目录下(被idea标识为绿色)使用 scope 为 test 的 junit 依赖中的类,是没问题的。同时在src/main/java
源代码目录下
也使用 junit 依赖中的类,会提示找不到依赖中的类。也就是说,src/test/java
目录下的类使用的Classpath是不同的,只有<scope>
为test的依赖才会被加载到该Classpath下。
2、provided:编译、测试阶段存在,运行阶段不存在。典型例子:servlet-api。Web 运行时容器已经提供了,所以不需要被打包,但是编译 & 测试阶段时需要该依赖。
3、runtime:测试、运行阶段存在,编译阶段不存在。程序中是无法直接 import 该依赖中的类的,编译会找到不类,但是可以通过反射调用获取到。也就说,如果不使用 Maven 打包,
程序中是可以正确 import 的(前提 jar 在 classpath中)。可以看出所谓的编译阶段不存在中的编译阶段,仅是 Maven 范畴内编译阶段,而非真的没有被 javac 编译。无论什么类型的 scope 依赖,如provided,都是需要先被编译为class打成jar包后,才能使用的。
那么这么做的目的是什么呢?知乎上看到的解释:依赖倒置原则,强制开发者使用接口,即依赖抽象不依赖具体,降低类之间的耦合。不使用带有具体实现的依赖。
4、system:与 provided 相比,唯一不同点在于不会去 Maven 仓库寻找、下载依赖,而是会根据配置的本地路径去找:
<dependency>
<groupId>xxx.xxx</groupId>
<artifactId>JarName</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${locationPath}/JarName.jar</systemPath>
</dependency>
5、import:在前面的继承 & 聚合中,我们引用 SpringBoot 定义依赖的 POM 依赖时,配合 <dependencyManagement>
标签使用。统一管理依赖版本。
6、compile:编译、测试、运行阶段都存在。最常用,也是默认的配置。
依赖传递
传递依赖是指:a->b,b中添加的依赖会被传递到a中。但是若有多个不同版本的传递依赖,该如何选择呢?下面有两个原则:
最短路径原则(路径不同):
a->b->c(1.0)
a->c(2.0)
取 c(2.0)
若 POM 中直接引入是 c,则直接取 POM 中的c,不去判断间接依赖中的c。
最先声明原则(路径相同):
a->b->c(1.0)
a->d->c(2.0)
取 c(1.0)
scope 对传递依赖的影响
并不是所有的依赖都能够被传递,scope的取值会对传递产生影响。
- 可以被传递:compile、runtime、import
- 不可被传递:test、provided、system
依赖范围 & 依赖传递总结
Scope | 编译阶段存在 | 测试阶段存在 | 运行阶段存在 | 可以依赖传递 |
---|---|---|---|---|
compile | √ | √ | √ | √ |
test | × | √ | × | × |
provided | √ | √ | × | × |
system | √ | √ | × | × |
runtime | × | √ | √ | √ |
import | × | × | × | √ |
依赖排除
有些场景,我们期望使用指定版本传递的依赖,比如:a->b(guava 25.1-jre)、a->c(guava 28.2-jre)。
a 同时依赖了 b、c,且b、c 同时使用了不同版本的guava。但是同长度路径下,根据最先声明原则使用了b的低版本guava,我们期望使用高版本的guava,该如何去做?
调整一下声明顺序?可以,但就不。
<dependency>
<groupId>xxx.xxx</groupId>
<artifactId>b</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
依赖树
当我们想要查看项目最终到底使用了哪些依赖,打包时最终都打了哪些包,即查看最终生效的POM内容,可以通过 idea 的 show effective pom 功能查看。(右键项目,找到 Maven 选项后就可以看到了)
但如果想要定位某个传递依赖具体是通过哪个引入的依赖间接传入的,这个时候就需要通过依赖树查看。
依赖树:将依赖关系树化展示,用于定位传递的依赖所在位置。
mvn dependency:tree
查看完整的依赖树,或者mvn dependency:tree -Dincludes=Keyword
查看包含关键词的依赖。- idea 在右侧的maven视图,找到项目的dependencies,右键analyze dependencies,即可以在搜索框内搜索指定依赖。
也可以点击Show as tree选项,转为依赖树,更直观的查看依赖关系。
还可以在顶部点击show conflicts only,查看依赖冲突。
多环境配置
不同环境应用不同的配置,下面是一个关于 SpringBoot 项目配置文件拆分的例子。common.properties 为各个环境通用的配置,dev、pro.properties 分别
为测试 & 正式环境。
<profiles>
<profile>
<id>dev</id>
<activation>
<!-- 默认使用 -->
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<profileActive>dev</profileActive>
<commonProfileActive>common</commonProfileActive>
</properties>
</profile>
<profile>
<id>prod</id>
<activation>
<activeByDefault>false</activeByDefault>
</activation>
<properties>
<profileActive>prod</profileActive>
<commonProfileActive>common</commonProfileActive>
</properties>
</profile>
</profiles>
配置文件列表:
application-common.properties
application-dev.properties
application-prod.properties
application.properties
application.properties:
spring.profiles.include=@commonProfileActive@,@profileActive@
如何应用:若配置成功,在 idea 右侧的 Maven 视图中,可以看到 Profiles 的菜单项,其中有名为 dev、prod 的单选框项。选中环境后,在对项目进行打包时,就会采用选中的环境名。
那么在 application.properties 中就可以根据环境名使用对应的配置文件。