文档章节

微服务架构下的服务关联图

杨尚川
 杨尚川
发布于 2018/09/28 20:20
字数 2227
阅读 1349
收藏 7

在微服务架构下,服务之间的关系是非常复杂的,是一个典型的有向有环图,在一个中等规模的项目中,一般会有100多个服务,而大型项目中,则会有数百个服务。

假设我们有如下6个服务:

每个服务都指定了自己依赖的服务:

AaaSvc:

BbbSvc:

CccSvc:

DddSvc:

EeeSvc:

FffSvc:

我们如何把如上6个服务中跟服务AaaSvc相关的服务可视化呢?如下图所示:

要完成这样的服务关联图,需要如下几个步骤:

1、遍历指定项目下的所有服务,构造两个map,serviceToServiceMap 和 reverseServiceToServiceMap,存储所有服务的直接依赖和反向直接依赖。


    public static void runService(String projectId,
                            Map<String, Set<String>> serviceToServiceMap,
                            Map<String, Set<String>> reverseServiceToServiceMap){
        if(! (serviceToServiceMap instanceof ConcurrentHashMap) ){
            throw new RuntimeException("参数serviceToServiceMap必须是ConcurrentHashMap的实例");
        }
        if(! (reverseServiceToServiceMap instanceof ConcurrentHashMap) ){
            throw new RuntimeException("参数reverseServiceToServiceMap必须是ConcurrentHashMap的实例");
        }
        MetaServiceRepository metaServiceRepository = SpringContextUtils.getBean("metaServiceRepository");
        List<MetaService> services = metaServiceRepository.findByProjectId(projectId);
        services.parallelStream().filter(item->!item.getName().contains("Deprecate")).forEach(item->{
            List<DependencyService> dependencyServices = item.constructDependencyServices();
            String key = item.getName()+"("+item.getDescription()+")";
            if(dependencyServices != null){
                dependencyServices.parallelStream().filter(dep->!dep.getName().contains("Deprecate")).forEach(dependencyService->{
                    String value = dependencyService.getName()+"("+dependencyService.getDescription()+")";
                    serviceToServiceMap.putIfAbsent(key, Collections.newSetFromMap(new ConcurrentHashMap<>()));
                    serviceToServiceMap.get(key).add(value);
                    reverseServiceToServiceMap.putIfAbsent(value, Collections.newSetFromMap(new ConcurrentHashMap<>()));
                    reverseServiceToServiceMap.get(value).add(key);
                });
            }
        });
    }

2、以服务AaaSvc为入口,利用直接依赖和反向直接依赖,构造服务依赖图和反向服务依赖图。

String name = metaService.getName()+"("+metaService.getDescription()+")";

Set<String> set = serviceToServiceMap.get(name);
ServiceDependencyGraph serviceDependencyGraph = new ServiceDependencyGraph(new HashMap<>(), name, set, serviceToServiceMap);

set = reverseServiceToServiceMap.get(name);
ServiceDependencyGraph reverseServiceDependencyGraph = new ServiceDependencyGraph(new HashMap<>(), name, set, reverseServiceToServiceMap);
import org.apache.commons.collections4.CollectionUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 服务依赖图
 * @date 2017-09-2
 * @author 杨尚川
 */
public class ServiceDependencyGraph {
    private String name;
    private List<ServiceDependencyGraph> children = new ArrayList<>();

