入职阿里啦!16步快速搭建Java开发项目模板,拒绝重复性工作

  • 测试策略:自动化测试如何分类,哪些必须写测试,哪些没有必要写测试;

  • 技术架构:技术架构图;

  • 部署架构:部署架构图;

  • 外部依赖:项目运行时所依赖的外部集成方,比如订单系统会依赖于会员系统;

  • 环境信息:各个环境的访问方式,数据库连接等;

  • 编码实践:统一的编码实践,比如异常处理原则、分页封装等;

  • FAQ:开发过程中常见问题的解答。

需要注意的是,README中的信息可能随着项目的演进而改变(比如引入了新的技术栈或者加入了新的领域模型),因此也是需要持续更新的。虽然我们知道,软件文档的一个痛点便是无法与项目实际进展保持同步,但是就README这点信息来讲,还是建议开发者们不要吝啬那一点点敲键盘的时间。

此外,除了保持README的持续更新,一些重要的架构决定可以通过示例代码的形式记录在代码库中,新开发者可以通过直接阅读这些示例代码快速了解项目的通用实践方式以及架构选择,请参考ThoughtWorks的技术雷达。

一键式本地构建


为了避免诸如前文中所提到的“请教了3位同事才本地构建成功”的尴尬,为了减少“懒惰”的程序员们的手动操作,也为了为所有开发者提供一种一致的开发体验,我们希望用一个命令就可以完成所有的事情。这里,对于不同的场景我总结出了以下命令:

  • 生成IDE工程:idea.sh,生成IntelliJ工程文件并自动打开IntelliJ

  • 本地运行:run.sh,本地启动项目,自动启动本地数据库,监听调试端口5005

  • 本地构建:local-build.sh,只有本地构建成功才能提交代码

以上3个命令基本上可以完成日常开发之所需,此时,对于新人的开发流程大致为:

  1. 拉取代码;

  2. 运行idea.sh,自动打开IntelliJ;

  3. 编写代码,包含业务代码和自动化测试;

  4. 运行run.sh,进行本地调试或必要的手动测试(本步骤不是必需);

  5. 运行local-build.sh,完成本地构建;

  6. 再次拉取代码,保证local-build.sh成功,提交代码。

事实上,这些命令脚本的内容非常简单,比如run.sh文件内容为:


#!/usr/bin/env bash

./gradlew clean bootRun 

然而,这种显式化的命令却可以减少新人的恐惧感,因为他们只需要知道运行这3个命令就可以搞开发了。另外,一个小小的细节:本地构建的local-build.sh命令本来可以重命名为更简单的build.sh,但是当我们在命令行中使用Tab键自动补全的时候,会发现自动补全到了build目录,而不是build.sh命令,并不方便,因此命名为了local-build.sh。细节虽小,但是却体现了一个宗旨,即我们希望给开发者一种极简的开发体验,我把这些看似微不足道的东西称作是对程序员的“人文关怀”。

目录结构


Maven所提倡的目录结构当前已经成为事实上的行业标准,Gradle在默认情况下也采用了Maven的目录结构,这对于多数项目来说已经足够了。此外,除了Java代码,项目中还存在其他类型的文件,比如Gradle插件的配置、工具脚本和部署配置等。无论如何,项目目录结构的原则是简单而有条理,不要随意地增加多余的文件夹,并且也需要及时重构。

在示例项目中,顶层只有2个文件夹,一个是用于放置Java源代码和项目配置的src文件夹,另一个是用于放置所有Gradle配置的gradle文件夹,此外,为了方便开发人员使用,将上文提到的3个常用脚本直接放到根目录下:


└── order-backend

    ├── gradle // 文件夹,用于放置所有Gradle配置

    ├── src // 文件夹,Java源代码

    ├── idea.sh //生成IntelliJ工程

    ├── local-build.sh // 提交之前的本地构建

    └── run.sh // 本地运行 

对于gradle而言,我们刻意地将Gradle插件脚本与插件配置放到了一起,比如Checkstyle:


├── gradle

│   ├── checkstyle

│   │   ├── checkstyle.gradle

│   │   └── checkstyle.xml 

事实上,在默认情况下Checkstyle插件会从项目根目录下的config目录查找checkstyle.xml配置文件,但是这一方面增加了多余的文件夹,另一方面与该插件相关的设施分散在了不同的地方,违背了广义上的内聚原则。

基于业务分包


早年的Java分包方式通常是基于技术的,比如与domain包平级的有controller包、service包和infrastructure包等。这种方式当前并不被行业所推崇,而是应该首先基于业务分包。比如,在订单示例项目中,有两个重要的领域对象OrderProduct(在DDD中称为聚合根),所有的业务都围绕它们展开,因此分别创建order包和product包,再分别在包下创建与之相关的各个子包。此时的order包如下:


