2024年8月

前言

之前的文章有提到部署 MatterMost 的事。

本文来记录一下。

关于 MatterMost

MatterMost 有点像 Slack 这种协作工具,而且和 GitLab 的集成还不错,正好我们一直在用 GitLab,所以就部署一个来试试看。

MatterMost 是一款开源的团队协作和通讯平台,设计初衷是为企业和组织提供安全、可控的即时消息解决方案。与其他即时通讯工具相比,MatterMost 具有以下几个显著的特点和优势:

开源和自托管

MatterMost 是开源的,这意味着任何人都可以自由查看、修改和扩展其源代码。用户可以选择自托管,这样可以完全掌控数据,确保敏感信息的安全性和隐私性。这对于那些对数据安全有严格要求的组织尤其重要。

多平台支持

MatterMost 支持多种平台,包括 Windows、macOS、Linux、iOS 和 Android,用户可以在不同设备上无缝使用。此外,MatterMost 提供了强大的 Web 端应用,使用户无需安装客户端软件也能使用其所有功能。

丰富的功能

MatterMost 提供了广泛的功能来满足团队协作的需求,包括:

  • 即时消息
    :支持一对一聊天和群组聊天,用户可以实时交流。
  • 文件共享
    :用户可以在对话中分享文件,支持多种文件格式。
  • 视频会议
    :集成了视频会议功能,方便用户进行面对面的交流。
  • 通知和提醒
    :灵活的通知设置,确保用户不会错过重要信息。
  • 搜索功能
    :强大的搜索功能,帮助用户快速找到所需的信息和文件。

集成和扩展性

MatterMost 拥有丰富的集成功能,可以与多种第三方应用和服务无缝对接,如 Jira、GitHub、Jenkins 等。此外,MatterMost 提供了强大的 API 和插件系统,开发者可以根据需求开发自定义功能,进一步扩展其功能。

安全性

安全性是 MatterMost 的核心优势之一。它提供了多层次的安全保护措施,包括数据加密、单点登录 (SSO)、多因素认证 (MFA)、角色和权限管理等,确保用户数据的安全性。

社区和支持

作为一个开源项目,MatterMost 拥有一个活跃的社区,用户可以在社区中获取帮助、分享经验和建议。MatterMost 还提供了商业支持服务,用户可以根据需要选择不同级别的技术支持和服务。

部署 MatterMost

官方文档:
https://docs.mattermost.com/install/install-docker.html#deploy-mattermost-on-docker-for-production-use

以下是我的部署过程

把项目拉下来

git clone https://github.com/mattermost/docker
mv docker mattermost
cd mattermost

修改
.env
配置

cp env.example .env

修改
DOMAIN
域名配置就行,其他的按需修改

创建目录并设置权限

mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes}
sudo chown -R 2000:2000 ./volumes/app/mattermost

docker compose

clone 以及创建几个文件夹之后的目录结构是这样

matter-most
├── contrib
├── docs
├── nginx
├── scripts
├── volumes
├── docker-compose.nginx.yml
├── docker-compose.swag.yml
├── docker-compose.without-nginx.yml
├── docker-compose.yml
├── env.example
├── LICENSE
└── README.md

这次没有修改官方的 compose 配置

而是新增了一个自己的配置
docker-compose.swag.yml

将 mattermost 服务接入到 swag 的网络中

services:
  postgres:
    container_name: mattermost_pgsql
    networks:
      - default

  mattermost:
    container_name: mattermost
    ports:
      - ${CALLS_PORT}:${CALLS_PORT}/udp
      - ${CALLS_PORT}:${CALLS_PORT}/tcp
    networks:
      - default
      - swag

networks:
  default:
    name: mattermost
  swag:
    external: true

启动

sudo docker compose -f docker-compose.yml -f docker-compose.swag.yml up -d

接入GitLab SSO

这个是有点折腾的

一开始老是提示
The redirect URI included is not valid.

查了好久资料,还是解决了

首先 MatterMost 里的文档就是有问题的,不能在用户个人设置那里创建 GitLab 应用

而是要进入 GitLab 的管理后台创建一个全局应用,才能实现 SSO

然后回调地址我是添加了这俩

https://mattermost.example.com/signup/gitlab/complete
https://mattermost.example.com/login/gitlab/complete

怎么发现的呢?其实 GitLab 本身可以提供 MatterMost 的集成功能,详见 GitLab 文档:
https://docs.gitlab.com/ee/integration/mattermost/

然后我在配置里启用了这个功能之后,GitLab 自动给我创建了这个应用,后面我又关闭这个功能,但依然使用这个应用,就成功实现了使用 GitLab 登录 MatterMost 的功能……

小结

好折腾啊

实际上发现 MatterMost 的手机App用不了playbooks?有点鸡肋了

然后这类团队协作工具,也许还是得用 SaaS 服务好一点。

我后面还试了一下 wekan ,结果这界面直接劝退了。

参考资料

在工作中,我们肯定遇到过一个接口要处理N多事项导致接口响应速度很慢的情况,通常我们会综合使用两种方式来提升接口响应速度

  1. 优化查询SQL,提升查询效率
  2. 开启多线程并发处理业务数据

这里讨论第二种方案:使用多线程并发处理业务数据,最后处理完成以后,拼装起来返回给前端,每个人的实现方案都不一样,我在工作的这几年也经历了几种写法。

一、几种常见的并行处理写法

方法一:Future写法

其代码形式如下

@Test
public void test1() {
    //定义线程池
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 4, 30,
                    TimeUnit.SECONDS,
                    new ArrayBlockingQueue<>(10),
                    Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.DiscardPolicy());
    //异步执行
    Future<String> getUserName = threadPoolExecutor.submit(() -> {
        //do something...
        return "kdyzm";
    });
    //异步执行
    Future<Integer> getUserAge = threadPoolExecutor.submit(() -> {
        //do something...
        return 12;
    });
    //拼装回调结果
    try {
        UserInfo user = new UserInfo();
        user.setName(getUserName.get());
        user.setAge(getUserAge.get());
        log.info(JsonUtils.toPrettyString(user));
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}

@Data
static class UserInfo {
    private String name;
    private Integer age;
}

多几个submit一起执行,最后集中get获取最终结果。

这种方式任务一旦多了,就会显得代码很乱,一堆的变量名会让代码可读性很差。

方法二:CompletableFuture.allOf写法

其代码形式如下

