Spring cloud 服务优雅上下线管理

在基于spring cloud的微服务开发过程中,很多人一定碰到了这样的困扰,当发现部署到服务器上(开发或者测试)的某个服务存在bug时,希望本地启动另外一个实例,通过断点调试去排除问题,这个时候通过网关进来去操作时,网关无法保证一定路由到本地的微服务,以往的操作,就是去服务器上通过kill的方式杀掉服务,这样就特别麻烦。本文通过一种官方推荐的方式更加方便的控制服务的状态。

spring boot: 2.1.3.RELEASE

配置文件,主要是要打开该端点

management:
  endpoints:
    web:
      exposure:
        include: 'service-registry'

使用方法

上线 http://ip+port/actuator/service-registry?status=UP
下线 http://ip+port/actuator/service-registry?status=DOWN

在eureka的管理页面就能查看到服务的状态,接下来通过一个简单的方式,通过一个UI页面去操作。

操作上下线

点击服务列表后面的 UP / DOWN,提示用户是否改变服务状态.
如果服务未暴露service-registry endpoint,弹出提示该服务未开启端点.

刷新服务列表

强制刷新,刷新数据库数据

创建服务

创建一个eureka client服务注册到eureka

pom.xml内容

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.20</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>LATEST</version>
        </dependency>
    </dependencies>

application.yml配置

server:
  port: 11111
eureka:
  client:
    service-url:
      defaultZone: http://10.0.0.182:2000/eureka/
spring:
  application:
    name: service-manager
  datasource:
    url: jdbc:mysql://localhost:3306/service_manage_dev?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password:
    platform: mysql
    driverClassName: com.mysql.cj.jdbc.Driver
    hikari:
      allow-pool-suspension: true
      minimum-idle: 5 #最小空闲连接数量
      idle-timeout: 180000 #空闲最大存活时间
      maximum-pool-size: 10 #连接池最大连接数
      auto-commit: true # 默认自动提交行为
      pool-name: MallHikariCP # 连接池名字
      max-lifetime: 1800000 #连接池中连接最大生命周期
      connection-timeout: 30000 #连接超时时间
      connection-test-query: SELECT 1
    type: com.zaxxer.hikari.HikariDataSource
  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        use_sql_comments: true
        format_sql: true

实现逻辑

存储model

@Entity
@Table(name = "service_instance")
@Data
public class ServiceInstance {

    @Id
    @GeneratedValue(strategy= GenerationType.AUTO)
    private Integer id;

    private String name;

    private String ip;

    private Integer port;
}

初始化增量更新服务列表

public class StartupRunner implements CommandLineRunner {

    @Autowired
    private ServiceInstanceRepository repository;

    @Autowired
    private EurekaClient eurekaClient;

    @Override
    public void run(String... args) throws Exception {
        Applications applications = eurekaClient.getApplications();
        List<Application> registeredApplications = applications.getRegisteredApplications();
        Map<String, ServiceInstance> map = revertToMap(registeredApplications);
        List<ServiceInstance> instanceList = repository.findAll();
        Map<String, ServiceInstance> existMap = revertToMap2(instanceList);

        map.forEach((key, value) -> {
            if (!existMap.containsKey(key)) {
                repository.save(value);
            }
        });
    }

    private Map<String, ServiceInstance> revertToMap2(List<ServiceInstance> instanceList) {
        Map<String, ServiceInstance> map = new HashMap<>();
        instanceList.forEach(instance -> {
            ServiceInstance serviceInstance = new ServiceInstance();
            serviceInstance.setName(instance.getName());
            serviceInstance.setIp(instance.getIp());
            serviceInstance.setPort(instance.getPort());
            map.put(instance.getName() + instance.getIp() + instance.getPort(), serviceInstance);

        });
        return map;
    }

    public Map<String, ServiceInstance> revertToMap(List<Application> applications) {
        Map<String, ServiceInstance> map = new HashMap<>();
        applications.forEach(application -> {
            application.getInstances().forEach(instanceInfo -> {
                ServiceInstance serviceInstance = new ServiceInstance();
                serviceInstance.setName(application.getName());
                serviceInstance.setIp(instanceInfo.getIPAddr());
                serviceInstance.setPort(instanceInfo.getPort());
                map.put(application.getName() + instanceInfo.getIPAddr() + instanceInfo.getPort(), serviceInstance);
            });
        });
        return map;
    }
}

获取服务列表包含状态

  1. 从数据库获取到所有服务
  2. 从eurekaClient获取所有的服务
  3. 如果数据库存在,eureka也存在,直接使用服务状态
  4. 如果数据库存在,eureka不存在,要么是服务已经移除了,要么服务是down掉了,可以通过模拟访问服务的比如health端点,看是否alive,如果alive就标记服务为DOWN,否则UNKOWN,
@Service
public class InstanceService {

    @Autowired
    EurekaClient eurekaClient;

    @Value("${spring.application.name}")
    private String applicationName;

    @Autowired
    private ServiceInstanceRepository instanceRepository;