    public ServiceDependencyGraph(Map<String, AtomicInteger> stopServiceNames, String name, Set<String> set, Map<String, Set<String>> serviceToServiceMap){
        this.name = name;
        if(CollectionUtils.isNotEmpty(set)) {
            for (String item : set) {
                String key = name+"_"+item;
                stopServiceNames.putIfAbsent(key, new AtomicInteger());
                stopServiceNames.get(key).incrementAndGet();
                if(stopServiceNames.get(key).get()<10) {
                    Set<String> sub = serviceToServiceMap.get(item);
                    ServiceDependencyGraph serviceDependencyGraph = new ServiceDependencyGraph(stopServiceNames, item, sub, serviceToServiceMap);
                    children.add(serviceDependencyGraph);
                }
            }
        }
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<ServiceDependencyGraph> getChildren() {
        return children;
    }
}

3、利用服务依赖图和反向服务依赖图,构造带有点和边描述信息的JSON结构:

List<Map<String, String>> nodes = new ArrayList<>();

addNode(serviceDependencyGraph, nodes);
addNode(reverseServiceDependencyGraph, nodes);

List<Map<String, Object>> edges = new ArrayList<>();

addEdge(reverseServiceToServiceMap, serviceDependencyGraph, edges, false);
addEdge(reverseServiceToServiceMap, reverseServiceDependencyGraph, edges, true);

Map<String, Object> graph = new HashMap<>();
graph.put("edges", edges);
graph.put("nodes", nodes);

private void addNode(String node, List<Map<String, String>> nodes){
    for(Map<String, String> item : nodes){
        if(node.equals(item.get("id"))){
            return;
        }
    }
    Map<String, String> nodeMap = new HashMap<>();
    nodeMap.put("id", node);
    nodeMap.put("name", node);
    nodes.add(nodeMap);
}

private void addNode(ServiceDependencyGraph serviceDependencyGraph, List<Map<String, String>> nodes){
    if(serviceDependencyGraph == null){
        return;
    }
    String node = serviceDependencyGraph.getName();
    addNode(node, nodes);
    if(serviceDependencyGraph.getChildren() != null){
        serviceDependencyGraph.getChildren().forEach(item->addNode(item, nodes));
    }
}

private void addEdge(Map<String, Set<String>> reverseServiceToServiceMap, ServiceDependencyGraph serviceDependencyGraph, List<Map<String, Object>> edges, boolean reverse){
    if(serviceDependencyGraph == null){
        return;
    }
    String source = serviceDependencyGraph.getName();
    serviceDependencyGraph.getChildren().forEach(target -> {
        boolean duplicate = false;
        Map<String, Object> map = new HashMap<>();
        if(reverse){
            String id = target.getName()+"-->"+source;
            for(Map<String, Object> item : edges){
                if(id.equals(item.get("id"))){
                    duplicate = true;
                }
            }
            map.put("id", id);
            map.put("target", source);
            map.put("source", target.getName());
            map.put("directed", true);
            map.put("source_score", reverseServiceToServiceMap.get(target.getName()) == null ? 0 : reverseServiceToServiceMap.get(target.getName()).size());
            map.put("target_score", reverseServiceToServiceMap.get(source) == null ? 0 : reverseServiceToServiceMap.get(source).size());
        }else {
            String id = source+"-->"+target.getName();
            for(Map<String, Object> item : edges){
                if(id.equals(item.get("id"))){
                    duplicate = true;
                }
            }
            map.put("id", id);
            map.put("source", source);
            map.put("target", target.getName());
            map.put("directed", true);
            map.put("source_score", reverseServiceToServiceMap.get(source) == null ? 0 : reverseServiceToServiceMap.get(source).size());
            map.put("target_score", reverseServiceToServiceMap.get(target.getName()) == null ? 0 : reverseServiceToServiceMap.get(target.getName()).size());
        }
        if(!duplicate) {
            edges.add(map);
        }
        addEdge(reverseServiceToServiceMap, target, edges, reverse);
    });
}

生成的JSON结构如下所示:

{
    "nodes":[
        {
            "globalWeight":4,
            "name":"AaaSvc(服务Aaa)"
        },
        {
            "globalWeight":4,
            "name":"CccSvc(服务Ccc)"
        },
        {
            "globalWeight":5,
            "name":"DddSvc(服务Ddd)"
        },
        {
            "globalWeight":4,
            "name":"EeeSvc(服务Eee)"
        },
        {
            "globalWeight":4,
            "name":"FffSvc(服务Fff)"
        },
        {
            "globalWeight":3,
            "name":"BbbSvc(服务Bbb)"
        }
    ],
    "edges":[
        {
            "distance":8,
            "source":0,
            "target":1
        },
        {
            "distance":8,
            "source":1,
            "target":0
        },
        {
            "distance":9,
            "source":0,
            "target":2
        },
        {
            "distance":9,
            "source":2,
            "target":3
        },
        {
            "distance":9,
            "source":3,
            "target":2
        },
        {
            "distance":9,
            "source":2,
            "target":4
        },
        {
            "distance":8,
            "source":4,
            "target":3
        },
        {
            "distance":8,
            "source":3,
            "target":4
        },
        {
            "distance":9,
            "source":4,
            "target":2
        },
        {
            "distance":7,
            "source":0,
            "target":5
        },
        {
            "distance":7,
            "source":5,
            "target":1
        },
        {
            "distance":7,
            "source":1,
            "target":5
        }
    ]
}

4、使用d3-force对如上的JSON进行展示:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>测试项目_testDemo -- 服务Aaa_AaaSvc -- 服务关联图</title>
    <style>
        .node {
            stroke: #fff;
            stroke-width: 1.5px;
        }
        .node-active{
            stroke: #555;
            stroke-width: 1.5px;
        }
        .link {
            stroke: #555;
            stroke-opacity: .3;
        }
        .link-active {
            stroke-opacity: 1;
            stroke-width: 1.5px;
        }
        .overlay {
            fill: none;
            pointer-events: all;
        }
        #map{
            height:100%;
        }

        #ex1Slider .slider-selection {
            background: #BABABA;
        }

        #ex2Slider .slider-selection {
            background: #BABABA;
        }

        #ex3Slider .slider-selection {
            background: #BABABA;
        }

        #ex4Slider .slider-selection {
            background: #BABABA;
        }

        #ex5Slider .slider-selection {
            background: #BABABA;
        }

    </style>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.2.0/css/bootstrap-slider.min.css"/>
    <script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
    <script src="https://code.jquery.com/jquery-2.2.4.min.js" charset="utf-8"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.2.0/bootstrap-slider.min.js" charset="utf-8"></script>
    <script type="text/javascript">
        var gravity = 0.03;
        var linkDistanceMultiFactor = 10;
        var linkDistance = 100;
        var circleRadiusMultiFactor = 2;
        var circleRadius = 4;
        var arrowhead = 10;

        var graph;

        function load(graph){
            console.log("loading ...... ");
            console.log("gravity: "+gravity);
            console.log("linkDistanceMultiFactor: "+linkDistanceMultiFactor);
            console.log("linkDistance: "+linkDistance);
            console.log("circleRadiusMultiFactor: "+circleRadiusMultiFactor);
            console.log("circleRadius: "+circleRadius);


            var margin = {top: -5, right: -5, bottom: -5, left: -5};
            var width = $(window).width() - margin.left - margin.right,
                height = $(window).height() - margin.top - margin.bottom;

            var color = d3.scale.category10();

            var force = d3.layout.force()
                .charge(-200)
                .linkDistance(function(d) {return (d.source.weight+d.target.weight)*linkDistanceMultiFactor+linkDistance;})
                .size([width + margin.left + margin.right, height + margin.top + margin.bottom])
                .charge([-500])
                .theta(0.1)
                .gravity(gravity);

            var zoom = d3.behavior.zoom()
                .scaleExtent([0.3, 10])
                .on("zoom", zoomed);

            var drag = d3.behavior.drag()
                .origin(function(d) { return d; })
                .on("dragstart", dragstarted)
                .on("drag", dragged)
                .on("dragend", dragended);

            var svg = d3.select("#map").append("svg")
                .attr("width", width + margin.left + margin.right)
                .attr("height", height + margin.top + margin.bottom)
                .append("g")
                .attr("transform", "translate(" + margin.left + "," + margin.right + ")")
                .call(zoom);

            var rect = svg.append("rect")
                .attr("width", width)
                .attr("height", height)
                .style("fill", "none")
                .style("pointer-events", "all");

            var container = svg.append("g");

            force
                .nodes(graph.nodes)
                .links(graph.edges)
                .start();

            var link = container.append("g")
                .attr("class", "links")
                .selectAll(".link")
                .data(force.links())
                .enter()
                .append("line")
                .attr("class", "link")
                .attr('marker-end', function(d,i) { return d.rpm === 0 ? '' : 'url(#arrowhead)'})
                .style("stroke-width", function(d) { return Math.sqrt(d.value); });

            var node = container.append("g")
                .attr("class", "nodes")
                .selectAll(".node")
                .data(force.nodes())
                .enter().append("g")
                .attr("class", "node")
                .attr("cx", function(d) { return d.x; })
                .attr("cy", function(d) { return d.y; })
                .call(drag);

            var nodeLabel = container.selectAll(".nodelabel")
                .data(force.nodes())
                .enter()
                .append("text")
                .style("pointer-events", "none")
                .attr({"x":function(d){return d.x;},
                    "y":function(d){return d.y;},
                    "class":"nodelabel",
                    "stroke":"#666"})
                .text(function(d){return d.name;});

            var linkPath = container.selectAll(".linkpath")
                .data(force.links())
                .enter()
                .append('path')
                .attr({'d': function(d) {return 'M '+d.source.x+' '+d.source.y+' L '+ d.target.x +' '+d.target.y},
                    'class':'linkpath',
                    'fill-opacity':0,
                    'stroke-opacity':0,
                    'fill':'blue',
                    'stroke':'red',
                    'id':function(d,i) {return 'linkpath'+i}})
                .style("pointer-events", "none");

            var linkLabel = container.selectAll(".linklabel")
                .data(force.links())
                .enter()
                .append('text')
                .style("pointer-events", "none")
                .attr({'class':'linklabel',
                    'id':function(d,i){return 'linklabel'+i},
                    'dx':90,
                    'dy':-5,
                    'font-size':12,
                    'fill':'#666'});

            linkLabel.append('textPath')
                .attr('xlink:href',function(d,i) {return '#linkpath'+i})
                .style("pointer-events", "none")
                .text(function(d,i){ return d.rpm > 0 ? d.rpm + ' req/min' : ""; });

            container.append('defs').append('marker')
                .attr({'id':'arrowhead',
                    'viewBox':'-0 -5 10 10',
                    'refX':25,
                    'refY':0,
                    'orient':'auto',
                    'markerWidth':arrowhead,
                    'markerHeight':arrowhead,
                    'xoverflow':'visible'})
                .append('svg:path')
                .attr('d', 'M 0,-5 L 10 ,0 L 0,5')
                .attr('fill', '#000')
                .attr('stroke','#000');

            node.append("circle")
                .attr("r", function(d) { return d.weight*circleRadiusMultiFactor + circleRadius; })
                .style("fill", function(d,i) { return "#e7ba52"; });

            force.on("tick", function() {
                link.attr("x1", function(d) { return d.source.x; })
                    .attr("y1", function(d) { return d.source.y; })
                    .attr("x2", function(d) { return d.target.x; })
                    .attr("y2", function(d) { return d.target.y; });

                node.attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });

                nodeLabel.attr("x", function(d) { return d.x; })
                    .attr("y", function(d) { return d.y; });

                linkPath.attr('d', function(d) {
                    var path='M '+d.source.x+' '+d.source.y+' L '+ d.target.x +' '+d.target.y;
                    return path;
                });

                linkLabel.attr('transform',function(d){
                    if (d.target.x < d.source.x) {
                        bbox = this.getBBox();
                        rx = bbox.x+bbox.width/2;
                        ry = bbox.y+bbox.height/2;
                        return 'rotate(180 ' + rx + ' ' + ry + ')';
                    }
                    else {
                        return 'rotate(0)';
                    }
                });
            });

            var linkedByIndex = {};
            force.links().forEach(function(d) {
                linkedByIndex[d.source.index + "," + d.target.index] = 1;
            });

            function isConnected(a, b) {
                return linkedByIndex[a.index + "," + b.index] || linkedByIndex[b.index + "," + a.index];
            }

            node.on("mouseover", function(d) {
                node.classed("node-active", function(o) {
                    thisOpacity = isConnected(d, o) ? true : false;
                    this.setAttribute('fill-opacity', thisOpacity);
                    return thisOpacity;
                });

                link.classed("link-active", function(o) {
                    return o.source === d || o.target === d ? true : false;
                });

                d3.select(this).classed("node-active", true);
                d3.select(this).select("circle").transition()
                    .duration(750)
                    .attr("r", function(d) { return (d.weight*circleRadiusMultiFactor + circleRadius)*1.5; });
            })
                .on("mouseout", function(d){
                    node.classed("node-active", false);
                    link.classed("link-active", false);

                    d3.select(this).select("circle").transition()
                        .duration(750)
                        .attr("r", function(d) { return d.weight*circleRadiusMultiFactor + circleRadius; });
                });

            function dottype(d) {
                d.x = +d.x;
                d.y = +d.y;
                return d;
            }

            function zoomed() {
                container.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
            }

            function dragstarted(d) {
                d3.event.sourceEvent.stopPropagation();
                d3.select(this).classed("dragging", true);
                force.start();
            }

            function dragged(d) {
                d3.select(this).attr("cx", d.x = d3.event.x).attr("cy", d.y = d3.event.y);
            }

            function dragended(d) {
                d3.select(this).classed("dragging", false);
            }
        }


        $(function() {
            $('#ex1').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                gravity = e.value/100;
                load(graph);
            });
            $('#ex2').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                linkDistance = e.value;
                load(graph);
            });
            $('#ex3').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                linkDistanceMultiFactor = e.value;
                load(graph);
            });
            $('#ex4').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                circleRadius = e.value;
                load(graph);
            });
            $('#ex5').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                circleRadiusMultiFactor = e.value;
                load(graph);
            });
            $('#ex6').slider({
                tooltip: 'hide'
            }).on('slideStop', function (e) {
                //获取新值
                arrowhead = e.value;
                load(graph);
            });
            graph = JSON.parse('{"nodes":[{"globalWeight":4,"name":"AaaSvc(服务Aaa)"},{"globalWeight":4,"name":"CccSvc(服务Ccc)"},{"globalWeight":5,"name":"DddSvc(服务Ddd)"},{"globalWeight":4,"name":"EeeSvc(服务Eee)"},{"globalWeight":4,"name":"FffSvc(服务Fff)"},{"globalWeight":3,"name":"BbbSvc(服务Bbb)"}],"edges":[{"distance":8,"source":0,"target":1},{"distance":8,"source":1,"target":0},{"distance":9,"source":0,"target":2},{"distance":9,"source":2,"target":3},{"distance":9,"source":3,"target":2},{"distance":9,"source":2,"target":4},{"distance":8,"source":4,"target":3},{"distance":8,"source":3,"target":4},{"distance":9,"source":4,"target":2},{"distance":7,"source":0,"target":5},{"distance":7,"source":5,"target":1},{"distance":7,"source":1,"target":5}]}');
            load(graph);
        });

    </script>
