2023年10月

作者:vivo 互联网中间件团队- Wang Xiaochuang

本文主要介绍在vivo内部针对Dubbo路由模块及负载均衡的一些优化手段,主要是异步化+缓存,可减少在RPC调用过程中路由及负载均衡的CPU消耗,极大提升调用效率。

一、概要

vivo内部Java技术栈业务使用的是Apache Dubbo框架,基于开源社区2.7.x版本定制化开发。在海量微服务集群的业务实践中,我们发现Dubbo有一些性能瓶颈的问题会极大影响业务逻辑的执行效率,尤其是在集群规模数量较大时(提供方数量>100)时,路由及负载均衡方面有着较大的CPU消耗,从采集的火焰图分析高达30%。为此我们针对vivo内部常用路由策略及负载均衡进行相关优化,并取得了较好的效果。接下来主要跟大家分析一下相关问题产生的根源,以及我们采用怎样的方式来解决这些问题。当前vivo内部使用的Dubbo的主流版本是基于2.7.x进行相关定制化开发。

二、背景知识

2.1 Dubbo客户端调用流程

1. 相关术语介绍

图片

2. 主要流程

客户端通过本地代理Proxy调用ClusterInvoker,ClusterInvoker从服务目录Directory获取服务列表后经过路由链获取新的服务列表、负载均衡从路由后的服务列表中根据不同的负载均衡策略选取一个远端Invoker后再发起远程RPC调用。

图片

2.2 Dubbo路由机制

Dubbo的路由机制实际是基于简单的责任链模式实现,同时Router继承了Comparable接口,自定义的路由可以设置不同的优先级进而定制化责任链上Router的顺序。基于责任链模式可以支持多种路由策略串行执行如就近路由+标签路由,或条件路由+就近路由等,且路由的配置支持基于接口级的配置也支持基于应用级的配置。常见的路由方式主要有:就近路由,条件路由,标签路由等。具体的执行过程如下图所示:

图片

1. 核心类

Dubbo路由的核心类主要有:
RouterChain、RouterFactory 与 Router 。

(1)RouterChain

RouterChain是路由链的入口,其核心字段有

  • invokers(List<invoker> 类型)

初始服务列表由服务目录Directory设置,当前RouterChain要过滤的Invoker集合

  • builtinRouters(List类型)

当前RouterChain包含的自动激活的Router集合

  • routers(List类型)

包括所有要使用的路由由builtinRouters加上通过addRouters()方法添加的Router对象

RouterChain核心逻辑:

public class RouterChain<T> {

    // 注册中心最后一次推送的服务列表
    private List<Invoker<T>> invokers = Collections.emptyList();

    // 所有路由,包括原生Dubbo基于注册中心的路由规则如“route://” urls .
    private volatile List<Router> routers = Collections.emptyList();

    // 初始化自动激活的路由
    private List<Router> builtinRouters = Collections.emptyList();
  
   private RouterChain(URL url) {
    //通过ExtensionLoader加载可自动激活的RouterFactory
        List<RouterFactory> extensionFactories = ExtensionLoader.getExtensionLoader(RouterFactory.class)
                .getActivateExtension(url, ROUTER_KEY);
    // 由工厂类生成自动激活的路由策略
        List<Router> routers = extensionFactories.stream()
                .map(factory -> factory.getRouter(url))
                .collect(Collectors.toList());

        initWithRouters(routers);
    }
  
    // 添加额外路由
    public void addRouters(List<Router> routers) {
        List<Router> newRouters = new ArrayList<>();
        newRouters.addAll(builtinRouters);
        newRouters.addAll(routers);
        Collections.sort(newRouters, comparator);
        this.routers = newRouters;
    }
  
   public List<Invoker<T>> route(URL url, Invocation invocation) {
        List<Invoker<T>> finalInvokers = invokers;
    // 遍历全部的Router对象,执行路由规则
        for (Router router : routers) {
            finalInvokers = router.route(finalInvokers, url, invocation);
        }
        return finalInvokers;
    }
}

(2)RouterFactory为Router的工厂类

RouterFactory接口定义:

@SPI
public interface RouterFactory {

    @Adaptive("protocol")
    Router getRouter(URL url);
}

(3)Router

Router是真正的路由实现策略,由RouterChain进行调用,同时Router继承了Compareable接口,可以根据业务逻辑设置不同的优先级。

Router主要接口定义:

public interface Router extends Comparable<Router> {

   
    /**
     *
     * @param invokers   带过滤实例列表
     * @param url        消费方url
     * @param invocation 会话信息
     * @return routed invokers
     * @throws RpcException
     */
    <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;


    /**
     * 当注册中心的服务列表发现变化,或有动态配置变更会触发实例信息的变化
     * 当时2.7.x的Dubbo并没有真正使用这个方法,可基于此方法进行路由缓存
     * @param invokers invoker list
     * @param <T>      invoker's type
     */
    default <T> void notify(List<Invoker<T>> invokers) {

    }

}

2. 同机房优先路由的实现

为方便大家了解路由的实现,给大家展示一下就近路由的核心代码逻辑:

public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL consumerUrl, Invocation invocation) throws RpcException {
        if (!this.enabled) {
            return invokers;
        }

      
    // 获取本地机房信息
        String local = getSystemProperty(LOC);
        if (invokers == null || invokers.size() == 0) {
            return invokers;
        }
        List<Invoker<T>> result = new ArrayList<Invoker<T>>();
        for (Invoker invoker: invokers) {
      // 获取与本地机房一致的invoker并加入列表中
            String invokerLoc = getProperty(invoker, invocation, LOC);
            if (local.equals(invokerLoc)) {
                result.add(invoker);
            }
        }

        if (result.size() > 0) {
            if (fallback){
                // 开启服务降级,available.ratio = 当前机房可用服务节点数量 / 集群可用服务节点数量
                int curAvailableRatio = (int) Math.floor(result.size() * 100.0d / invokers.size());
                if (curAvailableRatio <= availableRatio) {
                    return invokers;
                }
            }

            return result;
        } else if (force) {
            return result;
        } else {
            return invokers;
        }

    }

2.3 Dubbo负载均衡

Dubbo的负载均衡实现比较简单基本都是继承抽象类进行实现,主要作用就是根据具体的策略在路由之后的服务列表中筛选一个实例进行远程RPC调用,默认的负载均衡策略是随机。

整体类图如下所示:

图片

LoadBalance接口定义:

@SPI(RandomLoadBalance.NAME)
public interface LoadBalance {

    /**
     * 从服务列表中筛选一个.
     *
     * @param invokers   invokers.
     * @param url        refer url
     * @param invocation invocation.
     * @return selected invoker.
     */
    @Adaptive("loadbalance")
    <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;

}

随机负载均衡核心代码解析:

 // 预热过程权重计算
   static int calculateWarmupWeight(int uptime, int warmup, int weight) {
        int ww = (int) (uptime / ((float) warmup / weight));
        return ww < 1 ? 1 : (Math.min(ww, weight));
    }

