全新升级的AOP框架Dora.Interception[2]: 基于约定的拦截器定义方式

Dora.Interception(github地址,觉得不错不妨给一颗星)有别于其他AOP框架的最大的一个特点就是采用针对“约定”的拦截器定义方式。如果我们为拦截器定义了一个接口或者基类,那么拦截方法将失去任意注册依赖服务的灵活性。除此之外,由于我们采用了动态代码生成的机制,我们可以针对每一个目标方法生成对应的方法调用上下文,所以定义在拦截上下文上针对参数和返回值的提取和设置都是泛型方法,这样可以避免无谓的装箱和拆箱操作,进而将引入拦截带来的性能影响降到最低。

目录
一、方法调用上下文
二、拦截器类型约定
三、提取调用上下文信息
四、修改输出参数和返回值
五、控制拦截器的执行顺序
六、短路返回
七、构造函数注入
八、方法注入
九、ASP.NET Core应用的适配

一、方法调用上下文

针对同一个方法调用的所有拦截器都是在同一个方法调用上下文中进行的,我们将这个上下文定义成如下这个InvocationContext基类。我们可以利用Target和MethodInfo属性得到当前方法调用的目标对象和目标方法。泛型的GetArgument和SetArgument用于返回和修改传入的参数,针对返回值的提取和设置则通过GetReturnValue和SetReturnValue方法来完成。如果需要利用此上下文传递数据,可以将其置于Properties属性返回的字典中。InvocationServices属性返回针对当前方法调用范围的IServiceProvider。如果在ASP.NET Core应用中,这个属性将返回针对当前请求的IServiceProvider,否则Dora.Interception会为每次方法调用创建一个服务范围,并返回该范围内的IServiceProvider对象。

public abstract class InvocationContext
{public object Target { get; }public abstract MethodInfo MethodInfo { get; }public abstract IServiceProvider InvocationServices { get; }public IDictionary<object, object> Properties { get; } public abstract TArgument GetArgument<TArgument>(string name);public abstract TArgument GetArgument<TArgument>(int index);public abstract InvocationContext SetArgument<TArgument>(string name, TArgument value);public abstract InvocationContext SetArgument<TArgument>(int index, TArgument value);public abstract TReturnValue GetReturnValue<TReturnValue>();public abstract InvocationContext SetReturnValue<TReturnValue>(TReturnValue value);protected InvocationContext(object target);public ValueTask ProceedAsync() => Next.Invoke(this);
}

和ASP.NET Core的中间件管道类似,应用到同一个方法上的所有拦截器最终也会根据指定的顺序构建成管道。对于某个具体的拦截器来说,是否需要指定后续管道的操作是由它自己决定的。我们知道ASP.NET Core的中间件最终体现为一个Func<RequestDelegate,RequestDelegate>委托,作为输入的RequestDelegate委托代表后续的中间件管道,当前中间件利用它实现针对后续管道的调用。Dora.Interception针对拦截器采用了更为简单的设计,将其表示为如下这个InvokeDelegate(相当于RequestDelegate),因为InvocationContext(相当于HttpContext)的ProceedAsync方法直接可以帮助我们完整针对后续管道的调用。

public delegate ValueTask InvokeDelegate(InvocationContext context);

二、拦截器类型约定

虽然拦截器最终体现为一个InvokeDelegate对象,但是我们倾向于将其定义成一个类型。作为拦截器的类型具有如下的约定:

  • 必须是一个公共的实例类型;

  • 必须包含一个或者多个公共构造函数,针对构造函数的选择由依赖注入框架决定。被选择的构造函数可以包含任意参数,参数在实例化的时候由依赖注入容器提供或者手工指定。

  • 拦截方法被定义在命名为InvokeAsync的公共实例方法中,此方法的返回类型为ValueTask,其中包含一个表示方法调用上下文的InvocationContext类型的参数,能够通过依赖注入容器提供的服务均可以注入在此方法中。

三、提取调用上下文信息

由于拦截器类型的InvokeAsync方法提供了表示调用上下文的InvocationContext参数,我们可以利用它提取基本的调用上下文信息,包括当前调用的目标对象和方法,以及传入的参数和设置的返回值。如下这个FoobarInterceptor类型表示的拦截器会将上述的这些信息输出到控制台上。