├── order

│   ├── OrderApplicationService.java

│   ├── OrderController.java

│   ├── OrderNotFoundException.java

│   ├── OrderRepository.java

│   ├── OrderService.java

│   └── model

│       ├── Order.java

│       ├── OrderFactory.java

│       ├── OrderId.java

│       ├── OrderItem.java

│       └── OrderStatus.java 

可以看到,在order包下我们直接放置了OrderControllerOrderRepository等类,而没有必要再为这些类划分单独的子包。而对于领域模型Order来讲,由于包含了多个对象,因此基于内聚性原则将它们归到model包中。但是这并不是一个必须,如果业务足够简单,我们甚至可以将所有类直接放到业务包下,product包便是如此:


└── product

    ├── Product.java

    ├── ProductApplicationService.java

    ├── ProductController.java

    ├── ProductId.java

    └── ProductRepository.java 

在编码实践中,我们总是基于一个业务用例来实现代码,在技术分包场景下,我们需要在分散的各包中来回切换,增加了代码导航的成本;另外,代码提交的变更内容也是散落的,在查看代码提交历史时,无法直观的看出该次提交是关于什么业务功能的。在业务分包下,我们只需要在单个统一的包下修改代码,减少了代码导航成本;另外一个好处是,如果哪天我们需要将某个业务迁移到另外的项目(比如识别出了独立的微服务),那么直接整体移动业务包即可。

当然,基于业务分包并不意味着所有的代码都必须囿于业务包下,这里的逻辑是:优先进行业务分包,然后对于一些不隶属于任何业务的代码可以单独分包,比如一些util类、公共配置等。比如我们依然可以创建一个common包,下面放置了Spring公共配置、异常处理框架和日志等子包:


└── common

    ├── configuration

    ├── exception

    ├── loggin

    └── utils 

自动化测试分类


在当前的微服务和前后端分离的开发模式下,后端项目仅提供纯粹的业务API,而不包含UI逻辑,因此后端项目不会再包含诸如WebDriver的重量级端到端测试。同时,后端项目作为向外提供业务功能的独立运行单元,在API级别也应该有相应的测试。

此外,程序中有些框架性代码,要么是诸如Controller之类的技术性框架代码,要么是基于某种架构风格的代码(比如DDD实践中的ApplicationService),这些代码一方面并不包含业务逻辑,一方面是很薄的一个抽象层(即实现相对简单),用单元测试来覆盖显得没有必要,因此笔者的观点是可以不为此编写单独的单元测试。再者,程序中有些重要的组件性代码,比如访问数据库的Repository或者分布式锁,使用单元测试实际上“测不到点上”,而使用API测试又显得在分类逻辑上不合理,为此我们可以专门创建一种测试类型谓之组件测试。

基于以上,我们可以对自动化测试做个分类:

  • 单元测试:核心的领域模型,包括领域对象(比如Order类),Factory类,领域服务类等;

  • 组件测试:不适合写单元测试但是又必须测试的类,比如Repository类,在有些项目中,这种类型测试也被称为集成测试;

  • API测试:模拟客户端测试各个API接口,需要启动程序。

Gradle在默认情况下只提供src/test/java目录用于测试,对于以上3种类型的测试,我们需要将它们分开以便于管理(也是职责分离的体现)。为此,可以通过Gradle提供的SourceSets对测试代码进行分类:


sourceSets {

    componentTest {

        compileClasspath += sourceSets.main.output + sourceSets.test.output

        runtimeClasspath += sourceSets.main.output + sourceSets.test.output

    }



    apiTest {

        compileClasspath += sourceSets.main.output + sourceSets.test.output

        runtimeClasspath += sourceSets.main.output + sourceSets.test.output

    }

} 

到此,3种类型的测试可以分别编写在以下目录:

  • 单元测试:src/test/java

  • 组件测试:src/componentTest/java

  • API测试:src/apiTest/java

需要注意的是,这里的API测试更多强调的是对业务功能的测试,有些项目中可能还会存在契约测试和安全测试等,虽然从技术上讲都是对API的访问,但是这些测试都是单独的关注点,因此建议分开对待。

值得一提的是,由于组件测试和API测试需要启动程序,也即需要准备好本地数据库,我们采用了Gradle的docker-compose插件(或者jib插件),该插件会在运行测试之前自动运行Docker容器(比如MySQL):


apply plugin: 'docker-compose'



dockerCompose {

    useComposeFiles = ['docker/mysql/docker-compose.yml']

}



bootRun.dependsOn composeUp

componentTest.dependsOn composeUp

apiTest.dependsOn composeUp 