</head>
<body>

<h3>测试项目_testDemo -- 服务Aaa_AaaSvc -- 服务关联图</h3>
<table>
    <tr>
        <td width="50"></td>
        <td>
            <span class="item">
                重力调节
                <input id="ex1" type="text" data-slider-id="ex1Slider" class="span2" data-slider-min="0" data-slider-max="100" data-slider-step="1" data-slider-value="0.03"/>
            </span>
        </td>
        <td width="50"></td>
        <td>
            <span class="item">
                最短边长
                <input id="ex2" type="text" data-slider-id="ex2Slider" class="span2" data-slider-min="0" data-slider-max="250" data-slider-step="1" data-slider-value="100"/>
            </span>
        </td>
        <td width="50"></td>
        <td>
            <span class="item">
                边长放大倍数
                <input id="ex3" type="text" data-slider-id="ex3Slider" class="span2" data-slider-min="0" data-slider-max="50" data-slider-step="1" data-slider-value="10"/>
            </span>
        </td>
        <td width="50"></td>
        <td>
            <span class="item">
                最短半径
                <input id="ex4" type="text" data-slider-id="ex4Slider" class="span2" data-slider-min="0" data-slider-max="10" data-slider-step="1" data-slider-value="4"/>
            </span>
        </td>
        <td width="50"></td>
        <td>
            <span class="item">
                半径放大倍数
                <input id="ex5" type="text" data-slider-id="ex5Slider" class="span2" data-slider-min="0" data-slider-max="5" data-slider-step="1" data-slider-value="2"/>
            </span>
        </td>
        <td width="50"></td>
        <td>
            <span class="item">
                箭头大小
                <input id="ex6" type="text" data-slider-id="ex5Slider" class="span2" data-slider-min="10" data-slider-max="30" data-slider-step="1" data-slider-value="10"/>
            </span>
        </td>
    </tr>