int getWeight(Invoker<?> invoker, Invocation invocation) {
       int weight;
       URL url = invoker.getUrl();
       // 多注册中心场景下的,注册中心权重获取
       if (UrlUtils.isRegistryService(url)) {
           weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
       } else {
           weight = url.getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT);
           if (weight > 0) {
               // 获取实例启动时间
               long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L);
               if (timestamp > 0L) {
                   long uptime = System.currentTimeMillis() - timestamp;
                   if (uptime < 0) {
                       return 1;
                   }
                   // 获取预热时间
                   int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP);
                   if (uptime > 0 && uptime < warmup) {
                       weight = calculateWarmupWeight((int)uptime, warmup, weight);
                   }
               }
           }
       }
       return Math.max(weight, 0);
   }

@Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        // Number of invokers
        int length = invokers.size();
        // Every invoker has the same weight?
        boolean sameWeight = true;
        // the weight of every invokers
        int[] weights = new int[length];
        // the first invoker's weight
        int firstWeight = getWeight(invokers.get(0), invocation);
        weights[0] = firstWeight;
        // The sum of weights
        int totalWeight = firstWeight;
        for (int i = 1; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation);
            // save for later use
            weights[i] = weight;
            // Sum
            totalWeight += weight;
            if (sameWeight && weight != firstWeight) {
                sameWeight = false;
            }
        }
        if (totalWeight > 0 && !sameWeight) {
            // If (not every invoker has the same weight & at least one invoker's weight>0), select randomly based on totalWeight.
            int offset = ThreadLocalRandom.current().nextInt(totalWeight);
            // Return a invoker based on the random value.
            for (int i = 0; i < length; i++) {
                offset -= weights[i];
                if (offset < 0) {
                    return invokers.get(i);
                }
            }
        }
        // If all invokers have the same weight value or totalWeight=0, return evenly.
        return invokers.get(ThreadLocalRandom.current().nextInt(length));
    }

预热解释

预热是为了让刚启动的实例流量缓慢增加,因为实例刚启动时各种资源可能还没建立连接,相关代码可能还是处于解释执行,仍未变为JIT执行,此时业务逻辑较慢,不应该加载过大的流量,否则有可能造成较多的超时。Dubbo默认预热时间为10分钟,新部署的实例的流量会在预热时间段内层线性增长,最终与其他实例保持一致。Dubbo预热机制的实现就是通过控制权重来实现。如默认权重100,预热时间10分钟,则第一分钟权重为10,第二分钟为20,以此类推。

具体预热效果图如下:

图片

三、问题分析

使用Dubbo的业务方反馈,他们通过火焰图分析发现Dubbo的负载均衡模块+路由模块占用CPU超过了30%,框架层面的使用率严重影响了业务逻辑的执行效率急需进行优化。通过火焰图分析,具体占比如下图,其中该机器在业务忙时的CPU使用率在60%左右,闲时在30%左右。

图片

通过火焰图分析,负载均衡主要的消耗是在 getWeight方法。

图片

路由的主要消耗是在route方法:

  • 同机房优先路由

图片

  • 接口级标签路由+应用级标签路由

图片

这些方法都有一个特点,那就是遍历执行。如负载均衡,针对每一个invoker都需要通过getWeight方法进行权重的计算;就近路由的router方法对于每一个invoker都需要通过url获取及机房信息进行匹配计算。

我们分析一下getWeight及router时间复杂度,发现是O(n)的时间复杂度,而且路由是由路由链组成的,每次每个 Router的route方法调用逻辑都会遍历实例列表,那么当实例列表数量过大时,每次匹配的计算的逻辑过大,那么就会造成大量的计算成本,导致占用大量cpu,同时也导致路由负载均衡效率低下。

综上所述,罪恶的的根源就是遍历导致的,当服务提供方数量越多,影响越大。

四、优化方案

知道了问题所在,我们来分析一下是否有优化空间。

4.1 路由优化

1. 优化一:关闭无效路由

通过火焰图分析,我们发现有部分业务即使完全不使用应用级的标签路由,原生的TagRouter也存在遍历逻辑,原因是为了支持静态的标签路由,其实这部分的开销也不少,那对于根本不会使用应用级标签路由的可以手动进行关闭。关闭方式如下:

  • 客户端统一关闭

dubbo.consumer.router=-tag
  • 服务级别关闭

  • 注解方式:

@DubboReference(parameters = {"router","-tag"})
  • xml方式:

<dubbo:reference id="demoService" check="false" interface="com.dubbo.study.n.api.DemoService" router="-tag" />

2. 优化二:提前计算路由结果并进行缓存

每次路由目前都是进行实时计算,但是在大多数情况下,我们的实例列表是稳定不变的,只有在发布窗口或配置变更窗口内实例列表才会发生变更,那我们是否可以考虑缓存呢。如就近路由,可以以机房为key进行机房实例的全量缓存。针对接口级标签路由可以缓存不同标签值指定的实例信息。

我们知道路由的执行过程是责任链模式,每一个Router的实例列表入参实际上是一个Router的结果,可参考公式:target = rn(…r3(r2(r1(src))))。那么所有的路由可以基于注册中心推送的原始服务列表进行路由计算并缓存,然后不同的路由结果相互取交集就能得到最终的结果,当实例信息发生变更时,缓存失效并重新计算。

3. 缓存更新时机

当注册中心或者动态配置有变更时,相关通知会给到服务目录Directory,Directory收到通知后会重新创建服务列表,并把服务列表同步到路由链RouterChain,RouterChain再按顺序通知其链上的Router,各个Router再进行缓存清除并重新进行路由结果的计算及进行缓存。相关时序图如下所示:

图片

4. 具体路由流程

进入具体路由方法时,先判断是否存在缓存的路由值,且缓存值的epoch必须与上一个路由的epoch需一致,此时缓存才生效,然后缓存值与上个Router的结果取交集。

如果不存在缓存或epoch不一致则重新进行实时的路由计算。

图片

引入epoch的原因主要是保证各个路由策略缓存信息的一致性,保证所有的缓存计算都是基于同一份原始数据。当实例信息发生变更时,epoch会自动进行更新。

5. BitMap引入

上文我们说到,不同的路由策略之间的结果是取交集的,然后最终的结果才送入负载均衡流程。那如何在缓存的同时,加快交集的计算呢。答案就是基于位图:BitMap。

BitMap的基本原理就是用一个bit位来存放某种状态,适用于大规模数据的查找及位运算操作。如在路由场景,先基于全量的推送数据进行计算缓存。如果某个实例被路由选中,则其值为1,若两个路由的结果要取交集,那直接对BitMap进行"&"运行即可。

全量缓存示意图:

图片

路由交集计算示步骤:

按照路由链依次计算,tagRouter->vivoTag->vivoNearestRouter