@Test
public void test2() {
    try {
        UserInfo userInfo = new UserInfo();
        
        CompletableFuture.allOf(
            	//异步执行
                CompletableFuture.runAsync(() -> {
                    userInfo.setName("kdyzm");
                }),
            	//异步执行
                CompletableFuture.runAsync(() -> {
                    userInfo.setAge(12);
                })
        //同步返回
        ).get();

        log.info(JsonUtils.toPrettyString(userInfo));
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}

@Data
static class UserInfo {
    private String name;
    private Integer age;
}

这种方法使用了CompletableFuture的API,通过将多个异步任务收集起来统一调度最后通过一个get方法同步到主线程。比直接使用Future简化了些。

方法三:CompletableFuture::join写法

其代码形式如下

@Test
public void test3(){
    UserInfo userInfo = new UserInfo();
    Arrays.asList(
			//异步执行
            CompletableFuture.supplyAsync(()->{
                return "kdyzm";
            //回调执行
            }).thenAccept(name->{
                userInfo.setName(name);
            }),

        	//异步执行
            CompletableFuture.supplyAsync(()->{
                return 12;
            //回调执行
            }).thenAccept(age->{
                userInfo.setAge(age);
            })
        
        //等待所有线程执行完毕
    ).forEach(CompletableFuture::join);

    log.info(JsonUtils.toPrettyString(userInfo));

}

@Data
static class UserInfo {
    private String name;
    private Integer age;
}

这种写法和上面的写法相比具有更高的可读性,但是它也有缺点:thenAccept只能接收一个返回值,如果想处理多个值,则没有办法,只能使用方法2。

总结

几种写法中第二、三种写法比较常见,使用起来也更加方便,两者各有优缺点:方法2能处理多个返回值,方法3可读性更高。但是无论是方法2还是方法3,它们的使用总是要记住相关的API,使用起来总不是很顺手,可读性虽然方法3更强一些,但是总还是差点意思。此时我就有了自己设计一个简单的并行处理工具类的想法,既要易用,还要可读性高。

二、并行处理工具类设计

1、设计模式选型

因为平时比较喜欢链式调用的API,所以一开始一开始设计,我就想用建造者模式来实现这个工具类。关于建造者模式,详情可以看我之前的文章:
设计模式(六):建造者模式
。建造者模式在实际应用中的特点就是链式调用,无论是StringBuilder还是lombok的@Data注解,都使用了建造者模式。

2、第一版代码

仿照方法三,我开发了第一版代码

import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;

/**
 * @author kdyzm
 */
@Slf4j
public class ConcurrentWorker {

    private List<Task> workers = new ArrayList<>();

    public static ConcurrentWorker runner() {
        return new ConcurrentWorker();
    }

    public <R> ConcurrentWorker addTask(Consumer<? super R> action, Supplier<R> value) {
        Task<R> worker = new Task<>(action, value);
        this.workers.add(worker);
        return this;
    }

    public void run() {
        workers.forEach(item -> {
            CompletableFuture completableFuture = CompletableFuture.supplyAsync(item.getValue());
            item.setCompletableFuture(completableFuture);
        });
        workers
                .stream()
                .map(
                        item -> {
                            return item.completableFuture.thenAccept(item.getAction());
                        }
                )
                .forEach(CompletableFuture::join);
    }

    @Data
    public static class Task<R> {
        private Consumer<? super R> action;
        private Supplier<R> value;
        private CompletableFuture<R> completableFuture;

        public Task(Consumer<? super R> action, Supplier<R> value) {
            this.action = action;
            this.value = value;
        }
    }
}

这段代码一共不到60行,使用了Lambda表达式和函数式编程相关的API对方法三进行改造,最终使用效果如下

@Test
    public void test() {

        UserInfo userInfo = new UserInfo();

        ConcurrentWorker.runner()
            	//添加任务
                .addTask(userInfo::setName, () -> {
                    //延迟1000毫秒打印线程执行情况
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.info(Thread.currentThread().getName()+"-name");
                    return "张三";
                })
            	//添加任务
                .addTask(userInfo::setAge, () -> {
                    //延迟1000毫秒打印线程执行情况
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.info(Thread.currentThread().getName()+"-age");
                    return 13;
                })
            	//执行任务
                .run();
        log.info(JsonUtils.toPrettyString(userInfo));
    }

    @Data
    static class UserInfo {
        private String name;
        private Integer age;
        private String sex;
    }

它的使用方式就是

ConcurrentWorker.runner()
                .addTask(setter function, return_value function )
    			.addTask(setter function, return_value function)
    			.run()

可以看到易用性够了,可读性也很好,但是它的缺点和方法三一样,都只能接收一个参数,毕竟它是根据方法3封装的,接下来改造代码让它支持多参数处理。

3、第二版代码

已知,第一版代码已经支持了如下形式的功能

ConcurrentWorker.runner()
                .addTask(setter function, return_value function )
    			.addTask(setter function, return_value function)
    			.run()

现在我想添加以下形式的重载方法

.addTask(handle function)

没错,就一个参数,在这个方法中可以任意设置对象值。最终使用的效果如下

@Test
public void test() {

    UserInfo userInfo = new UserInfo();

    ConcurrentWorker.runner()
            .addTask(userInfo::setName, () -> {
                try {
                    Thread.sleep(1000);
                    log.info(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info(Thread.currentThread().getName()+"-name");
                return "张三";
            })
            .addTask(userInfo::setAge, () -> {
                try {
                    Thread.sleep(1000);
                    log.info(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info(Thread.currentThread().getName()+"-age");
                return 13;
            })
        	//新方法:处理任意多属性值填充
            .addTask(()->{
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info(Thread.currentThread().getName()+"-sex");
                userInfo.setSex("男");
            })
            .run();
    log.info(JsonUtils.toPrettyString(userInfo));
}

@Data
static class UserInfo {
    private String name;
    private Integer age;
    private String sex;
}

完整工具类方法如下

import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;

/**
 * @author kdyzm
 */
@Slf4j
public class ConcurrentWorker {

    private List<Task> workers = new ArrayList<>();

    public static ConcurrentWorker runner() {
        return new ConcurrentWorker();
    }

    public <R> ConcurrentWorker addTask(Consumer<? super R> action, Supplier<R> value) {
        Task<R> worker = new Task<>(action, value);
        this.workers.add(worker);
        return this;
    }

    public <R> ConcurrentWorker addTask(Runnable runnable) {
        Task<R> worker = new Task<>(runnable);
        this.workers.add(worker);
        return this;
    }

    public void run() {
        workers.forEach(item -> {
            int taskType = item.getTaskType();
            CompletableFuture completableFuture = null;
            switch (taskType) {
                case TaskType.RETURN_VALUE:
                    completableFuture = CompletableFuture.supplyAsync(item.getValue());
                    break;
                case TaskType.VOID_RETURN:
                    completableFuture = CompletableFuture.runAsync(item.getRunnable());
                    break;
                default:
                    break;
            }
            item.setCompletableFuture(completableFuture);
        });
        workers
                .stream()
                .map(
                        item -> {
                            int taskType = item.getTaskType();
                            switch (taskType) {
                                case TaskType.RETURN_VALUE:
                                    return item.completableFuture.thenAccept(item.getAction());
                                default:
                                    return item.completableFuture.thenAccept(temp->{
                                        //空
                                    });
                            }
                        }
                )
                .forEach(CompletableFuture::join);
    }

    @Data
    public static class Task<R> {
        private Consumer<? super R> action;
        private Supplier<R> value;
        private CompletableFuture<R> completableFuture;
        private Runnable runnable;
        private int taskType;

        public Task(Consumer<? super R> action, Supplier<R> value) {
            this.action = action;
            this.value = value;
            this.taskType = TaskType.RETURN_VALUE;
        }


        public Task(Runnable runnable) {
            this.runnable = runnable;
            this.taskType = TaskType.VOID_RETURN;
        }
    }


    public static class TaskType {

        /**
         * 有返回值的
         */
        public static final int RETURN_VALUE = 1;

        /**
         * 没有返回值的
         */
        public static final int VOID_RETURN = 2;
    }
}

我将任务类型分为两种,并使用TaskType类封装成常量值:1表示任务执行回调有返回值;2表示任务执行没有返回值,属性填充将在任务执行过程中完成,该类型任务使用Runnable接口实现。

4、工具类jar包

相关代码我已经打包成jar包上传到maven中央仓库,可以通过引入以下maven依赖使用
ConcurrentWorker
工具类

<dependency>
    <groupId>cn.kdyzm</groupId>
    <artifactId>kdyzm-util</artifactId>
    <version>0.0.2</version>
</dependency>



最后,欢迎关注我的博客:https://blog.kdyzm.cn

END.

神秘 Arco 样式出现,祭出 Webpack 解决预期外的引用问题

Webpack
是现代化的静态资源模块化管理和打包工具,其能够通过插件配置处理和打包多种文件格式,生成优化后的静态资源,核心原理是将各种资源文件视为模块,通过配置文件定义模块间的依赖关系和处理规则,从而实现模块化开发。
Webpack
提供了强大的插件和加载器系统,支持了代码分割、热加载和代码压缩等高效构建能力,显著提升了开发效率和性能。
Webpack Resolve

Webpack
中用于解析模块路径的配置项,其负责告诉
Webpack
如何查找和确定模块的位置,核心功能是通过配置来定义模块的查找机制和优先级,从而确保
Webpack
能够正确地找到和加载依赖模块。

描述

先来聊聊故事的背景,在前段时间隔壁老哥需要将大概五年前的项目逐步开发重构新版本
2.0
,通常如果我们开发新版本的话可能会从零启动新项目,在新项目中重新复用组件模块,但是由于新项目时间紧任务重,并且由于项目模块众多且结构复杂,在初版规划中需要修改和新增的模块并非大多数,综合评估下来从零开发新版本的成本太高,所以最终敲定的方案是依然在旧版本上逐步过渡到新版本。

当然,如果只是在原项目上修改与新增模块也不能称为重构新版本,方案的细节是在原项目上逐步引入新的组件,而这些新的组件都是在新的
package
中实现的,并且以
StoryBook
的形式作为基本调试环境。这么做的原因首先是能够保证新组件的独立性,这样便可以逐步替换原有组件模块,还有一个原因是在这里新的组件还需要发布
SDK
包供外部接入,这样更便于我们复用这些组件。

那么这件事情本来是可以有条不紊地进行下去,在新组件开发的过程中也进行地非常顺利,然而在我们需要将新组件引入到原有项目中时,在这里便出现了问题,实际上回过来看这个问题并不是很复杂,但是在不熟悉
Webpack
以及
Less
的情况下,处理起来确实需要一些时间。恰逢周五,原本是快乐的周末,然而将这个问题成功解决便用了两天多一点的时间,在解决问题后也便有了这篇文章,当然解决的方案肯定不只是文章中提到的方法,也希望能为遇到类似问题的同学带来一些参考。

这个问题主要是出现在样式的处理上,五年前的项目对于一些配置确实已经不适合当前的组件模块。那么在发现问题之后,我们就要进入经典的排查问题阶段了,在历经检索异常抛出原因、排除法定位问题组件、不断生成并定位问题配置之后,最终决定在
Webpack
的层面上处理这些问题。实际上如果完全是我们自己的代码还好,如果不适配的话我们可以直接修改,问题就出现在组件引用的第三方依赖上,这些依赖的内容我们是没有办法强行修改的,所以我们只能借助
Webpack
的能力去解决第三方依赖的问题。综合来看,在文章中主要解决了下面三个问题:

  • less-loader
    样式引用问题: 因为是最低五年前的项目,对于
    Webpack
    的管理还是使用的旧版本的
    Umi
    脚手架,且
    less-loader

    5.0.0
    版本,而当前最新版本已经到了
    12.2.0
    ,在这其中配置和处理方式已经发生了很大变化,所以这里需要解决
    less-loader
    的对于样式的处理问题。
  • 组件样式覆盖问题: 经常用组件库的同学应该都知道,在公司内部统一样式规范之后,是不能够随意再引入原本的样式内容的,否则就会出现样式覆盖的问题,但是发布到
    Npm
    的包并不一定都遵守了这个规范,其还是有可能引用旧版本的样式文件,因此我们就需要避免由此造成的样式覆盖问题。
  • 依赖动态引入问题: 实际上在我们解决上述的问题之后,关于样式部分的问题已经结束了,而在这里也引申出了新的问题,我们在本质上是处理了
    Webpack
    的模块引用问题,那么在其他场景下,例如我们需要在海外部署的服务引入专用的依赖,或者幽灵依赖造成的编译问题,此时就需要解决动态引入依赖的问题。

针对这三个问题分别使用
Webpack
实现了相关
DEMO
,相关的代码都在
https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/webpack-resolver
中。

LessLoader

那么我们先来看看
less-loader
的问题,当我们打开
Npm
找到
less-loader@5.0.0

README
文档时,可以看到
webpack resolver
一节中明确了如果需要从
node_modules
中引用样式的话,是需要在引用路径前加入
~
符号的,这样才能让
less-loader
能够正确地从
node_modules
中引用样式文件,否则都会被认为是相对路径导入。

@import "~@arco-design/web-react/es/style/index.less";

在我们的项目中,其本身的依赖是没有问题的,既然能够编译通过那么必然在
.less
文件的引入都是携带了
~
标识的,但是当前我们的新组件中引入的样式文件并没有携带
~
标识,这就导致了
less-loader
无法正确地解析样式文件的位置,从而抛出模块找不到的异常。如果仅仅是我们新组件中的样式没有携带标识的话,我们是可以手动加入的,然而经过排查这部分内容是新引入的组件导致的,而且还是依赖的依赖,这就导致我们无法直接修改样式引入来解决这个问题。

那么针对于这类问题,我们首先想到的肯定是升级
less-loader
的版本,但是很遗憾的是当升级到最新的
12
版本之后,项目同样跑不起来,这个问题大概是根某些依赖有冲突,抛出了一些很古怪的异常,在检索了一段时间这个错误信息之后,最终放弃了升级
less-loader
的方案,毕竟如果钻牛角尖的话我们需要不断尝试各种依赖版本,需要花费大量的时间测试,而且也不一定能够解决问题。

此时我们就需要换个思路,既然本质上还是
less-loader
的问题,而
loader
本质上是通过处理各种资源文件的原始内容来处理的,那么我们是不是可以在直接实现
loader
来在
less-loader
之前预处理
.less
文件,将相关样式的引用都加入
~
标识,这样就能够在
less-loader
之前将正确的
.less
文件处理好。那么在这里的思路就是在解析到引用
.less
文件的
.js
文件时,将其匹配并且加入
~
标识,这里只是简单表示下正则匹配,实际需要考虑的情况还会复杂一些。

/**
 * @typedef {Record<string, unknown>} OptionsType
 * @this {import("webpack").LoaderContext<OptionsType>}
 * @param {string} source
 * @returns {string}
 */
module.exports = function (source) {
  const regexp = /@import\s+"@arco-design\/web-react\/(.*)\/index\.less";/g;
  const next = source.replace(regexp, '@import "~@arco-design/web-react/$1/index.less";');
  return next;
};

理论上这个方式是没有问题的,但是在实际使用的过程中发现依然存在报错的情况,只不过报错的文件发生了改变。经过分析之后发现这是因为在
.less
文件中内部的样式引用是由
less-loader
处理的,而我们编写的
loader
只是针对于入口的
.less
文件做了处理,深层次的
.less
文件并没有经过我们的预处理,依然会抛出找不到模块的异常。实际上在这里也发现了之前使用
less
的误区,如果我们在
.less
文件中随意引用样式的话,即使没有被使用,也会被重复打包出来的,因为独立的
.less
入口最终是会生成单个
.css
再交予后续的
loader
处理。

/* index.ts ok */
/* import "./index.less"; */

/* index.less ok */
@import "@arco-design/web-react-pro/es/style/index.less";

/* @arco-design/web-react-pro/es/style/index.less error */
@import "@arco-design/web-react/es/Button/style/index.less";

在这种存在多级样式引用的情况下,我们处理起来似乎就只能关注
less-loader
本身的能力了,不过实际上这种情况还是不容易出现的,一般只有在复杂业务组件库引用或者多级
UI
规范的情况下才可能出现。但是既然已经在我们的项目中出现了就必须要解决,幸运的是
less-loader
本身是支持插件化的,我们可以通过实现
less-loader

loader
来处理这个问题,只不过因为文档并不完善,所以我们只能参考其他插件的源码来实现。

在这里我们就参考
less-plugin-sass2less
来实现,
less-loader
的插件实际上是一个对象,而在这个对象中我们可以定义
install
方法,其中第二个参数就是插件的管理器实例,通过在这里调用
addPreProcessor
方法来加入我们的预处理器对象,预处理对象实现
process
方法即可,这样就可以实现我们的
less-loader

loader
。而对于
process
函数的思路就比较简单了,在这里我们可以将其按照
\n
切割,在处理字符串时判断是否是相关第三方库的
@import
语句,如果是的话就将其加入
~
标识,并且由于这是在
less-loader
中处理的,其引用路径必然是样式文件,不需要考虑非样式的内容引用。同时为了增加通用性,我们还可以将需要处理的组件库名称在实例化对象的时候传递进去,当然由于是偏向业务数据处理的,通用性可以没必要很高。

// packages/webpack-resolver/src/less/import-prefix.js
module.exports = class LessImportPrefixPlugin {
  constructor(prefixModules) {
    this.prefixModules = prefixModules || [];
    this.minVersion = [2, 7, 1];
  }

  /**
   * @param {string} source
   * @param {object} extra
   * @returns {string}
   */
  process(source) {
    const lines = source.split("\n");
    const next = lines.map(line => {
      const text = line.trim();
      if (!text.startsWith("@import")) return line;
      const result = /@import ['"](.+)['"];?/.exec(text);
      if (!result || !result[1]) return line;
      const uri = result[1];
      for (const it of this.prefixModules) {
        if (uri.startsWith(it)) return `@import "~${uri}";`;
      }
      return line;
    });
    return next.join("\n");
  }

  install(less, pluginManager) {
    pluginManager.addPreProcessor({ process: this.process.bind(this) }, 3000);
  }
};

插件已经实现了,我们同样需要在
less-loader
中将其配置进去,实际上由于项目时
Umi
脚手架搭建起来的,修改配置就必须要借助
webpack-chain
,不熟悉的话还是有些麻烦,所以我们这里直接在
rules

less-loader
中将插件配置好即可。

// packages/webpack-resolver/webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          {
            loader: "less-loader",
            options: {
              plugins: [new LessImportPrefix(["@arco-design/web-react"])],
            },
          },
        ],
      },
      // ...
    ],
  },
  // ...
};

