本地服务发现:一种方便的方式(或更繁琐)用于开发学习和测试

137 阅读6分钟

我只是厌倦👺了(本地环境下)开发学习和测试时,手动更新各个服务间调用的地址(端口号),所以心血来潮写了一个本地环境下的服务发现——也是为了今后写套云原生的服务发现做技术准备——它使用本地 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 打交道麻烦,代码中给出了基于 jacksonJacksonHelper)的实现,当然你也可以基于自己喜欢的 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"
    }

这样,当前工作空间下打开的任何终端都会配置如上的环境变量。