(1)tagRouter计算逻辑:

  1. 按照Invocation计算出目标的Tag,假设是tag1

  2. 然后从缓存Cache根据key:tag1,取出对应的targetAddrPool

  3. 将原始传入的addrPool与targetAddrPool,得到结果resultAddrPool

  4. 将resultAddrPool传入vivoTagRouter

(2)vivoTag计算逻辑:

  1. 按照Invocation计算出目标的Tag,假设是tabB

  2. 然后从缓存Cache根据key:tag1,取出对应的targetAddrPool

  3. 将上一次传入的addrPool与targetAddrPool,得到结果resultAddrPooll

  4. 将resultAddrPool传入vivoNearestRouter

(3)vivoNearestRouter计算逻辑

  1. 从环境变量取出当前机房,假设是bj01

  2. 然后从缓存Cache根据key:bj01,取出对应的targetAddrPool

  3. 将上一次传入的addrPool与targetAddrPool,取出resultAddrPool

  4. 将上一次传入的addrPool与targetAddrPool,得到结果resultAddrPool

  5. 将resultAddrPool为最终路由结果,传递给LoadBalance

图片

6. 基于缓存的同机房优先路由源码解析

缓存刷新:

/**
     * Notify router chain of the initial addresses from registry at the first time.
     * Notify whenever addresses in registry change.
     */
    public void setInvokers(List<Invoker<T>> invokers) {
    // 创建带epoch的BitList
        this.invokers = new BitList<Invoker<T>>(invokers == null ? Collections.emptyList() : invokers,createBitListEpoch());
        routers.forEach(router -> router.notify(this.invokers));
    }

同机房优先路由源码解读:

public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL consumerUrl, Invocation invocation) throws RpcException {
        …………//省略非核心代码
        BitList<Invoker<T>> bitList = (BitList<Invoker<T>>) invokers;
    //获取路由结果
        BitList<Invoker<T>> result = getNearestInvokersWithCache(bitList);
        if (result.size() > 0) {
            if (fallback) {
                // 开启服务降级,available.ratio = 当前机房可用服务节点数量 / 集群可用服务节点数量
                int curAvailableRatio = (int) Math.floor(result.size() * 100.0d / invokers.size());
                if (curAvailableRatio <= availableRatio) {
                    return invokers;
                }
            }

            return result;
        } else if (force) {
            return result;
        } else {
            return invokers;
        }

    }   
   /**
     * 获取缓存列表
     * @param invokers
     * @param <T>
     * @return
     */
    private <T> BitList<Invoker<T>> getNearestInvokersWithCache(BitList<Invoker<T>> invokers) {
        ValueWrapper valueWrapper = getCache(getSystemProperty(LOC));
        // 是否存在缓存
        if (valueWrapper != null) {
            BitList<Invoker<T>> invokerBitList = (BitList<Invoker<T>>) valueWrapper.get();
            // 缓存的epoch与源列表是否一致
            if (invokers.isSameEpoch(invokerBitList)) {
                BitList<Invoker<T>> tmp = invokers.clone();
                // 结果取交集
                return tmp.and(invokerBitList);
            }
        }
        // 缓存不存在 实时计算放回
        return getNearestInvokers(invokers);
    }
  
  /**
     * 新服务列表通知
     * @param invokers
     * @param <T>
     */
 @Override
    public <T> void notify(List<Invoker<T>> invokers) {
        clear();
        if (invokers != null && invokers instanceof BitList) {
            BitList<Invoker<T>> bitList = (BitList<Invoker<T>>) invokers;
      // 设置最后一次更新的服务列表
            lastNotify = bitList.clone();
            if (!CollectionUtils.isEmpty(invokers) && this.enabled) {
        // 获取机房相同的服务列表并进行缓存
                setCache(getSystemProperty(LOC), getNearestInvokers(lastNotify));
            }
        }
    }

4.2 负载均衡优化

1. 优化一

针对getWeight方法,我们发现有部分业务逻辑较为消耗cpu,但是在大多数场景下业务方并不会使用到,于是进行优化。

getWeight方法优化:

优化前:
//这里主要要用多注册中心场景下,注册中心权重的获取,绝大多数情况下并不会有这个逻辑
 if (UrlUtils.isRegistryService(url)) {
           weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
       }  
优化后:
 if (invoker instanceof ClusterInvoker && UrlUtils.isRegistryService(url)) {
            weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
   }

2. 优化二

遍历是罪恶的源泉,而实例的数量决定这罪恶的深浅,我们有什么办法减少负载均衡过程中的遍历呢。一是根据group及version划分不同的集群,但是这需要涉及到业务方代码或配置层面的改动,会带来额外的成本。所以我们放弃了。

二是没有什么是加一层解决不了的问题,为了尽量减少进入负载均衡的节点数量,考虑新增一个垫底的路由策略,在走完所有的路由策略后,若节点数量>自定义数量后,进行虚拟分组,虚拟分组的策略也可进行自定义,然后随机筛选一组进入负载均衡。此时进入负载均衡的实例数量就会有倍数的下降。

需要注意的是分组路由必须保证是在路由链的最后一环,否则会导致其他路由计算错误。

图片

分组路由示意:

/**
     * 
     * @param invokers 待分组实例列表
     * @param groupNum 分组数量
     * @param <T>
     * @return
     */
    public <T> List<Invoker<T>> doGroup(List<Invoker<T>> invokers, int groupNum) {
        int listLength = invokers.size() / groupNum;
        List<Invoker<T>> result = new ArrayList<>(listLength);
        int random = ThreadLocalRandom.current().nextInt(groupNum);
        for (int i = random; i < invokers.size(); i = i + groupNum) {
            result.add(invokers.get(i));
        }
        return result;
    }

五、优化效果

针对优化前和优化后,我们编写Demo工程分别压测了不配置路由/配置就近+标签路由场景。Provider节点梯度设置100/500/1000/2000/5000,TPS在1000左右,记录了主机的cpu等性能指标,并打印火焰图。发现,配置路由后,采用相同并发,优化后的版本tps明显高于优化前版本,且新版本相较于没有配置路由时tps显著提高,下游节点数大于2000时,tps提升达到100%以上,下游节点数越多,AvgCpu优化效果越明显,并且路由及负载均衡CPU占比明显更低,详细数据可见下表:

图片

图片

备注:-tag,表示显示禁用原生Dubbo应用级标签路由。该路由默认开启。

六、总结