至此我们使用
less-loader

loader
解决了样式引用的解析问题,实际上如果我们不借助
less-loader
的话依然可以继续延续
webpack-loader
的思路来解决问题,当我们发现样式引用的问题时,我们可以实现
loader
避免其内部深层次的调用,再将其交予项目的根目录中将样式重新引用出来,这样同样可以解决问题,但是需要我们手动分析依赖并且引入,需要一定的时间成本。

WebpackLoader

当解决了
less-loader
的适配问题之后,项目已然能够成功运行起来了,但是在调试的过程中又发现了新的问题。通常我们的项目通常都是直接引入
ArcoDesign
作为组件库使用的,而内部后期推出了统一的设计规范,这个新的规范是在
ArcoDesign
的基础上对组件进行了调整,我们就姑且将其命名为
web-react-pro
,并且引入了一套新的样式设计,那么这就造成了新的问题,如果在项目中引用的顺序不正确,就会导致样式的覆盖问题。

// ok
import "@arco-design/web-react/es/style/index.less";
import "@arco-design/web-react-pro/es/style/index.less";

// error
import "@arco-design/web-react-pro/es/style/index.less";
import "@arco-design/web-react/es/style/index.less";

实际上
web-react-pro
内部已经帮我们实际引用了
web-react
的样式,原本是不需要我们主动引入的,然而由于先前提到的并不是所有的项目都遵循了新的设计规范,特别是很多历史三方库的样式引用,这就导致了我们整个引入的顺序是不可控的,这就导致了样式覆盖问题,特别是由于我们的项目通常会配置按需引用,这就会导致部分组件设计规范是新的,部分组件的样式是旧的,在主体页面还是新的样式,打开表单之后就发现组件风格明显发生了变化,整体
UI
会显得比较混乱。

