背景

TLS(Transport Layer Security)是一种安全协议,用于在两个通信应用程序之间提供保密性和数据完整性。TLS是SSL(Secure Sockets Layer)的继任者。

不同的编程语言处理TLS配置的方式各有千秋, 本文针对TLS配置参数的设计进行探讨。

代码配置中,建议使用反映状态的参数名。

通用参数

  • tlsEnable: 是否启用TLS

Go

推荐使用方式一

方式一:

  • tlsConfig *tls.Config: Go标准库的内置TLS结构体

方式二:

由于Go不支持加密的私钥文件,推荐使用文件内容,而不是文件路径,避免敏感信息泄露。

  • tlsCertContent []byte: 证书文件内容
  • tlsPrivateKeyContent []byte: 私钥文件内容
  • tlsMinVersion uint16: TLS最低版本
  • tlsMaxVersion uint16: TLS最高版本
  • tlsCipherSuites []uint16: TLS加密套件列表

Java

Java的TLS参数基本上都是基于keystore和truststore来配置的。一般常见设计如下参数:

  • keyStorePath: keystore文件路径
  • keyStorePassword: keystore密码
  • trustStorePath: truststore文件路径
  • trustStorePassword: truststore密码
  • tlsVerificationDisabled: 是否禁用TLS校验
  • tlsHostnameVerificationDisabled: 是否禁用TLS主机名校验,仅部分框架支持。
  • tlsVersions: TLS版本列表
  • tlsCipherSuites: TLS加密套件列表

JavaScript

JavaScript可以使用标准库里的tls.SecureContextOptions

Kotlin

kotlin的Tls与Java相同:

  • keyStorePath: keystore文件路径
  • keyStorePassword: keystore密码
  • trustStorePath: truststore文件路径
  • trustStorePassword: truststore密码
  • tlsVerificationDisabled: 是否禁用TLS校验
  • tlsHostnameVerificationDisabled: 是否禁用TLS主机名校验,仅部分框架支持。
  • tlsVersions: TLS版本列表
  • tlsCipherSuites: TLS加密套件列表

Python

推荐使用方式一

方式一

  • ssl.SSLContext: Python标准库的内置TLS结构体

方式二

Python可以使用文件路径以及加密的私钥文件。

  • tlsCertPath: 证书文件路径
  • tlsPrivateKeyPath: 私钥文件路径
  • tlsPrivateKeyPassword: 私钥密码
  • tlsMinVersion: TLS最低版本
  • tlsMaxVersion: TLS最高版本
  • tlsCipherSuites: TLS加密套件列表

Rust

由于常见的Rust TLS实现不支持加密的私钥文件,推荐使用文件内容,而不是文件路径,避免敏感信息泄露。 一般常见如下设计参数:

  • tls_cert_content Vec: 证书内容
  • tsl_private_key_content Vec: 私钥内容
  • tls_versions: TLS版本列表
  • tls_cipher_suites: TLS加密套件列表
  • tls_verification_disabled: 是否禁用TLS校验

根据Python项目的需求和特性,可以为Python的Http SDK项目选择以下命名方式:

  • xxx-client-python:如果这个项目只有Http SDK,没有其他协议的SDK,推荐使用这个命名方式。
  • xxx-http-client-python:当存在其他协议的SDK时,可以使用这个命名方式,以区分不同协议的SDK。
  • xxx-admin-python:当项目使用其他协议作为数据通道,使用HTTP协议作为管理通道时,可以使用这个命名方式。

由于Python的调用方式通常是模块名.类名.方法名

TypeScript的调用方式通常是

1
2
import { ClassName } from 'moduleName';
const object = new ClassName();

根据TypeScript项目的需求和特性,可以为TypeScript的Http SDK项目选择以下命名方式:

  • xxx-client-ts:如果这个项目只有Http SDK,没有其他协议的SDK,推荐使用这个命名方式。在npm可以注册为”xxx”。
  • xxx-http-client-ts:当存在其他协议的SDK时,可以使用这个命名方式,以区分不同协议的SDK。
  • xxx-admin-ts:当项目使用其他协议作为数据通道,使用HTTP协议作为管理通道时,可以使用这个命名方式。

