我只是厌倦👺了(本地环境下)开发学习和测试时,手动更新各个服务间调用的地址(端口号),所以心血来潮写了一个本地环境下的服务发现——也是为了今后写套云原生的服务发现做技术准备——它使用本地 JSON 文件作为数据存储,读写流程基本是:
graph LR
读json文件 --> 反序列化
反序列化 --> 获取
获取 --> 更新
更新 --> 序列化
序列化 --> 写json文件
它支持的特性有:
- 服务实例发布
- 服务注销
- 服务查询
- 多语言支持
- 健康检查
- 心跳机制
- 监听器
json file 的读写使用一个 LOCK 文件来保证串行化的。
为了保证简单:复制源码即可使用,所有实现代码都尽量单文件里。
这里给出 python 和 java 两个语言的实现,实现很简单,无论你使用 nodejs 还是 go 你都可以很快地复刻出来。
首先先看一些约定(当然你可以完全制定自己的约定)
约定
Record- 描述 service 服务实例的信息和位置的数据,格式:
{
"id" : "396233b7-67ce-456b-978e-e4b18b6870fc", // 服务实例ID,唯一
"name" : "Web-Service-A", // 服务名,不唯一
"location" : {
"ip" : "127.0.0.1",
"port" : 9001
},
"type" : "REST", // 服务类型,比如 REST、HTTP、RPC、MYSQL
"metadata" : { }, // 扩展信息
"healthcheck" : {
"open" : true, // 健康检查开关
"ttl" : 3000, // TTL 单位ms
"lastsync" : 1682136583818 // 上次心跳时间 单位ms
}
}
LocalServiceDiscovery- 服务发布、注销、查询、健康检查等功能集合data.json- 以Record数组格式保存发布的服务实例LOCK- 可选,保证data.json的读写操作串行化- 健康判断 -
(!record.healthcheck.open) || (record.healthcheck.open && record.healthcheck.lastsync + record.healthcheck.ttl > System.currentTimeMillis());
Python 实现
python 版本简单,方便快速了解逻辑,不过 python 版本目前没有实现健康检查和监听器机制(以后会补充吧?)。
"""
简单的服务发现来方便本地开发
@Since 2023-04
"""
import json
from typing import List, Dict
from collections.abc import Callable
import os
import time
import uuid
import random
Predicate = Callable[[Dict], bool] # Like java.util.function.Predicate
class LocalServiceDiscovery:
"""
简单的服务发现系统,基于本地文件提供发布、注销、查询功能
"""
DATA_FILE_NAME = "data.json"
LOCK_FILE_NAME = "LOCK"
def __init__(self, store_path) -> None:
"""
@param store_path 保存注册信息的文件路径
"""
self.store_path = store_path
if not os.path.exists(self.store_path):
os.mkdir(self.store_path)
self.data_file = os.path.join(self.store_path, self.DATA_FILE_NAME)
self.lock_file = os.path.join(self.store_path, self.LOCK_FILE_NAME)
def publish(self, record: dict) -> str:
"""
服务发布
@return 注册服务ID
"""
try:
self.__trylock()
records = self.__read()
record["id"] = str(uuid.uuid1())
records.append(record)
self.__write(records)
return record['id']
finally:
self.__unlock()
def unpublish(self, id: str = None, name = None) -> List[str]:
"""
服务注销
@return 注销服务IDs
"""
try:
self.__trylock()
records = self.__read()
removed = []
news = []
for _ in records:
if _["id"] == id or _["name"] == name:
removed.append(_["id"])
else:
news.append(_)
self.__write(news)
return removed
finally:
self.__unlock()
def get_record(self, predicate: Predicate) -> Dict|None:
"""
获取单个服务
"""
records = self.get_records(predicate)
return records[0] if records else None
def get_records(self, predicate: Predicate = lambda _:True) -> List[Dict]:
"""
获取服务
"""
if not predicate: return []
try:
self.__trylock()
records = self.__read()
return list(filter(predicate, records))
finally:
self.__unlock()
def __read(self) -> List[Dict]:
if not os.path.exists(self.data_file):
return []
with open(self.data_file, "r", encoding="utf-8") as f:
text = "".join(f.readlines())
if len(text) == 0 or text.isspace(): return []
return json.loads(text)
def __write(self, records: List[Dict]):
with open(self.data_file, "w", encoding="utf-8") as f:
return json.dump(records, f, indent=4)
def __unlock(self):
os.remove(self.lock_file)
def __trylock(self):
start = time.time()
while os.path.exists(self.lock_file) and time.time() - start < 2:
time.sleep(random.randint(1, 10) / 1000)
with open(self.lock_file, "x"):
return
Quickstart
store_path = "C:\\tmp\\service-discovery"
service_discovery = LocalServiceDiscovery(store_path)
### 某个地方启动了一个 RPC 服务
service_name = "RPC-Service-A"
web_record = {
"name": service_name,
"type": "GRPC",
"localtion": {
"ip": "127.0.0.1",
"port": 5050
},
"healthcheck": {
"open": False,
"ttl": 0,
"lastsync": 0
}
}
# 服务发布
service_id = service_discovery.publish(web_record)
print(f"服务已发布 {service_id}")
# 服务查询
find_record = service_discovery.get_record(lambda _:_["name"] == service_name)
print(f"找到 {service_name} 服务实例 - {find_record}")
# 服务注销
removed_servier_ids = service_discovery.unpublish(id=service_id)
print(f"服务已注销 {removed_servier_ids}")
# 服务查询
find_record = service_discovery.get_record(lambda _:_["name"] == service_name)
print(f"找到 {service_name} 服务实例 - {find_record}")
服务已发布 498f658c-e0cc-11ed-b874-d4548b2c4b91
找到 RPC-Service-A 服务实例 - {'name': 'RPC-Service-A', 'type': 'GRPC', 'localtion': {'ip': '127.0.0.1', 'port': 5050}, 'healthcheck': {'open': False, 'ttl': 0, 'lastsync': 0}, 'id': '498f658c-e0cc-11ed-b874-d4548b2c4b91'}
服务已注销 ['498f658c-e0cc-11ed-b874-d4548b2c4b91']
找到 RPC-Service-A 服务实例 - None
Java 实现
java 版本全面一点,包含了健康检查和监听器机制
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.ToString;
/**
* 简单的服务发现系统,基于本地文件提供发布、注销、查询、健康检查等功能
*
* @Since 2023-04
*/
public class LocalServiceDiscovery {
private static final String DATA_FILE_NAME = "data.json";
private static final String LOCK_FILE_NAME = "LOCK";
private final Path storePath;
private final Path dataFile;
private final Path lockFile;
private Function<String, List<Record>> loader;
private Function<List<Record>, String> dumper;
private LocalServiceDiscovery(String storePath) throws Exception {
this.storePath = Path.of(storePath);
if (Files.notExists(this.storePath)) {
Files.createDirectories(this.storePath);
}
dataFile = Path.of(storePath, DATA_FILE_NAME);
lockFile = Path.of(storePath, LOCK_FILE_NAME);
}
/**
* 创建实例
*
* @param storePath 保存注册信息的文件路径
* @param loader for jsonStr -> pojo
* @param dumper for pojo -> jsonStr
* @throws Exception 如果发生IO错误
*/
public static LocalServiceDiscovery create(String storePath,
Function<String, List<Record>> loader,
Function<List<Record>, String> dumper) throws Exception {
var serviceDiscovery = new LocalServiceDiscovery(Objects.requireNonNull(storePath));
serviceDiscovery.loader = Objects.requireNonNull(loader);
serviceDiscovery.dumper = Objects.requireNonNull(dumper);
return serviceDiscovery;
}
/**
* 服务发布。服务名可重复,即同服务名下注册多个服务实例
*
* @return 注册服务ID
*/
public String publish(Record record) {
try {
trylock();
List<Record> records = read();
record.id = UUID.randomUUID().toString();
records.add(record);
write(records);
return record.id;
} finally {
unlock();
}
}
/**
* 服务注销
*
* @param id 服务ID
* @param name 服务名
* @return 注销服务ID
*/
public List<String> unpublish(String id, String name) {
if (id == null && name == null) return Collections.emptyList();
Predicate<Record> predicate = record -> Objects.equals(record.id, id) || Objects.equals(record.name, name);
return unpublish(predicate);
}
/**
* 服务注销
*
* @return 注销服务ID
*/
public List<String> unpublish(Predicate<Record> predicate) {
if (predicate == null) return Collections.emptyList();
try {
trylock();
List<String> removed = new ArrayList<>();
List<Record> news = new ArrayList<>();
for (Record record : read()) {
if (predicate.test(record)) {
removed.add(record.id);
} else {
news.add(record);
}
}
write(news);
return removed;
} finally {
unlock();
}
}
/**
* 获取单个服务
*
* @param predicate
* @return
*/
public Record getRecord(Predicate<Record> predicate) {
var records = getRecords(predicate);
return records.isEmpty() ? null : records.get(0);
}
/**
* 获取服务
*
* @param predicate
* @return
*/
public List<Record> getRecords(Predicate<Record> predicate) {
if (predicate == null) return new ArrayList<>();
try {
trylock();
return read().stream().filter(predicate).toList();
} finally {
unlock();
}
}
/**
* 开启健康检查
* @param id
* @param ttl TTL 毫秒
*/
public void openHealthCheck(String id, long ttl) {
executeHeartbeat(id, record -> {
record.healthcheck.open = true;
record.healthcheck.ttl = ttl;
record.healthcheck.lastsync = System.currentTimeMillis();
});
}
/**
* 关闭监控检查
* @param id
*/
public void closeHealthCheck(String id) {
executeHeartbeat(id, record -> {
record.healthcheck.open = false;
record.healthcheck.ttl = 0L;
record.healthcheck.lastsync = 0L;
});
}
/**
* 心跳同步
* @param id
*/
public void heartbeat(String id) {
executeHeartbeat(id, r -> r.healthcheck.lastsync = System.currentTimeMillis());
}
/**
* 检查健康状态。未开启健康检查将总是返回 true,不存在返回 false
* @param id
* @param consumer
*/
public boolean checkHealth(String id) {
return HEALTH_CHECKER.test(getRecord(r -> Objects.equals(r.id, id)));
}
private static final ThreadFactory FACTORY = r -> {
var t = Executors.defaultThreadFactory().newThread(r);
t.setDaemon(true);
return t;
};
private static final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2, FACTORY);
private final Map<String, ScheduledFuture<?>> listeners = new HashMap<>();
/**
* 注册监听器
* @param listener
* @return 监听器 ID
*/
public synchronized String registerListener(Runnable listener) {
var f = executor.scheduleAtFixedRate(listener, 100, 100, TimeUnit.MILLISECONDS);
String id = ThreadLocalRandom.current().ints(16, 1, 36)
.mapToObj( n -> Integer.toString(n, 36))
.collect(Collectors.joining(""));
listeners.put(id, f);
return id;
}
/**
* 注销监听器
*/
public synchronized void unregisterListener(String listenerId) {
if (listenerId == null) return;
if (listeners.containsKey(listenerId)) {
listeners.remove(listenerId).cancel(false);
}
}
private void executeHeartbeat(String id, Consumer<Record> consumer) {
try {
trylock();
List<Record> records = read();
records.stream().filter(record -> Objects.equals(record.id, id))
.forEach(consumer);
write(records);
} catch (Exception e) {
} finally {
unlock();
}
}
private List<Record> read() {
try {
if (!dataFile.toFile().canRead()) {
return new ArrayList<>();
}
return loader.apply(Files.readString(dataFile));
} catch (Exception e) {
return new ArrayList<>();
}
}
private void write(List<Record> records) {
try {
Files.writeString(dataFile, dumper.apply(records));
} catch (Exception e) {}
}
private synchronized void trylock() {
try {
long start = System.currentTimeMillis();
while (Files.exists(lockFile) && System.currentTimeMillis() - start < 2000) {
TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextInt(1, 10));
}
Files.createFile(lockFile);
} catch (Exception e) {
Thread.currentThread().interrupt();
}
}
private void unlock() {
try {
Files.deleteIfExists(lockFile);
} catch (Exception e) {}
}
/**
* 描述 service 服务的信息和位置
*
* <p>*各语言约定俗称的数据结构
*/
@ToString
public static class Record {
public String id;
public String name;
public Location location = new Location();
public String type;
public Map<String, Object> metadata = new HashMap<>();
public HealthCheck healthcheck = new HealthCheck();
}
@ToString
public static class Location {
public String ip;
public int port;
}
@ToString
public static class HealthCheck {
public boolean open;
public long ttl;
public long lastsync;
}
/**
* Record 健康状态判断器
*/
public static final Predicate<Record> HEALTH_CHECKER = record -> {
if (record == null) return false;
return (!record.healthcheck.open) || (record.healthcheck.open &&
record.healthcheck.lastsync + record.healthcheck.ttl > System.currentTimeMillis());
};
// Implement loader and dumper with jackson
public static class JacksonHelper {
private static final ObjectMapper om = new ObjectMapper();
static {
om.enable(SerializationFeature.INDENT_OUTPUT);
var pp = new DefaultPrettyPrinter();
pp.indentArraysWith(new DefaultIndenter(" ", "\n"));
pp.indentObjectsWith(new DefaultIndenter(" ", "\n"));
om.setDefaultPrettyPrinter(pp);
}
private JacksonHelper() {}
public static List<Record> load(String text) {
try {
return om.readValue(text, new TypeReference<List<Record>>() {});
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static String dump(List<Record> list) {
try {
return om.writeValueAsString(list);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
众所周知 java 和 json 打交道麻烦,代码中给出了基于 jackson(JacksonHelper)的实现,当然你也可以基于自己喜欢的 json 库来实现:
loader- jsonText ->List<Record>dumper-List<Record>-> jsonText
Quickstart
// 服务发布
var storePath = "C:\\tmp\\service-discovery";
var serviceDiscovery = LocalServiceDiscovery.create(storePath, JacksonHelper::load, JacksonHelper::dump);
String serviceName = "Web-Service-A";
serviceDiscovery.unpublish(null, serviceName);
var webRecord = new Record();
webRecord.name = serviceName;
webRecord.location.ip = "127.0.0.1";
webRecord.location.port = 9001;
webRecord.type = "REST";
var webRecord2 = new Record();
webRecord2.name = serviceName;
webRecord2.location.ip = "127.0.0.1";
webRecord2.location.port = 9002;
webRecord2.type = "REST";
String serviceId = serviceDiscovery.publish(webRecord);
String serviceId2 = serviceDiscovery.publish(webRecord2);
System.out.println("服务已发布 " + serviceId + " 健康状态 " + serviceDiscovery.checkHealth(serviceId));
System.out.println("服务已发布 " + serviceId2 + " 健康状态 " + serviceDiscovery.checkHealth(serviceId2));
// 开启健康检查 TTL 3000ms
serviceDiscovery.openHealthCheck(serviceId, 3000);
// 停止心跳 3500ms
TimeUnit.MILLISECONDS.sleep(3500);
System.out.println("服务 " + serviceId + " 健康状态 " + serviceDiscovery.checkHealth(serviceId));
// 获取健康状态的服务
serviceDiscovery.getRecords(HEALTH_CHECKER.and(r -> Objects.equals(r.name, serviceName)))
.forEach(System.out::println);
System.out.println();
// 发送心跳
serviceDiscovery.heartbeat(serviceId);
// 注册监听器
AtomicReference<List<Record>> heatchServices = new AtomicReference<>();
heatchServices.set(serviceDiscovery.getRecords(HEALTH_CHECKER.and(r -> Objects.equals(r.name, serviceName))));
Runnable listener = () -> {
heatchServices
.set(serviceDiscovery.getRecords(HEALTH_CHECKER.and(r -> Objects.equals(r.name, serviceName))));
};
String listenerId = serviceDiscovery.registerListener(listener);
System.out.println("监听器已注册 " + listenerId);
System.out.println("健康服务数量 " + heatchServices.get().size());
serviceDiscovery.unpublish(serviceId2, null);
System.out.println("注销服务 " + serviceId2);
TimeUnit.MILLISECONDS.sleep(150);
System.out.println("健康服务数量 " + heatchServices.get().size());
服务已发布 b79b1d27-fa83-432c-907d-c2f7a9662069 健康状态 true
服务已发布 49948c0a-6a2a-4116-a030-97fb049b55cc 健康状态 true
服务 b79b1d27-fa83-432c-907d-c2f7a9662069 健康状态 false
LocalServiceDiscovery.Record(id=49948c0a-6a2a-4116-a030-97fb049b55cc, name=Web-Service-A, location=LocalServiceDiscovery.Location(ip=127.0.0.1, port=9002), type=REST, metadata={}, healthcheck=LocalServiceDiscovery.HealthCheck(open=false, ttl=0, lastsync=0))
监听器已注册 8mxz47n2ddx3l74z
健康服务数量 2
注销服务 49948c0a-6a2a-4116-a030-97fb049b55cc
健康服务数量 1
环境变量: 使用 VSCode 的自动配置
另外一种方式是把端口号或地址的方式写到环境变量里,每个程序需要的时候直接读取环境变量。
如果使用 VSCode,在当前工作空间的 .vscode\settings.json 文件里可以配置 terminal.integrated.env.windows (windows下)属性,比如:
"terminal.integrated.env.windows": {
"JAVA_SERVICE_PORT": "7701",
"PYTHON_SERVICE_PORT": "7702",
"VERTX_SERVICE_PORT": "7703",
"NODE_SERVICE_PORT": "7704"
}
这样,当前工作空间下打开的任何终端都会配置如上的环境变量。