在这里最开始的思路是想查找出究竟是哪个三方库导致的这个问题,然而由于项目引用关系太复杂,约定式路由的扫描还会导致实际未引用的组件依然被编译,二分排除法查找的过程耗费了不少时间,当然最终还是定位到了问题表单引擎组件。那么继续设想一下,现在的问题无非就是样式加载顺序的问题,如果我们主动控制引用到
web-react
的样式是不是就能解决这个问题,除了控制
import
的顺序之外,我们还可以通过
lazy-load
的形式将相关组件库引用到项目中,也就是使原有组件先加载,之后再加载新增的组件就可以避免新样式覆盖。

import App from "...";
import React, { Suspense, lazy } from "react";

const Next = lazy(() => import("./component/next"));
export const LazyNextMain = (
  <Suspense fallback={<React.Fragment></React.Fragment>}>
    <Next />
  </Suspense>
);

然而很明显这样只是能够暂时解决问题,如果后续需要直接在新增的组件中引入
web-react
的样式,例如需要继续基于表单引擎扩展功能,或者引入文档预览组件,都会需要间接地引入
web-react
的样式,如果依旧按照这个模式来处理的话就需要不断
lazy
组件。那么转换下思路,我们是不是可以直接在
Webpack
的层面上直接处理这些问题,如果我们能够直接将
web-react
的样式
resolve
到空的文件中,那么就可以解决这个问题了。

实际上由于这个问题普遍存在,内部是存在
Webpack
的插件来处理这个问题的,但是在我们的项目中引用会对
mini-css-extract-plugin
产生影响,造成一个很奇怪的异常抛出,同样也是经过了一段时间的排查无果之后放弃了这个方案。说到处理引用我们可能首先想到的就是
babel-import-plugin
这个插件,那么我们同样可以实现
babel
的插件来处理这个问题,而且由于场景简单,不需要太复杂的处理逻辑。

// packages/webpack-resolver/src/loader/babel-import.js
/**
 * @param {import("@babel/core") babel}
 * @returns {import("@babel/core").PluginObj<{}>}
 */
module.exports = function (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      ImportDeclaration(path) {
        const { node } = path;
        if (!node) return;
        if (node.source.value === "@arco-design/web-react/dist/css/index.less") {
          node.source = t.stringLiteral(require.resolve("./index.less"));
        }
      },
      CallExpression(path) {
        if (
          path.node.callee.name === "require" &&
          path.node.arguments.length === 1 &&
          t.isStringLiteral(path.node.arguments[0]) &&
          path.node.arguments[0].value === "@arco-design/web-react/dist/css/index.less"
        ) {
          path.node.arguments[0] = t.stringLiteral(require.resolve("./index.less"));
        }
      },
    },
  };
};

在这里我们只需要处理
import
语句对应的
ImportDeclaration
以及
require
语句的
CallExpression
即可,当我们匹配到相关的插件时将其替换到目标的空样式文件中即可,这样就相当于抹除了所有的
web-react
的样式引用,以此来解决样式覆盖问题。而将这个插件加入
babel
也只需要在
.babelrc
文件中配置下
plugin
引用即可。

// packages/webpack-resolver/.babelrc
{
  "plugins": ["./src/loader/babel-import.js"]
}