根据Go项目的需求和特性,可以为Go的Http SDK项目选择以下命名方式:

  • xxx-client-go:如果这个项目只有Http SDK,没有其他协议的SDK,推荐使用这个命名方式。
  • xxx-http-client-go:当存在其他协议的SDK时,可以使用这个命名方式,以区分不同协议的SDK。
  • xxx-admin-go:当项目使用其他协议作为数据通道,使用HTTP协议作为管理通道时,可以使用这个命名方式。

由于Go语言的调用方式是包名.结构体名.方法名,所以在设计SDK时,需要考虑包名、结构体名、方法名的设计。

以xxx业务为例,假设业务名为xxx,推荐包名也为xxx,结构体名为Client

目录布局可以是这样子的:

1
2
3
xxx-client-go/
|-- xxx/
| |-- client.go

Java Http SDK设计

根据Java项目的需求和特性,可以为Java的Http SDK项目选择以下命名方式:

  • xxx-client-java:如果这个项目只有Http SDK,没有其他协议的SDK,推荐使用这个命名方式。
  • xxx-http-client-java:当存在其他协议的SDK时,可以使用这个命名方式,以区分不同协议的SDK。
  • xxx-admin-java:当项目使用其他协议作为数据通道,使用HTTP协议作为管理通道时,可以使用这个命名方式。

maven模块设计

maven module命名可以叫xxx-client或者xxx-http-client,这通常取决于你的项目是否有其他协议的client,如果没有,那么推荐直接使用xxx-client。

假设包名前缀为com.xxx,module视图如下:

1
2
3
4
5
6
xxx-client-java(maven artifactId: xxx-client-parent)/
|-- xxx-client-api(接口定义,包名com.xxx.client.api,jdk8+)
|-- xxx-client-common/core(核心实现,包名com.xxx.client.common,jdk8+)
|-- xxx-client-jdk(基于jdk http client的实现,包名com.xxx.client.jdk,jdk17+)
|-- xxx-client-okhttp(基于okhttp的实现,包名com.xxx.client.okhttp,jdk8+)
|-- xxx-client-reactor(基于reactor-netty的实现,包名com.xxx.client.reactor,jdk8+)

依赖关系图:

graph TD
api[xxx-client-api]
common[xxx-client-common]
jdk[xxx-client-jdk]
okhttp[xxx-client-okhttp]
reactor[xxx-client-reactor]

common --> api

jdk --> common
okhttp --> common
reactor --> common

ZooKeeper,是一个开源的分布式协调服务,不仅支持分布式选举、任务分配,还可以用于微服务的注册中心和配置中心。本文,我们将深入探讨ZooKeeper用做微服务注册中心的场景。

ZooKeeper中的服务注册路径

SpringCloud ZooKeeper遵循特定的路径结构进行服务注册

1
/services/${spring.application.name}/${serviceId}

示例:

1
/services/provider-service/d87a3891-1173-45a0-bdfa-a1b60c71ef4e

/services/${spring.application.name}是ZooKeeper中的永久节点,/${serviceId}是临时节点,当服务下线时,ZooKeeper会自动删除该节点。

注:当微服务的最后一个实例下线时,SpringCloud ZooKeeper框架会删除/${spring.application.name}节点。

ZooKeeper中的服务注册数据

下面是一个典型的服务注册内容示例:

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
{
"name":"provider-service",
"id":"d87a3891-1173-45a0-bdfa-a1b60c71ef4e",
"address":"192.168.0.105",
"port":8080,
"sslPort":null,
"payload":{
"@class":"org.springframework.cloud.zookeeper.discovery.ZookeeperInstance",
"id":"provider-service",
"name":"provider-service",
"metadata":{
"instance_status":"UP"
}
},
"registrationTimeUTC":1695401004882,
"serviceType":"DYNAMIC",
"uriSpec":{
"parts":[
{
"value":"scheme",
"variable":true
},
{
"value":"://",
"variable":false
},
{
"value":"address",
"variable":true
},
{
"value":":",
"variable":false
},
{
"value":"port",
"variable":true
}
]
}
}

其中,address、port和uriSpec是最核心的数据。uriSpec中的parts区分了哪些内容是可变的,哪些是固定的。

SpringCloud 服务使用OpenFeign互相调用

一旦两个微服务都注册到了ZooKeeper,那么它们就可以通过OpenFeign互相调用了。简单的示例如下

服务提供者

创建SpringBoot项目

创建SpringBoot项目,并添加spring-cloud-starter-zookeeper-discoveryspring-boot-starter-web依赖。