经过我们关闭不必要的路由逻辑、对路由缓存+异步化计算、新增分组路由等优化后,Dubbo在负载均衡及路由模块整体的性能有了显著的提升,为业务方节省了不少CPU资源。在正常业务场景下当提供方数量达到2000及以上时,tps提升可达100%以上,消费方平均CPU使用率下降约27%,且提供方数量越多优化效果越明显。但是我们也发现当前的随机负载均衡依然还是会消耗一定的CPU资源,且只能保证流量是均衡的。当前我们的应用基本部署在虚拟机及容器上。这两者均存在超卖的状况,且同等配置的宿主机性能存在较大差异等问题。最终会导致部分请求超时、无法最大化利用提供方的资源。我们下一步将会引入Dubbo 3.2的自适应负载均衡并进行调优减少其CPU使用率波动较大的问题,其次我们自身也扩展了基于CPU负载均衡的单一因子算法,最终实现不同性能的机器CPU负载趋于均衡,最大程度发挥集群整体的性能。

参考资料:

  1. Dubbo 负载均衡

  2. Dubbo 流量管控

  3. Dubbo 3 StateRouter:下一代微服务高效流量路由

网站上线中遇到的问题(跨域,404,空白页解决方案)

因为本人是后端开发工程师,对前端开发不了解,踩了很多坑,所以将踩过的坑分享出来,以供参考

网站地址:
这里

这段时间将项目部署到服务器中引发了几个问题

第一个问题:跨域

一般跨域是由后端来解决的,但是由于项目中加入了追踪请求的日志,频繁options会使后台服务多一些无用的请求日志,所以使用
nginx
来做跨域

image-20231012165033063

在对应的后端
api

location
块儿中增加
Access-Control-Allow-Origin

Access-Control-Allow-Methods

Access-Control-Allow-Credentials

Access-Control-Allow-Headers
,然后判断请求方法为options则返回204状态码

第二个问题:404

将vue项目打包部署到服务器后访问网站,当点击某个页面,然后刷新此网页时会有404错误(图1和图2),解决方法还是修改
nginx
配置文件

图一:

image-20231012170354648

图二:

image-20231012170500340

我的
nginx
配置为:

image-20231012170000040

关键点是
try_files $uri $uri/ /index.html;
,这段的意思是
nginx
服务器会先尝试在本地查找你请求的uri地址,若请求不到则交给vue的路由处理

第三个问题:空白页

不知道因为什么在
vue.config.js
的配置中加入了

image-20231012176666668425

在生产环境中打包后出现了这个问题:
Uncaught SyntaxError: Unexpected token '<' (at chunk-vendors.f35dccd9.js:1:1)

image-20231012171439417

通过
https://blog.csdn.net/HeXinT/article/details/125082562
找到了解决方案,将
publicPath
注释掉就可以解决掉这个问题了

本文演示如何在K8s集群下部署Kafka集群,并且搭建后除了可以K8s内部访问Kafka服务,也支持从K8s集群外部访问Kafka服务。服务的集群部署通常有两种方式:一种是 StatefulSet,另一种是 Service&Deployment。本次我们使用 StatefulSet 方式搭建 ZooKeeper 集群,使用 Service&Deployment 搭建 Kafka 集群。

一、创建 NFS 存储

NFS 存储主要是为了给 Kafka、ZooKeeper 提供稳定的后端存储,当 Kafka、ZooKeeper 的 Pod 发生故障重启或迁移后,依然能获得原先的数据。

1,安装 NFS

这里我选择在 master 节点创建 NFS 存储,首先执行如下命令安装 NFS:
yum -y install nfs-utilsyum -y install rpcbind

2,创建共享文件夹

(1)执行如下命令创建 6 个文件夹:
mkdir -p /usr/local/k8s/zookeeper/pv{1..3}mkdir -p /usr/local/k8s/kafka/pv{1..3}

(2)编辑 /etc/exports 文件:

vim /etc/exports

(3)在里面添加如下内容:

/usr/local/k8s/kafka/pv1 *(rw,sync,no_root_squash)/usr/local/k8s/kafka/pv2 *(rw,sync,no_root_squash)/usr/local/k8s/kafka/pv3 *(rw,sync,no_root_squash)/usr/local/k8s/zookeeper/pv1 *(rw,sync,no_root_squash)/usr/local/k8s/zookeeper/pv2 *(rw,sync,no_root_squash)/usr/local/k8s/zookeeper/pv3 *(rw,sync,no_root_squash)

(4)保存退出后执行如下命令重启服务:

如果执行 systemctl restart nfs 报“Failed to restart nfs.service: Unit nfs.service not found.”错误,可以尝试改用如下命令:

    • sudo service nfs-server start
systemctl restart rpcbind
systemctl restart nfs
systemctl enable nfs

(5)执行 exportfs -v 命令可以显示出所有的共享目录:

(6)而其他的 Node 节点上需要执行如下命令安装 nfs-utils 客户端:

yum -y install nfs-util

(7)然后其他的 Node 节点上可执行如下命令(ip 为 Master 节点 IP)查看 Master 节点上共享的文件夹:

showmount -e  107.106.37.33(nfs服务端的IP)

二、创建 ZooKeeper 集群

1,创建 ZooKeeper PV

(1)首先创建一个 zookeeper-pv.yaml 文件,内容如下:

注意:170.106.37.33 需要改成实际 NFS 服务器地址:
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv-zk01
labels:
app: zk
annotations:
volume.beta.kubernetes.io/storage-class: "anything"
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/zookeeper/pv1"
persistentVolumeReclaimPolicy: Recycle
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv-zk02
labels:
app: zk
annotations:
volume.beta.kubernetes.io/storage-class: "anything"
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/zookeeper/pv2"
persistentVolumeReclaimPolicy: Recycle
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv-zk03
labels:
app: zk
annotations:
volume.beta.kubernetes.io/storage-class: "anything"
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/zookeeper/pv3"
persistentVolumeReclaimPolicy: Recycle

(2)然后执行如下命令创建 PV:

kubectl apply -f zookeeper-pv.yaml

(3)执行如下命令可以查看是否创建成功:

kubectl get pv

2,创建 ZooKeeper 集群

(1)我们这里要搭建一个包含 3 个节点的 ZooKeeper 集群。首先创建一个 zookeeper.yaml 文件,内容如下:
apiVersion: v1
kind: Service
metadata:
name: zk-hs
labels:
app: zk
spec:
selector:
app: zk
clusterIP: None
ports:
- name: server
port: 2888
- name: leader-election
port: 3888
---
apiVersion: v1
kind: Service
metadata:
name: zk-cs
labels:
app: zk
spec:
selector:
app: zk
type: NodePort
ports:
- name: client
port: 2181
nodePort: 31811
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: zk
spec:
serviceName: "zk-hs"
replicas: 3 # by default is 1
selector:
matchLabels:
app: zk # has to match .spec.template.metadata.labels
updateStrategy:
type: RollingUpdate
podManagementPolicy: Parallel
template:
metadata:
labels:
app: zk # has to match .spec.selector.matchLabels
spec:
containers:
- name: zk
imagePullPolicy: Always
image: leolee32/kubernetes-library:kubernetes-zookeeper1.0-3.4.10
ports:
- containerPort: 2181
name: client
- containerPort: 2888
name: server
- containerPort: 3888
name: leader-election
command:
- sh
- -c
- "start-zookeeper \
--servers=3 \
--data_dir=/var/lib/zookeeper/data \
--data_log_dir=/var/lib/zookeeper/data/log \
--conf_dir=/opt/zookeeper/conf \
--client_port=2181 \
--election_port=3888 \
--server_port=2888 \
--tick_time=2000 \
--init_limit=10 \
--sync_limit=5 \
--heap=4G \
--max_client_cnxns=60 \
--snap_retain_count=3 \
--purge_interval=12 \
--max_session_timeout=40000 \
--min_session_timeout=4000 \
--log_level=INFO"
readinessProbe:
exec:
command:
- sh
- -c
- "zookeeper-ready 2181"
initialDelaySeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- sh
- -c
- "zookeeper-ready 2181"
initialDelaySeconds: 10
timeoutSeconds: 5
volumeMounts:
- name: datadir
mountPath: /var/lib/zookeeper
volumeClaimTemplates:
- metadata:
name: datadir
annotations:
volume.beta.kubernetes.io/storage-class: "anything"
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi

(2)然后执行如下命令开始创建:

kubectl apply -f zookeeper.yaml

(3)执行如下命令可以查看是否创建成功:

kubectl get pods
kubectl get service

三、创建 Kafka 集群

(1)我们这里要搭建一个包含 3 个节点的 Kafka 集群。首先创建一个 kafka.yaml 文件,内容如下:
注意:

  • nfs 地址需要改成实际 NFS 服务器地址。
  • status.hostIP 表示宿主机的 IP,即 Pod 实际最终部署的 Node 节点 IP(本文我是直接部署到 Master 节点上),将 KAFKA_ADVERTISED_HOST_NAME 设置为宿主机 IP 可以确保 K8s 集群外部也可以访问 Kafka

apiVersion: v1
kind: Service
metadata:
name: kafka-service-1
labels:
app: kafka-service-1
spec:
type: NodePort
ports:
- port: 9092
name: kafka-service-1
targetPort: 9092
nodePort: 30901
protocol: TCP
selector:
app: kafka-1
---
apiVersion: v1
kind: Service
metadata:
name: kafka-service-2
labels:
app: kafka-service-2
spec:
type: NodePort
ports:
- port: 9092
name: kafka-service-2
targetPort: 9092
nodePort: 30902
protocol: TCP
selector:
app: kafka-2
---
apiVersion: v1
kind: Service
metadata:
name: kafka-service-3
labels:
app: kafka-service-3
spec:
type: NodePort
ports:
- port: 9092
name: kafka-service-3
targetPort: 9092
nodePort: 30903
protocol: TCP
selector:
app: kafka-3
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kafka-deployment-1
spec:
replicas: 1
selector:
matchLabels:
app: kafka-1
template:
metadata:
labels:
app: kafka-1
spec:
containers:
- name: kafka-1
image: wurstmeister/kafka
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9092
env:
- name: KAFKA_ZOOKEEPER_CONNECT
value: zk-0.zk-hs.default.svc.cluster.local:2181,zk-1.zk-hs.default.svc.cluster.local:2181,zk-2.zk-hs.default.svc.cluster.local:2181
- name: KAFKA_BROKER_ID
value: "1"
- name: KAFKA_CREATE_TOPICS
value: mytopic:2:1
- name: KAFKA_LISTENERS
value: PLAINTEXT://0.0.0.0:9092
- name: KAFKA_ADVERTISED_PORT
value: "30901"
- name: KAFKA_ADVERTISED_HOST_NAME
valueFrom:
fieldRef:
fieldPath: status.hostIP
volumeMounts:
- name: datadir
mountPath: /var/lib/kafka
volumes:
- name: datadir
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/kafka/pv1"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kafka-deployment-2
spec:
replicas: 1
selector:
matchLabels:
app: kafka-2
template:
metadata:
labels:
app: kafka-2
spec:
containers:
- name: kafka-2
image: wurstmeister/kafka
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9092
env:
- name: KAFKA_ZOOKEEPER_CONNECT
value: zk-0.zk-hs.default.svc.cluster.local:2181,zk-1.zk-hs.default.svc.cluster.local:2181,zk-2.zk-hs.default.svc.cluster.local:2181
- name: KAFKA_BROKER_ID
value: "2"
- name: KAFKA_LISTENERS
value: PLAINTEXT://0.0.0.0:9092
- name: KAFKA_ADVERTISED_PORT

value: "30902"
- name: KAFKA_ADVERTISED_HOST_NAME
valueFrom:
fieldRef:
fieldPath: status.hostIP
volumeMounts:
- name: datadir
mountPath: /var/lib/kafka
volumes:
- name: datadir
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/kafka/pv2"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kafka-deployment-3
spec:
replicas: 1
selector:
matchLabels:
app: kafka-3
template:
metadata:
labels:
app: kafka-3
spec:
containers:
- name: kafka-3
image: wurstmeister/kafka
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9092
env:
- name: KAFKA_ZOOKEEPER_CONNECT
value: zk-0.zk-hs.default.svc.cluster.local:2181,zk-1.zk-hs.default.svc.cluster.local:2181,zk-2.zk-hs.default.svc.cluster.local:2181
- name: KAFKA_BROKER_ID
value: "3"
- name: KAFKA_LISTENERS
value: PLAINTEXT://0.0.0.0:9092
- name: KAFKA_ADVERTISED_PORT
value: "30903"
- name: KAFKA_ADVERTISED_HOST_NAME
valueFrom:
fieldRef:
fieldPath: status.hostIP
volumeMounts:
- name: datadir
mountPath: /var/lib/kafka
volumes:
- name: datadir
nfs:
server: 170.106.37.33
path: "/usr/local/k8s/kafka/pv3"

(2)然后执行如下命令开始创建:

kubectl apply -f kafka.yaml

(3)执行如下命令可以查看是否创建成功:

kubectl get pods
kubectl get service

四、开始测试

1,K8s 集群内部测试

(1)首先执行如下命令进入一个容器:
kubectl exec -it kafka-deployment-1-59f87c7cbb-99k46 /bin/bash

(2)接着执行如下命令创建一个名为 test_topic 的 topic:

kafka-topics.sh --create --topic test_topic --zookeeper zk-0.zk-hs.default.svc.cluster.local:2181,zk-1.zk-hs.default.svc.cluster.local:2181,zk-2.zk-hs.default.svc.cluster.local:2181 --partitions 1 --replication-factor 1

(3)创建后执行如下命令开启一个生产者,启动后可以直接在控制台中输入消息来发送,控制台中的每一行数据都会被视为一条消息来发送。

kafka-console-producer.sh --broker-list kafka-service-1:9092,kafka-service-2:9092,kafka-service-3:9092 --topic test_topic

(4)重新再打开一个终端连接服务器,然后进入容器后执行如下命令开启一个消费者:

kafka-console-consumer.sh --bootstrap-server kafka-service-1:9092,kafka-service-2:9092,kafka-service-3:9092 --topic test_topic

(5)再次打开之前的消息生产客户端来发送消息,并观察消费者这边对消息的输出来体验 Kafka 对消息的基础处理。

2,集群外出测试

使用 Kafka 客户端工具(Offset Explorer)连接 Kafka 集群(可以通过 zookeeper 地址连接,也可以通过 kafka 地址连接),可以连接成功并能查看到数据。

更多的测试命令参考:

二、Kafka生产者消费者实例(基于命令行)1.创建一个itcasttopic的主题
代码如下(示例):
kafka
-topics.sh --create --topic itcasttopic --partitions 3 --replication-factor 2 -zookeeper 10.0.0.27:2181,10.0.0.103:2181,10.0.0.37:2181 2.hadoop01当生产者
代码如下(示例):
kafka
-console-producer.sh --broker-list kafka-service-1:9092,kafka-service-2:9092,kafka-service-3:9092 --topic itcasttopic3.hadoop02当消费者
代码如下(示例):
kafka
-console-consumer.sh --from-beginning --topic itcasttopic --bootstrap-server kafka-service-1:9092,kafka-service-2:9092,kafka-service-3:9092 3.–list查看所有主题
代码如下(示例):
kafka
-topics.sh --list --zookeeper 10.0.0.27:2181,10.0.0.103:2181,10.0.0.37:2181 4.删除主题
代码如下(示例):
kafka
-topics.sh --delete --zookeeper 10.0.0.27:2181,10.0.0.103:2181,10.0.0.37:2181 --topic itcasttopic5.关闭kafka
代码如下(示例):
bin
/kafka-server-stop.sh config/server.properties

1.简介

上一篇中宏哥主要讲解和分享了一下,我们常见或者传统的select下拉框的操作,但是近几年又出现了了一种新的select下拉框,其和我们传统的select下拉框完全不一样,那么我们如何使用playwright对其进行定位操作了。宏哥今天就来讲解和分享一下仅供大家参考,不喜勿喷。

2.新的select

宏哥发现随着技术的更新换代,现在好多下拉选择都很少用以前那种的方式,而是采用一种类似pop弹出的效果,直接弹出一个一个页面选择,如下图所示:

12306网站:

快递:

3.Select用法

在Playwright中使用locator.select_option()选择元素中的一个或多个选项。我们可以指定选项value,或label选择并且可以选择多个选项。官方使用示例如下:

# Single selection matching the value
page.get_by_label('Choose a color').select_option('blue')

# Single selection matching the label
page.get_by_label('Choose a color').select_option(label='Blue')

# Multiple selected items
page.get_by_label('Choose multiple colors').select_option(['red', 'green', 'blue'])

3.1操作select选择框

3.1.1语法

第一种方法:通过page对象直接调用,如下:

page.select_option(selector,value)        # 通过value选择
page.select_option(selector,index)        # 通过index选择
page.select_option(selector,label)        # 通过label选择

以上方法是:使用selector选择器,先定位元素

第一种通过value选择,顾名思义,可以通过我们的选择框的value元素进行选择
第二种通过index选择,意思是我们可以通过下标来选择
第三种通过label选择,意思是我们可以通过选项值来选择

第二种方法:先定位select元素,再定位选项,如下:

select = page.get_by_label("选择:")
select.select_option(label
="forth")

4.牛刀小试

宏哥这里就宏哥这里就以12306网站为例查询北京到上海的列车信息。给小伙伴们或者童鞋们来打个样。但是实际操作中发现利用select操作的方法有点繁琐,还是用常规方法非常简单。

具体步骤:
1.首先访问12306网站。

2.定位到出发地,点击,弹出选项,定位要选择的选项,点击即可。

3.定位到到达地,点击,弹出选项,定位要选择的选项,点击即可。

4.日期默认查询当天的,宏哥这里不定位,后期会专门讲解定位日期控件的。

5.点击查询按钮,查询车次信息。

4.1代码设计

4.2参考代码


# coding=utf-8

前段时间有分享一篇
electron25+vite4搭建跨桌面端vue3应用
实践。今天带来最新捣鼓的
electron27+react18创建跨端程序、electron多开窗体(模拟QQ登录窗口切换主窗口)、自定义无边框窗口拖拽导航栏
的一些知识分享。

electron团队更新迭代比较快,目前稳定版本已经升级到了electron27。而且运行及构建速度有了大幅度的提升!

  • 版本信息

"vite": "^4.4.5"
"react": "^18.2.0"
"electron": "^27.0.1"
"electron-builder": "^24.6.4"
"vite-plugin-electron": "^0.14.1"
  • 搭建react18项目

使用vite4构建工具创建react项目模板。

yarn create vite electron-vite4-react18
# 选择创建react模板
cd electron
-vite4-react18
yarn
installyarn dev

至此,一个基础的react模板项目就创建好了。接下来就是安装electron相关的依赖配置。

  • 安装electron关联依赖包

注意:如果安装出现卡顿情况,建议设置淘宝镜像源。

//安装electron
yarn add -D electron//安装electron-builder 用于构建打包可安装exe程序
yarn add -D electron-builder//安装electron-devtools-installer 用于开发调试electron项目
yarn add -D electron-devtools-installer

另外还需要安装一个electron和vite的桥接插件
vite-plugin-electron

yarn add -D vite-plugin-electron

vite-plugin-electron:一款快速集成整合Vite和Electron,方便在渲染进程中使用Node API或Electron API功能。

到这里,所依赖的electron插件已经安装完毕。接下来就是创建主进程,启动项目了。

  • 创建主进程配置

const { app, BrowserWindow } = require('electron')

const MultiWindow
= require('./src/windows')

const createWindow
= () =>{
let win
= newMultiWindow()
win.createWin({ isMainWin:
true})
}