那么我们还有没有别的思路能够解决类似的问题,假如此时我们的项目不是使用
babel
,而是通过
ESBuild
或者
SWC
来编译的
js
文件,那么又该如何处理。按照我们现在的思路,究其本质是将目标的
.less
文件引用重定向到空的样式文件中,那么我们完全可以延续使用
loader
来处理的思路,实际上
babel-loader
也只是帮我们把纯文本的内容编译为
AST
得到结构化的数据方便我们使用插件调整输出的结果。

那么如果依照类似于
babel-loader
的思路,我们处理引用端的话还是需要解析
import
等语句,还是会比较麻烦,而如果换个思路直接处理
.less
文件,如果这个文件的绝对路径是从
web-react
中引入的,那么我们就可以将其替换成空的样式文件即可,针对于
.less
文件中的样式引入,我们同样可以采取
less-loader

loader
去处理这个问题。

// packages/webpack-resolver/src/loader/import-loader.js
/**
 * @typedef {Record<string, unknown>} OptionsType
 * @this {import("webpack").LoaderContext<OptionsType>}
 * @param {string} source
 * @returns {string}
 */
module.exports = function (source) {
  const regexp = /@arco-design\/web-react\/.+\.less/;
  if (regexp.exec(this.resourcePath)) {
    return "@empty: 1px;";
  }
  return source;
};

别看这段
loader
的实现很简单,但是确实能够帮助我们解决样式覆盖的问题,高端的食材往往只需要最简单的烹饪方式。那么紧接着我们只需要将
loader
配置到
webpack
当中即可,由于我们是直接配置的
webpack.config.js
,可以比较方便地加入规则,如果是
webpack-chain
等方式还是新建一个规则效率比较高。

// packages/webpack-resolver/webpack.config.js
/**
 * @typedef {import("webpack").Configuration} WebpackConfig
 * @typedef {import("webpack-dev-server").Configuration} WebpackDevServerConfig
 * @type {WebpackConfig & {devServer?: WebpackDevServerConfig}}
 */
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "less-loader",
          require.resolve("./src/loader/import-loader"),
        ],
      },
      // ...
    ],
  },
  // ...
};

WebpackResolver

在这里我们的样式引入问题已经解决了,总结起来我们实际上就是通过各种方式处理了
Webpack

Resolve
问题,所以最开始的就提到了这并不是个复杂的问题,只是因为我们并不熟悉这部分能力导致需要探索问题所在以及解决方案。那么在
Webpack
中针对于
Resolve
的问题是不是有什么更通用的解决方案,实际上在
Webpack
中提供了
resolve.plugins
这个配置项,我们可以通过这个配置项来定义
Resolve
的插件,这样就可以在
Webpack

Resolve
阶段处理模块的查找和解析。

我们首先来设想一个场景,当我们的项目需要专属的部署服务,例如我们需要在海外引入专有的依赖版本,这个依赖主要
API
与通用版本并无差别,主要是一些合规的数据上报接口等等,但是问题来了,专有版本和通用版本的包名是不一样的,如果是我们希望在编译时直接处理这个问题而不是需要人工维护版本的话,可行的一个解决方案是在编译之前通过脚本将相关依赖
alias
为海外的包版本,如果还有深层依赖的话同样需要通过包管理器锁定版本,这样就可以解决多版本的维护问题。

// package.json
{
  "dependencies": {
    // common
    "package-common": "1.0.0",
    // oversea
    "package-common": "npm:package-oversea@1.0.0",
  }
}

然而通过脚本不断修改
package.json
的配置还是有些麻烦,并且每次修改过后还是需要重新安装依赖,这样的操作显然不够友好,那么我们可以考虑下更优雅的一些方式,我们可以在
package.json
中预先安装好
common

oversea
的版本依赖,然后在
Webpack

resolve.alias
文件中动态修改相关依赖的
alias

module.exports = {
  resolve: {
    alias: {
      "package-common": process.env.OVERSEA ? "package-oversea" : "package-common",
    },
  },
}

那么如果我们需要更细粒度的控制,例如由于幽灵依赖的问题我们不能将所有的包版本都
alias
为统一版本,去年我就遇到了
Yarn+Next.js
导致的
core.js
依赖冲突问题,绝大部分以来都是
3
版本,而某些依赖就是需要
2
版本却被错误地
resolve

3
版本了,这种情况下就需要控制某些模块的
resolve
行为来解决这类问题。

熟悉
vite
的同学都知道,基于
@rollup/plugin-alias
插件在
vite
中的
alias
还提供了更高级的配置,其可以支持我们动态处理
alias
行为,除了
find/replace
这种基于正则的解析方式之外,还支持传递
ResolveFunction/ResolveObject
的形式用来处理
Rollup
解析时的
Hook
行为。

// rollup/dist/rollup.d.ts
export type ResolveIdResult = string | NullValue | false | PartialResolvedId;
export type ResolveIdHook = (
	this: PluginContext,
	source: string,
	importer: string | undefined,
	options: { attributes: Record<string, string>; custom?: CustomPluginOptions; isEntry: boolean }
) => ResolveIdResult;

// vite/dist/node/index.d.ts
interface ResolverObject {
  buildStart?: PluginHooks['buildStart']
  resolveId: ResolverFunction
}
interface Alias {
  find: string | RegExp
  replacement: string
  customResolver?: ResolverFunction | ResolverObject | null
}
type AliasOptions = readonly Alias[] | { [find: string]: string }

实际上
Webpack
中还内置了
NormalModuleReplacementPlugin
插件来更加灵活地处理模块引用的替换问题,在使用的时候直接调用
new webpack.NormalModuleReplacementPlugin(resourceRegExp, newResource)
即可,需要注意的是
newResource
是支持函数形式的,如果需要修改其行为则直接原地修改
context
参数对象即可,而且
context
参数中携带了大量的信息,我们完全可以借助其携带的信息判断解析来源。

// webpack/types.d.ts
// https://github.com/webpack/webpack/blob/main/lib/NormalModuleReplacementPlugin.js
declare interface ModuleFactoryCreateDataContextInfo {
	issuer: string;
	issuerLayer?: null | string;
	compiler: string;
}
declare interface ResolveData {
	contextInfo: ModuleFactoryCreateDataContextInfo;
	resolveOptions?: ResolveOptions;
	context: string;
	request: string;
	assertions?: Record<string, any>;
	dependencies: ModuleDependency[];
	dependencyType: string;
	createData: Partial<NormalModuleCreateData & { settings: ModuleSettings }>;
	fileDependencies: LazySet<string>;
	missingDependencies: LazySet<string>;
	contextDependencies: LazySet<string>;
	/**
	 * allow to use the unsafe cache
	 */
	cacheable: boolean;
}
declare class NormalModuleReplacementPlugin {
	constructor(resourceRegExp: RegExp, newResource: string | ((arg0: ResolveData) => void));
}

NormalModuleReplacementPlugin
是通过
NormalModuleFactory

beforeResolve
来实现的,然而这里还是具有一定的局限性,其只能处理我们应用本身的依赖解析,而例如我们的第一个问题中,
less-loader@5.0
是主动调度
LoaderContext.resolve
方法来执行文件解析的,也就是说这是
loader
借助
webpack
的能力来实现本身的文件解析需要,而
NormalModuleReplacementPlugin
是无法处理这种情况的。