public class FoobarInterceptor
{public async ValueTask InvokeAsync(InvocationContext invocationContext){var method = invocationContext.MethodInfo;var parameters = method.GetParameters();Console.WriteLine($"Target: {invocationContext.Target}");Console.WriteLine($"Method: {method.Name}({string.Join(", ", parameters.Select(it => it.ParameterType.Name))})");if (parameters.Length > 0){Console.WriteLine("Arguments (by index)");for (int index = 0; index < parameters.Length; index++){Console.WriteLine($"{index}:{invocationContext.GetArgument<object>(index)}");}Console.WriteLine("Arguments (by name)");foreach (var parameter in parameters){var parameterName = parameter.Name!;Console.WriteLine($"{parameterName}:{invocationContext.GetArgument<object>(parameterName)}");}}await invocationContext.ProceedAsync();if (method.ReturnType != typeof(void)){Console.WriteLine($"Return: {invocationContext.GetReturnValue<object>()}");}}
}

我们利用InterceptorAttribute特性将这个拦截器应用到如下这个Calculator类型的Add方法中。由于我们没有为它定义接口,只能将它定义成虚方法才能被拦截。

public class Calculator
{[Interceptor(typeof(FoobarInterceptor))]public virtual int Add(int x, int y) => x + y;
}

在如下这段演示程序中,在将Calculator作为服务注册到创建的ServiceCollection集合后,我们调用BuildInterceptableServiceProvider扩展方法构建一个IServiceCollection对象。在利用它得到Calculator对象之后,我们调用其Add方法。

using App;
using Microsoft.Extensions.DependencyInjection;var calculator = new ServiceCollection().AddSingleton<Calculator>().BuildInterceptableServiceProvider().GetRequiredService<Calculator>();Console.WriteLine($"1 + 1 = {calculator.Add(1, 1)}");

针对Add方法的调用会被FoobarInterceptor拦截下来,后者会将方法调用上下文信息以如下的形式输出到控制台上(源代码)。

403a6e146e5132732c9b3c2b89fac6db.png

四、修改输出参数和返回值

拦截器可以篡改输出的参数值,比如我们将上述的FoobarInterceptor类型改写成如下的形式,它的InvokeAsync方法会将输入的两个参数设置为0(源代码)。

public class FoobarInterceptor
{public ValueTask InvokeAsync(InvocationContext invocationContext){invocationContext.SetArgument("x", 0);invocationContext.SetArgument("y", 0);return invocationContext.ProceedAsync();}
}

再次执行上面的程序后就会出现1+1=0的现象。

5708243dcfc207be18e25ba798653eb8.png

在完成目标方法的调用后,返回值会存储到上下文中,拦截器也可以将其篡改。如下这个改写的FoobarInterceptor选择将返回值设置为0。程序执行后也会出现上面的输出结果(源代码)。

public class FoobarInterceptor
{public async ValueTask InvokeAsync(InvocationContext invocationContext){await invocationContext.ProceedAsync();invocationContext.SetReturnValue(0);}
}

五、控制拦截器的执行顺序

拦截器最终被应用到某个方法上,多个拦截器最终会构成一个由InvokeDelegate委托表示的执行管道,构造管道的拦截器的顺序可以由指定的序号来控制。如下所示的代码片段定义了三个派生于同一个基类的拦截器类型(FooInterceptor、BarInterceptor、BazInterceptor),它们会在目标方法之前后输出当前的类型进而确定它们的执行顺序。

public class InterceptorBase
{public async ValueTask InvokeAsync(InvocationContext invocationContext){Console.WriteLine($"[{GetType().Name}]: Before invoking");await invocationContext.ProceedAsync();Console.WriteLine($"[{GetType().Name}]: After invoking");}
}public class FooInterceptor : InterceptorBase { }
public class BarInterceptor : InterceptorBase { }
public class BazInterceptor : InterceptorBase { }

我们利用InterceptorAttribute特性将这三个拦截器应用到如下这个Invoker类型的Invoke方法上。指定的Order属性最终决定了对应的拦截器在构建管道的位置,进而决定了它们的执行顺序。