    public List<ServiceVo> getServices() {
        List<ServiceInstance> instanceList = instanceRepository.findAll();
        Map<String, List<ServiceInstance>> sericeMap = instanceList.stream().collect(Collectors.groupingBy(ServiceInstance::getName));
        Applications applications = eurekaClient.getApplications();
        List<Application> registeredApplications = applications.getRegisteredApplications();
        List<ServiceVo> serviceVos = new ArrayList<>();
        sericeMap.forEach((serviceName, list) -> {
        // 为防止把自己也down掉这里过滤掉
            if (!serviceName.equals(applicationName)) {
                ServiceVo serviceVo = new ServiceVo();
                serviceVo.setServiceName(serviceName);
                serviceVos.add(serviceVo);
                Application application = registeredApplications.stream().filter(app->app.getName().equals(serviceName)).findAny().orElse(null);
                serviceVo.setInstanceList(list.stream().map(instance -> {
                    InstanceVo instanceVo = new InstanceVo();
                    instanceVo.setIp(instance.getIp());
                    instanceVo.setPort(instance.getPort());
                    if (application == null) {
                        boolean isAlive = isAlive(instance.getIp(), instance.getPort());
                        instanceVo.setStatus(isAlive ? InstanceInfo.InstanceStatus.DOWN.name(): InstanceInfo.InstanceStatus.UNKNOWN.name());
                    } else {
                        List<InstanceInfo> instances = application.getInstances();
                        InstanceInfo instanceInfo = instances.stream().filter(in -> in.getIPAddr().equals(instance.getIp()) && in.getPort() == instance.getPort()).findAny().orElse(null);
                        if (instanceInfo == null) {
                            boolean isAlive = isAlive(instance.getIp(), instance.getPort());
                            instanceVo.setStatus(isAlive ? InstanceInfo.InstanceStatus.DOWN.name(): InstanceInfo.InstanceStatus.UNKNOWN.name());
                        } else {
                            instanceVo.setStatus(instanceInfo.getStatus().name());
                        }
                    }
                    return instanceVo;
                }).collect(Collectors.toList()));
            }
        });
        return serviceVos;
    }

    /**
     * 探知服务状态
     * @param ip
     * @param port
     * @return
     */
    private boolean isAlive(String ip, int port) {
        return true;
    }
}

更改服务状态接口

    @PostMapping("/service/status")
    public void changeServiceStatus(@RequestBody InstanceVo instanceVo) {
        String url = "http://" + instanceVo.getIp() + ":" + instanceVo.getPort() + "/actuator/service-registry?status=" + instanceVo.getStatus();
        HashMap<String, Object> hashMap = new HashMap<>();
        ResponseEntity<Object> resp = restTemplate.postForEntity(url, hashMap, Object.class);
    }
thymeleaf 源码 (jquery + bootstrap)
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
    <meta name="description" content="">
    <meta name="author" content="">
    <link rel="icon" href="../../favicon.ico"/>

    <title>服务上下线管理</title>

    <!-- Bootstrap core CSS -->
    <link href="../static/css/bootstrap.css" rel="stylesheet" th:href="@{/css/bootstrap.css}"/>
    <style>
        .status {
            cursor: pointer;
            font-weight: 500;
            text-decoration: underline;
        }

        .up {
            color: green;
        }

        .down {
            color: red;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="page-header">
        <h1><button type="button" class="btn btn-primary" onclick="refresh()">刷新服务列表</button></h1>
    </div>

    <table class="table">
        <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">服务名</th>
            <th scope="col">服务列表</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="service, idx : ${services}">
            <th scope="row" th:text="${idx.count}"></th>
            <td th:text="${service.serviceName}"></td>
            <td>
                <p th:each="instance : ${service.instanceList}">
                    <span><a target="_blank" th:href="@{'http://' + ${instance.ip}+':'+${instance.port}+'/actuator/health'}">http://[[${instance.ip}]]:[[${instance.port}]]</a></span>
                    <span th:text="${instance.status}" th:if="${!instance.status.equals('UP')}" th:ip="${instance.ip}"
                          th:port="${instance.port}"
                          class="status down" onclick="changeStatus(this)"></span>
                    <span th:text="${instance.status}" th:if="${instance.status.equals('UP')}" th:ip="${instance.ip}"
                          th:port="${instance.port}" th:status="${instance.status}"
                          class="status up" onclick="changeStatus(this)"></span>
                </p>
            </td>
        </tr>
        </tbody>
    </table>
</div> <!-- /container -->
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script src="../static/js/bootstrap.js" th:src="@{/js/bootstrap.js}"></script>
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<script>
    $(document).ready(function () {

    })

    function changeStatus(obj) {
        var ip = $(obj).attr('ip');
        var port = $(obj).attr('port');
        var status = $(obj).attr('status');
        if (confirm("确定要更改服务状态吗?")) {
            $.post({
                url: 'service/status',
                dataType: "json",
                type: 'post',
                contentType: "application/json",
                data: JSON.stringify({"ip": ip, "port": port, "status": status === 'UP' ? 'DOWN' : 'UP'}),
                success: function () {
                    $(obj).attr('status', status === 'UP' ? 'DOWN' : 'UP');
                },
                error: function(res) {
                    if (res.status !== 200) {
                        if (res.responseJSON.message && res.responseJSON.message.includes('404')) {
                            alert('没有开启下线服务的endpoint,无法操作')
                        }
                    } else {
                        $(obj).attr('status', status === 'UP' ? 'DOWN' : 'UP');
                    }
                }
            })
        }
    }
    function refresh() {
        $.post({
            url: 'service/refresh',
            dataType: "json",
            type: 'post',
            contentType: "application/json",
            success: function () {
                setTimeout(window.location.reload(), 400)
            }
        })
    }
</script>
</body>
</html>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359