配置application.yaml

1
2
3
4
5
6
7
8
9
spring:
application:
name: provider-service
cloud:
zookeeper:
connect-string: localhost:2181

server:
port: 8082

注册到ZooKeeper

在启动类上添加@EnableDiscoveryClient注解。

创建一个简单的REST接口

1
2
3
4
5
6
7
@RestController
public class ProviderController {
@GetMapping("/hello")
public String hello() {
return "Hello from Provider Service!";
}
}

服务消费者

创建SpringBoot项目

创建SpringBoot项目,并添加spring-cloud-starter-zookeeper-discoveryspring-cloud-starter-openfeignspring-boot-starter-web依赖。

配置application.yaml

1
2
3
4
5
6
7
8
9
spring:
application:
name: consumer-service
cloud:
zookeeper:
connect-string: localhost:2181

server:
port: 8081

注册到ZooKeeper

在启动类上添加@EnableDiscoveryClient注解。

创建一个REST接口,通过OpenFeign调用服务提供者

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class ConsumerController {

@Autowired
private ProviderClient providerClient;

@GetMapping("/getHello")
public String getHello() {
return providerClient.hello();
}
}

运行效果

1
2
3
4
5
6
7
curl localhost:8081/getHello -i
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 28
Date: Wed, 18 Oct 2023 02:40:57 GMT

Hello from Provider Service!

非Java服务在SpringCloud ZooKeeper中注册

可能有些读者乍一看觉得有点奇怪,为什么要在SpringCloud ZooKeeper中注册非Java服务呢?没有这个应用场景。

当然,这样的场景比较少,常见于大部分项目都是用SpringCloud开发,但有少部分项目因为种种原因,不得不使用其他语言开发,比如Go、Rust等。这时候,我们就需要在SpringCloud ZooKeeper中注册非Java服务了。

对于非JVM语言开发的服务,只需确保它们提供了Rest/HTTP接口并正确地注册到ZooKeeper,就可以被SpringCloud的Feign客户端所调用。

Go服务在SpringCloud ZooKeeper

example代码组织:

1
2
3
4
5
6
├── consumer
│ └── consumer.go
├── go.mod
├── go.sum
└── provider
└── provider.go

Go服务提供者在SpringCloud ZooKeeper

注:该代码的质量为demo级别,实际生产环境需要更加严谨的代码,如重连机制、超时机制、更优秀的服务ID生成算法等。

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
package main

import (
"fmt"
"log"
"net/http"
"time"

"encoding/json"
"github.com/gin-gonic/gin"
"github.com/samuel/go-zookeeper/zk"
)

const (
zkServers = "localhost:2181" // Zookeeper服务器地址
)

func main() {
// 初始化gin框架
r := gin.Default()

// 添加一个简单的hello接口
r.GET("/hello", func(c *gin.Context) {
c.String(http.StatusOK, "Hello from Go service!")
})

// 注册服务到zookeeper
registerToZookeeper()

// 启动gin服务器
r.Run(":8080")
}

func registerToZookeeper() {
conn, _, err := zk.Connect([]string{zkServers}, time.Second*5)
if err != nil {
panic(err)
}

// 检查并创建父级路径
ensurePathExists(conn, "/services")
ensurePathExists(conn, "/services/provider-service")

// 构建注册的数据
data, _ := json.Marshal(map[string]interface{}{
"name": "provider-service",
"address": "127.0.0.1",
"port": 8080,
"sslPort": nil,
"payload": map[string]interface{}{"@class": "org.springframework.cloud.zookeeper.discovery.ZookeeperInstance", "id": "provider-service", "name": "provider-service", "metadata": map[string]string{"instance_status": "UP"}},
"serviceType": "DYNAMIC",
"uriSpec": map[string]interface{}{
"parts": []map[string]interface{}{
{"value": "scheme", "variable": true},
{"value": "://", "variable": false},
{"value": "address", "variable": true},
{"value": ":", "variable": false},
{"value": "port", "variable": true},
},
},
})

// 在zookeeper中注册服务
path := "/services/provider-service/" + generateServiceId()
_, err = conn.Create(path, data, zk.FlagEphemeral, zk.WorldACL(zk.PermAll))
if err != nil {
log.Fatalf("register service error: %s", err)
} else {
log.Println(path)
}
}