更多的测试分类配置细节,比如JaCoCo测试覆盖率配置等,请参考本文的示例项目代码。对Gradle不熟悉的读者可以参考笔者的Gradle学习系列文章。

日志处理


在日志处理中,除了完成基本配置外,还有2个需要考虑的点:

在日志中加入请求标识,便于链路追踪。在处理一个请求的过程中有时会输出多条日志,如果每条日志都共享统一的请求ID,那么在日志追踪时会更加方便。此时,可以使用Logback原生提供的MDC(Mapped Diagnostic Context)功能,创建一个RequestIdMdcFilter:


protected void doFilterInternal(HttpServletRequest request,

                                    HttpServletResponse response,

                                    FilterChain filterChain)

            throws ServletException, IOException {

        //request id in header may come from Gateway, eg. Nginx

        String headerRequestId = request.getHeader(HEADER_X_REQUEST_ID);

        MDC.put(REQUEST_ID, isNullOrEmpty(headerRequestId) ? newUuid() : headerRequestId);

        try {

            filterChain.doFilter(request, response);

        } finally {

            clearMdc();

        }

    } 

集中式日志管理,在多节点部署的场景下,各个节点的日志是分散的,为此可以引入诸如ELK之类的工具将日志统一输出到ElasticSearch中。本文的示例项目使用了RedisAppender将日志输出到Logstash:




    ecommerce-order-backend-${ACTIVE_PROFILE}

    elk.yourdomain.com

    6379

    whatever

    ecommerce-ordder-log

    true

    redis

 

当然,统一日志的方案还有很多,比如Splunk和Graylog等。

异常处理


在设计异常处理的框架时,需要考虑以下几点:

  • 向客户端提供格式统一的异常返回

  • 异常信息中应该包含足够多的上下文信息,最好是结构化的数据以便于客户端解析

  • 不同类型的异常应该包含唯一标识,以便客户端精确识别

异常处理通常有两种形式,一种是层级式的,即每种具体的异常都对应了一个异常类,这些类最终继承自某个父异常;另一种是单一式的,即整个程序中只有一个异常类,再以一个字段来区分不同的异常场景。层级式异常的好处是能够显式化异常含义,但是如果层级设计不好可能导致整个程序中充斥着大量的异常类;单一式的好处是简单,而其缺点在于表意性不够。

本文的示例项目使用了层级式异常,所有异常都继承自一个AppException:


public abstract class AppException extends RuntimeException {

    private final ErrorCode code;

    private final Map data = newHashMap();

} 

这里,ErrorCode枚举中包含了异常的唯一标识、HTTP状态码以及错误信息;而data字段表示各个异常的上下文信息。

在示例系统中,在没有找到订单时抛出异常:


public class OrderNotFoundException extends AppException {

    public OrderNotFoundException(OrderId orderId) {

        super(ErrorCode.ORDER_NOT_FOUND, ImmutableMap.of("orderId", orderId.toString()));

    }

} 

在返回异常给客户端时,通过一个ErrorDetail类来统一异常格式:


public final class ErrorDetail {

    private final ErrorCode code;

    private final int status;

    private final String message;

    private final String path;

    private final Instant timestamp;

    private final Map data = newHashMap();

} 

最终返回客户端的数据为:


{

  requestId: "d008ef46bb4f4cf19c9081ad50df33bd",

  error: {

    code: "ORDER_NOT_FOUND",

    status: 404,

    message: "没有找到订单",

    path: "/order",

    timestamp: 1555031270087,

    data: {

      orderId: "123456789"

    }

  }

} 

可以看到,ORDER_NOT_FOUNDdata中的数据结构是一一对应的,也即对于客户端来讲,如果发现了ORDER_NOT_FOUND,那么便可确定data中一定存在orderId字段,进而完成精确的结构化解析。

后台任务与分布式锁


除了即时完成客户端的请求外,系统中通常会有一些定时性的例行任务,比如定期地向用户发送邮件或者运行数据报表等;另外,有时从设计上我们会对请求进行异步化处理。此时,我们需要搭建后台任务相关基础设施。Spring原生提供了任务处理(TaskExecutor)和任务计划(TaskSchedulor)机制;而在分布式场景下,还需要引入分布式锁来解决并发冲突,为此我们引入一个轻量级的分布式锁框架ShedLock。

启用Spring任务配置如下:


@Configuration

@EnableAsync

@EnableScheduling

public class SchedulingConfiguration implements SchedulingConfigurer {



    @Override

    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {

        taskRegistrar.setScheduler(newScheduledThreadPool(10));

    }



    @Bean(destroyMethod = "shutdown")

    @Primary

    public TaskExecutor taskExecutor() {

        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);

        executor.setMaxPoolSize(5);

        executor.setQueueCapacity(10);

        executor.setTaskDecorator(new LogbackMdcTaskDecorator());

