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

__MACOS文件夹介绍

在 macOS 系统上创建 ZIP 文件时,系统会自动添加一些额外的信息,这些信息被储存在一个隐藏的文件夹中,名为 __MACOSX。这个文件夹中包含的数据用来存储文件的一系列属性和资源叉信息。

  • 元数据: 元数据是描述其他数据的数据。在 macOS 中,文件元数据可以包括文件的创建和修改时间,文件的权限,标签和更多。
  • 资源叉 (Resource Forks): 资源叉是 Apple 文件系统中一个特殊的结构,它可以存储文件的图标,预览图片和其他与文件相关的属性。资源叉信息使文件能够保持其特有的视觉和功能属性,即使它们被移动到新的位置或被编辑。

macOS 创建 __MACOSX 文件夹的原因是为了保留文件的原始属性和资源叉信息。当您在另一台运行 macOS 的计算机上解压这个 ZIP 文件时,所有的文件都会保持其原始的属性和设置,提供一个无缝的用户体验。

然而,这种机制有一个缺点: 当 ZIP 文件被解压到非 macOS 系统(例如 Windows 或 Linux)上时,__MACOSX 文件夹会显得多余,因为这些系统不使用 macOS 的文件元数据和资源叉信息。

避免创建不包含__MACOSX文件夹的zip文件

1
zip -r -X archive.zip FolderName/

这个zip文件,不会包含 __MACOSX 文件夹。

背景

你可能会在一些场景下碰到需要返回多个不同类型的方法。比如协议解析读取报文时,更具体地像kubernetes在开始解析Yaml的时候,怎么知道这个类型是属于Deployment还是Service?

C

C语言通常通过使用Struct(结构体)和Union(联合体)的方式来实现这个功能,如下文例子

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef enum {
MONKEY,
COW,
UNKNOWN
} AnimalType;

typedef struct {
char* description;
} Monkey;

typedef struct {
char* description;
} Cow;

typedef struct {
AnimalType type;
union {
Monkey monkey;
Cow cow;
};
} Animal;

Animal createAnimal(const char* animalType) {
Animal animal;
if (strcmp(animalType, "Monkey") == 0) {
animal.type = MONKEY;
animal.monkey.description = "I am a monkey!";
} else if (strcmp(animalType, "Cow") == 0) {
animal.type = COW;
animal.cow.description = "I am a cow!";
} else {
animal.type = UNKNOWN;
}
return animal;
}

int main() {
Animal animal1 = createAnimal("Monkey");
if (animal1.type == MONKEY) {
printf("%s\n", animal1.monkey.description);
}

Animal animal2 = createAnimal("Cow");
if (animal2.type == COW) {
printf("%s\n", animal2.cow.description);
}

Animal animal3 = createAnimal("Dog");
if (animal3.type == UNKNOWN) {
printf("Unknown animal type\n");
}

return 0;
}

C++

在C++中,我们可以使用基类指针来指向派生类的对象。可以使用动态类型识别(RTTI)来在运行时确定对象的类型

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
#include <iostream>
#include <stdexcept>

class Animal {
public:
virtual std::string toString() const = 0;
};

class Monkey : public Animal {
public:
std::string toString() const override {
return "I am a monkey!";
}
};

class Cow : public Animal {
public:
std::string toString() const override {
return "I am a cow!";
}
};

Animal* createAnimal(const std::string& animalType) {
if (animalType == "Monkey") {
return new Monkey();
}
if (animalType == "Cow") {
return new Cow();
}
throw std::runtime_error("Unknown animal type: " + animalType);
}

int main() {
try {
Animal* animal1 = createAnimal("Monkey");

if (Monkey* monkey = dynamic_cast<Monkey*>(animal1)) {
std::cout << monkey->toString() << std::endl;
}
delete animal1;

Animal* animal2 = createAnimal("Cow");

if (Cow* cow = dynamic_cast<Cow*>(animal2)) {
std::cout << cow->toString() << std::endl;
}
delete animal2;
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << std::endl;
}

return 0;
}

Go