app.whenReady().then(()
=>{
createWindow()
app.on(
'activate', () =>{if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on(
'window-all-closed', () =>{if (process.platform !== 'darwin') app.quit()
})

配置vite.config.js

在vite.config.js中引入vite-plugin-electron配置主进程入口electron-main.js文件。

import { defineConfig, loadEnv } from 'vite'import react from'@vitejs/plugin-react'import electron from'vite-plugin-electron'import { resolve } from'path'import { parseEnv } from'./src/utils/env'

//https://vitejs.dev/config/
export default defineConfig(({ command, mode }) =>{
const viteEnv
=loadEnv(mode, process.cwd())
const env
=parseEnv(viteEnv)return{
plugins: [
react(),
electron({
entry:
'electron-main.js',
})
],

esbuild: {
//打包去除 console.log 和 debugger drop: env.VITE_DROP_CONSOLE && command === 'build' ? ["console", "debugger"] : []
},
/*开发服务器配置*/server: {//端口 port: env.VITE_PORT,//代理配置 proxy: {//... }
},

resolve: {
//设置别名 alias: {'@': resolve(__dirname, 'src'),'@assets': resolve(__dirname, 'src/assets'),'@components': resolve(__dirname, 'src/components'),'@views': resolve(__dirname, 'src/views')
}
}
}
})

配置package.json

在package.json文件中加入
"main": "electron-main.js"
入口配置,并且需要去掉
"type": "module"

注意:官方提示electron28之后可以支持"type": "module"

接下来就运行yarn electron:serve桌面端项目就能运行了。

至于一些electron打包配置,这里就不详细介绍了,之前有相关分享的文章。

https://www.cnblogs.com/xiaoyan2017/p/17436076.html

  • electron自定义无边框拖拽导航栏

创建窗口的时候设置
frame: false
即可创建一个无系统边框的窗体。

通过css设置
-webkit-app-region: drag
来实现拖拽区域。设置
-webkit-app-region: no-drag
取消拖拽响应。

自定义最大化/最小化/关闭

import { useState, useContext } from 'react'
import { Modal } from '@arco-design/web-react'
import { setWin } from '@/windows/action'

function WinBtn(props) {
const {
color = '#fff',
minimizable = true,
maximizable = true,
closable = true,
zIndex = 2023,

children
} = props

const [hasMaximized, setHasMaximized] = useState(false)

window.electronAPI.invoke('win__isMaximized').then(res => {
setHasMaximized(res)
})
window.electronAPI.receive('win__hasMaximized', (e, res) => {
setHasMaximized(res)
})

// 最小化
const handleWinMin = () => {
window.electronAPI.send("win__minimize")
}
// 最大化/还原
const handleWinMax2Min = () => {
window.electronAPI.invoke("win__max2min").then(res => {
console.log(res)
setHasMaximized(res)
})
}
// 关闭
const handleWinClose = () => {
if(window.config.isMainWin) {
Modal.confirm({
title: '提示',
content:
<divstyle={{textAlign: 'center' }}>是否最小化至托盘,不退出程序?</div>,
okButtonProps: {status: 'warning'},
style: {width: 360},
cancelText: '最小化至托盘',
okText: '残忍退出',
onOk: () => {
setWin('close')
},
onCancel: () => {
setWin('hide', window.config.id)
}
})
}else {
setWin('close', window.config.id)
}
}

return (
<> <divclassName="vui__macbtn flexbox flex-alignc"style={{zIndex:zIndex}}> <divclassName="vui__macbtn-groups flexbox flex-alignc"style={{color:color}}>{ JSON.parse(minimizable) &&<aclassName="mbtn min"title="最小化"onClick={handleWinMin}><svgx="0"y="0"width="10"height="10"viewBox="0 0 10 10"><pathfill="#995700"d="M8.048,4.001c0.163,0.012 0.318,0.054 0.459,0.137c0.325,0.191 0.518,0.559 0.49,0.934c-0.007,0.097 -0.028,0.192 -0.062,0.283c-0.04,0.105 -0.098,0.204 -0.171,0.29c-0.083,0.098 -0.185,0.181 -0.299,0.24c-0.131,0.069 -0.271,0.103 -0.417,0.114c-2.031,0.049 -4.065,0.049 -6.096,0c-0.163,-0.012 -0.318,-0.054 -0.459,-0.137c-0.325,-0.191 -0.518,-0.559 -0.49,-0.934c0.007,-0.097 0.028,-0.192 0.062,-0.283c0.04,-0.105 0.098,-0.204 0.171,-0.29c0.083,-0.098 0.185,-0.181 0.299,-0.24c0.131,-0.069 0.271,-0.103 0.417,-0.114c2.031,-0.049 4.065,-0.049 6.096,0Z"></path></svg></a>}
{ JSON.parse(maximizable) &&
<aclassName="mbtn max"title={hasMaximized? '向下还原' : '最大化'} onClick={handleWinMax2Min}>{
hasMaximized ?
<svgx="0"y="0"width="10"height="10"viewBox="0 0 10 10"><pathfill="#4d0000"d="M5,10c0,0 0,-2.744 0,-4.167c0,-0.221 -0.088,-0.433 -0.244,-0.589c-0.156,-0.156 -0.368,-0.244 -0.589,-0.244c-1.423,0 -4.167,0 -4.167,0l5,5Z"></path><pathfill="#006400"d="M5,0c0,0 0,2.744 0,4.167c0,0.221 0.088,0.433 0.244,0.589c0.156,0.156 0.368,0.244 0.589,0.244c1.423,0 4.167,0 4.167,0l-5,-5Z"></path></svg>:<svgx="0"y="0"width="10"height="10"viewBox="0 0 10 10"><pathfill="#4d0000"d="M2,3c0,0 0,2.744 0,4.167c0,0.221 0.088,0.433 0.244,0.589c0.156,0.156 0.368,0.244 0.589,0.244c1.423,0 4.167,0 4.167,0l-5,-5Z"></path><pathfill="#006400"d="M8,7c0,0 0,-2.744 0,-4.167c0,-0.221 -0.088,-0.433 -0.244,-0.589c-0.156,-0.156 -0.368,-0.244 -0.589,-0.244c-1.423,0 -4.167,0 -4.167,0l5,5Z"></path></svg>}</a>}
{ JSON.parse(closable) &&
<aclassName="mbtn close"title="关闭"onClick={handleWinClose}><svgx="0"y="0"width="10"height="10"viewBox="0 0 10 10"><pathfill="#4d0000"d="M5,3.552c0.438,-0.432 0.878,-0.861 1.322,-1.287c0.049,-0.044 0.101,-0.085 0.158,-0.119c0.149,-0.091 0.316,-0.137 0.49,-0.146c0.04,0 0.04,0 0.08,0.001c0.16,0.011 0.314,0.054 0.453,0.135c0.08,0.046 0.154,0.104 0.218,0.171c0.252,0.262 0.342,0.65 0.232,0.996c-0.045,0.141 -0.121,0.265 -0.218,0.375c-0.426,0.444 -0.855,0.884 -1.287,1.322c0.432,0.438 0.861,0.878 1.287,1.322c0.097,0.11 0.173,0.234 0.218,0.375c0.04,0.126 0.055,0.26 0.043,0.392c-0.011,0.119 -0.043,0.236 -0.094,0.344c-0.158,0.327 -0.49,0.548 -0.852,0.566c-0.106,0.005 -0.213,-0.007 -0.315,-0.035c-0.156,-0.043 -0.293,-0.123 -0.413,-0.229c-0.444,-0.426 -0.884,-0.855 -1.322,-1.287c-0.438,0.432 -0.878,0.861 -1.322,1.287c-0.11,0.097 -0.234,0.173 -0.375,0.218c-0.126,0.04 -0.26,0.055 -0.392,0.043c-0.119,-0.011 -0.236,-0.043 -0.344,-0.094c-0.327,-0.158 -0.548,-0.49 -0.566,-0.852c-0.005,-0.106 0.007,-0.213 0.035,-0.315c0.043,-0.156 0.123,-0.293 0.229,-0.413c0.426,-0.444 0.855,-0.884 1.287,-1.322c-0.432,-0.438 -0.861,-0.878 -1.287,-1.322c-0.106,-0.12 -0.186,-0.257 -0.229,-0.413c-0.025,-0.089 -0.037,-0.182 -0.036,-0.275c0.004,-0.363 0.211,-0.704 0.532,-0.874c0.13,-0.069 0.272,-0.105 0.418,-0.115c0.04,-0.001 0.04,-0.001 0.08,-0.001c0.174,0.009 0.341,0.055 0.49,0.146c0.057,0.034 0.109,0.075 0.158,0.119c0.444,0.426 0.884,0.855 1.322,1.287Z"></path></svg></a>}<iclassName="mr-10"></i>{ children }</div> <divclassName="vui__mactitle">{window.config.title || '首页'}</div> </div> </>)
}

export default WinBtn
  • electron自定义托盘图标/托盘闪烁

/**
* Electron多窗口管理器
* @author Andy Q:282310962
*/const { app, BrowserWindow, ipcMain, Menu, Tray, dialog, globalShortcut }= require('electron')//const { loadEnv } = require('vite') const { join } = require('path')//根目录路径 process.env.ROOT = join(__dirname, '../../')

const isDev
= process.env.NODE_ENV === 'development' //const winURL = isDev ? 'http://localhost:3000/' : join(__dirname, 'dist/index.html') const winURL = isDev ? process.env.VITE_DEV_SERVER_URL : join(process.env.ROOT, 'dist/index.html')

class MultiWindow {
constructor() {
//主窗口对象 this.main = null //窗口组 this.group ={}//托盘图标 this.tray = null this.flashTimer = null this.trayIco1 = join(process.env.ROOT, 'resource/tray.ico')this.trayIco2 = join(process.env.ROOT, 'resource/tray-empty.ico')//监听ipcMain事件 this.listenIpc()//创建系统托盘 this.createTray()
}
//系统配置参数 winOptions() {return{//窗口图标 icon: join(process.env.ROOT, 'resource/shortcut.ico'),
backgroundColor:
'#fff',
autoHideMenuBar:
true,
titleBarStyle:
'hidden',
width:
900,
height:
600,
resizable:
true,
minimizable:
true,
maximizable:
true,
frame:
false, //设置为 false 时可以创建一个无边框窗口 默认值为 true show: false, //窗口是否在创建时显示 webPreferences: {
contextIsolation:
true, //启用上下文隔离(为了安全性)(默认true) nodeIntegration: false, //启用Node集成(默认false) preload: join(process.env.ROOT, 'electron-preload.js')
}
}
}
//创建新窗口 createWin(options) {//... }//... //主进程监听事件 listenIpc() {//创建新窗体 ipcMain.on('win-create', (event, args) => this.createWin(args))//... //托盘图标闪烁 ipcMain.on('win__flashTray', (event, bool) => this.flashTray(bool))//屏幕截图 ipcMain.on('win__setCapture', () =>{//... })
}
//创建系统托盘图标 createTray() {
console.log(__dirname)
console.log(join(process.env.ROOT,
'resource/tray.ico'))