// less-loader@5.0.0/dist/createWebpackLessPlugin.js
const resolve = pify(loaderContext.resolve.bind(loaderContext));
loadFile(filename, currentDirectory, options) {
  // ...
  const moduleRequest = loaderUtils.urlToRequest(url, url.charAt(0) === '/' ? '' : null);
  const context = currentDirectory.replace(trailingSlash, '');
  let resolvedFilename;
  return resolve(context, moduleRequest).then(f => {
    resolvedFilename = f;
    loaderContext.addDependency(resolvedFilename);
    if (isLessCompatible.test(resolvedFilename)) {
      return readFile(resolvedFilename).then(contents => contents.toString('utf8'));
    }
    return loadModule([stringifyLoader, resolvedFilename].join('!')).then(JSON.parse);
    
    // ...
  })
  // ...
}

那么这时候就需要用到我们的
resolve.plugins
了,我们可以将
resolve
完全作为一个独立的模块来看待,当然其本身也是基于
enhanced-resolve
来实现的,而我们在这里实现的插件相当于对解析行为实现了
Hook
,因此即使类似于
less-loader
这种独立调度的插件也能正常调度,而且此配置在
webpack2
中就已经实现了,已经是非常通用的能力。那么我们可以基于这个能力在
before-hook
的钩子来解决我们之前提到第二个问题,即样式覆盖的问题。

// packages/webpack-resolver/src/resolver/import-resolver.js
module.exports = class ImportResolver {
  constructor() {}

  /**
   * @typedef {Required<import("webpack").Configuration>["resolve"]} ResolveOptionsWebpackOptions
   * @typedef {Exclude<Required<ResolveOptionsWebpackOptions>["plugins"]["0"], "...">} ResolvePluginInstance
   * @typedef {Parameters<ResolvePluginInstance["apply"]>["0"]} Resolver
   * @param {Resolver} resolver
   */
  apply(resolver) {
    const target = resolver.ensureHook("resolve");

    resolver
      .getHook("before-resolve")
      .tapAsync("ImportResolverPlugin", (request, resolveContext, callback) => {
        const regexp = /@arco-design\/web-react\/.+\.less/;
        const prev = request.request;
        const next = require.resolve("./index.less");
        if (regexp.test(prev)) {
          const newRequest = { ...request, request: next };
          return resolver.doResolve(
            target,
            newRequest,
            `Resolved ${prev} to ${next}`,
            resolveContext,
            callback
          );
        }
        return callback();
      });
  }
};

// packages/webpack-resolver/webpack.config.js
module.exports = {
  // ...
  resolve: {
    plugins: [new ImportResolver()],
  },
  // ...
}

因为其对
less-loader
同样也会生效,我们同样也可以匹配解析内容,将其处理为正确的引用地址,这样就不用实现
less-loader

loader
来处理这个问题了,也就是说我们可以通过一个插件来同时解决两个问题。并且前边提到的差异化解析问题也可以通过
request

resolveContext
参数来确定来源,由此来处理特定条件下的引用或者幽灵依赖带来的编译问题等等。

// index.less => @import "@arco-design/web-react/es/style/index.less"
 {
  context: {},
  path: '/xxx/webpack-simple-environment/packages/webpack-resolver/src/less',
  request: './@arco-design/web-react/es/style/index.less'
}

// index.ts => import "./index.less"
{
  context: {
    issuer: '/xxx/webpack-simple-environment/packages/webpack-resolver/src/less/index.ts',
    issuerLayer: null,
    compiler: undefined
  },
  path: '/xxx/webpack-simple-environment/packages/webpack-resolver/src/less',
  request: './index.less'
}

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://webpack.js.org/api/loaders
https://webpack.js.org/configuration/resolve/#resolveplugins
https://github.com/webpack/enhanced-resolve?tab=readme-ov-file#plugins
https://github.com/less/less-docs/blob/master/content/tools/plugins.md
https://github.com/less/less-docs/blob/master/content/features/plugins.md
https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md

前言

在Web应用项目中权限认证是个绕不开的话题,传统方法复杂又耗时。MiniAuth推出专为.NET开发者设计的简单、实用的权限认证项目。

MiniAuth,作为ASP.NET Core的插件,让我们快速轻松实现用户登录、权限检查等功能。它支持多种认证方式,如JWT、Cookie,且易于集成到现有项目中。

无论是开发WebAPI 还是MVC应用,MiniAuth都能帮助我们快速搭建起后台管理系统。它简单易用,不改变现有数据库结构,也不增加学习成本。

MiniAuth,让权限管理不再繁琐,快速开发更加高效。快来试试吧!

项目介绍

MiniAuth 一个轻量 ASP.NET Core Identity Web 后台管理中间插件。

「一行代码」为「新、旧项目」 添加 Identity 系统跟用户、权限管理网页后台系统。

开箱即用,避免打掉重写或是严重耦合情况。

项目特点

  • 兼容 : 支持 .NET identity Based on JWT, Cookie, Session 等
  • 简单 : 拔插设计,API、MVC、Razor Page 等开箱即用
  • 支持多数据库 : 支持 Oracle, SQL Server, MySQL 等 EF Core
  • 非侵入式 : 不影响现有数据库、项目结构
  • 多平台 : 支持 Linux, macOS 环境

项目使用

MiniAuth作为一个轻量级的ASP.NET Core Identity Web后台管理插件,其使用过程相对简单直观。

下面是一个基本的使用示例,帮助我们快速集成MiniAuth到ASP.NET Core项目中,具体步骤可以参考。

1、安装MiniAuth

首先,需要通过NuGet包管理器安装MiniAuth。

或者在Visual Studio中,打开NuGet包管理器控制台(或使用NuGet包管理器UI),并执行以下命令来安装MiniAuth:

Install-Package MiniAuth

也可以使用.NET CLI,通过以下命令安装:

dotnet add package MiniAuth

2、配置MiniAuth

安装完成后,需要在ASP.NET Core项目的Startup类或Program类(取决于使用的.NET Core版本)中配置MiniAuth。

对于.NET 6 或更高版本,这通常在Program.cs文件中完成配置

public classProgram  
{
public static void Main(string[] args)
{
var builder =WebApplication.CreateBuilder(args);//添加MiniAuth服务 builder.Services.AddMiniAuth();//如果需要自定义配置,如使用JWT认证, builder.Services.AddMiniAuth(options =>{
options.AuthenticationType
=MiniAuthOptions.AuthType.BearerJwt;
options.JWTKey
= new SymmetricSecurityKey(Encoding.UTF8.GetBytes("自己的JWT密钥"));
});
var app =builder.Build();//其他配置... app.Run();
}
}

3、访问管理页面

配置完成后,运行当前项目。MiniAuth将自动注册必要的路由和中间件,并提供一个默认的管理界面。

你可以通过访问以下URL来访问管理界面(请根据实际部署情况替换
localhost:5000
):

http://localhost:5000/miniauth/index.html

首次访问时,可以使用预设的管理员账号

账号:
admin@mini-software.github.io

密码: E7c4f679-f379-42bf-b547-684d456bc37f (请记得修改密码)

即可管理你的 Identity 用户、角色、端点。

4、权限管理

MiniAuth提供了用户、角色和权限的管理功能。可以通过管理界面来创建新用户、分配角色以及管理权限。

对于需要权限控制的API或页面,可以在相应的控制器或方法上使用[Authorize]属性或[Authorize(Roles = "角色名")]属性来限制访问。

5、自定义和扩展

MiniAuth提供了灵活的扩展点,可以根据项目需求进行自定义。通过实现或扩展MiniAuth提供的接口和类来定制认证流程、用户数据存储等。

  • MiniAuth Cookie Identity

MiniAuth 预设为单体 Coookie Based identity,如前后端分离项目请更换 JWT 等 Auth。

  • MiniAuth JWT Identity

指定 AuthenticationType 为 BearerJwt