Go的常见处理方式,是返回一个接口或者**interface{}**类型。调用者使用Go语言类型断言来检查具体的类型

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

import (
"fmt"
)

type Animal interface {
String() string
}

type Monkey struct{}

func (m Monkey) String() string {
return "I am a monkey!"
}

type Cow struct{}

func (c Cow) String() string {
return "I am a cow!"
}

func createAnimal(typeName string) (Animal, error) {
switch typeName {
case "Monkey":
return Monkey{}, nil
case "Cow":
return Cow{}, nil
default:
return nil, fmt.Errorf("Unknown animal type: %s", typeName)
}
}

func main() {
animal1, err := createAnimal("Monkey")
if err != nil {
fmt.Println(err)
return
}

if monkey, ok := animal1.(Monkey); ok {
fmt.Println(monkey)
}

animal2, err := createAnimal("Cow")
if err != nil {
fmt.Println(err)
return
}

if cow, ok := animal2.(Cow); ok {
fmt.Println(cow)
}
}

Java

Java语言的常见处理方式,是返回Object类型或者一个基础类型。然后由调用方在进行instance of判断。或者Java17之后,可以使用模式匹配的方式来简化转型

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
public class MultiTypeReturnExample {
static class Monkey {
@Override
public String toString() {
return "I am a monkey!";
}
}

static class Cow {
@Override
public String toString() {
return "I am a cow!";
}
}

public static Object createAnimal(String type) throws IllegalArgumentException {
switch (type) {
case "Monkey":
return new Monkey();
case "Cow":
return new Cow();
default:
throw new IllegalArgumentException("Unknown animal type: " + type);
}
}

public static void main(String[] args) throws Exception {
Object animal1 = createAnimal("Monkey");

// java8 写法,后面如果明确用做精确的类型,需要强制转换

if (animal1 instanceof Monkey) {
System.out.println(animal1);
}

Object animal2 = createAnimal("Cow");
if (animal2 instanceof Cow) {
System.out.println(animal2);
}

// java17 写法,不需要强制转换
if (createAnimal("Monkey") instanceof Monkey animal3) {
System.out.println(animal3);
}

if (createAnimal("Cow") instanceof Cow animal4) {
System.out.println(animal4);
}
}
}

Javascript

动态类型语言,使用instanceof运算符判断

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
class Animal {
toString() {
return 'I am an animal';
}
}

class Monkey extends Animal {
toString() {
return 'I am a monkey';
}
}

class Cow extends Animal {
toString() {
return 'I am a cow';
}
}

function createAnimal(animalType) {
switch (animalType) {
case 'Monkey':
return new Monkey();
case 'Cow':
return new Cow();
default:
throw new Error(`Unknown animal type: ${animalType}`);
}
}

try {
const animal1 = createAnimal('Monkey');
if (animal1 instanceof Monkey) {
console.log(animal1.toString());
}

const animal2 = createAnimal('Cow');
if (animal2 instanceof Cow) {
console.log(animal2.toString());
}

const animal3 = createAnimal('Dog');
} catch (error) {
console.error(error.message);
}

Kotlin

Kotlin可以使用Sealed Class(密封类)和Any类型两种方式。使用Any的场景,与Java返回Object类似。Sealed Class更加安全、更方便一些。

使用Any类型

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
open class Animal

class Monkey: Animal() {
override fun toString(): String {
return "I am a monkey!"
}
}

class Cow: Animal() {
override fun toString(): String {
return "I am a cow!"
}
}

fun createAnimal(type: String): Any {
return when (type) {
"Monkey" -> Monkey()
"Cow" -> Cow()
else -> throw IllegalArgumentException("Unknown animal type: $type")
}
}

fun main() {
val animal1 = createAnimal("Monkey")
when (animal1) {
is Monkey -> println(animal1)
is Cow -> println(animal1)
}

val animal2 = createAnimal("Cow")
when (animal2) {
is Monkey -> println(animal2)
is Cow -> println(animal2)
}
}

使用SealedClass

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
sealed class Animal {
data class Monkey(val info: String = "I am a monkey!") : Animal()
data class Cow(val info: String = "I am a cow!") : Animal()
}