func ensurePathExists(conn *zk.Conn, path string) {
exists, _, err := conn.Exists(path)
if err != nil {
log.Fatalf("check path error: %s", err)
}
if !exists {
_, err := conn.Create(path, []byte{}, 0, zk.WorldACL(zk.PermAll))
if err != nil {
log.Fatalf("create path error: %s", err)
}
}
}

func generateServiceId() string {
// 这里简化为使用当前时间生成ID,实际生产环境可能需要更复杂的算法
return fmt.Sprintf("%d", time.Now().UnixNano())
}

调用效果

1
2
3
4
5
6
7
curl localhost:8081/getHello -i
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 28
Date: Wed, 18 Oct 2023 02:43:52 GMT

Hello from Go Service!

Go服务消费者在SpringCloud ZooKeeper

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
package main

import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"

"github.com/samuel/go-zookeeper/zk"
)

const (
zkServers = "localhost:2181" // Zookeeper服务器地址
)

var conn *zk.Conn

func main() {
// 初始化ZooKeeper连接
initializeZookeeper()

// 获取服务信息
serviceInfo := getServiceInfo("/services/provider-service")
fmt.Println("Fetched service info:", serviceInfo)

port := int(serviceInfo["port"].(float64))

resp, err := http.Get(fmt.Sprintf("http://%s:%d/hello", serviceInfo["address"], port))
if err != nil {
panic(err)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}

fmt.Println(string(body))
}

func initializeZookeeper() {
var err error
conn, _, err = zk.Connect([]string{zkServers}, time.Second*5)
if err != nil {
log.Fatalf("Failed to connect to ZooKeeper: %s", err)
}
}

func getServiceInfo(path string) map[string]interface{} {
children, _, err := conn.Children(path)
if err != nil {
log.Fatalf("Failed to get children of %s: %s", path, err)
}

if len(children) == 0 {
log.Fatalf("No services found under %s", path)
}

// 这里只获取第一个服务节点的信息作为示例,实际上可以根据负载均衡策略选择一个服务节点
data, _, err := conn.Get(fmt.Sprintf("%s/%s", path, children[0]))
if err != nil {
log.Fatalf("Failed to get data of %s: %s", children[0], err)
}

var serviceInfo map[string]interface{}
if err := json.Unmarshal(data, &serviceInfo); err != nil {
log.Fatalf("Failed to unmarshal data: %s", err)
}

return serviceInfo
}

Rust服务在SpringCloud ZooKeeper

example代码组织:

1
2
3
4
5
6
├── Cargo.lock
├── Cargo.toml
└── src
└── bin
├── consumer.rs
└── provider.rs

Rust服务提供者在SpringCloud ZooKeeper

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
use std::collections::HashMap;
use std::time::Duration;
use serde_json::Value;
use warp::Filter;
use zookeeper::{Acl, CreateMode, WatchedEvent, Watcher, ZooKeeper};

static ZK_SERVERS: &str = "localhost:2181";

static mut ZK_CONN: Option<ZooKeeper> = None;

struct LoggingWatcher;
impl Watcher for LoggingWatcher {
fn handle(&self, e: WatchedEvent) {
println!("WatchedEvent: {:?}", e);
}
}

#[tokio::main]
async fn main() {
let hello = warp::path!("hello").map(|| warp::reply::html("Hello from Rust service!"));
register_to_zookeeper().await;

warp::serve(hello).run(([127, 0, 0, 1], 8083)).await;
}

async fn register_to_zookeeper() {
unsafe {
ZK_CONN = Some(ZooKeeper::connect(ZK_SERVERS, Duration::from_secs(5), LoggingWatcher).unwrap());
let zk = ZK_CONN.as_ref().unwrap();

let path = "/services/provider-service";
if zk.exists(path, false).unwrap().is_none() {
zk.create(path, vec![], Acl::open_unsafe().clone(), CreateMode::Persistent).unwrap();
}

let service_data = get_service_data();
let service_path = format!("{}/{}", path, generate_service_id());
zk.create(&service_path, service_data, Acl::open_unsafe().clone(), CreateMode::Ephemeral).unwrap();
}
}

fn get_service_data() -> Vec<u8> {
let mut data: HashMap<&str, Value> = HashMap::new();
data.insert("name", serde_json::Value::String("provider-service".to_string()));
data.insert("address", serde_json::Value::String("127.0.0.1".to_string()));
data.insert("port", serde_json::Value::Number(8083.into()));
serde_json::to_vec(&data).unwrap()
}

