0%

动机

人类对事物的认知过程是递归的。举个例子,我们如何识别一个物体是汽车,先看到一个物体,它有底盘、车架、外壳、内饰,看到这些我们觉得这可能是汽车,也可能知识汽车的模型。再打开机盖能看到发动机、变速箱,车底能看到车轮,我们进一步识别到这是一部真实的汽车。

模块化的汽车

随着我们一步步深入看到更多的汽车组成部分,我们便更确定这是一台汽车,这就是对一部汽车的递归认知过程。

而对于天气,它是一个异常复杂的系统,我们很难直观的对其进行递归分析,这也是我们无法精准预测天气的原因。人类对事物的认知能力是有极限的。

一辆汽车由数万个独立部件组成,很难说它不是个复杂系统,为什么我们可以很容易对它做递归分析。我们可以看到汽车是个高度模块化的系统,我们对它做递归分析的过程,实际上就是对其进行模块化拆解的过程。

直接分析复杂系统很困难,但只要它能够拆解为独立的更小的模块,我们总能够将之拆解到一个简单系统层次,然后再去分析识别,最终实现对这个复杂系统的完全认知

一个系统越容易进行递归分析,我们越容易理解它,进一步,也越容易改造它。对于汽车如此,对于软件系统亦如是。

软件工程的一个重要目标就是要令开发的软件系统更易扩展和维护,通过模块化方式构建一个系统,会令该系统易于理解,也就更容易实现这一目标。

额外的好处

除了令系统更容易被递归分析外,还具有以下好处:

  1. 可重用:具备抽象良好接口的模块,可以很容易被重用,特别是基础组件。业务系统研发过程中,通过不断优化模块最终沉淀到最下层的模块通常与具体具体的业务语意无关了,自然而然就变成了可被重用的公共组件。
  2. 可以灵活部署:不同的模块可以打包在一起部署,也可以利用SOA工具分布式部署。
  3. 富有弹性:模块可以独立进行资源伸缩,提升整个系统的弹性。例如:汽车动力不足,可以加装一个发动机,实现四驱(这也是现在大部分电动车的四驱方案)。可以提供备胎,替换破损的轮胎。

模块化的障碍

  1. 循环依赖:

    如果两个模块间有循环依赖,那么当我们通过递归分析时将陷入无限递归,永远得不到结果。两个模块如果有循环依赖,那么他们是无法拆分的,实际上他们只能作为一个模块来使用。

  2. 封闭性不足:

    如果一个模块不仅开放有限的接口,那么它将可能被其他模块随意依赖,其自身难以被修改,因为它被迫与其他模块进行深度绑定,最终仅能作为其他模块的一部分存在。

    例如:如果车轮直接依赖变速箱齿轮进行传动,那么我们只能将变速箱和车轮安装在一起,可以想象这种结构是非常难加装转向装置的。

    实际上模块应当遵循所有SOLID原则。

    S:Single responsibility/单一权责

    O:Open/Closed/开放封闭

    L:Liskov Substitution/替换原则

    I:interface-segregation/接口隔离

    D:Dependency inversion/依赖反转

  3. 无法自我管理:

    如果一个模块无法管理自身的组件及其数据,那么它将不得不与能够管理这些的模块集成在一起。也就是说它必须与管理这些内容的组件集成在一起。

    例如:如果汽车内燃气燃烧室的大小必须由底盘决定,那么我们永远无法制造出可以安装在任意车辆中的发东西。

    模块提供的能力特性,必须由自身定义,而不取决于外部环境

引言

打造便携(portable)的,轻量级的开发环境,是Docker吸引应用研发同学的主要原因。本文涉及的内容是我认为要实现这一目标,研发同学应当掌握的基本Docker知识。

本文内容源自Docker官方文档的翻译,简化和重组。下文中会有一些到官方文档的连接,方便希望进一步学习Docker知识的同学。

安装Docker Desktop for Mac

在mac中安装docker可以通过homebrew进行安装

1
2
3
brew cask install docker
## 验证安装是否成功
docker --help

也可以到参照文档进行手动下载安装

应用操作系统

我们开发一个应用通常需要如下依赖

  • 指定版本的操作系统,JDK,数据库
  • 经过一些调整的配置文件
  • 绑定指定的端口并占用一定数量的内存
  • 其他类似的依赖,比如环境变量

应用运行需要的这些组件和配置组织在一起,我们称之为”应用操作系统

你可以提供一个安装脚本来下载安装这些依赖组件和配置。Docker简化了这个过程,使用Docker可以创建一个包含你的应用和它的基础设施的镜像,然后可以在容器虚拟化平台上通过容器来运行这个镜像。

Docker引擎(Docker Engine)

Docker引擎是一个客户端-服务器应用程序,包含以下组件:

  • 一个服务端,它长期运行在后台,称为daemon进程,通过dockerd启动。
  • 一组用于跟daemon进程通信的REST API,用于给daemon发送指令。
  • 一个命令行接口客户端(CLI),使用docker命令启动。

Docker Engine Components Flow

命令行通过Docker REST API与daemon进程进行通信交互,向其发送脚本或命令。许多七大的Docker应用程序会使用底层API和命令行接口。

daemon进程会创建和管理Docker组件例如镜像(Images),容器(containers),网络(networks),卷(volume)

主要组件

Docker具有三个主要组件:

  1. 镜像是Docker的构建组件,它是定义了应用操作系统只读模板。
  2. 容器是从镜像创建的Docker运行组件。容器可以运行有,启动,停止,迁移和删除。
  3. 镜像是Docker的分发组件,被存储在注册服务中并通过该服务进行共享和管理。Docker-Hub是个公开可用的公共镜像注册服务。

为了令三种组件在一起工作,需要在一个主机上运行Docker Daemon(或Docker Engine),它会处理构建,运行,和分发容器的工作。另外,用户可以使用Docker客户端(Client)来与Docker Daemon进行命令交互。

Docker Architecture Diagram

客户端可以与本地主机或其他主机通信,使用pull命令来请求Docker Daemon从注册服务拉取镜像,然后Docker Daemon会从Docker Hub或配置的其他注册服务下载镜像并安装,最后客户端使用run来运行容器。

Docker镜像(Docker Image)

上文我们说过Docker容器是从只读的Docker镜像启动的。每个镜像由一系列的层组成,Docker利用联合文件系统来将这些层合并到一个单独镜像中。

Docker镜像的层结构,令Docker变得轻量。当你是用虚拟机的时候,如要更新应用到新版本,你需要替换应用程序包然后重新构建整个虚拟机镜像,使用Docker仅仅醒要添加或修改一个层。你不需要分发整个镜像,仅仅需要更新,这领分发Docker镜像变得更快,更简单。

每个镜像起始于一个基本镜像,例如ubuntufedora分别表示基本Ubuntu和Fedora操作系统的基本镜像。你也可以使用你自己的镜像作为基本镜像,例如如果你有一个基本Apache镜像,你可以用基于它构建新的应用镜像。

默认情况下,Docker从[Docker Hub](Docker makes use of union file systems to combine these layers into a single image)获取这些基本镜像。

Docker镜像使用简单易懂的指令集来从构建新镜像,其中RUN,COPY,ADD每个指令会在镜像中创建一个新的层,其他的指令会只会创建一个临时中间镜像,不会增加最终镜像的体积,例如:

  1. RUN 运行一个命令
  2. ADD,COPY 添加一个文件或文件夹
  3. ENV 创建一个环境变量
  4. ENTRY_POINT,CMD 在启动容器时运行一个进程

这些指令存储在Dockerfile文件中。构建镜像时Docker Daemon会读取Dockerfile文件,执行其中的指令,生成最终镜像。

假设有一个目录内容如下:

1
2
3
4
.
├── Dockerfile
├── app.py
└── requirements.txt

这个目录称为Docker Context,其中Dockerfile内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用官方的Python运行时镜像作为基本镜像
FROM python:2.7-slim

# 设置工作目录 /app
WORKDIR /app

# 拷贝当前目录内容到容器的/app目录中
COPY . /app

# 安装requirements.txt文件中指定的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 开放容器的80端口
EXPOSE 80

# 定义一个环境变量
ENV NAME World

# 容器启动时执行“python app.py”
CMD ["python", "app.py"]

使用这个Docker Context即可构建一个开放80端口,容器启动后执行python app.py的镜像。

完整的Dockerfile手册可以查看官方文档

构建命令如下:

1
docker build -t friendlyhello . # 注意最后的“.”表示使用当前目录作为Docker Context

Docker容器(Docker Container)

容器的基本结构

容器由一个操作系统,用户添加的文件和元数据组成。每个容器都是从一个镜像构建的。镜像告诉Docker引擎容器的内容、容器启动的时候要运行什么进程以及各种各样其他的配置数据。Docker镜像是只读的,当Docker从一个镜像运行一个容器时,会在镜像顶层添加一个读写层,以便于你的应用可以正常运行读写数据。这个读写层与容器的生命周期相同,如果应用要持久化保存数据需要通过mount机制来与主机存储通信。

容器和镜像的关系如下图所示:

Layers of a container based on the Ubuntu image

从镜像运行容器的命令如下:

1
docker run -it friendlyhello

容器与虚拟机对比

开发人员可以简单的任务应用程序在容器中运行跟在虚拟机中运行的情况大体一致。但容器和虚拟机还是有本质区别的。

容器原生运行在Linux操作系统中,与主机上的其他容器共享内核。运行运行一个独立的进程,内存占用与系统中执行的其他进程无异,这令容器非常轻量。

与之对比的是虚拟机,它运行一个完整的客户端操作系统通过虚拟化技术(hypervisor)访问主机资源。通常虚拟机比大多数其他类型的应用程序要占用更多的资源来提供一个隔离的运行环境。

image-20190522103006562

Docker 虚拟机
启动速度 几秒 几分钟
资源占用 普通进程资源 GustOS需要占用更多额外资源
性能 直接跟系统内核交互几乎无损耗 通过Hypervisor访问资源带来额外消耗
隔离 进程级别 系统级别
安全性 工宿主机共享用户权限,安全性差 GustOS具有独立用户权限,安全性更高
可管理性 云原生等概念促使相关工具迅速发展(例如k8s) 发展多年已经比较成熟
创建/删除 秒级别创建,适合快速迭代,节省大量时间 分钟级别
分发/部署 简单的Dockerfile,以及Docker Registry
提供了快速分发能力
通过镜像保证分发环境一致
通过虚拟机镜像分发,保证环境一致
没有Registry,无法体系化
资源限制 Linux的Control Group(cgroup)
部分应用无法正确识别到,比如Java7之前
Hypervisor层的资源限制
镜像 小,几MB~几百MB,不可变 大,数GB,需要配置管理

Docker后台常驻进程(Docker Daemon)

在你的电脑上安装Docker的同时会安装Docker主机(docker host)。一旦创建了Docker主机就可以通过它管理镜像和容器。例如下载镜像,启停、重启容器。

Docker客户端(Docker Client)

客户端与Docker主机通信,令用户可以操作镜像和容器。

可以通过如下命令检查当前主机上安装的Docker是否可以正常工作:

1
2
3
4
docker -v

# 正常会输出类似下面的文本:
Docker version 17.09.0-ce-rc3, build 2357fb2

输出的版本号会随安装的Docker程序包变化。

准确的客户端和服务端版本可以使用docker version命令查看,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Client:
Version: 17.09.0-ce-rc3
API version: 1.32
Go version: go1.8.3
Git commit: 2357fb2
Built: Thu Sep 21 02:31:18 2017
OS/Arch: darwin/amd64

Server:
Version: 17.09.0-ce-rc3
API version: 1.32 (minimum version 1.12)
Go version: go1.8.3
Git commit: 2357fb2
Built: Thu Sep 21 02:36:52 2017
OS/Arch: linux/amd64
Experimental: true

完整的指令列表可以使用docker --help命令查看,下面是常用命令表

功能 命令
镜像相关
当前目录作为Context构建镜像 docker image build --rm=true .
拉取镜像安装到本地 docker image pull ${IMAGE}
列出本地安装的镜像 docker image ls
列出本地安装的镜像(详细信息) docker image ls --no-trunc
删除本地镜像 docker image rm ${IMAGE_ID}
删除所有无用的镜像(没有创建容器) docker image prune
删除所有本地镜像 docker image rm $(docker image ls -aq)
容器相关
运行容器 docker container run
列出所有运行中的容器 docker container ls
List of all containers docker container ls -a
停止容器 docker container stop ${CID}
停止所有运行的容器 docker container stop $(docker container ls -q)
列出所有退出码为1的容器 docker container ls -a --filter "exited=1"
移除容器 docker container rm ${CID}
移除所有容器 docker container rm $(docker container ls -aq)
查找运行容器的ip地址 docker container inspect --format '{{ NetworkSettings.IPAddress }}' ${CID}
关联到容器 docker container attach ${CID}
打开一个容器的命令行 docker container exec -it ${CID} bash

Docker应用数据管理

Docker容器这个章节中有提到要持久化保存应用数据需要使用mount机制,本章详细说明Docker的应用数据管理。

默认情况下容器中创建的所有文件存储在容器的读写层上,这意味着:

  • 当容器退出后数据不能持久化,并且容器外部的其他进程是很难访问到容器内部的数据的。
  • 容器的读写层与容器运行的主机紧密耦合,其中的数据很难移动到其他什么地方。
  • 痛惜需要使用存储驱动来管理文件系统,存储驱动会利用Linux内核提供一个统一文件系统。这个附加的抽象层降低了性能。

Docker有两种方式来直接让容器存储文件到主机上,以便于文件即使在容器停止后也能吃就存储:volumes和bind mounts。如果是在Linux主机上运行Docker,那么还可以使用tmpfs。

三种方式和容器的关系如下图所示:

types of mounts and where they live on the Docker host

  • Volumes存储在主机文件系统上的(/var/lib/docker/volumes/ on Linux),由Docker管理.。非Docker进程不应修改。Volumes是持久化数据的最佳方式。

    1
    2
    docker volume create # 创建volume
    docker volume prune # 移除无用的volume
  • Bind mounts可以存储数据到主机系统的任意位置(内存或文件系统)。他们甚至有可能是重要的系统文件或目录。主机上的非Docker进程或Docker容器可以随时修改这些文件或目录。

  • tmpfs mounts只存储在主机系统的内存中,永远不会写的主机系统文件系统中。

Volumes使用场景

Volumes是Docker容器和服务持久化数据的推荐方式,一些用例如下:

  • 多个运行的容器共享数据。如果没有显式创建Volume,volume会在第一次挂载到同期上的时候被创建。当同期停止或被移除时,这个volume依然存在。多个容器可以同时以读写或只读方式挂载同一个volume。Volumes只会能人工显式使用命令移除。
  • 如果Docker主机不一定有只能结构的文件夹或文件,Volumes会帮你解耦Docker主机和容器运行时的配置。
  • 如果你想存储容器的数据到远程主机或云服务上
  • 如果你需要备份备份,恢复或迁移数据从一个Docker主机到另一个,Volumes是更好的选择。你可以停止使用volume的容器,然后备份volume的文件夹(例如:/var/lib/docker/volumes/<volume-name>)

Bind mounts使用场景