</table>
<div id="map"></div>
</body>
</html>

最终运行的效果如下:

 

 

© 著作权归作者所有

杨尚川

杨尚川

粉丝 1103
博文 220
码字总数 1624053
作品 12
东城
架构师
私信 提问
EdgeX Foundry理论篇

总体架构 核心服务层 核心数据微服务 搜集持久化设备和传感器等边缘设备数据,支持导出到云。目前,数据本地存储交互通过REST APIs,未来会支持更多协议:MQTT, AMQP等。数据导出到Export Se...

武巅
2018/07/09
0
0
漫谈何时从单体架构迁移到微服务?

面对微服务如火如荼的发展,很多人都在了解,学习希望能在自己的项目中帮得上忙,当你对微服务的庐山真面目有所了解后,接下来就是说服自己了,到底如何评估微服务,什么时候使用微服务,什么...

dotNET跨平台
01/01
0
0
单体架构,SOA架构,微服务架构,分布式架构,集群架构

单体架构 什么是单体架构 一个归档包(例如war格式或者Jar格式)包含了应用所有功能的应用程序,我们通常称之为单体应用。架构单体应用的方法论,我们称之为单体应用架构,这是一种比较传统的...

ben4
2018/05/17
0
0
干货下载:谷歌、亚马逊等十大公司微服务案例精选