fn generate_service_id() -> String {
format!("{}", chrono::Utc::now().timestamp_nanos())
}

Rust服务消费者在SpringCloud ZooKeeper

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
use std::collections::HashMap;
use std::time::Duration;
use zookeeper::{WatchedEvent, Watcher, ZooKeeper};
use reqwest;
use serde_json::Value;

static ZK_SERVERS: &str = "localhost:2181";

struct LoggingWatcher;
impl Watcher for LoggingWatcher {
fn handle(&self, e: WatchedEvent) {
println!("WatchedEvent: {:?}", e);
}
}

#[tokio::main]
async fn main() {
let provider_data = fetch_provider_data_from_zookeeper().await;
let response = request_provider(&provider_data).await;
println!("Response from provider: {}", response);
}

async fn fetch_provider_data_from_zookeeper() -> HashMap<String, Value> {
let zk = ZooKeeper::connect(ZK_SERVERS, Duration::from_secs(5), LoggingWatcher).unwrap();

let children = zk.get_children("/services/provider-service", false).unwrap();
if children.is_empty() {
panic!("No provider services found!");
}

// For simplicity, we just take the first child (i.e., service instance).
// In a real-world scenario, load balancing strategies would determine which service instance to use.
let data = zk.get_data(&format!("/services/provider-service/{}", children[0]), false).unwrap();
serde_json::from_slice(&data.0).unwrap()
}

async fn request_provider(provider_data: &HashMap<String, Value>) -> String {
let address = provider_data.get("address").unwrap().as_str().unwrap();
let port = provider_data.get("port").unwrap().as_i64().unwrap();
let url = format!("http://{}:{}/hello", address, port);

let response = reqwest::get(&url).await.unwrap();
response.text().await.unwrap()
}

为什么需要自解压的可执行文件

大部分软件的安装包是一个压缩包,用户需要自己解压,然后再执行安装脚本。常见的两种格式是tar.gzzip。常见的解压执行脚本如下

tar.gz

1
2
3
4
5
#!/bin/bash

tar -zxvf xxx.tar.gz
cd xxx
./install.sh

zip

1
2
3
4
5
#!/bin/bash

unzip xxx.zip
cd xxx
./install.sh

在有些场景下,为了方便分发、安装,我们需要将多个文件和目录打包并与一个启动脚本结合。这样子就可以实现一键安装,而不需要用户自己解压文件,然后再执行启动脚本。

核心原理是,通过固定分隔符分隔脚本和压缩包部分,脚本通过分隔符将压缩包部分提取出来,然后解压,执行安装脚本,脚本不会超过固定分隔符。解压可以通过临时文件(zip)或流式解压(tar.gz)的方式实现。

创建包含zip压缩包的自解压可执行文件

构造一个zip压缩包

1
2
3
echo "hello zip" > temp.txt
zip -r temp.zip temp.txt
rm -f temp.txt

构造可执行文件 self_extracting.sh

以使用__ARCHIVE_BELOW__做分隔符为例,self_extracting.sh里面内容:

推荐把临时文件放在内存文件路径下,这样子可以避免磁盘IO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash
CURRENT_DIR="$(dirname "$0")"

ARCHIVE_START_LINE=$(awk '/^__ARCHIVE_BELOW__/ {print NR + 1; exit 0; }' $0)

tail -n+$ARCHIVE_START_LINE $0 > /tmp/temp.zip
unzip /tmp/temp.zip" -d "$CURRENT_DIR"
rm "$CURRENT_DIR/temp.zip"

# replace the following line with your own code
cat temp.txt

exit 0

__ARCHIVE_BELOW__

将zip文件追加到self_extracting.sh文件的尾部

1
2
cat temp.zip >> self_extracting.sh
chmod +x self_extracting.sh

创建包含tar.gz压缩包的自解压可执行文件

构造一个tar.gz压缩包

1
2
3
echo "hello tar.gz" > temp.txt
tar -czf temp.tar.gz temp.txt
rm -f temp.txt

构造可执行文件 self_extracting.sh

以使用__ARCHIVE_BELOW__做分隔符为例,self_extracting.sh里面内容:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
CURRENT_DIR="$(dirname "$0")"

ARCHIVE_START_LINE=$(awk '/^__ARCHIVE_BELOW__/ {print NR + 1; exit 0; }' $0)
tail -n+$ARCHIVE_START_LINE $0 | tar xz -C "$CURRENT_DIR"