var builder =WebApplication.CreateBuilder(args);
builder.Services.AddMiniAuth(options:(options)
=>{
options.AuthenticationType
=AuthType.BearerJwt;
});

请记得自定义 JWT Security Key,如:

var builder =WebApplication.CreateBuilder(args);
builder.Services.AddMiniAuth(options: (options)
=>{
options.JWTKey
= new SymmetricSecurityKey(Encoding.UTF8.GetBytes("6ee3edbf-488e-4484-9c2c-e3ffa6dcbc09"));
});
  • MiniAuth 预设模式

为IT Admin 集中用户管理,用户注册、密码重置等操作需要 Admin 权限账号操作,预设 Role = miniauth-admin

  • 关闭 MiniAuth Login

如果你只想用自己的登录逻辑、页面、API,可以指定登录路径,关闭开关

//放在 service 注册之前
builder.Services.AddMiniAuth(options: (options) =>{
options.LoginPath
= "/Identity/Account/Login";
options.DisableMiniAuthLogin
= true;
});
  • 自定义预设的 SQLite Connection String
builder.Services.AddMiniAuth(options: (options) =>{
options.SqliteConnectionString
= "Data Source=miniauth_identity.db";
});
  • 自定义数据库、用户、角色

MiniAuth 系统预设使用 SQLite EF Core、IdentityUser、IdentityRole开箱即用 如果需要切换请在
app.UseMiniAuth
泛型指定不同的数据库、自己的用户、角色类别。

app.UseMiniAuth<YourDbContext, YourIdentityUser, YourIdentityRole>();

注意事项

1、确保项目已经安装了ASP.NET Core Identity相关的包,因为MiniAuth是基于ASP.NET Core Identity构建的。

2、根据项目需求,选择合适的认证方式(如JWT、Cookie等)。

3、注意顺序,请将 UseMiniAuth 放在路由生成之后,否则系统无法获取路由数据作权限判断,如 :

app.UseRouting();
app.UseMiniAuth();

4、请添加 Role 规则

请添加 AddRoles
<IdentityRole>
(),否则 [Authorize(Roles = "权限")] 不会生效

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles
<IdentityRole>() //❗❗❗ .AddEntityFrameworkStores<ApplicationDbContext>();

项目地址

Github:
https://github.com/mini-software/MiniAuth

Gitee:
https://gitee.com/dotnetchina/MiniAuth

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!

摘要:
解读业界首个云原生边缘计算框架KubeEdge的架构设计,如何实现边云协同AI,将AI能力无缝下沉至边缘,让AI赋能边侧各行各业,构建智能、高效、自治的边缘计算新时代,共同探索智能边缘的新篇章。

本文分享自华为云社区
《DTSE Tech Talk | 第63期:KubeEdge架构设计与边缘AI实践探索》
,作者:华为云社区精选。

本期直播的主题是《边云协同新场景,KubeEdge架构设计与边缘AI实践探索》,华为云云原生DTSE技术布道师Elias,与开发者们交流了云原生边缘计算领域的理论与技术研究,跟大家分享了云原生边缘计算平台KubeEdge的核心架构、基于KubeEdge的边缘AI实现以及多行业、多场景下的实践经验与优秀案例,展望了云原生边缘计算的未来。

云原生边缘计算的行业背景与挑战

随着云原生技术的发展,云原生正在从数据中心向边缘延伸,云原生边缘计算技术应运而生。云原生边缘计算是一种新型的边缘计算架构,将云计算的弹性和可扩展性与边缘计算的低延迟和数据处理能力相结合,基于Kubernetes、Docker等云原生技术,将计算、存储、网络等资源部署在靠近数据源的边缘节点上,实现数据的实时处理和分析,在物联网、智能制造、智慧医疗等领域有着广阔的应用前景。

云原生边缘计算能带来更高效、更稳定的资源调度与管理,拥有丰富的技术生态集成,带来经济利益的提升。但由于边缘计算细分领域众多、互操作性差,边云通信网络质量低、时延高,云原生边缘计算仍存在很多技术难题与挑战。

云原生边缘计算平台KubeEdge架构解析

