零基础写框架:从零设计一个模块化和自动服务注册框架

模块化和自动服务注册

基于 ASP.NET Core 开发的 Web 框架中,最著名的是 ABP,ABP 主要特点之一开发不同项目(程序集)时,在每个项目中创建一个模块类,程序加载每个程序集中,扫描出所有的模块类,然后通过模块类作为入口,初始化程序集。

使用模块化开发程序,好处是不需要关注程序集如何加载配置。开发人员开发程序集时,在模块类中配置如何初始化、如何读取配置,使用者只需要将模块类引入进来即可,由框架自动启动模块类。

Maomi.Core 也提供了模块化开发的能力,同时还包括简单易用的自动服务注册。Maomi.Core 是一个很简洁的包,可以在控制台、Web 项目、WPF 项目中使用,在 WPF 项目中结合 MVVM 可以大量减少代码复杂度,让代码更加清晰明朗。

快速入手

有 Demo1.Api、Demo1.Application 两个项目,每个项目都有一个模块类,模块类需要实现 IModule 接口。

image-20240218083153329

Demo1.Application 项目的 ApplicationModule.cs 文件内容如下:

    public class ApplicationModule : IModule{// 模块类中可以使用依赖注入private readonly IConfiguration _configuration;public ApplicationModule(IConfiguration configuration){_configuration = configuration;}public void ConfigureServices(ServiceContext services){// 这里可以编写模块初始化代码}}

如果要将服务注册到容器中,在 class 上加上 [InjectOn] 特性即可。

    public interface IMyService{int Sum(int a, int b);}[InjectOn] // 自动注册的标记public class MyService : IMyService{public int Sum(int a, int b){return a + b;}}

上层模块 Demo1.Api 中的 ApiModule.cs 可以通过特性注解引用底层模块。

    [InjectModule<ApplicationModule>]public class ApiModule : IModule{public void ConfigureServices(ServiceContext services){// 这里可以编写模块初始化代码}}

最后,在程序启动时配置模块入口,并进行初始化。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();// 注册模块化服务,并设置 ApiModule 为入口
builder.Services.AddModule<ApiModule>();var app = builder.Build();

模块可以依赖注入

在 ASP.NET Core 配置 Host 时,会自动注入一些框架依赖的服务,如 IConfiguration 等,因此在 .AddModule<ApiModule>() 开始初始化模块服务时,模块获取已经注入的服务。

image-20240218164324287

每个模块都需要实现 IModule 接口,其定义如下:

    /// <summary>/// 模块接口/// </summary>public interface IModule{/// <summary>/// 模块中的依赖注入/// </summary>/// <param name="context">模块服务上下文</param>void ConfigureServices(ServiceContext context);}

除了可以直接在模块构造函数注入服务之外,还可以通过 ServiceContext context 获取服务和配置。

    /// <summary>/// 模块上下文/// </summary>public class ServiceContext{private readonly IServiceCollection _serviceCollection;private readonly IConfiguration _configuration;internal ServiceContext(IServiceCollection serviceCollection, IConfiguration configuration){_serviceCollection = serviceCollection;_configuration = configuration;}/// <summary>/// 依赖注入服务/// </summary>public IServiceCollection Services => _serviceCollection;/// <summary>/// 配置/// </summary>public IConfiguration Configuration => _configuration;}

模块化

因为模块之间会有依赖关系,为了识别这些依赖关系,Maomi.Core 使用树来表达依赖关系。

Maomi.Core 在启动模块服务时,扫描所有模块类,然后将模块依赖关系存放到模块树中,然后按照左序遍历的算法对模块逐个初始化,也就是先从底层模块开始进行初始化。

循环依赖检测

Maomi.Core 可以识别模块循环依赖

比如,有以下模块和依赖:

[InjectModule<A>()]
[InjectModule<B>()]
class C:IModule[InjectModule<A>()]
class B:IModule// 这里出现了循环依赖
[InjectModule<C>()]
class A:IModule// C 是入口模块
services.AddModule<C>();

因为 C 模块依赖 A、B 模块,所以 A、B 是节点 C 的子节点,而 A、B 的父节点则是 C。当把 A、B、C 三个模块以及依赖关系扫描完毕之后,会得到以下的模块依赖树。

如下图所示,每个模块都做了下标,表示不同的依赖关系,一个模块可以出现多次,C1 -> A0 表示 C 依赖 A。

image-20240218165015839

C0 开始,没有父节点,则不存在循环依赖。

从 A0 开始,A0 -> C0 ,该链路中也没有出现重复的 A 模块。

从 C1 开始,C1 -> A0 -> C0 ,该链路中 C 模块重复出现,则说明出现了循环依赖。

从 C2 开始,C2 -> A1 -> B0 -> C0 ,该链路中 C 模块重复出现,则说明出现了循环依赖。

模块初始化顺序

在生成模块树之后,通过对模块树进行后序遍历即可。

比如,有以下模块以及依赖。

[InjectModule<C>()]
[InjectModule<D>()]
class E:IModule[InjectModule<A>()]
[InjectModule<B>()]
class C:IModule[InjectModule<B>()]
class D:IModule[InjectModule<A>()]
class B:IModuleclass A:IModule// E 是入口模块
services.AddModule<E>();

生成模块依赖树如图所示:

首先从 E0 开始扫描,因为 E0 下存在子节点 C0、 D0,那么就会先顺着 C0 再次扫描,扫描到 A0 时,因为 A0 下已经没有子节点了,所以会对 A0 对应的模块 A 进行初始化。根据上图模块依赖树进行后序遍历,初始化模块的顺序是(已经被初始化的模块会跳过):

服务自动注册

Maomi.Core 是通过 [InjectOn] 识别要注册该服务到容器中,其定义如下:

    /// <summary>/// 依赖注入标记/// </summary>[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]public class InjectOnAttribute : Attribute{/// <summary>/// 要注入的服务/// </summary>public Type[]? ServicesType { get; set; }/// <summary>/// 生命周期/// </summary>public ServiceLifetime Lifetime { get; set; }/// <summary>/// 注入模式/// </summary>public InjectScheme Scheme { get; set; }/// <summary>/// 是否注入自己/// </summary>public bool Own { get; set; } = false;/// <summary>/// /// </summary>/// <param name="lifetime"></param>/// <param name="scheme"></param>public InjectOnAttribute(ServiceLifetime lifetime = ServiceLifetime.Transient, InjectScheme scheme = InjectScheme.OnlyInterfaces){Lifetime = lifetime;Scheme = scheme;}}

使用 [InjectOn] 时,默认是注册服务为 Transient 生命周期,且注册所有接口。

    [InjectOn]public class MyService : IAService, IBService

等同于:

services.AddTransient<IAService, MyService>();
services.AddTransient<IBService, MyService>();

如果只想注册 IAService,可以将注册模式设置为InjectScheme.Some ,然后自定义注册的类型:

    [InjectOn(lifetime: ServiceLifetime.Transient,Scheme = InjectScheme.Some,ServicesType = new Type[] { typeof(IAService) })]public class MyService : IAService, IBService

也可以把自身注册到容器中:

[InjectOn(Own = true)]
public class MyService : IMyService

等同于:

services.AddTransient<IAService, MyService>();
services.AddTransient<MyService>();

如果服务继承了类、接口,只想注册父类,那么可以这样写:

    public class ParentService { }[InjectOn(Scheme = InjectScheme.OnlyBaseClass)]public class MyService : ParentService, IDisposable 

等同于:

services.AddTransient<ParentService, MyService>();
services.AddTransient<MyService>();

如果只注册自身,忽略接口等,可以使用:

[InjectOn(ServiceLifetime.Scoped, Scheme = InjectScheme.None, Own = true)]

模块化和自动服务注册的设计和实现

在本小节中,我们将会开始设计一个支持模块化和自动服务注册的小框架,从设计和实现 Maomi.Core 开始,我们在后面的章节中会掌握更多框架技术的设计思路和实现方法,从而掌握从零开始编写一个框架的能力。

项目说明

创建一个名为 Maomi.Core 的类库项目,这个类库中将会包含框架核心抽象和实现代码。

为了减少命名空间长度,便于开发的时候引入需要的命名空间,打开 Maomi.Core.csproj 文件,在 PropertyGroup 属性中,添加一行配置:

<RootNamespace>Maomi</RootNamespace>

配置 <RootNamespace> 属性之后,我们在 Maomi.Core 项目中创建的类型,其命名空间都会以 Maomi. 开头,而不是 Maomi.Core

接着为项目添加两个依赖包,以便实现自动依赖注入和初始化模块时提供配置。

Microsoft.Extensions.DependencyInjection
Microsoft.Extensions.Configuration.Abstractions

模块化设计

当本章的代码编写完毕之后,我们可以这样实现一个模块、初始化模块、引入依赖模块。代码示例如下:

    [InjectModule<ApplicationModule>]public class ApiModule : IModule{private readonly IConfiguration _configuration;public ApiModule(IConfiguration configuration){_configuration = configuration;}public void ConfigureServices(ServiceContext context){var configuration = context.Configuration;context.Services.AddCors();}}

从这段代码,笔者以从上到下的顺序来解读我们需要实现哪些技术点。

1,模块依赖。

[InjectModule<ApplicationModule>] 表示当前模块需要依赖哪些模块。如果需要依赖多个模块,可以使用多个特性,示例如下:

[InjectModule<DomainModule>]
[InjectModule<ApplicationModule>]

2,模块接口和初始化。

每一个模块都需要实现 IModule 接口,框架识别到类型继承了这个接口后才会把类型当作一个模块类进行处理。IModule 接口很简单,只有 ConfigureServices(ServiceContext context) 一个方法,可以在这个方法中编写初始化模块的代码。ConfigureServices 方法中有一个 ServiceContext 类型的参数, ServiceContext 中包含了 IServiceCollection、IConfiguration ,模块可以从 ServiceContext 中获得当前容器的服务、启动时的配置等。

3,依赖注入

每个模块的构造函数都可以使用依赖注入,可以在模块类中注入需要的服务,开发者可以在模块初始化时,通过这些服务初始化模块。

基于以上三点,我们可以先抽象出特性类、接口等,由于这些类型不包含具体的逻辑,因此从这一部分先下手,实现起来会更简单,可以避免大脑混乱,编写框架时不知道要从哪里先下手。

创建一个 ServiceContext 类,用于在模块间传递服务上下文信息,其代码如下:

    public class ServiceContext{private readonly IServiceCollection _serviceCollection;private readonly IConfiguration _configuration;internal ServiceContext(IServiceCollection serviceCollection, IConfiguration configuration){_serviceCollection = serviceCollection;_configuration = configuration;}public IServiceCollection Services => _serviceCollection;public IConfiguration Configuration => _configuration;}

根据实际需求,还可以在 ServiceContext 中添加日志等属性字段。

创建 IModule 接口。

    public interface IModule{void ConfigureServices(ServiceContext services);}

创建 InjectModuleAttribute 特性,用于引入依赖模块。

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]public class InjectModuleAttribute : Attribute{// 依赖的模块public Type ModuleType { get; private init; }public InjectModuleAttribute(Type type){ModuleType = type;}}[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]public sealed class InjectModuleAttribute<TModule> : InjectModuleAttributewhere TModule : IModule{public InjectModuleAttribute() : base(typeof(TModule)){}}

泛型特性属于 C# 11 的新语法。

定义两个特性类后,我们可以使用 [InjectModule(typeof(AppModule))] 或 InjectModule<AppModule> 的方式定义依赖模块。

自动服务注册的设计

当完成本章的代码编写后,如果需要注入服务,只需要标记 [InjectOn] 特性即可。

// 简单注册
[InjectOn]
public class MyService : IMyService
// 注注册并设置生命周期为 scope
[InjectOn(ServiceLifetime.Scoped)]
public class MyService : IMyService// 只注册接口,不注册父类
[InjectOn(InjectScheme.OnlyInterfaces)]
public class MyService : ParentService, IMyService

有时我们会有各种各样的需求,例如 MyService 继承了父类 ParentService 和接口 IMyService,但是只需要注册 ParentService,而不需要注册接口;又或者只需要注册 MyService,而不需要注册 ParentService 、 IMyService

创建 InjectScheme 枚举,定义注册模式:

    public enum InjectScheme{// 注入父类、接口Any,// 手动选择要注入的服务Some,// 只注入父类OnlyBaseClass,// 只注入实现的接口OnlyInterfaces,// 此服务不会被注入到容器中None}

定义服务注册特性:

    // 依赖注入标记[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]public class InjectOnAttribute : Attribute{// 要注入的服务public Type[]? ServicesType { get; set; }// 生命周期public ServiceLifetime Lifetime { get; set; }// 注入模式public InjectScheme Scheme { get; set; }// 是否注入自己public bool Own { get; set; } = false;public InjectOnAttribute(ServiceLifetime lifetime = ServiceLifetime.Transient, InjectScheme scheme = InjectScheme.OnlyInterfaces){Lifetime = lifetime;Scheme = scheme;}}

模块依赖

因为模块之间会有依赖关系,因此为了生成模块树,需要定义一个 ModuleNode 类表示模块节点,一个 ModuleNode 实例标识一个依赖关系

    /// <summary>/// 模块节点/// </summary>internal class ModuleNode{// 当前模块类型public Type ModuleType { get; set; } = null!;// 链表,指向父模块节点,用于循环引用检测public ModuleNode? ParentModule { get; set; }// 依赖的其它模块public HashSet<ModuleNode>? Childs { get; set; }// 通过链表检测是否出现了循环依赖public bool ContainsTree(ModuleNode childModule){if (childModule.ModuleType == ModuleType) return true;if (this.ParentModule == null) return false;// 如果当前模块找不到记录,则向上查找return this.ParentModule.ContainsTree(childModule);}public override int GetHashCode(){return ModuleType.GetHashCode();}public override bool Equals(object? obj){if (obj == null) return false;if(obj is ModuleNode module){return GetHashCode() == module.GetHashCode();}return false;}}

框架在扫描所有程序集之后,通过 ModuleNode 实例将所有模块以及模块依赖组成一颗模块树,通过模块树来判断是否出现了循环依赖。

比如,有以下模块和依赖:

[InjectModule<A>()]
[InjectModule<B>()]
class C:IModule[InjectModule<A>()]
class B:IModule// 这里出现了循环依赖
[InjectModule<C>()]
class A:IModule// C 是入口模块
services.AddModule<C>();

因为 C 模块依赖 A、B 模块,所以 A、B 是节点 C 的子节点,而 A、B 的父节点则是 C。

C.Childs = new (){ A , B}A.ParentModule => C
B.ParentModule => C

当把 A、B、C 三个模块以及依赖关系扫描完毕之后,会得到以下的模块依赖树。一个节点即是一个 ModuleNode 实例,一个模块被多次引入,就会出现多次。

那么,如果识别到循环依赖呢?只需要调用 ModuleNode.ContainsTree()从一个 ModuleNode 实例中,不断往上查找 ModuleNode.ParentModule 即可,如果该链表中包含相同类型的模块,即为循环依赖,需要抛出异常。

比如从 C0 开始,没有父节点,则不存在循环依赖。

从 A0 开始,A0 -> C0 ,该链路中也没有出现重复的 A 模块。

从 C1 开始,C1 -> A0 -> C0 ,该链路中 C 模块重复出现,则说明出现了循环依赖。

所以,是否出现了循环依赖判断起来是很简单的,我们只需要从 ModuleNode.ContainsTree() 往上查找即可。

在生成模块树之后,通过对模块树进行后序遍历即可。

比如,有以下模块以及依赖。

[InjectModule<C>()]
[InjectModule<D>()]
class E:IModule[InjectModule<A>()]
[InjectModule<B>()]
class C:IModule[InjectModule<B>()]
class D:IModule[InjectModule<A>()]
class B:IModuleclass A:IModule// E 是入口模块
services.AddModule<E>();

伪代码示例如下:

		private static void InitModuleTree(ModuleNode moduleNode){if (moduleNode.Childs != null){foreach (var item in moduleNode.Childs){InitModuleTree(item);}}// 如果该节点已经没有子节点// 如果模块没有处理过if (!moduleTypes.Contains(moduleNode.ModuleType)){InitInjectService(moduleNode.ModuleType);}}

未完待续......

文章转载自:痴者工良

原文链接:https://www.cnblogs.com/whuanle/p/18227954

体验地址:引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/846955.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Elasticsearch】IK分词器的下载及使用

安装IK分词器 网址&#xff1a;https://github.com/infinilabs/analysis-ik 3.1.在线安装ik插件&#xff08;较慢,不推荐&#xff09; # 进入容器内部 es为容器名称 docker exec -it es /bin/bash# 在线下载并安装 7.17.21为镜像版本要与之前保持一致 ./bin/elasticsearch-pl…

Anacode+YOLO识别图片

一、安装Anacoda 因为我原本是已经安装了python&#xff0c;后面直接卸载了&#xff0c;然后安装了最新版的anacoda 下载网址为&#xff1a; Index of /anaconda/archive/ | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror 下载版本是&#xff1a; 按照安装教程直接…

android framework input 及多指协议 (一)

触摸事件查询 getevent -lrt 以上数据格式&#xff1a;时间&#xff0c;具体节点文件名&#xff0c;事件类型&#xff0c;事件值 input问题排查&#xff0c;如果数据能打印出来&#xff0c;则是framework 问题&#xff0c;如果没有数据打印出来&#xff0c;则要看驱动问题。 …

80V高耐压低静态线性稳压器/LDO,Vout 1v-65v 3.3V及5V方案最佳选择

概述 PC93XX系列专为动力而设计-敏感应用程序。它包括一个精度第二个高压输入级&#xff0c;超低功率 偏置电流分支&#xff0c;并产生超低功率和低压差线性调节器。PC93XX通过输入电压工作VOUT1V至65V&#xff0c;仅消耗1.8μA的静态电流&#xff0c;并提供1%的初始精度和低…

kernelbase.dll故障怎么处理的几种常见方法,有效的解决kernelbase.dll故障

kernelbase.dll是 Windows 操作系统的一个系统文件&#xff0c;它是 Windows NT 基本 API 客户端库的一部分。如果你遇到了kernelbase.dll出现故障的情况&#xff0c;这可能会导致软件崩溃或无法正常运行。下面是一些处理kernelbase.dll故障的常见方法。 重新启动计算机&#x…

34. 【Java教程】反射

本小节我们来学习一个 Java 语言中较为深入的概念 —— 反射&#xff08;reflection&#xff09;&#xff0c;很多小伙伴即便参与了工作&#xff0c;可能也极少用到 Java 反射机制&#xff0c;但是如果你想要开发一个 web 框架&#xff0c;反射是不可或缺的知识点。本小节我们将…

机器视觉检测--光源

一&#xff0c;环形光源 较为常见的LED光源之一&#xff0c;提供基本的照明作用。 随着光源距离产品的工作距离LWD变化而产生的亮度分布&#xff0c;如下图暖色表示亮&#xff1b;冷色表示暗。 同时该图示是针对特定一款大小的环形光源的数据&#xff08;下同&#xff09;。 二…

酒店旅游API服务汇总

各大旅游平台常用API服务汇总&#xff1a; 实时房源服务【Airbnb】飞猪旅行开放服务途牛旅行开放平台API华为云数字差旅【差旅管理】动态信息接口【美团酒店】旅行商城商家管理API【马蜂窝】交易流程接口【美团酒店】电子导游【携程旅行】

【MachineLearning】| 机器学习:推动未来技术革新与应用的新引擎

一、引言 随着计算能力的飞速提升和大数据的广泛应用&#xff0c;机器学习已成为推动现代科技发展的关键力量。从自动化驾驶到精准医疗&#xff0c;再到金融风险评估&#xff0c;机器学习正逐步改变着我们的工作和生活方式。本文将围绕机器学习的技术革新及其在不同领域的应用…

TDMQ CKafka 版弹性存储能力重磅上线!

导语 自 2024年5月起&#xff0c;TDMQ CKafka 专业版支持弹性存储能力&#xff0c;这种产品形态下&#xff0c;存储可按需使用、按量付费&#xff0c;一方面降低消费即删除、存储使用波动大场景下的存储成本&#xff0c;另一方面存储空间理论上无穷大。 TDMQ CKafka 版产品能…

Python实用代码片段分享(三)

在今天的博文中&#xff0c;我们将继续分享一些Python编程中非常实用的代码片段。这些代码片段将帮助你更高效地处理常见任务&#xff0c;从字符转换到数据类型检查&#xff0c;应有尽有。 1. ord函数和chr函数 Python的ord()函数可以返回Unicode字符对应的ASCII码值&#xf…

数据结构——二叉树(C语言版)

前言 二叉树是一种非线性的数据结构。二叉搜索树、堆、红黑树等高阶数据结构都是依托于二叉树的基础实现的&#xff0c;所以我们有必要好好研究一下“二叉树”这种数据结构。本文只介绍二叉树的基础及中等用法&#xff0c;笔者能力有限&#xff0c;欠妥当之处欢迎批评指正。 树…

【RS】哨兵系列新网站无法下载的问题及解决办法(Sentinel-2)

最近有些小伙伴留言说哨兵数据无法下载&#xff0c;网站打开后会有一层蒙版&#xff0c;无法选取研究区等信息&#xff0c;今天就跟大家分享一下如何解决这个问题。还知道如何下载的小伙伴可以移步到之前的文章&#xff1a;【RS】欧空局Sentinel-2卫星数据下载(哨兵1、2、3、5P…

海外短剧看剧系统搭建部署,h5/app双端,系统页面一键翻译功能,批量上传素材等功能。

目录 前言&#xff1a; 一、海外短剧系统有是吗功能&#xff1f; 二、海外短剧项目在海外反馈怎么样&#xff1f; 总结&#xff1a; 前言&#xff1a; 海外短剧系统搭建开发&#xff0c;想进军海外短剧市场的&#xff0c;搭建这样一款海外短剧系统是必要的。海外短剧市场规…

ATA-4051C高压功率放大器应用分享:超声波测量液位系统

超声波测量液位是一种非接触式液位测量方法&#xff0c;其原理是利用超声波的传播特性来测量液位。超声波是一种高频机械波&#xff0c;其频率高于人类能够听到的频率&#xff0c;通常在100kHz以上。超声波具有较好的穿透性和反射性&#xff0c;可以在固体、液体和气体中传播&a…

FTP

文章目录 概述主动模式和被动模式的工作过程注意事项 概述 文件传输协议 FTP&#xff08;File Transfer Protocol&#xff09;在 TCP/IP 协议族中属于应用层协议&#xff0c;是文件传输标准。主要功能是向用户提供本地和远程主机之间的文件传输&#xff0c;尤其在进行版本升级…

ThinkBook 14 G6+ IMH(21LD)原厂Win11系统oem镜像下载

lenovo联想笔记本电脑原装出厂Windows11系统安装包&#xff0c; 恢复开箱状态自带预装系统&#xff0c;含恢复重置还原功能 链接&#xff1a;https://pan.baidu.com/s/1WIPNagHrC0wqYC3HIcua9A?pwdhzqg 提取码&#xff1a;hzqg 联想原装出厂系统自带所有驱动、出厂主题壁…

Zabbix安装:构建高效可靠的Zabbix监控系统

目录 引言 一、zabbix基本介绍 &#xff08;一&#xff09;什么是zabbix &#xff08;二&#xff09;zabbix结构体系 &#xff08;三&#xff09;zabbix监控对象 &#xff08;四&#xff09;zabbix进程 &#xff08;五&#xff09;zabbix监控模式 &#xff08;六&#…

【SQL边干边学系列】01介绍性问题

文章目录 前言介绍性问题1.我们有哪些承运商&#xff1f;2. 从目录表中查询特定字段3.销售代表4.在美国的销售代表5.由特定员工ID下的订单6.供应商和联系人信息 答案1.我们有哪些承运商&#xff1f;2. 从目录表中查询特定字段3.销售代表4.在美国的销售代表5.由特定员工ID下的订…

Codes 重新定义 SaaS 模式的研发项目管理平台开源版 4.5.5 发布

一&#xff1a;简介 Codes 重新定义 SaaS 模式 云端认证 程序及数据本地安装 不限功能 30 人免费 Codes 是一个 高效、简洁、轻量的一站式研发项目管理平台。包含需求管理&#xff0c;任务管理&#xff0c;测试管理&#xff0c;缺陷管理&#xff0c;自动化测试&#xff0…