# replace the following line with your own code
cat temp.txt

exit 0

__ARCHIVE_BELOW__

Ignite Java 客户端最佳实践

背景

本文总结了在使用Apache Ignite(Ignite2.0)的Java客户端时,需要注意的一些问题,以及一些最佳实践。值得一提的是 Ignite的Java客户端有一些跟直觉上不太一样的地方,需要注意下。

客户端相关

Ignite客户端有两处跟直觉上相差较大:

  • Ignite客户端连接没有默认超时时间,如果连接不上,有概率会导致创建客户端一直阻塞,所以一定要设置timeout参数
  • Ignite客户端默认不会重连,更不用说无限重连了。并且Ignite客户端重连的实现方式是预先计算出所有重连的时间戳,然后在这些时间戳到达时重连,由于要预先计算出重连的时间戳存入数组,这也就意味着不能无限重连。如果您的应用程序需要无限重连(在云原生环境下,这是非常常见的场景),那么您需要自己实现重连逻辑。

ClientConfiguration里的重要参数

ClientConfiguration timeout

控制连接超时的参数,单位是毫秒。必须设置!如果不设置,有概率会导致创建客户端一直阻塞。

SQL相关

SQL查询典型用法

1
2
3
SqlFieldsQuery query = new SqlFieldsQuery("SELECT 42").setTimeout(5, TimeUnit.SECONDS);
FieldsQueryCursor<List<?>> cursor = igniteClient.query(query))
List<List<?>> result = cursor.getAll();

注意:Ignite query出来的cursor如果自己通过iterator遍历则必须要close,否则会导致内存泄漏。

Query相关参数

SqlFieldsQuery timeout

SqlQuery的超时时间,必须设置。默认是0,表示永不超时。如果不设置,有概率会导致查询一直阻塞。

Web后端项目结构组织

要点:

  • 使用modelservice,而不是modlesservices。差别不大,节约一个字母,更加简洁。
  • 如果是企业内部的微服务,基本不会、极少把部分的功能以library的形式开放出去,internal目录在这个时候就略显鸡肋,可以省略。

备注:

  • xxx、yyy代表大块的业务区分:如用户、订单、支付
  • aaa、bbb代表小块的业务区分:如(用户的)登录、注册、查询

方案一:多业务模块通过文件名区分,不分子包

适用于小型项目

注:handler、model、service要留意方法、结构体、接口的命名,避免冲突

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
example/
|-- cmd/
| |-- example-server/
| |-- example-server.go (start gin app, manage handler, middleware)
|-- pkg/
| |-- handler/
| |-- aaa_handler.go
| |-- bbb_handler.go
| |-- middleware/
| |-- aaa_middleware.go
| |-- bbb_middleware.go
| |-- model/
| |-- aaa_model.go
| |-- bbb_model.go
| |-- service/
| |-- aaa_service.go
| |-- bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

方案二:多业务模块通过包名区分,但不拆分model和service

方案二更适用于由多个小模块组合而成的项目,每个小模块不会太大,复用度较高。

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
example/
|-- cmd/
| |-- example-server/
| |-- example-server.go (start gin app, manage handler, middleware)
|-- pkg/
| |-- handler/
| |-- xxx/
| |-- xxx_aaa_handler.go
| |-- xxx_bbb_handler.go
| |-- yyy/
| |-- yyy_aaa_handler.go
| |-- yyy_bbb_handler.go
| |-- middleware/
| |-- xxx/
| |-- xxx_aaa_middleware.go
| |-- yyy/
| |-- yyy_bbb_middleware.go
| |-- xxx/
| |-- xxx_aaa_model.go
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- yyy_bbb_model.go
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

方案三:多业务模块通过包名区分,并在下层拆分model和service

方案三更适用于由多个大模块组合而成的项目,每个大模块都很大,复用度较低,较少的互相调用。

方案三在service依赖多个service的情况下,会发生命名冲突。

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
example/
|-- cmd/
| |-- example-server/
| |-- example-server.go (start gin app, manage handler, middleware)
|-- pkg/
| |-- handler/
| |-- xxx/
| |-- xxx_aaa_handler.go
| |-- yyy/
| |-- yyy_bbb_handler.go
| |-- middleware/
| |-- xxx/
| |-- xxx_aaa_middleware.go
| |-- yyy/
| |-- yyy_bbb_middleware.go
| |-- xxx/
| |-- model/
| |-- xxx_aaa_model.go
| |-- service/
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- model/
| |-- yyy_bbb_model.go
| |-- service/
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