KubeEdge(https://github.com/kubeedge/kubeedge)是CNCF首个云原生边缘计算项目,也是业界首个云原生边缘计算框架。KubeEdge不断在边缘计算领域进行技术探索,例如面向边缘AI的场景实现了业界首个分布式AI协同框架子项目Sedna;面向边缘容器网络通信领域实现Edgemesh;面向边缘设备管理领域发布了云原生边缘设备管理接口DMI,支持边缘设备以云原生的方式接入集群。除在技术方面不断探索外,KubeEdge还积极与友商和高校等研究机构合作推动云原生边缘计算的案例落地,例如全国高速公路取消省界收费站、智能汽车等项目。

KubeEdge架构图如下图所示。

KubeEdge核心的设计理念是凭借Kubernetes中的云原生管理能力,在边缘计算的场景对原有Kubernetes做了功能增强,主要包含以下三点:

  1. 云边消息可靠性的增强。云端向边端发送控制命令时会检测边缘是否回传ACK应答,确保消息下发成功;另一方面,云端会对控制命令编号,记录消息的下发,避免重发消息可能导致的带宽冲击问题。
  2. 组件的轻量化。为了应对边缘场景资源受限的问题,KubeEdge在edgecore中集成了一个经过裁剪后轻量级的kubelet,用以管理边缘应用的容器,目前KubeEdge自身组件占用已经能够减少至70M左右。
  3. 边缘物理设备管理。KubeEdge利用设备管理插件Mapper以云原生化的方式纳管边缘设备。用户能够定义设备配置文件,以Kubernetes自定义资源的方式云原生化管理边缘物理设备。

KubeEdge核心技术介绍

本次直播主要介绍了KubeEdge边缘设备管理与边缘容器网络这两个关键技术。

KubeEdge使用云原生的方式管理边缘设备,实现了基于物模型的设备管理API,表现为DeviceModel与DeviceInstance这两个Kubernetes CRD:

  1. DeviceModel是同类设备通用抽象。同一类同一批次的设备中一些设备属性往往是相同的,能够抽象为DeviceModel进行管理。
  2. DeviceInstance是设备实例的抽象。一个DeviceInstance就对应一个实际边缘设备,定义了设备协议、设备访问方式等内容。

KubeEdge使用Mapper设备管理插件实际管理边缘设备。Mapper中集成了设备驱动,能够与设备通信、采集设备数据与状态。Mapper通过实现KubeEdge edgecore中的DMI设备管理统一接口完成自身向KubeEdge集群注册、设备数据上报的能力。

KubeEdge中已经内置了例如Modbus、Onvif等典型协议的Mapper,也提供Mapper开发框架Mapper-Framework,便于开发者自行开发其他Mapper。Mapper-Framework内置了DMI API以及数据面、管理面的能力,能够自动生成Mapper工程的模板,用户只需实现设备驱动层能力即可实现全量Mapper能力。

在边缘场景下,边云、边边网络割裂,微服务之间无法跨子网直接通信;而且边缘侧网络质量不稳定,节点离线、网络抖动是常态,且边缘节点常位于私有网络,难以实现双向通信。为应对边缘容器网络通信存在的问题,KubeEdge构建了数据面组件Edgemesh,为应用程序提供了服务发现与流量代理功能,同时屏蔽了边缘场景下复杂的网络结构。

Edgemesh的功能特点如下:

  1. 采用P2P打洞技术。Edgemesh通过P2P打洞技术打通边缘节点间的网络,让边缘节点在局域网内或跨局域网的情况下都能通信。
  2. 内部DNS服务器。Edgemesh内部实现轻量级的 DNS 服务器,让域名请求在节点内闭环。这一特性主要针对边云连接不稳定的情况,目的是在边缘节点与云节点断开连接后也能正常完成域名解析。
  3. 轻量级部署。Edgemesh仅以一个Agent的方式部署在节点上,能够节省边缘资源。

Edgemesh的结构如下图所示:

Edgemesh结构主要包括五个部分:

  1. Proxier: 负责配置内核的 iptables 规则,将请求拦截到 Edgemesh 进程内
  2. DNS: 内置的 DNS 解析器,将节点内的域名请求解析成一个服务的集群 IP
  3. LoadBalancer: 集群内流量负载均衡
  4. Controller: 通过 KubeEdge 的边缘侧 Local APIServer 能力获取 Services、Endpoints、Pods 等元数据
  5. Tunnel:利用中继和打洞技术来提供跨子网通讯的能力

基于KubeEdge的边缘AI实现

随着人工智能技术的发展,将AI能力下沉边缘侧也是目前重要的研究方向,边缘AI指在边缘计算环境中实现的人工智能,允许在生成数据的边缘设备附近进行计算,具有实时性、隐私性、降低功耗和带宽的优势,本次直播也介绍了基于KubeEdge的边缘AI实现。

KubeEdge面向边缘AI场景提出边缘智能框架Sedna,是业界首个分布式协同AI开源项目,基于KubeEdge提供的边云协同能力,支持现有AI类应用无缝下沉到边缘,能够降低构建与部署成本、提升模型性能、保护数据隐私。Sedna拥有以下特点:

  1. 提供AI边云协同框架。Sedna为用户提供了跨边云的数据集和模型管理能力,帮助开发者快速构建自己的AI应用。
  2. 支持多种边云协同训练和推理模式。当前Sedna拥有协同推理、增量学习、联邦学习与终身学习四大范式,分别针对边侧资源受限、模型更新、原始数据不出边缘和小样本与边缘数据异构问题做了改进优化。
  3. 具有开放生态。Sedna支持业界主流AI框架例如TensorFlow, Pytorch, Paddle, Mindspore等,还提供开发者扩展接口,能够支持快速集成第三方算法。

Sedna也可以理解为云原生的边云协同框架,兼容Kubernetes KubeEdge云原生生态,架构图如图所示:

Sedna的架构主要包含以下四个部分:

  1. Global Manager: 是拓展的Kubernetes的CRD资源,实现的功能主要有AI任务的生命周期管理,比如创建、删除等
  2. Local Controller: 辅助云侧做一些边缘化的自治,并且完成本地模型数据集的管理控制
  3. Worker: 计算任务和推理任务的对象,对应于Kubernetes表现为部署创建的容器,用来实际进行训练推理
  4. Lib库: 能够将用户已有的AI应用改造成边云协同的方式

KubeEdge典型案例解读

KubeEdge目前已广泛应用于智能交通、智慧园区、工业制造、金融、航天、物流、能源、智能 CDN 等行业,本期直播选取多个典型案例进行了解读,包括基于KubeEdge的多云原生机器人编排、大规模CDN节点管理平台、基于KubeEdge/Sedna的楼宇热舒适度预测控制、基于KubeEdge的智慧园区等。

1、基于KubeEdge的多云原生机器人编排

目前机器人处于智能化的初级阶段,只能完成特定的一项或几项任务,不具备理解复杂指令和自主探索解决方案的能力。随着大语言模型的发展,我们希望借助大模型的能力助力机器人复杂指令的拆解,实现具身智能。

基于KubeEdge的多云原生机器人编排系统架构如图所示,主要分为云端大脑、边侧小脑、端侧机器人躯干。云端大脑部署大语言模型,能够按照用户指令自动生成机器人的控制代码并下发边侧小脑;边侧小脑具有机器人的一些基本技能,例如3D环境感知、路径规划、实时定位与导航,能够控制机器人完成移动、抓取;端侧机器人躯干具有众多传感器,能够向云端大脑、边侧小脑反馈状态,更新系统。

当前基于KubeEdge的多云原生机器人编排实现了基于多机器人协调的NLP驱动的任务理解和任务执行功能,能将云边端系统端到端部署周期缩短30%,机器人效率提高 25%,新型机器人集成周期由数月缩短至数天。

2、基于KubeEdge的大规模CDN节点管理平台

CDN节点指距离最终用户接入具有较少中间环节的网络节点,具有较好的响应能力和连接速度。CDN节点中往往存储了网站和应用程序的静态内容,能够提高访问速度;同时,CDN节点在物理布局上通常具有离散分布的特点,且网络连接可能不稳定。

基于KubeEdge的大规模CDN节点管理平台架构如下图所示。需要在各区域中心及数据中心建若干个Kubernetes集群,这些中心具有全量Kubernetes能力,包括负载均衡、网络插件相关能力,能够满足业务部署在中心云的需求,例如区域的日志汇聚、监控汇聚、镜像分发加速的能力,除了传统Kubernetes组件,在区域中心还部署了KubeEdge的云侧管理面组件cloudcore,通过cloudcore纳管边缘的CDN节点,边缘CDN节点全部以edgecore的形式,就近接入区域云端。

基于KubeEdge实现的大规模CDN节点管理平台具有边缘自治、智能化调度等多种优势,在边缘节点断连后容器无需重建,服务不中断,并且能提供节点间亲和性调度以及应用间亲和性调度,已经成功管理1W+边缘CDN节点,助力直播加速、视频点播加速。

3、基于KubeEdge/Sedna的楼宇热舒适度预测控制

智能楼宇是智慧城市的重要组成部分,智能楼宇的自控系统通常位于边缘。热舒适度被定义为楼宇中的人对环境冷热的满意程度,这是一种定量的评估指标,能够把室内冷热环境参数的物理设定与人的主观评估联系起。准确的热舒适度预测结果能够帮助管理人员探索舒适度最佳的楼宇温度调整策略。但由于人员个体差异、房间与城市差异,楼宇热舒适度预测具有突出的数据异构与小样本问题。

基于Sedna的边云协同终身学习的热舒适预测控制具有云边协同和终身学习预测这两个优势,设计图如下图所示。云侧Sedna知识库会利用多地点多人员的历史数据集进行初始化,向边侧应用提供推理更新接口,实现云边协同推理;对于推理任务的复杂性,我们采用终身学习的机制,边端推理时面向已知任务直接推理,未知任务则联合知识库推理,并会对未知任务机进行学习,更新知识库。实验表明,热舒适度预测在KotaKinabalu数据集中预测率相对提升24.04%,能够为楼宇的温度调整策略提供依据。

更多KubeEdge应用案例,可访问
直播回放链接
回顾:https://bbs.huaweicloud.com/live/DTT_live/202407241630.html

作为业界首个云原生边缘计算社区,KubeEdge社区生态蓬勃发展,社区已吸引来自全球80+贡献组织的1600+贡献者, GitHub Star 超过7.5 k。KubeEdge最新版v1.18.0现已发布,新版本中,路由器管理器支持高可用性(HA)、增强CloudCore Websocket API 的授权,支持设备状态上报,Keadm 工具增强功能, 增强封装Token、CA、证书操作功能,欢迎前往社区下载体验https://github.com/kubeedge/kubeedge/releases/tag/v1.18.0

KubeEdge网站: https://kubeedge.io

GitHub地址: https://github.com/kubeedge/kubeedge

Slack地址 : https://kubeedge.io/docs/community/slack

每周三下午四点社区例会 : https://zoom.us/j/4167237304

点击关注,第一时间了解华为云新鲜技术~