一般来说应该尽可能使用Volumes,Bind mounts适用于一下用例:

  • 共享主机的配置文件给容器。默认情况下Docker通过挂载主机的/etc/resolv.conf文件给每个容器来提供DNS解析给容器。

  • 在主机上的开发环境和容器间共享源代码或构件。例如你可以挂载Maven项目的target/到一个容器,这样每次在主机上构建Maven项目,容器就可以访问到重新构建的构件。

    如果在开发环境中一这种方式使用Docker,需要确保生产环境使用的Dockerfile将生产环境的构件直接拷贝到镜像中,而不能依赖bind mount

  • 当Docker主机上的文件或目录结构能够确保与容器需要的一致时,可以使用bind mount

tmpfs mounts使用场景

如果你不想数据持久化存储在主机上或容器内,tmpfs mount是最佳方式。这可能是由于安全原因比如临时使用敏感数据,或当你的应用需要写大量的非持久化数据时提升容器的性能。

Docker网络

Docker容器和服务之所以这么强大的一个原因是可以将多个容器或服务连接在一起,甚至可以将容器和非容器进程连接在一起。Docker容器和服务不需要意识到他们是部署在Docker上的,也不关心与他们连接的是否是Docker容器。无论你的Docker主机运行在Linux,Window或者两者混合,你都可以使用Docker一平台无关的方式管理他们。

网络驱动

Docker的网络子系统是插件式的,默认的几个驱动提供了核心的网络功能:

  • bridge(桥接):默认的网络驱动。如果不明确指定,它就是你创建的网络的类型。当你的应用运行在一些相互通信的独立容器中时,通常使用桥接网络。
  • host(主机):对于独立的容器,移除容器和Docker主机的网络隔离,直接使用主机的网络。host只对Docker 17.06或以上的swarm服务可用。
  • overlay(叠加):叠加网络将多个Docker daemon连接在一起,令swarm服务可以相互通信。也可以使用叠加网络令swarm服务或独立容器,或两个不同Docker daemon上的独立容器可以通信。这个策略不需要在多个容器间进行操作系统级别的路由。
  • macvlan:Maclan网络允许给一个容器分配一个MAC地址,令其在你的网络表现的像一个物理设备一样。Docker daemon将靠MAC地址来在容器间路由数据。当你的应用希望直接与物理网络通信时,使用macvlan或许是最佳选择。
  • none:禁止网络通信。通常与自定义网络驱动结合使用。none不能使用在swarm服务上。
  • Network plugins:Docker可以安装和使用第三方网络插件。这些插件可以同Docker Hub获取,由第三方开发商提供。

由于网络通常与容器编排有关,而容器编排是另外一个比较复杂的主题,所以在此仅对比较简单的bridge网络做进一步阐述。该类型网络的开发环境特别用有。

使用bridge网络

桥接网络只对运行在同一个Docker deamon上的容器有效。对于不同Docker daemon上的容器通信,你可以管理操作系统级别的路由或使用overlay网络。

往Docker启动,会自动创建一个默认桥接网络(Default Bridge Network,下文简称DBN),新启动的容器如果没有特别指定会自动连接到这个默认桥接网络上。你也可以创建用户自定义的桥接网络(User-Defined Bridge Network,下文简称UDBN)。应当优先使用用户定义的桥接网络。

img

用户定义的与默认桥接网络区别

  • UDBN提供更好的隔离性和容器间的交互性。

    连接到同一个UDBN的容器相互暴露所有的端口,但对外部世界不暴露任何端口。这让容器化的应用很容易与彼此通信,又不会意外对外部世界开放访问权限。

    想象一个场景,一个应用有一个web前端和一个数据库后端。外部世界需要通过80端口访问web前端,但是只有后端自己需要访问数据的主机和端口。利用UDBN只有web端口需要被开放,数据库应用不需要开放任何端口,因为web前端可以通过UDBN访问它。

    如果在DBN上运行同样的应用,你需要同时使用-p--publish开放web端口和数据库端口,这意味着Docker主机需要通过其它方式组织对数据库端口的访问。

  • UDBN在容器间提供自动DNS解析

    在DBN上的容器只能通过IP地址进行彼此的访问,除非你使用--link来连接容器,这个参数已经过时了。在UDBN上容器可以通过名称和别名来解析彼此。

    再看前面提到的场景,如果将前端和后端容器分别命名为webdbweb容器可以通过db这个名称来访问数据库。

  • 容器可以在运行中和UDBN连接或分离

    在整个容器的声明周期内,你可以随时与UDBN连接或断开。要从DBN中移除一个容器,需要停止通知并使用不同的网络选项重建它。

  • 每个UDBN创建一个可配置的桥接

    如果你的容器使用DBN,你可以配置它,但是所有的容器只能使用相同的设置,例如MTUiptables规则。另外,只能在Docker外部进行设置,需要重启Docker才能生效。

    UDBN使用docker network create创建和设置。如果不同组的应用有不同的网络需求,你可以为它们独立配置UDBN。

  • 在DBN上关联的容器会共享环境变量

    起初在两个容器间共享环境变量的唯一方式是使用--link来连接他们。UDBN是不能以这种方式共享变量的。然而有更好的方式来共享环境变量。例如:

    • 多个容器使用一个Docker volume挂载一个包含共享信息的文件或目录。
    • 多个容器可以使用docker-compose一起启动,在compse文件中可以定义共享变量。

连接到同一个UDBN的容器跑彼此暴露所有的端口。对于需要被外部世界访问的端口必须使用-p--publish进行公开发布。

管理UDBN

使用docker network create命令创建UDBN

1
docker network create my-net

你可以指定子网,IP地址范围,网关和其他选项。可以通过docker network create —help查看具体细节

使用docker network rm命令移除UDBN。如果有容器正连接到要移除的网络,需要先断开连接。

1
docker network rm my-net

连接容器到UDBN

创建新容器时可以通过一个或多个--network选项来指定要连接到的网络。下面这个例子连接一个Nginx容器到my-net网络。同时公开了容器的80端口到Docker主机的8080端口映射,这样外部的客户端可以通过主机的8080端口访问到容器的80端口。连接到my-net网络的其他容器可以通过my-nginx这个名称访问所有的端口。

1
2
3
4
docker create --name my-nginx \
--network my-net \
--publish 8080:80 \
nginx:latest

要连接一个正在运行的容器到已存在的UDBN,使用docker network connect命令。下面的命令连接已经运行的my-nginx容器到已存在的my-net网络。

1
docker network connect my-net my-nginx

断开容器和UDBN的连接

要断开一个正在运行的容器到UDBN的链接,使用docker network disconnect命令。下面的将my-nginx容器从my-net网络中断开。

1
docker network disconnect my-net my-nginx

本文内容承接上一篇:在开发中使用Docker

运行多个容器

下面我们用一个稍微复杂点儿的服务来演示如何搭配使用多个容器。演示使用的计数器服务由web服务,保存计数用的Redis实例,以及保存业务数据的Mysql实例组成,共运行三个容器。

使用Docker运行mysql和redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建一个自定义bridge network,后续所有的容器都连接到这个网络上以便相互访问
docker network create counter-net
# 运行mysql 5,该命令会自动从Docker Hub拉取镜像
docker run --rm \
--network counter-net \
--name mysql \
-e MYSQL_ROOT_PASSWORD=12345678 \
-d mysql:5

# 运行redis,该命令会自动从Docker Hub拉去镜像redis
docker run --rm \
--name redis \
--network counter-net \
-d redis