        executor.initialize();

        return executor;

    }



} 

然后配置Shedlock:


@Configuration

@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")

public class DistributedLockConfiguration {



    @Bean

    public LockProvider lockProvider(DataSource dataSource) {

        return new JdbcTemplateLockProvider(dataSource);

    }



    @Bean

    public DistributedLockExecutor distributedLockExecutor(LockProvider lockProvider) {

        return new DistributedLockExecutor(lockProvider);

    }



} 

实现后台任务处理:


@Scheduled(cron = "0 0/1 * * * ?")

    @SchedulerLock(name = "scheduledTask", lockAtMostFor = 

THIRTY_MIN, lockAtLeastFor = ONE_MIN)

    public void run() {

        logger.info("Run scheduled task.");

    } 

为了支持代码直接调用分布式锁,基于Shedlock的LockProvider创建DistributedLockExecutor:


public class DistributedLockExecutor {

    private final LockProvider lockProvider;



    public DistributedLockExecutor(LockProvider lockProvider) {

        this.lockProvider = lockProvider;

    }



    public  T executeWithLock(Supplier supplier, LockConfiguration configuration) {

        Optional lock = lockProvider.lock(configuration);

        if (!lock.isPresent()) {

            throw new LockAlreadyOccupiedException(configuration.getName());

        }



        try {

            return supplier.get();

        } finally {

            lock.get().unlock();

        }

    }



} 

使用时在代码中直接调用:


public String doBusiness() {

        return distributedLockExecutor.executeWithLock(() -> "Hello World.",

                new LockConfiguration("key", Instant.now().plusSeconds(60)));

    } 

本文的示例项目使用了基于JDBC的分布式锁,事实上任何提供原子操作的机制都可用于分布式锁,Shedlock还提供基于Redis、ZooKeeper和Hazelcast等的分布式锁实现机制。

统一代码风格


除了Checkstyle统一代码格式之外,项目中有些通用的公共的编码实践方式也需要在整个开发团队中进行统一,包括但不限于以下方面:

  • 客户端的请求数据类统一使用相同后缀,比如Command

  • 返回给客户端的数据统一使用相同后缀,比如Represetation

  • 统一对请求处理的流程框架,比如采用传统的3层架构或者DDD战术模式

  • 提供一致的异常返回(请参考“异常处理”小节)

  • 提供统一的分页结构类

  • 明确测试分类以及统一的测试基础类(请参考“自动化测试分类”小节)

静态代码检查


静态代码检查主要包含以下Gradle插件,具体配置请参考本文示例代码:

  • Checkstyle:用于检查代码格式,规范编码风格

  • Spotbugs:Findbugs的继承者

  • Dependency check:OWASP提供的Java类库安全性检查

  • Sonar:用于代码持续改进的跟踪

健康检查


健康检查主要用于以下场景:

  • 我们希望初步检查程序是否运行正常

  • 有些负载均衡软件会通过一个健康检查URL判断节点的可达性

此时,可以实现一个简单的API接口,该接口不受权限管控,可以公开访问。如果该接口返回HTTP的200状态码,便可初步认为程序运行正常。此外,我们还可以在该API中加入一些额外的信息,比如提交版本号、构建时间、部署时间等。

启动本文的示例项目:


./run.sh 



## 最后

以上分享的全部分布式技术专题+面试解析+相关的手写和学习的笔记pdf,**[高清完整版戳这里免费领取](https://gitee.com/vip204888/java-p7)**

还有更多Java笔记分享如下:

分类”小节)



[](https://gitee.com/vip204888/java-p7)静态代码检查

-------------------------------------------------------------------------



静态代码检查主要包含以下Gradle插件,具体配置请参考本文示例代码:



*   Checkstyle:用于检查代码格式,规范编码风格

*   Spotbugs:Findbugs的继承者

*   Dependency check:OWASP提供的Java类库安全性检查

*   Sonar:用于代码持续改进的跟踪



[](https://gitee.com/vip204888/java-p7)健康检查

-----------------------------------------------------------------------



健康检查主要用于以下场景:



*   我们希望初步检查程序是否运行正常

*   有些负载均衡软件会通过一个健康检查URL判断节点的可达性



此时,可以实现一个简单的API接口,该接口不受权限管控,可以公开访问。如果该接口返回HTTP的200状态码,便可初步认为程序运行正常。此外,我们还可以在该API中加入一些额外的信息,比如提交版本号、构建时间、部署时间等。



启动本文的示例项目:



./run.sh

最后

以上分享的全部分布式技术专题+面试解析+相关的手写和学习的笔记pdf,高清完整版戳这里免费领取

还有更多Java笔记分享如下:

image

你可能感兴趣的:(程序员,后端,java,面试)