自去年以来,微服务受到了前所未有的关注,众多的互联网巨头开始实施微服务架构并取得了不错的反响,话不多说,今天我们就为大家盘点一下谷歌、亚马逊等十大科技公司的微服务实践案例。 1. 谷...

good_rain
2016/12/08
38
0
使用 Spring Cloud 搭建微服务项目架构

前言: 本文为使用 Spring Cloud 搭建微服务项目架构的整体思路讲述,欢迎讨论。文章对新手不友好,推荐新手访问此文:史上最简单的 SpringCloud 教程 终章,讲得很好。 1、微服务的定义 微服...

木云凌
2018/08/30
736
4

没有更多内容

加载失败,请刷新页面

加载更多

会用python把linux命令写一遍的人,进大厂有多容易?

看过这篇《2000字谏言,给那些想学Python的人,建议收藏后细看!》的读者应该都对一个命令有点印象吧?没错,就是 linux 中经常会用到的 ls 命令。 文章中我就提到如何提升自己的 python 能力...

上海小胖
18分钟前
6
0
HashMap的特性

一、hashmap数据结构:哈希表结构:数组+链表 hashmap调用默认构造方法会产生一个默认底层是长度为16的Entry数组,首先调用key的hasCode()方法来得到一个整数, int hash = hash(key.hashCode...

GGbird
19分钟前
16
0
第五章 spring-connet之Imports注解来龙去脉

前言 imports是一个在spring体系里非常重要的注解,基本每个Enable开头的注解必然有一个import注解。接下来我们深入研究下import的作用。看小节的同学建议先取看PostProcessorRegistrationDe...

鸟菜啊
22分钟前
4
0
CentOS部署Harbor镜像仓库

关于Harbor Harbor是用于存储和分发Docker镜像的镜像仓库服务,相比Docker Registry,Harbor在安全、标识、管理等方面做了增强,更适合企业使用; 官方网站:https://goharbor.io/ 官方开源:...

程序员欣宸
27分钟前
5
0
JavaScript调试必会的8个console方法

每个JavaScript开发者都用过console.log()来调试程序,但实际上Console对象还提供了很多其他方法可以提高调试效率。本文将介绍8个有趣的Console方法,即使JavaScript老手也不一定知道! 1、c...

汇智网教程
49分钟前
9
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部