fun createAnimal(type: String): Animal {
return when (type) {
"Monkey" -> Animal.Monkey()
"Cow" -> Animal.Cow()
else -> throw IllegalArgumentException("Unknown animal type: $type")
}
}

fun main() {
val animal1 = createAnimal("Monkey")
when (animal1) {
is Animal.Monkey -> println(animal1.info)
is Animal.Cow -> println(animal1.info)
}

val animal2 = createAnimal("Cow")
when (animal2) {
is Animal.Monkey -> println(animal2.info)
is Animal.Cow -> println(animal2.info)
}
}

Python

Python是动态类型的语言,可以简单基于一些条件返回不同类型的对象,然后在接收到返回值之后使用type()函数或isinstance()函数来确定其类型

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
class Animal:
def __str__(self):
return "I am an animal"

class Monkey(Animal):
def __str__(self):
return "I am a monkey"

class Cow(Animal):
def __str__(self):
return "I am a cow"

def create_animal(animal_type):
if animal_type == "Monkey":
return Monkey()
elif animal_type == "Cow":
return Cow()
else:
raise ValueError(f"Unknown animal type: {animal_type}")

def main():
animal1 = create_animal("Monkey")
if isinstance(animal1, Monkey):
print(animal1)

animal2 = create_animal("Cow")
if isinstance(animal2, Cow):
print(animal2)

if __name__ == "__main__":
main()

Ruby

Ruby也较为简单,在方法内部直接返回不同类型的对象。然后,可以使用is_a方法或class方法来确定返回对象的实际类型。

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
class Animal
def to_s
"I am an animal"
end
end

class Monkey < Animal
def to_s
"I am a monkey"
end
end

class Cow < Animal
def to_s
"I am a cow"
end
end

def create_animal(animal_type)
case animal_type
when "Monkey"
Monkey.new
when "Cow"
Cow.new
else
raise "Unknown animal type: #{animal_type}"
end
end

begin
animal1 = create_animal("Monkey")
if animal1.is_a? Monkey
puts animal1
end

animal2 = create_animal("Cow")
if animal2.is_a? Cow
puts animal2
end
end

Rust

在Rust中,可以使用enum(枚举)来创建一个持有多种不同类型的数据结构。然后使用match语句来做模式匹配。

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
use std::fmt;

enum Animal {
Monkey,
Cow,
}

impl fmt::Display for Animal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Animal::Monkey => write!(f, "I am a monkey!"),
Animal::Cow => write!(f, "I am a cow!"),
}
}
}

fn create_animal(animal_type: &str) -> Result<Animal, String> {
match animal_type {
"Monkey" => Ok(Animal::Monkey),
"Cow" => Ok(Animal::Cow),
_ => Err(format!("Unknown animal type: {}", animal_type)),
}
}

fn main() {
match create_animal("Monkey") {
Ok(animal) => match animal {
Animal::Monkey => println!("{}", animal),
_ => (),
},
Err(e) => println!("{}", e),
}

match create_animal("Cow") {
Ok(animal) => match animal {
Animal::Cow => println!("{}", animal),
_ => (),
},
Err(e) => println!("{}", e),
}

match create_animal("Dog") {
Ok(_) => (),
Err(e) => println!("{}", e),
}
}

Scala

scala中,可以使用sealed trait和case class来创建一个能够返回多种不同类型的方法。Sealed trait可以定义一个有限的子类集合,可以确保类型安全

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
sealed trait Animal {
def info: String
}

case class Monkey() extends Animal {
val info: String = "I am a monkey!"
}

case class Cow() extends Animal {
val info: String = "I am a cow!"
}

object MultiTypeReturnExample {
def createAnimal(animalType: String): Animal = {
animalType match {
case "Monkey" => Monkey()
case "Cow" => Cow()
case _ => throw new IllegalArgumentException(s"Unknown animal type: $animalType")
}
}

def main(args: Array[String]): Unit = {
try {
val animal1 = createAnimal("Monkey")
animal1 match {
case Monkey() => println(animal1.info)
case _ =>
}

val animal2 = createAnimal("Cow")
animal2 match {
case Cow() => println(animal2.info)
case _ =>
}
} catch {
case e: IllegalArgumentException => println(e.getMessage)
}
}
}