构建simple-counter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 从samples仓库中签出simple-counter
git init simple-counter
cd simple-counter
git remote add origin git@github.com:hadix-lin/samples.git
git config core.sparsecheckout true
echo "simple-counter/" > .git/info/sparse-checkout
git pull origin master
# 构建simple-counter
mvn package
cp target/simple-counter-1.0-SNAPSHOT.jar docker-image/
cd docker-image
# 在docker-image文件夹中有Dockerfile文件
docker build -t simple-counter .
docker run -p 8080:8080 \
--rm \
--name simple-counter \
--network counter-net \
-d simple-counter

simple-counter/docker-image/Dockerfile内容如下:

1
2
3
4
FROM openjdk:8
COPY simple-counter-1.0-SNAPSHOT.jar app.jar
EXPOSE 8080/TCP
CMD ["java","-jar","app.jar"]

然后浏览器访问http://127.0.0.1:8080/可以得到如下响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"hostSummary": { //数据库主机信息
"statement_avg_latency": "175.19 us",
"current_connections": "12",
"total_connections": "24",
"unique_users": "1",
"current_memory": "0 bytes",
"file_ios": "1393",
"host": "172.17.0.1",
"total_memory_allocated": "0 bytes",
"statements": "3407",
"file_io_latency": "3.85 ms",
"statement_latency": "596.86 ms",
"table_scans": "217"
},
"counter": 24 //每次访问计数器+1
}

要关闭服务使用如下命令:

1
2
3
4
5
6
7
8
docker stop <container id or name>
# 本文启动了三个容器,故需要下面三条指令来关闭容器
docker stop mysql
docker stop redis
docker stop simple-counter

#获取container id or name
docker container ls

默认情况下,同一个主机上运行的docker容器会加入一个默认的bridge网络,可以通过容器的ip地址互相访问。但是镜像构建时是无法知道依赖容器的ip地址的,在上面的示例中是通过”mysql”,”redis”这样的hostname进行访问的,所以在此需要使用docker network create <net-name> 来创建自定义网络,自定义网络会为加入该网络的容器提供自动DNS解析,容器可以通过名称互相访问。

在上面的示例中,simple-counter中有如下配置来控制redis和mysql连接:

1
2
spring.datasource.url=jdbc:mysql://mysql:3306/sys
spring.redis.host=redis

由于以上配置的存在,要让simple-counter正常运行,redis和mysql的容器名称必须与配置中使用的主机名称一致。

使用docker-compose

上面的命令比较繁琐,可以提前准备一个docker-compose.yml文件来进行管理。

simple-counter/docker-image/docker-compose.yml内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
version: "3"
services:
redis: # 服务名
image: redis # 要部署的镜像
networks:
- counter-net
mysql:
image: mysql:5 # 要部署的镜像
environment:
- MYSQL_ROOT_PASSWORD=12345678
networks:
- counter-net
web:
build: . # 构建镜像使用的context
ports:
- "8080:8080"
networks:
- counter-net
networks: # 定义网络,相当于docker network create <net-name>
counter-net:

该文件定义了三个服务redis、mysql、web。其中web是通过simple-counter/docker-image/Dockerfile构建的。

有了这个文件后,运行simple-counter服务仅需要如下命令即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 从samples仓库中签出simple-counter
git init simple-counter
cd simple-counter
git remote add origin git@github.com:hadix-lin/samples.git
git config core.sparsecheckout true
echo "simple-counter/" > .git/info/sparse-checkout
git pull origin master
# 构建simple-counter
mvn package
cp target/simple-counter-1.0-SNAPSHOT.jar docker-image/
## 如果之前已经构建过simple-counter前面都可以跳过
cd docker-image
docker-compose up

关闭服务使用

1
2
cd docker-image
docker-compose down

命令总结

1
2
3
4
5
6
7
8
9
# 创建自定义网络
docker network create <net_name>
# 运行容器并加入网络
docker run --network <net_name> [other_params] <image:tag>
# 列出所有运行的容器
docker container ls
# 使用编排文件管理多个容器
docker-compose up
docker-compose down

引言

随着微服务和云原生概念的盛行,未来的服务端架构将越来越多得将项目构建为Docker镜像来进行部署。在开发和集成构建阶段使用Docker成为服务端开发人员的必须技能。

本文主要关注在开发阶段使用Docker,所以不包括容器编排,集群网络,负载均衡等分布式相关的内容。有关分布式主题相关内容可以通过学习kubernetes来理解。

安装Docker Desktop for Mac

在mac中安装docker可以通过homebrew进行安装

1
2
3
brew cask install docker
## 验证安装是否成功
docker --help

也可以到参照文档进行手动下载安装

示例项目准备

本文以SpringFox-Plus这个项目的示例模块为例,该项目使用kotlin开发,提供Swagger-API接口文档的WEB服务。

  1. 下载并构建该项目

    1
    2
    3
    git clone https://github.com/hadix-lin/springfox-plus.git
    cd springfox-plus/springfox-plus-sample
    mvn package -P with-api-doc
  2. 准备Docker镜像

    1
    2
    3
    4
    5
    echo $(pwd) # 接第一步中的命令,当前workdir应该为springfox-plus-sample
    mkdir docker-image
    cp target/springfox-plus-sample-1.0.1.jar docker-image/sample.jar
    cd docker-image
    vim Dockerfile

    编辑Dockerfile文件内容如下:

    1
    2
    3
    4
    5
    FROM openjdk:8
    COPY . /app
    WORKDIR /app
    EXPOSE 8080/tcp
    CMD ["java","-jar","sample.jar"]

    开始构建镜像

    1
    docker build -t springfox-plus:sample .

运行

前面的操作已经准备好了Docker镜像,下面开始让应用运行起来

1
2
3
4
5
6
7
8
9
docker run  # 运行指令
-d\ # 后台运行(detached)
-p 8080:8080\ # 绑定主机和容器的端口
--rm\ # 容器退出后自动清除容器
--name sample # 指定容器名称
springfox-plus:sample # 要运行的镜像

# 将命令写在单行
docker run -d -p 8080:8080 --rm --name sample springfox-plus:sample