public class Invoker
{[Interceptor(typeof(BarInterceptor), Order = 2)][Interceptor(typeof(BazInterceptor), Order = 3)][Interceptor(typeof(FooInterceptor), Order = 1)]public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下所示的演示程序中,我们按照上述的方式得到Invoker对象,并调用其Invoke方法。

var invoker = new ServiceCollection().AddSingleton<Invoker>().BuildInterceptableServiceProvider().GetRequiredService<Invoker>();invoker.Invoke();

按照标注InterceptorAttribute特性指定的Order属性,三个拦截器执行顺序依次是:FooInterceptor、BarInterceptor、BazInterceptor,如下所示的输出结果体现了这一点(源代码)。

eabccb5623c389c01e3dd70950623c32.png

六、短路返回

任何一个拦截器都可以根据需要选择是否继续执行后续的拦截器以及目标方法,比如入门实例中的缓存拦截器将缓存结果直接设置为调用上下文的返回值,并不再执行后续的操作。对上面定义的三个拦截器类型,我们将第二个拦截器BarInterceptor改写成如下的形式。它的InvokeAsync在输出一段指示性文字后,不再调用上下文的ProceedAsync方法,而是直接返回一个ValueTask对象。

public class BarInterceptor
{public virtual  ValueTask InvokeAsync(InvocationContext invocationContext){Console.WriteLine($"[{GetType().Name}]: InvokeAsync");return ValueTask.CompletedTask;}
}

再次执行我们的演示程序后会发现FooInterceptor和BarInterceptor会正常执行,但是BazInterceptor目标方法均不会执行(源代码)。

8bc0cb1b773d9f0f7d5dfff1426b32ba.png

七、构造函数注入

由于拦截器是由依赖注入容器创建的,其构造函数中可以注入依赖服务。但是拦截器具有全局生命周期,所以我们不能将生命周期模式为Scoped的服务对象注入到构造函数中。我们可以利用一个简单的实例来演示这一点。我们定义了如下一个拦截器类型FoobarInspector,其构造函数中注入了依赖服务FoobarSerivice。FoobarInspector被采用如下的方式利用InterceptorAttribute特性应用到Invoker类型的Invoke方法上。

public class FoobarInterceptor
{public FoobarInterceptor(FoobarService foobarService)=> Debug.Assert(foobarService != null);public async  ValueTask InvokeAsync(InvocationContext invocationContext){Console.WriteLine($"[{GetType().Name}]: Before invoking");await invocationContext.ProceedAsync();Console.WriteLine($"[{GetType().Name}]: After invoking");}
}public class FoobarService { }public class Invoker
{[Interceptor(typeof(FoobarInterceptor))]public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下的演示程序中,我们利用命令行参数(0,1,2)来指定依赖服务FoobarService采用的生命周期,然后将其作为参数调用辅助方法Invoke方法完成必要的服务注册,利用构建的依赖注入容器提取Invoker对象,并调用应用了FoobarInspector拦截器的Invoke方法。

var lifetime = (ServiceLifetime)int.Parse(args.FirstOrDefault() ?? "0");
Invoke(lifetime);static void Invoke(ServiceLifetime lifetime)
{Console.WriteLine(lifetime);try{var services = new ServiceCollection().AddSingleton<Invoker>();services.Add(ServiceDescriptor.Describe(typeof(FoobarService), typeof(FoobarService), lifetime));var invoker = services.BuildInterceptableServiceProvider().GetRequiredService<Invoker>();invoker.Invoke();}catch (Exception ex){Console.WriteLine(ex.Message);}
}

我们以命令行参数的形式启动程序,并指定三种不同的生命周期模式。从输出结果可以看出,如果注册的FoobarService服务采用Scoped生命周期模式会抛出异常(源代码)。

36e0558640cdb887c25354c7f0dbf44c.png

八、方法注入

如果FoobarInspector依赖一个Scoped服务,或者依赖的服务采用Transient生命周期模式,但是希望在每次调用的时候创建新的对象(如果将生命周期模式设置为Transient,实际上是希望采用这样的服务消费方式)。此时可以利用InvocationContext的InvocationServices返回的IServiceProvider对象。在如下的实例演示中,我们定义了派生于ServiceBase 的三个将会注册为对应生命周期的服务类型SingletonService 、ScopedService 和TransientService 。为了确定依赖服务实例被创建和释放的时机,ServiceBase实现了IDisposable接口,并在构造函数和Dispose方法中输出相应的文字。在拦截器类型FoobarInterceptor的InvokeAsync方法中,我们利用InvocationContext的InvocationServices返回的IServiceProvider对象两次提取这三个服务实例。FoobarInterceptor依然应用到Invoker类型的Invoke方法中。

public class FoobarInterceptor
{public async  ValueTask InvokeAsync(InvocationContext invocationContext){var provider = invocationContext.InvocationServices;_ = provider.GetRequiredService<SingletonService>();_ = provider.GetRequiredService<SingletonService>();_ = provider.GetRequiredService<ScopedService>();_ = provider.GetRequiredService<ScopedService>();_ = provider.GetRequiredService<TransientService>();_ = provider.GetRequiredService<TransientService>();Console.WriteLine($"[{GetType().Name}]: Before invoking");await invocationContext.ProceedAsync();Console.WriteLine($"[{GetType().Name}]: After invoking");}
}public class ServiceBase : IDisposable
{public ServiceBase()=>Console.WriteLine($"{GetType().Name}.new()");public void Dispose() => Console.WriteLine($"{GetType().Name}.Dispose()");
}public class SingletonService : ServiceBase { }
public class ScopedService : ServiceBase { }
public class TransientService : ServiceBase { }public class Invoker
{[Interceptor(typeof(FoobarInterceptor))]public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下的演示程序中,我们将三个服务按照对应的生命周期模式添加到创建的ServiceCollection集合中。在构建出作为依赖注入容器的IServiceProvider对象后,我们利用它提取出Invoker对象,并先后两次调用应用了拦截器的Invoke方法。为了释放所有由ISerivceProvider对象提供的服务实例,我们调用了它的Dispose方法。

var provider = new ServiceCollection().AddSingleton<SingletonService>().AddScoped<ScopedService>().AddTransient<TransientService>().AddSingleton<Invoker>().BuildInterceptableServiceProvider();
using (provider as IDisposable)
{var invoker = provider .GetRequiredService<Invoker>();invoker.Invoke();Console.WriteLine();invoker.Invoke();
}

程序运行后会在控制台上输出如下的结果,可以看出SingletonService 对象只会创建一次,并最终在作为跟容器的ISerivceProvider对象被释放时随之被释放。ScopedSerivce对象每次方法调用都会创建一次,并在调用后自动被释放。每次提取TransientService 都会创建一个新的实例,它们会在方法调用后与ScopedSerivce对象一起被释放(源代码)。

e4def5bc7966d850258b87de1d752f5f.png

其实利用InvocationServices提取所需的依赖服务并不是我们推荐的编程方式,更好的方式是以如下的方式将依赖服务注入拦截器的InvokeAsync方法中。上面演示程序的FoobarInterceptor改写成如下的方式后,执行后依然会输出如上的结果(源代码)。

public class FoobarInterceptor
{public async  ValueTask InvokeAsync(InvocationContext invocationContext,SingletonService singletonService1, SingletonService singletonService2,ScopedService scopedService1, ScopedService scopedService2,TransientService transientService1, TransientService transientService2){Console.WriteLine($"[{GetType().Name}]: Before invoking");await invocationContext.ProceedAsync();Console.WriteLine($"[{GetType().Name}]: After invoking");}
}

九、ASP.NET Core应用的适配

对于上面演示实例来说,Scoped服务所谓的“服务范围”被绑定为单次方法调用,但是在ASP.NET Core应用应该绑定为当前的请求上下文,Dora.Interception对此做了相应的适配。我们将上面定义的FoobarInterceptor和Invoker对象应用到一个ASP.NET Core MVC程序中。为此我们定义了如下这个HomeController,其Action方法Index中注入了Invoker对象,并先后两次调用了它的Invoke方法。

public class HomeController
{[HttpGet("/")]public string Index([FromServices] Invoker invoker){invoker.Invoke();Console.WriteLine();invoker.Invoke();return "OK";}
}

MVC应用的启动程序如下。

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseInterception();
builder.Services.AddLogging(logging=>logging.ClearProviders()).AddSingleton<Invoker>().AddSingleton<SingletonService>().AddScoped<ScopedService>().AddTransient<TransientService>().AddControllers();
var app = builder.Build();
app.UseRouting().UseEndpoints(endpint => endpint.MapControllers());
app.Run();

启动程序后针对根路径“/”(只想HomeController的Index方法)的请求(非初次请求)会在服务端控制台上输出如下的结果,可以看出ScopedSerivce对象针对每次请求只会被创建一次。

051588ad422cce09cba1c9cb8fac9604.png

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

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

相关文章

加快Android Studio的编译速度

从Eclipse切换到Android Studio后&#xff0c;感觉Android Studio的build速度比Eclipse慢很多&#xff0c;以下几个方法可以提高Android Studio的编译速度使用Gradle 2.4Gradle 2.4对执行性能有很大的优化&#xff0c;但Android Studio现在默认使用的是Gradle 2.2,所以我们需要…

PaddleOCR在 Linux下的webAPI部署方案

很多小伙伴在使用OCR时都希望能采用API的方式调用&#xff0c;这样就可以跨端跨平台了。本文将介绍一种基于python的PaddleOCR识别WebAPI部署方案。喜欢的可以关注公众号&#xff0c;获取更多内容。一、 Linux环境下部署1.环境要求操作系统&#xff1a;CenterOS7&#xff1b;主…

影响程序员生涯的三个错误观念,你千万不要犯!

程序员在社会上&#xff0c;到底是怎样一个生活群体&#xff1f;是否能找到自己方向&#xff1f;其实&#xff0c;路一直都在那里&#xff0c;只是你看不到而已&#xff01; 当初的你&#xff0c;可能一直被一些技术牵着鼻子走&#xff0c;并不是自己在做着自己想做的&#xff…

心电图计算心率公式_心电图到底能反应啥问题,看过之后你也能当“医生”

只要是经历过健康体检的健康人&#xff0c;或者做过手术的患者&#xff0c;基本都做过心电图检查。都说久病成医&#xff0c;所以有些人对血、尿常规等各项检查的结果都门清儿得很&#xff0c;最起码看一眼也能说出个大概齐。偏偏心电图这种常做的检查&#xff0c;不但老病号如…

获取正在运行的服务

手机上安装的App&#xff0c;在后台运行着很多不同功能的服务&#xff0c;最常见的例如消息推送相关的服务。如何查看这些服务&#xff1f;如何判断某个服务是否正在运行&#xff1f;如何停止某一个服务呢&#xff1f;请看下面的方法&#xff1a; package com.example.servicel…

开发composer包

一、初始化&#xff08;生成composer.json文件&#xff09; composer init#输入你要创建的composer包项目命名空间 Package name (<vendor>/<name>) [root/tiny-laravel]: #haveyb/tiny-laravel #输入composer包的描述 Description []:#this is a tiny laravel h…

Linux本地yum源配置以及使用yum源安装gcc编译环境

本文档是图文安装本地yum源的教程&#xff0c;以安装gcc编译环境为例。 适用范围&#xff1a;所有的cetos,红帽,fedroa版本 适用人群&#xff1a;有一点linux基础的小白 范例系统版本&#xff1a;CentOS Linux release 7.3.1611 (Core) 范例环境&#xff1a;vmware 虚拟机 安装…

word如何设置上标形式_如何在word中设置特殊页码

获取更多业界资讯和深度好文● 点击蓝字关注我们 ●在日常工作中&#xff0c;我们编辑的word文档经常需要设置页码&#xff0c;但有时文档的第一页是封面&#xff0c;第二页才是正文&#xff0c;或者第二页是目录&#xff0c;第三页才是正文&#xff0c;如下图所示&#xff0c;…

发布composer包到 Packagist,并设置自动同步(从github到Packagist)

一、发布composer包 1、将我们写好的项目包发布到github上 这一步不赘述&#xff0c;应该都会。 但是需要注意的是&#xff0c;我们一定要为我们的项目包打上tag之后再提交&#xff0c;否则 我们composer require时可能会报错 Could not find a version of package。 # 设置…

教你在CorelDRAW中导入位图

在CorelDRAW软件中不能直接打开位图图像&#xff0c;在实际操作中&#xff0c;用户需要使用导入位图图像的方法进行操作。导入位图图像时&#xff0c;可以导入整幅图像&#xff0c;也可以在导入的过程中对图像进行裁剪&#xff0c;或重新取样图像&#xff0c;导入整幅位图图像时…

.NET 6 中将 ASP.NET Core 注册成 Windows Service

前言使用 Visual Studio 中的 Worker Service项目模板:我们很容易创建出 Windows Service&#xff1a;IHost host Host.CreateDefaultBuilder(args).UseWindowsService().ConfigureServices(services >{services.AddHostedService<Worker>();}).Build();await host.R…

19.12 添加自定义监控项目 配置邮件告警 测试告警

9月12日任务19.12 添加自定义监控项目19.13/19.14 配置邮件告警19.15 测试告警19.16 不发邮件的问题处理19.12 添加自定义监控项目需求&#xff1a;监控某台web的80端口连接数&#xff0c;并出图两步&#xff1a;1&#xff09;zabbix监控中心创建监控项目&#xff1b;2&#xf…

wab框架

http协议 一、http简介 1.HTTP是一个基于TCP/IP通信协议来传递数据&#xff08;HTML 文件, 图片文件, 查询结果等&#xff09;。 2.HTTP是一个属于应用层的面向对象的协议&#xff0c;由于其简捷、快速的方式&#xff0c;适用于分布式超媒体信息系统。它于1990年提出&#xff0…

c++ 二维矩阵 转vector_Python线性代数学习笔记——矩阵的基本运算和基本性质,实现矩阵的基本运算...

当学习完矩阵的定义以后&#xff0c;我们来学习矩阵的基本运算&#xff0c;与基本性质矩阵的基本运算&#xff1a;矩阵的加法&#xff0c;每一个对应元素相加&#xff0c;对应结果的矩阵例子&#xff1a;矩阵A和矩阵B表示的是同学上学期和下学期的课程的成绩&#xff0c;两个矩…

android 4.4以上能够实现的沉浸式状态栏效果

仅仅有android4.4以及以上的版本号才支持状态栏沉浸效果 先把程序执行在4.4下面的手机上,看下效果: 在4.4以上的效果: 当然图片也是能够作为背景的.效果: 代码: if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {Window window getWindow();window.setFlags(Wind…

为abp vnext生成C#客户端给非abp第三方net程序使用

abp vnext提供了动态C#API客户端和静态C#API客户端来调用abp项目的接口&#xff0c;但是有局限性&#xff1b;要使用动态C#API客户端的项目必须也是ABP vnext的项目。静态C#API客户端也依赖abp的包&#xff0c;如下图为的静态客户端依赖于 Volo.Abp.DependencyInjection、Volo.…

C#项目代码规范

目的 1.方便代码的交流和维护。 2.不影响编码的效率&#xff0c;不与大众习惯冲突。 3.使代码更美观、阅读更方便。 4.使代码的逻辑更清晰、更易于理解。 在C#中通常使用的两种编码方式如下 Camel(驼峰式)&#xff1a; 大小写形式&#xff0d;除了第一个单词&#xff0c;所有单…

.NET MAUI实战 FolderPicker

1.概要最近在迁移 GeneralUpdate.Tool的时候需要用到文件夹选择&#xff0c;在MAUI中可以使用FolderPicker进行选择。注意&#xff0c;和上篇文章的文件选择不一样。因为在.NET MAUI中目前还没有傻瓜式直接可用的FolderPicker供开发者使用所以需要自己动手做一些修改。完整示例…

h5外卖源码php_校园食堂外卖APP走红 更多APP定制开发上一品威客网

近日&#xff0c;西安一高校推出了一款校园食堂外卖APP走红网络。该APP涵盖学校食堂的所有饭菜&#xff0c;并可给该校的师生提供校园食堂饭菜外卖服务。饭菜价格与食堂统一&#xff0c;且仅供该校内的师生使用。 目前开发校园外卖订餐系统可谓是一个较热门的创业项目&#xff…

微信自定义tabbar有小红点_自定义微信小程序tabBar组件上边框的颜色

背景&#xff1a;在微信小程序的实际开发过程中&#xff0c;有时候我们需要修改微信小程序提供的 tabBar 组件顶部边框的颜色&#xff0c;以满足项目需求解决方案&#xff1a;方式一&#xff1a;通过tabBar组件自带的 borderStyle 属性来控制边框的颜色&#xff0c;将边框的颜色…