TypeScript

总得来说,和javascript区别不大

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
abstract class Animal {
abstract toString(): string;
}

class Monkey extends Animal {
toString(): string {
return 'I am a monkey';
}
}

class Cow extends Animal {
toString(): string {
return 'I am a cow';
}
}

function createAnimal(animalType: string): Animal {
switch (animalType) {
case 'Monkey':
return new Monkey();
case 'Cow':
return new Cow();
default:
throw new Error(`Unknown animal type: ${animalType}`);
}
}

try {
const animal1 = createAnimal('Monkey');
if (animal1 instanceof Monkey) {
console.log(animal1.toString());
}

const animal2 = createAnimal('Cow');
if (animal2 instanceof Cow) {
console.log(animal2.toString());
}

const animal3 = createAnimal('Dog');
} catch (error) {
console.error(error.message);
}

目录

  • 模块组织
  • 测试手段
  • 依赖组件

典型Spring单元测试模块组织

1
2
3
-- xxx-app
-- xxx-util
-- test-common

test-common尽量减少依赖,仅依赖必须的非spring组件。也可以统一将需要使用的resources文件放到test-common中。由test-common统一管理,避免每个模块测试都需要拷贝必须的文件。所需的maven配置如下:

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
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**</include>
<include>**/**</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/resources</outputDirectory>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

一些典型的配置文件,比如log4j2配置文件,同时,由于test-common不属于测试代码,可能在某些组织下会有更高的要求(如不能存在敏感信息等),如组织有这样的要求,则这类内容不适合放在test-common里统一复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="info" monitorInterval="10">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern='%d{yyyy-MM-dd,HH:mm:ss,SSSXXX}(%C:%L):%4p%X[%t#%T]-->%m%n'/>
</Console>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>

测试手段

利用RestAssured端到端测试http接口

添加依赖

1
2
3
4
5
6
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>

为了在SpringBoot测试中使用 RestAssured, 需要配置端口 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT。如:

1
2
3
4
5
6
7
8
9
10
11
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MyRestControllerTest {

@LocalServerPort
int port;

@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}

随后可以使用RestAssured来请求接口

1
RestAssured.given().contentType(ContentType.JSON).body("{}").post("url").then().statusCode(200);

依赖组件

mariadb

mariadb可以使用mariadb4j

1
2
3
4
5
6
<dependency>
<groupId>ch.vorburger.mariaDB4j</groupId>
<artifactId>mariaDB4j</artifactId>
<version>3.0.1</version>
<scope>test</scope>
</dependency>

书写Extension并使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import ch.vorburger.mariadb4j.DB;
import ch.vorburger.mariadb4j.DBConfigurationBuilder;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MariaDBExtension implements BeforeAllCallback, AfterAllCallback {

private DB database;

@Override
public void beforeAll(ExtensionContext context) throws Exception {
DBConfigurationBuilder configBuilder = DBConfigurationBuilder.newBuilder();
configBuilder.setPort(3306);
database = DB.newEmbeddedDB(configBuilder.build());
}

@Override
public void afterAll(ExtensionContext context) throws Exception {
if (database != null) {
database.stop();
}
}
}

ignite

Ignite可以使用现有的junit5集成

1
2
3
4
5
<dependency>
<groupId>io.github.embedded-middleware</groupId>
<artifactId>embedded-ignite-junit5</artifactId>
<version>0.0.3</version>
</dependency>

可以直接使用EmbeddedIgniteExtension,还可以使用EmbeddedIgnitePorts自定义Ignite的关键端口号

Java

Apache http client

Wire log

Apache http client会打印请求和响应的wire log,包含请求和响应的header和body,打印在debug级别。

Apache http client的日志都通过org.apache.http.wire这个logger打印,可以通过配置这个logger来控制wire log的打印。

注:Apache http client默认通过apache common logging来打印日志,可以通过配置

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>1.7.32</version>
</dependency>

来使用slf4j来打印日志。

0%