然后使用浏览器访问地址[http://127.0.0.1:8080/swagger-ui.html](http://127.0.0.1:8080/swagger-ui.html]即可访问swagger-ui,如下图所示:

image-20190514180551496

改进开发流程

上文的构建->运行的流程比较适合将发布到生产环境的时候使用,在开发阶段,我们会经常进行修改测试,如果每次都要重新构建镜像然后运行,就会比较繁琐。我们期望在开发阶段只构建一次镜像,该镜像保持和生产一直的配置,但是应用包本身不需要包含在其中,这样更改应用包就不需要重新构建镜像了。

可以通过VOLUME来将让容器直接访问本地的应用,下面开发修改Dockerfile为如下内容:

1
2
3
4
5
FROM openjdk:8
RUN mkdir /app
WORKDIR /app
EXPOSE 8080/TCP
CMD ["java","-jar","springfox-plus-sample-1.0.1.jar"]

使用上面的Dockerfile构建一个新的Docker镜像,然后使用—mount参数来运行

1
2
3
4
5
6
7
8
9
# workdir = docker-image
docker build -t springfox-plus:sample-use-mount .
cd ..
# workdir = springfox-plus-sample
docker run -d -p 8080:8080 --name sample-use-mount --mount \
type=bind,source="$(pwd)"/target,target=/app,readonly \
springfox-plus:sample-use-mount
# 停止容器运行
docker stop sample-use-mount

这样每次更改应用包只要使用mvn package重新打包,然后使用上面的命令重启容器即可,避免了重新构建镜像。

如果使用IntellijIDEA,那么可以通过Run Configuration进行配置:

image-20190514194653182

可以直接将mvn package配置到Before launch的列表中,这样每次使用这个Run Configuration运行docker容器时都会重新构建应用包,可以进一步减少开发过程中的人工操作。

调试

应用运行在Docker容器中,对开发者来说可以视为运行在另外远程服务器上,所以可以通过远程调试端口来进行调试。

只需要更改Dockerfile中的CMD,在入口命令加入java远程调试的相关设置即可。例如:

1
2
3
4
5
6
FROM openjdk:8
COPY . /app
WORKDIR /app
EXPOSE 8080/tcp
CMD ["java","-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005",\
"-jar","sample.jar"]

重新构建镜像并运行,即可通过端口5005进行远程调试了。

在IntellijIDEA中配置远程调试,如图:

image-20190514195906722

访问运行的容器

在上文中运行容器我们使用的命令是docker run -d …… 其中的-d参数表示后台运行容器,此时我们看不到容器中运行的应用的标准输入/输出。

使用docker attach <container-name>可以将当前终端的标准输入/输出/错误流关联到指定的容器上。执行该命令后,就可以在当前终端中看到容器的控制台输出了。

如果想在容器中运行命令可以使用docker exec [OPTIONS] CONTAINER COMMAND [ARG…] ,进一步可以利用该命令获得与运行的容器进行交互的能力。

如果容器运行的镜像中包含bash命令,可以使用docker exec -it CONTAINER bash来在容器中运行bash,并将当前终端的标准输入/输出/错误流关联到容器。这样就可以通过命令行跟运行的容器进行交互了,在这个命令行中能够使用的命令取决于运行的镜像内容。

有关docker命令的具体使用可以使用docker help COMMAND来查看使用说明,可以参考文档

命令总结

1
2
3
4
5
6
7
8
9
10
11
# 验证docker安装,输出使用说明
docker --help
# 构建镜像
docker build -t <tag> <context_path>
# 启动容器
docker run -d -p <container_port>:<host_port> --name <container_name> \
--mount <mount_params> <image:tag>
# 停止容器
docker stop <container_name or id>
# 将标准输入、输出、错误流关联到运行的容器
docker attach <container_name or id>

后续内容在开发中使用Docker-续.md

我算是个IDEA资深用户了,应该有10年了吧.

IDEA默认提供的插件功能已经相当丰富,对流行的开源框架支持非常好.

下面说一下我安装的非默认插件:

IdeaVim

在IDEA的编辑器使用vim编辑模式,如果你想尝试脱离鼠标做全键盘操作,这个就是神器.

IdeaVim - Plugins | JetBrains

IdeaVimExtension

作为上面插件的扩展,只提供一个功能,就是在normal模式的时候保持输入法是英文状态.你知道中文用户总是有各种场景需要切换输入法的.

IdeaVimExtension - Plugins | JetBrains

Google Java Format

按照Google的Java代码规范格式化代码,有了这个插件就不用在IDE里面这种配置了,这个插件帮助搞定一切,这个插件格式化的效果我非常喜欢,代码紧凑,易读.如果团队没有特别的需求,可以利用这个插件做团队代码规范实施.

google-java-format - Plugins | JetBrains

Lombok Plugin

lombok工具可以通过注解处理器来生成样板代码,比如JavaBean的getter,setter.这个插件帮助IDE识别自动生成的代码.非常好用的工具,一定要安装.

Lombok Plugin - Plugins | JetBrains

Maven Helper

maven的扩展工具,maven用户一定要安装!!!.用来分析依赖冲突相当好用.IDEA自带的依赖图真的很难用.

Maven Helper - Plugins | JetBrains

.ignore

这个插件帮助你编写各种vcs系统的ignore文件,例如.gitignore.提供语法高亮,并可以将忽略的文件和文件夹用特别的颜色标记出来.

.ignore - Plugins | JetBrains

String Manipulation

提供非常多的文本操作,非常好用,我经常用这个来编写分表的SQL语句,结合IDEA的列编辑功能批量生成序列号之类的操作骚得很.

String Manipulation - Plugins | JetBrains

Custom Postfix Templates

IDEA的后缀自动完成功能特别好用,比如写一个表达式可以通过接一个.var自动声明变量.这个插件提供了非常多的后缀模板,而且支持在线更新,功能非常丰富,支持多种编程语言..toString,toInt,toLong….

Custom Postfix Templates - Plugins | JetBrains

Free Mybatis Plugin

mybatis插件的免费支持框架,提供xml mapper代码检查和导航等功能.这个免费插件的功能对我而言够用了.还有个收费的MbatisPlugin,功能更丰富,但是我没有特别强的需求用不上.

另外再次推荐一个Myabtis的扩展框架MybatisPlus非常好用.

Free MyBatis plugin - Plugins | JetBrains

Mybatis Log Plugin

将myabtis输出的日志整理成更易读的形式进行输出,在调试sql语句的时候还挺有用的.个人用的不是很多.

MyBatis Log Plugin - Plugins | JetBrains

Grep Console

对控制台输出的日志按日志级别进行高亮,可以进行很多自定义,我用的比较简单,安装之后就使用了默认配置.

Grep Console - Plugins | JetBrains

BashSupport

编写bash标本的辅助工具.个人不怎么常用,偶尔用来看一下别人写的脚本,有语法高亮.

BashSupport - Plugins | JetBrains

HotSwapAgent

JVM原有的HotSwap功能可以在类型和方法定义未变更的情况下对类进行重加载,在调试时非常有用.这个插件可以支持类型变更比如添加属性,方法之后进行重加载,也提供了一些框架的支持,有一定效果,但是支持不完善.用得不多.

HotSwapAgent - Plugins | JetBrains

Key Promoter X

利用这个插件来熟悉IDEA的快捷键,每当你用鼠标单击UI上的功能按钮的时候,这个插件会通过右下角的气泡消息提示你对应的快捷键是什么.还会统计次数.

Key Promoter X - Plugins | JetBrains

Kotlin Sequence Debugger

提供类型Java Stream Debugger的功能,在调试的时候可以针对.filter{}.foreach{}这样的代码进行调试,对每一步操作提供详细的视图,Java8和kotlin的用户强烈建议使用这个插件.

Kotlin Sequence Debugger - Plugins | JetBrains

MetricsReloaded

提供了各种算法来评估代码的复杂度,对代码质量评估有帮助.关注代码质量的用户请一定试用一下.

MetricsReloaded - Plugins | JetBrains

Rainbow Brackets

增强的括号匹配插件,将配对的括号标记成各种各样的颜色,配色非常好看.对代码阅读有一定帮助.

Rainbow Brackets - Plugins | JetBrains

Translation

最近才安装的插件,利用Google,有道或百度的API将翻译功能集成到IDE里面,英文不是很好的朋友建议安装.翻译个单词,找个变量名什么的还是挺方便的.

不过使用GoogleApi需要翻墙,有道和百度需要申请应用开发者账号,而且可能还要被收费.

Translation - Plugins | JetBrains

Settings Repository

这个插件是自带的,开启之后可以将IDEA的配置同步到git仓库(比如github).

使用说明再次https://www.jetbrains.com/help/idea/sharing-your-ide-settings.html#settings-repository

最后附上我的IDEA配置仓库:

GitHub仓库主页: https://github.com/hadix-lin/idea-setting

仓库地址: https://github.com/hadix-lin/idea-setting.git

动机

Intellij IDEA是个非常优秀的开发环境,提供了相当好用的快捷键,让用户有机会脱离鼠标来使用IDE.

另外官方还提供了IdeaVim插件,熟悉vim的用户可以使用几乎与vim相同的方式操作IDEA的编辑器来编写代码.但是作为非英文用户,却有一个”输入法切换”的痛点.

考虑如下两种情况:

  1. 编辑器中在insert模式下,编写了一段中文注释,然后需要回到normal模式移动光标到其他位置继续编写代码.
  2. 正在编辑器normal模式下操作,此时突然有紧急邮件或者钉钉消息需要回复,输入法切换为中文回复后再回到编辑器.

类似上面两种场景,当回到normal模式编辑器时,我们都不得不人工操作一次输入法切换,即使您已经为输入法切换设置了非常好用的快捷键(按一下Shift),这个操作依然是恼人的,经常会忘记,输入几个字符后才发现,这可能带来误操作.

我们的期望只有一个:

在任何情况下,进入normal模式,输入法必是英文状态.

因此我基于IdeaVim的扩展点开发了一个帮助切换输入法的小插件IdeaVimExtension

IdeaVimExtension安装使用

  1. 如果您已经是IdeaVim的用户,那么直接在Intellj IDEA的插件中心搜索IdeaVimExtension进行安装.或者到IdeaVimExtension插件主页进行下载安装.

    IdeaVimExtension是依赖IdeaVim的,需要事先安装IdeaVim

  2. 确保你的操作系统已经开启了英文输入法

    • Windows需要开启en_USimage-20190320104934233输入法

    • macOS需要开启ABCimage-20190320104604867

      或en_USimage-20190320104634422输入法

    • Linux 需要使用小企鹅输入法(Fcitx)

  3. 安装重启IDEA后,输入法自动切换功能会默认启用

也可以通过打开任意代码编辑器在normal模式下输入如下两个命令来激活IdeaVimExtension插件

  • :set keep-english-in-normal : 在normal模式保持英文状态(这也是默认启用的状态)
  • :set keep-english-in-normal-and-restore-in-insert : 在normal模式保持英文状态,并在回到insert时恢复输入法到原来的状态.例如,编写一段中文注释,用中文输入法写了一段文字,进入normal模式移动光标到下一行,再回到插入模式继续使用中文编辑.
  1. 上面两个命令在每次IDEA重启后都需要重新输入,也可以通过在用户目录下添加 .ideavimrc文件,将命令添加到该文件中,这样在IDEA重启时可以自动执行该文件中的指令.另外,该文件中也可以添加其他受支持的vim指令.类似vim的.vimrc文件.

  2. 如果要关闭输入法切换功能直接直接输入对应的:set nokeep-english-in-normal[-and-restore-in-insert]

接下来就可以愉快的使用全键盘操作来编写代码了.

额外一点分享

相信很多同学都是使用苹果电脑的.苹果电脑的键盘设计对vim用户越来越不友好了.对vim用户来说ESC是个非常高频的按键.但是苹果键盘的ESC设计比较小,且位置偏远,有bar的新MBP的ESC是虚拟的基本没有任何按键反馈.我的处理方法是使用karabiner软件将ESC和不常用的~进行交换.

另外键盘上的方向键,新MBP的上下被缩小到一个按键的面积偏小,且需要移动右手去按,对编写代码这样的文字工作是不友好的在使用vim编辑器是可以通过hkjl来替换,但也总有非vim环境需要使用方向键,比如IDEA的Project视图等.我的处理方法是设置Ctrl+hkjl来替换方向键,为了纠正习惯将原始的方向键禁用了.

最后CapsLock键用处不大,有时会误按造成vim-normal模式下的误操作,所以我将该键跟左下角的Ctrl交换了.这样既改善了Ctrl的按键体验,也可以避免误按CapsLock

Windows用户请自行寻找键位映射工具.

在此提供我的配置文件,可以直接copy到~/.config/karabiner/karabiner.json来使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
{
"global": {
"check_for_updates_on_startup": true,
"show_in_menu_bar": false,
"show_profile_name_in_menu_bar": false
},
"profiles": [
{
"complex_modifications": {
"parameters": {
"basic.simultaneous_threshold_milliseconds": 50,
"basic.to_delayed_action_delay_milliseconds": 500,
"basic.to_if_alone_timeout_milliseconds": 1000,
"basic.to_if_held_down_threshold_milliseconds": 500
},
"rules": [
{
"description": "Vi Mode [left_control + hjkl]",
"manipulators": [
{
"from": {
"key_code": "h",
"modifiers": {
"mandatory": [
"control"
],
"optional": [
"caps_lock",
"command",
"option",
"shift",
"fn"
]
}
},
"to": [
{
"key_code": "left_arrow"
}
],
"type": "basic"
},
{
"from": {
"key_code": "j",
"modifiers": {
"mandatory": [
"control"
],
"optional": [
"caps_lock",
"command",
"option",
"shift",
"fn"
]
}
},
"to": [
{
"key_code": "down_arrow"
}
],
"type": "basic"
},
{
"from": {
"key_code": "k",
"modifiers": {
"mandatory": [
"control"
],
"optional": [
"caps_lock",
"command",
"option",
"shift",
"fn"
]
}
},
"to": [
{
"key_code": "up_arrow"
}
],
"type": "basic"
},
{
"from": {
"key_code": "l",
"modifiers": {
"mandatory": [
"control"
],
"optional": [
"caps_lock",
"command",
"option",
"shift",
"fn"
]
}
},
"to": [
{
"key_code": "right_arrow"
}
],
"type": "basic"
}
]
}
]
},
"name": "Default profile",
"selected": true,
"simple_modifications": [
{
"from": {
"key_code": "caps_lock"
},
"to": {
"key_code": "left_control"
}
},
{
"from": {
"key_code": "escape"
},
"to": {
"key_code": "grave_accent_and_tilde"
}
},
{
"from": {
"key_code": "grave_accent_and_tilde"
},
"to": {
"key_code": "escape"
}
},
{
"from": {
"key_code": "left_control"
},
"to": {
"key_code": "caps_lock"
}
},
{
"from": {
"key_code": "right_command"
},
"to": {
"key_code": "left_control"
}
}
],
"virtual_hid_keyboard": {
"country_code": 0
}
}
]
}

引言

如果您有一个开源项目,为了让更多人轻松获取使用,那么发布到maven中央仓库是必然选择.本文是我自己发布springfox-plus项目的记录.包含整个过程中我遇到的麻烦.希望可以给其他有同样需求的朋友提供一些参考.

可能您使用过公司的maven仓库,按照公司的流程,可以很容通过maven deploy将自己的构件发布到仓库中,公司的同事就可以用了.但是中央仓库是不能够直接发布的,必须通过先将构件发布到授信的第三方仓库,经过审核后才能同步到中央仓库.

本文选择的第三方仓库是sonatype.它也是中央仓库的管理者.英文好的朋友可以自行到它的网站上去看详尽的发布说明.本文涵盖的内容除了基本的发布方法外,还包含我遇到的麻烦的解决方法.所以依然对您有参考价值.

STEP1 : 注册Sonatype的JIRA帐户并发布ISSUE

  1. sonatype的页面注册一个账号.一个常规的简单表单,将邮箱,用户名,密码按要求填好即可.

    注册成功后,使用该账号进行登陆.可以看到如下的界面

    image-20190423002605866

  2. 按照上图说明,点击按钮后会弹出创建ISSUE的界面

image-20190423003718569

这个图里面的字段都是必填的,实际的界面还会多一些选填的内容.

  • Summary : 项目的简介(一句话描述)
  • Group Id : 这个比较有讲究,必须是你管理的域名的倒置,例如我的Github Pages域名hadix-lin.github.io,那么我的GroupId为io.github.hadix-lin
  • Project URL : 项目的主页地址,github可以直接填你的项目地址,例如:https://github.com/hadix-lin/springfox-plus
  • SCM url : 源码控制系统地址,例如:https://github.com/hadix-lin/springfox-plus.git
  • Project : 按图选择即可(社区支持的开源项目)
  • Issue Type : 按图选择即可(新项目)
  • Already Synced to Central : 您的项目是否已经通过其他方式同步到中央仓库了.选择No.

界面上的其他选填内容,按需填写即可.

ISSUE创建成功后进入如下图页面

image-20190424225424590

主要关注评论部分,如果之前表单填写的内容不规范,会在评论区里收到反馈,按要求编辑修改ISSUE即可.例如途中第一条评论告知我填写的groupId不和规范,并给了我建议的写法.

ISSUE正确后,过一段时间会收到类似图中的第二条评论,告知您现在可以向仓库提交构件了.

SETP2 : 配置您的POM,令您的项目满足先决条件

先决条件

您的开源项目必须符合以下条件才能允许被发布:

  1. 提供javadoc和源代码包

    1
    2
    example-application-1.4.7-sources.jar
    example-application-1.4.7-javadoc.jar
  2. 所有发布的文件必须使用GPG签名

    所有的文件使用GPG签名后,将签名内容存放在.asc文件中,例如:您的项目包含如下文件:

    1
    2
    3
    4
    example-application-1.4.7.pom
    example-application-1.4.7.jar
    example-application-1.4.7-sources.jar
    example-application-1.4.7-javadoc.jar

    那么得到的签名文件如下:

    1
    2
    3
    4
    example-application-1.4.7.pom.asc
    example-application-1.4.7.jar.asc
    example-application-1.4.7-sources.jar.asc
    example-application-1.4.7-javadoc.jar.asc
  3. POM文件中要包含足够的元数据

    • 正确的坐标GAV(<groupId>,<artifactId>,<version>)
    • 项目名称<name>, 描述<description> 和项目主页地址<url>
    • 许可证信息<licenses>
    • 开发者信息<developers>
    • 源码控制系统(SCM)信息<scm>

文章最后会提供我的springfox-plus的pom文件供参考.

为满足上述条件,编写如下的pom文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>io.github.hadix-lin</groupId>
<artifactId>springfox-plus-parent</artifactId>
<packaging>pom</packaging>
<version>0.0.1</version>
<name>springfox-plus</name>
<description>An extension of SpringFox that supports read javadoc as API document</description>
<url>https://github.com/hadix-lin/springfox-plus</url>

<!-- 项目使用的许可证 -->
<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>

<!-- 开发者 -->
<developers>
<developer>
<name>hadix</name>
<email>hadix.lin@gmail.com</email>
</developer>
</developers>

<!-- 源码控制管理系统 -->
<scm>
<connection>scm:git@github.com:hadix-lin/springfox-plus.git</connection>
<developerConnection>scm:git@github.com:hadix-lin/springfox-plus.git</developerConnection>
<url>https://github.com/hadix-lin/springfox-plus</url>
</scm>

<!-- 项目的依赖 -->
<dependencies/>

<!-- 这里使用一个profile来管理发布时的构建插件 -->
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<!-- 打源码包 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.0.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- 打javadoc包 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<show>private</show>
<nohelp>true</nohelp>
<charset>UTF-8</charset>
<encoding>UTF-8</encoding>
<docencoding>UTF-8</docencoding>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- 对构件进行GPG签名 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>1.6</version>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<!--
nexus-staging 这个插件用于在最后执行一些仓库操作
这些操作也可以手动在sonatype的仓库上进行,但是相对繁琐,推荐使用插件.
手动操作不在本文进行描述,可以自行查阅[文档](https://central.sonatype.org/pages/releasing-the-deployment.html)
-->
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>1.6.8</version>
<extensions>true</extensions>
<configuration>
<serverId>oss</serverId>
<nexusUrl>https://oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
</configuration>
</plugin>
</plugins>
</build>
<!-- 配置发布的仓库 -->
<distributionManagement>
<snapshotRepository>
<id>oss</id>
<url>https://oss.sonatype.org/content/repositories/snapshots/</url>
</snapshotRepository>
<repository>
<id>oss</id>
<url>https://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
</repository>
</distributionManagement>
</profile>
</profiles>
</project>

上面的pom文件是从我的项目中截取的,全文可以看这里

另外还要在您的全局maven配置中添加如下内容

1
2
3
4
5
6
7
8
9
<servers>
<server>
<!-- 这个id要跟上面pom中配置的repository的id一致 -->
<id>oss</id>
<!-- 用户名/密码就是您在上面注册得到的 -->
<username>用户名</username>
<password>密码</password>
</server>
</servers>

准备GPG签名密钥

上文中配置的maven-gpg-plugin需要本机配置好GPG密钥,GPG是一种非对称加密算法,密钥要公钥私钥,公钥需要上传到公钥服务器上,私钥安装在本地.

在macOS下通过brew安装gpg命令,然后生成密钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 安装
brew install gpg
# 检查是否安装成功
gpg --version
# 生成密钥
gpg --gen-key
# 之后会在命令行中以交互模式让你填写姓名,邮箱等等信息,最后输入两次用于保护密钥的密码
# 然后使用如下命令查看生成的密钥
gpg --list-keys
# 如下输出
# /Users/hadix/.gnupg/pubring.kbx -> 密钥文件,内含私钥
# -------------------------------
# pub rsa2048 2019-04-21 [SC] [expires: 2021-04-20]
# 58FD688CA40BE2002C1B68C848492F673D89A858 -->公钥
# uid [ultimate] hadix <hadix.lin@gamil.com>
# sub rsa2048 2019-04-21 [E] [expires: 2021-04-20]

# 然后将公钥发布到密钥服务器
gpg --keyserver hkp://pool.sks-keyservers.net --send-keys <您的公钥>
# 如果发布失败请尝试如下命令
gpg --keyserver hkp://pool.sks-keyservers.net:80 --send-keys <您的公钥>
# 检查是否发布成功
gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys <您的公钥>
# 如果失败请尝试如下命令
gpg --keyserver hkp://pool.sks-keyservers.net:80 --recv-keys <您的公钥>

STEP3 : 发布到sonatype仓库

上面已经配置好了maven的pom文件,可以直接使用如下命令进行发布

1
2
3
4
5
6
mvn clean deploy -P release
# 如果构件过程中无法正确生成gpg签名,出现类似"Exit Code 2"的错误,那么请执行如下命令
export GPG_TTY=$(tty)
mvn clean deploy -P release
# 执行成功后 由于我们在上面的pom文件中配置了<autoReleaseAfterClose>true</autoReleaseAfterClose>
# maven构件过程会自动进行release构件上架

如果您的项目版本号是以-SNAPSHOT结尾,那么构件将发布到snapshot仓库,不会同步到中央仓库.

如果您的项目构建不满足上文中的先决条件,那么上述命令会返回类似如下的输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[ERROR] Rule failure while trying to close staging repository with ID "iogithubhadix-lin-1002".
[ERROR]
[ERROR] Nexus Staging Rules Failure Report
[ERROR] ==================================
[ERROR]
[ERROR] Repository "iogithubhadix-lin-1002" failures
[ERROR] Rule "pom-staging" failures
[ERROR] * Invalid POM: /io/github/hadix-lin/http/1.0/http-1.0.pom: Project name missing, Project description missing, Project URL missing
[ERROR]
[ERROR]
[ERROR] Cleaning up local stage directory after a Rule failure during close of staging repositories: [iogithubhadix-lin-1002]
[ERROR] * Deleting context ac894b84a446a7.properties
[ERROR] Cleaning up remote stage repositories after a Rule failure during close of staging repositories: [iogithubhadix-lin-1002]
[ERROR] * Dropping failed staging repository with ID "iogithubhadix-lin-1002" (Rule failure during close of staging repositories: [iogithubhadix-lin-1002]).

按提示修改项目的pom文件即可.例如上面这段错误信息提示我的项目没有Project name.在pom中增加<name>http</name>即可.

STEP4 : 回复ISSUE来发起同步

首次发布构件的时候,是不会自动同步到中央仓库的.当上面的步骤全部执行成功后,需要找到最初创建的ISSUE页面.在评论区用英文写一条评论,比如”the first release version(0.0.1) uploaded via maven”.然后耐心等待,会收到回复.像下面图中的样子.

image-20190427204606484

以后只要是同一个groupId的构件发布就不需要再来回复了.发布后过差不多两个小时就会自动同步到中央仓库.

如果要发不不同groupId的构件,那么请重复STEP1中创建ISSUE的操作.

问题概述

在类似Java的语言中,非常容易见到多层嵌套的for循环和if条件判断判断,特别是在Java8出现以前.这类代码对于维护低级的数据结构(arrays,collection,…)来说是完全可以接受的.但是当这样的代码出现在你的业务领域代码中时,就会令你的代码恶丑无比,特别是嵌套很深的时候.

症状

  • 深层嵌套的代码(超过1层,就要注意,超过2层就会开始发臭),通常是for/while循环和if条件判断.

    通过代码的缩进程度可以很明显的嗅到臭味.

  • 链式调用的getter方法.加入一个链式调用的语句中,每个调用都是getter方法,那么也应当视为一个深层嵌套的情况.

    Builder模式或其他Domain-DSL的链式调用情况除外.

可能的解决方案

  • 嵌套的for循环往往是通过遍历数据结构来检查是否满足业务逻辑或提取部分数据进行后续处理.可以考虑将检查数据的代码放入持有该数据结构的的对象中,然后通过一个良好命名的方法来回答其他对象的问题.

  • 检查你的代码,考虑数据结构是否需要直接泄露给其他类.对数据的处理和职责是否可以限定在持有类中,然后通过helper方法提供给其他类进行操作.这样可以将对数据的处理逻辑放置在靠近数据结构的地方,提高代码内聚性.

  • 集合封装:使用这种模式意味着你正在将一种数据结构当做领域对象来使用,考虑将这种数据结构封装在领域对象自身中,与其暴露内部实现,不如通过提供helper方法来解决其他业务对象的问题.

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public MappedField getMappedField(final String storedName) {
for (final MappedField mf : persistenceFields) {
// 可以将内嵌的循环直接放入MappedField中
for (final String n : mf.getLoadNames()) {
if (storedName.equals(n)) {
return mf;
}
}
}
return null;
}

// 改为如下
public class MappedField {
private List<String> loadNames = ...;

public boolean hasName(String storedName){
for (final String n : mf.getLoadNames()) {
if (storedName.equals(n)) {
return mf;
}
}
}

// 使用Java8可以定义为如下
public boolean hasName(String storedName){
return getLoadNames().stream().anyMatch(Objects::equals);
}
}

public MappedField getMappedField(final String storedName) {
for (final MappedField mf : persistenceFields) {
if(mf.hasName(storedName)){
return mf;
}
}
return null;
}

// 使用Java8可以定义为如下
public MappedField getMappedField(final String storedName) {
persistenceFields.stream()
.filter(mf->mf.hasName(storedName))
.findFirst()
.orElse(null);
}

// 避免Null-Smell可以改为直接返回Optional
public Optional<MappedField> getMappedField(final String storedName) {
persistenceFields.stream()
.filter(mf->mf.hasName(storedName))
.findFirst();
}

问题概述

if可能是代码中最常用的逻辑控制语句,但不合理的复杂的if会让代码变得冗长复杂难以理解.

if让代码的逻辑分支成指数级上升.不合理的if还会带来深层嵌套问题.

症状

  • 过多的if语句
  • if语句缺少else
  • 相同或类似的条件出现在多个if语句中
  • 多个if语句块中存在退出语句(抛异常,返回值),而且散落在代码各处
  • 多个if语句块中抛出了相同的异常内容.

可能的解决方案

  • 翻转逻辑.人们更容易理解正向逻辑,所以尽可能在if语句中使用正向条件判断.如果一定要使用逆向逻辑条件,请将该条件封装为一个方法并提供便于理解的方法名.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //逆向条件
    if(! exists(something)){
    doSomeThingA();
    }else{
    doSomeThingB();
    }

    //重构为正向,上面的代码在Intellij IDEA中会有高亮提示
    //按下Alt+Enter可以在弹出的菜单中选择"invert 'if' condition",即可自动重构为下面的代码
    if(exists(something)){
    doSomeThingB();
    }else{
    doSomeThingA();
    }
  • 如果前面的代码中已经对某个条件进行了判断(例如判断某个变量是否为null),那么后续代码中出现的相同判断都可以移除.

  • 代码中通过一系列的if(without else)来控制流程,那么要思考一下是否可以通过通过添加else来简化流程.一系列的if表达所有的代码都可能会被执行,而if-else表达的是二选一来执行.if-else不仅简化了代码,令逻辑更容易理解,而且还提升了代码的执行效率.

  • 一旦你减少了代码中对相同条件的重复判断,就可以将这些if语句块提取到具有良好明明的自解释的方法中.这可以极大的改善代码的可读性.

  • 一些简单的校验或检查会导致提前退出或返回的代码,请放到方法的最顶端.这样代码读者可以清楚知道哪些代码不是主要逻辑可以跳过.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    if(args[length] >= 1){
    // some lang code;
    doSomethingWith(args[0]);
    }else{
    return -1;
    }

    //上述代码可以改为
    if(args[length] < 1){
    return -1
    }
    // some lang code;
    doSomeThingWith(args[0]);
  • 将异常语句放在最后的else语句中,这样读者可以明确知道,这是在所有条件判断都失败的情况下做出的处理.

问题概述

当你看到通过for循环或者streams-api遍历集合的代码时,需要明确知道迭代遍历是必须的(意味着对集合中的每个元素都进行了必要的逻辑处理).如果遍历一个数据结构总是为了从中查找某个值,那么最好将集合转换为Set或Map.这样做不仅可以令代码更简洁已读,而且还能提供更好的性能.

每当选择使用一种数据结构时,都要考虑一下对该数据结构的更新(add/update)只读操作的频率,根据读写操作的占比和更新操作的类型来决定使用何种数据结构会更高效.举例来说:

  • 根据key寻找value当然是用Map
  • 检查value是否存在用Set
  • 使用索引进行随机写入使用ArrayList,顺序写入任意位置增删元素用LinkedList
  • 更多

全部都是常识.

症状

  • 使用for循环遍历列表来查找某个符合条件的值
  • 使用streams-api调用findFirst/findAny/anyMatch

解决方案

  • 如果总是通过for循环遍历来检查某个值是否存在于List,可以想想List中的元素是否唯一,如果是可以使用Set进行替换.

  • 如果总是查找一个具有特定属性值的对象,可以考虑使用以属性值为key的Map进行替换,可以提升查找效率.

  • java.util.Collections API提供了非常多有用的方法,如果你无法变更数据结果,可以考虑看看看Collections中有没有方法可以替换循环迭代的代码.例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    //代码片段1
    for (String s : getLoadNames()) {
    if (storedName.equals(s)) {
    return true;
    }
    }
    return false;

    //可以改为如下代码
    return Collections.contains(getLoadNames,storedName);

    //代码片段2
    Iterator<String> iter = getLoadNames().iterator();
    while(iter.hasNext()){
    String name = iter.next();
    if(storedName.equals()){
    iter.remove();
    }
    }

    //可以改为
    Collections.removeIf(name->storedName.equals(name))

    示例代码中的转换,IntellijIdea IDE提供了快速识别转换的能力,通常在编辑器中会自动标注可以转换的代码,只需要按下Alt+Enter就可以快速转换为Collections API代码.

  • 如果确实需要对数据结构进行遍历,最好将遍历代码封装的业务领域对象的方法中,然后给它起一个容易识别的名字.