简单的library

对于简单的library来说,我更推荐将所有的文件都放在同一个package下面,如简单的client封装

1
2
3
4
5
package:com.xxx.yyy/
|-- XxClient
|-- XxDTO
|-- XxException
|-- XxUtil

复杂的SpringBoot项目,负责多个业务模块

备注:

  • xxx、yyy代表大块的业务区分:如用户、订单、支付
  • aaa、bbb代表小块的业务区分:如(用户的)登录、注册、查询

方案一:多业务模块通过子包来区分,不分子module

module视图:

1
2
3
4
5
6
7
example(maven artifactId: example-parent)/
|-- example-service(业务逻辑)
|-- example-spring-ignite(依赖spring,常见为中间件client,适配spring模块用于方便单元测试)
|-- example-spring-ignite-test(依赖spring,不依赖test-common,spring模块单元测试用)
|-- example-starter(启动类)
|-- example-test-common(不依赖example-common)
|-- example-util(不依赖Spring框架,可选模块,为service与其他spring集成组件共用)

依赖关系图:

graph TD
service[example-service]
springIgnite[example-spring-ignite]
springIgniteTest[example-spring-ignite-test]
starter[example-starter]
testCommon[example-test-common]
util[example-util]

starter --> service

service --> springIgnite
service --> util
service -.-> testCommon

testCommon --> springIgniteTest

springIgniteTest --> springIgnite

springIgnite --> util

service包内视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
io.hezhangjian.example/
|-- service/
| |-- common/
| |-- module/
| | |-- aaaModule
| | |-- bbbModule
| |-- mapper/
| | |-- aaaMapper
| | |-- bbbMapper
| |-- repo/
| | |-- aaaRepo
| | |-- bbbRepo
| |-- service/
| | |-- aaaService
| | |-- bbbService

方案二:根据业务模块拆分子module

适用于大型项目,每个业务模块都比较大。

module视图:

1
2
3
4
5
6
7
8
9
10
11
example(maven artifactId: example-parent)/
|-- example-common(可依赖spring模块)
|-- example-rest-xxx(xxx功能模块的rest接口)
|-- example-rest-yyy(yyy功能模块的rest接口)
|-- example-service-xxx(xxx功能的业务逻辑)
|-- example-service-yyy(yyy功能的业务逻辑)
|-- example-spring-ignite(依赖spring,常见为中间件client,适配spring模块用于方便单元测试)
|-- example-spring-ignite-test(依赖spring,不依赖test-common,spring模块单元测试用)
|-- example-starter(启动类)
|-- example-test-common(不依赖example-common)
|-- example-util(不依赖example-common,可选模块,为service、common与其他spring集成组件共用)

依赖关系图:

graph TD
common[example-common]
rest-xxx[example-rest-xxx]
rest-yyy[example-rest-yyy]
service-xxx[example-service-xxx]
service-yyy[example-service-yyy]
springIgnite[example-spring-ignite]
springIgniteTest[example-spring-ignite-test]
starter[example-starter]
testCommon[example-test-common]
util[example-util]

starter --> rest-xxx
starter --> rest-yyy

rest-xxx --> common
rest-xxx --> service-xxx

rest-yyy --> common
rest-yyy --> service-yyy

service-xxx --> common
service-xxx --> springIgnite

service-yyy --> common
service-yyy --> util

common -.-> testCommon

testCommon --> springIgniteTest

springIgniteTest --> springIgnite

springIgnite --> util

关于service模块引不引用rest模块的DTO,我的想法:

如果确实service模块和rest模块DTO差距比较大,可以拆分做转换,如果差距很小/没有差距,可以复用同一个DTO,放在service模块或者更底层的依赖。

service-xxx包内视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
io.hezhangjian.example.service/
|-- xxx/
| |-- common/
| |-- module/
| | |-- aaaModule
| | |-- bbbModule
| |-- mapper/
| | |-- aaaMapper
| | |-- bbbMapper
| |-- repo/
| | |-- aaaRepo
| | |-- bbbRepo
| |-- service/
| | |-- aaaService
| | |-- bbbService
0%