const trayMenu
=Menu.buildFromTemplate([
{
label:
'打开主界面',
icon: join(process.env.ROOT,
'resource/home.png'),
click: ()
=>{try{for(let i in this.group) {
let win
= this.getWin(i)if(!win) return //是否主窗口 if(this.group[i].isMainWin) {if(win.isMinimized()) win.restore()
win.show()
}
}
}
catch(error) {
console.log(error)
}
}
},
{
label:
'设置中心',
icon: join(process.env.ROOT,
'resource/setting.png'),
click: ()
=>{for(let i in this.group) {
let win
= this.getWin(i)if(win) win.webContents.send('win__ipcData', { type: 'CREATE_WIN_SETTING', value: null})
}
},
},
{
label:
'锁屏',
click: ()
=> null,
},
{
label:
'关闭托盘闪烁',
click: ()
=>{this.flashTray(false)
}
},
{type:
'separator'},/*{
label: '重启',
click: () => {
// app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']) })
// app.exit(0)

app.relaunch()
app.quit()
}
},
*/{
label:
'关于',
click: ()
=>{for(let i in this.group) {
let win
= this.getWin(i)if(win) win.webContents.send('win__ipcData', { type: 'CREATE_WIN_ABOUT', value: null})
}
}
},
{
label:
'关闭应用并退出',
icon: join(process.env.ROOT,
'resource/logout.png'),
click: ()
=>{
dialog.showMessageBox(
this.main, {
title:
'询问',
message:
'确定要退出应用程序吗?',
buttons: [
'取消', '最小化托盘', '退出应用'],
type:
'error',
noLink:
false, //true传统按钮样式 false链接样式 cancelId: 0}).then(res=>{
console.log(res)

const index
=res.responseif(index == 0) {
console.log(
'取消')
}
if(index == 1) {
console.log(
'最小化托盘')for(let i in this.group) {
let win
= this.getWin(i)if(win) win.hide()
}
}
else if(index == 2) {
console.log(
'退出应用')try{for(let i in this.group) {
let win
= this.getWin(i)if(win) win.webContents.send('win__ipcData', { type: 'WIN_LOGOUT', value: null})
}
//app.quit 和 app.exit(0) 都可退出应用。 //前者可以被打断并触发一些事件,而后者将强制应用程序退出而不触发任何事件或允许应用程序取消操作。 app.quit()
}
catch(error) {
console.log(error)
}
}
})
}
}
])
this.tray = new Tray(this.trayIco1)this.tray.setContextMenu(trayMenu)this.tray.setToolTip(app.name)this.tray.on('double-click', () =>{
console.log(
'double clicked')
})
//开启托盘闪烁 //this.flashTray(true) }//托盘图标闪烁 flashTray(flash) {
let hasIco
= false if(flash) {if(this.flashTimer) return this.flashTimer = setInterval(() =>{this.tray.setImage(hasIco ? this.trayIco1 : this.trayIco2)
hasIco
= !hasIco
},
500)
}
else{if(this.flashTimer) {
clearInterval(
this.flashTimer)this.flashTimer = null}this.tray.setImage(this.trayIco1)
}
}
//销毁托盘图标 destoryTray() {this.flashTray(false)this.tray.destroy()this.tray = null}
}

module.exports
= MultiWindow

electron支持的一些环境变量。

process.env.NODE_ENV
process.
env.VITE_DEV_SERVER_URL

另外需要注意,
__dirname
变量指向当前文件。如:src/windows/index.js文件,则根目录需要
../../
返回。

Okay,以上就是electron27+react18+vite4搭建桌面端实践项目的一些分享知识,希望对大家有所帮助哈!