节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
12.39 KiB
Stardust
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Moq;
using NewLife.Log;
using Stardust.Extensions;
using Xunit;

namespace Stardust.Tests;

/// <summary>性能跟踪中间件测试。覆盖埋点名称自动截断、学习归并、解析器定制与旧规则回退</summary>
public class TracerMiddlewareTests : IDisposable
{
    #region 辅助
    private readonly String _oldPrefixes;
    private readonly Boolean _oldEnable;
    private readonly String[] _oldArea;
    private readonly IList<String> _oldAllow;
    private readonly Func<HttpContext, Boolean>? _oldAbandon;
    private readonly ITracer? _oldTracer;

    /// <summary>实例化。临时重置全局配置,避免外部状态影响测试</summary>
    public TracerMiddlewareTests()
    {
        _oldPrefixes = StarSetting.Current.TraceNamePrefixes;
        _oldEnable = TracerMiddleware.EnableAutoName;
        _oldArea = TracerMiddleware.AreaNames;
        _oldAllow = TracerMiddleware.AllowPaths;
        _oldAbandon = TracerMiddleware.ShouldAbandon;
        _oldTracer = TracerMiddleware.Tracer;

        StarSetting.Current.TraceNamePrefixes = "";
        TracerMiddleware.EnableAutoName = true;
        TracerMiddleware.AreaNames = [];
        TracerMiddleware.AllowPaths = [];
        TracerMiddleware.ShouldAbandon = null;
    }

    /// <summary>恢复全局配置</summary>
    public void Dispose()
    {
        StarSetting.Current.TraceNamePrefixes = _oldPrefixes;
        TracerMiddleware.EnableAutoName = _oldEnable;
        TracerMiddleware.AreaNames = _oldArea;
        TracerMiddleware.AllowPaths = _oldAllow;
        TracerMiddleware.ShouldAbandon = _oldAbandon;
        TracerMiddleware.Tracer = _oldTracer;
    }

    private static TracerMiddleware Create() => new(static ctx => Task.CompletedTask);

    /// <summary>创建中间件与可验证的埋点,用于验证埋点是否被抛弃</summary>
    private static (TracerMiddleware, Mock<ISpan>) CreateWithSpan()
    {
        var span = new Mock<ISpan>();
        span.SetupAllProperties();

        var tracer = new Mock<ITracer>();
        tracer.Setup(t => t.NewSpan(It.IsAny<String>())).Returns(span.Object);
        TracerMiddleware.Tracer = tracer.Object;

        return (Create(), span);
    }

    private static DefaultHttpContext CreateContext(String path)
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Scheme = "http";
        ctx.Request.Host = new HostString("127.0.0.1");
        ctx.Request.Path = path;

        // 让自动学习的访问地址与配置保持一致,避免测试期间写配置文件
        StarSetting.Current.ServiceAddress = "http://127.0.0.1";

        return ctx;
    }

    private static async Task<List<String>> Run(TracerMiddleware mw, params String[] paths) => await Run(mw, null, paths);

    private static async Task<List<String>> Run(TracerMiddleware mw, ITracerResolver? resolver, params String[] paths)
    {
        var names = new List<String>();
        var span = new Mock<ISpan>();
        span.SetupAllProperties();

        var tracer = new Mock<ITracer>();
        tracer.Setup(t => t.NewSpan(It.IsAny<String>())).Callback((String name) => names.Add(name)).Returns(span.Object);
        if (resolver != null) tracer.SetupGet(t => t.Resolver).Returns(resolver);

        var old = TracerMiddleware.Tracer;
        TracerMiddleware.Tracer = tracer.Object;
        try
        {
            foreach (var path in paths)
            {
                var ctx = CreateContext(path);
                await mw.Invoke(ctx);
            }
        }
        finally
        {
            TracerMiddleware.Tracer = old;
        }

        return names;
    }

    private sealed class FixedNameResolver : DefaultTracerResolver
    {
        private readonly String _name;
        public FixedNameResolver(String name) => _name = name;

        public override String? ResolveName(String name, Object? userState) => _name;
    }

    private static String ToLetters(Int32 value)
    {
        var chars = new Char[8];
        var i = chars.Length;
        do
        {
            chars[--i] = (Char)('a' + value % 26);
            value /= 26;
        } while (value > 0 && i > 0);

        return new String(chars, i, chars.Length - i);
    }
    #endregion

    [Fact]
    public async Task AutoName_StablePath_KeepFullName()
    {
        var mw = Create();

        // 下游系统把参数放在查询串,路径本身稳定,应完整保留
        var names = await Run(mw, "/v1/chat/completions", "/v1/chat/completions");

        Assert.Equal(2, names.Count);
        Assert.All(names, e => Assert.Equal("/v1/chat/completions", e));
    }

    [Fact]
    public async Task AutoName_ActionSet_KeepFullName()
    {
        var mw = Create();

        // 控制器固定动作集合(6个 action,低于阈值16):不同子值增长有限,不会被归并
        var actions = new[] { "a1", "a2", "a3", "a4", "a5", "a6" };
        var paths = actions.Concat(actions).Select(e => $"/v1/user/{e}").ToArray();
        var names = await Run(mw, paths);

        Assert.Equal(12, names.Count);
        Assert.Equal(6, names.Distinct().Count());
        Assert.All(names, e => Assert.StartsWith("/v1/user/a", e));
        Assert.Contains("/v1/user/a6", names);
    }

    [Fact]
    public async Task AutoName_AllowPaths_KeepFullName()
    {
        var mw = Create();

        // 自定义放行规则支持*模糊匹配,可直接追加:命中后保留完整路径且不参与学习,20个动作也不会被归并
        TracerMiddleware.AllowPaths.Add("/t7/user/*");
        var paths = Enumerable.Range(1, 20).Select(e => $"/t7/user/a{e}").ToArray();
        var names = await Run(mw, paths);

        Assert.Equal(20, names.Count);
        Assert.Equal(20, names.Distinct().Count());
        Assert.All(names, e => Assert.StartsWith("/t7/user/a", e));
    }

    [Fact]
    public async Task AutoName_AreaNames_KeepFullName()
    {
        var mw = Create();

        // 魔方区域整体放行:首段命中即保留完整路径且不参与学习
        TracerMiddleware.AreaNames = ["t8"];
        var paths = Enumerable.Range(1, 20).Select(e => $"/t8/User/a{e}").ToArray();
        var names = await Run(mw, paths);

        Assert.Equal(20, names.Count);
        Assert.Equal(20, names.Distinct().Count());
        Assert.All(names, e => Assert.StartsWith("/t8/User/a", e));
    }

    [Fact]
    public async Task AutoName_RepeatedValue_KeepFullName()
    {
        var mw = Create();

        // 少量或重复的参数值不会触发学习阈值,保持完整名称(基数有界)
        var names = await Run(mw, "/t2/api/order/12345", "/t2/api/order/12345");

        Assert.Equal(2, names.Count);
        Assert.All(names, e => Assert.Equal("/t2/api/order/12345", e));
    }

    [Fact]
    public async Task AutoName_ParameterBurst_MergeToOneName()
    {
        var mw = Create();

        // 60秒内1000次调用,参数不同导致URL不同,客户端必须归并为一个埋点
        var paths = Enumerable.Range(0, 1000).Select(e => $"/t3/api/user/{100000 + e}").ToArray();
        var names = await Run(mw, paths);

        // 冷启动前16个不同参数为完整名称,第17个起归并
        Assert.Equal(1000, names.Count);
        Assert.Equal(17, names.Distinct().Count());
        Assert.Equal(984, names.Count(e => e == "/t3/api/user"));
        Assert.Equal("/t3/api/user", names[^1]);
    }

    [Fact]
    public async Task AutoName_SlugBurst_LimitTransientNames()
    {
        var mw = Create();

        // 字母型参数同样依靠不同子值数量识别,瞬态名称数量有界
        var paths = Enumerable.Range(0, 100).Select(e => $"/t4/files/{ToLetters(e)}").ToArray();
        var names = await Run(mw, paths);

        Assert.Equal(100, names.Count);
        Assert.Equal(17, names.Distinct().Count());
        Assert.Equal(84, names.Count(e => e == "/t4/files"));
        Assert.Equal("/t4/files", names[^1]);
    }

    [Fact]
    public async Task AutoName_ActionMatrix_KeepFullName()
    {
        var mw = Create();

        // 固定动作集合反复出现,不应被截断(不劣于传统三段规则)
        var actions = new[] { "Edit", "Add", "Delete", "Info" };
        var paths = Enumerable.Range(0, 10).SelectMany(e => actions.Select(a => $"/t5/Admin/User/{a}")).ToArray();
        var names = await Run(mw, paths);

        Assert.Equal(40, names.Count);
        Assert.All(names, e => Assert.StartsWith("/t5/Admin/User/", e));
        Assert.Contains("/t5/Admin/User/Edit", names);
    }

    [Fact]
    public async Task AutoName_Disabled_UseLegacyRule()
    {
        var old = TracerMiddleware.EnableAutoName;
        TracerMiddleware.EnableAutoName = false;
        try
        {
            var mw = Create();

            // 传统规则:WebApi保留两段,第三个路径段命中魔方动作时保留三段
            var names = await Run(mw, "/api/user/list", "/Admin/User/Edit");

            Assert.Equal("/api/user", names[0]);
            Assert.Equal("/Admin/User/Edit", names[1]);
        }
        finally
        {
            TracerMiddleware.EnableAutoName = old;
        }
    }

    [Fact]
    public async Task Resolver_CustomName_Applied()
    {
        var mw = Create();

        var names = await Run(mw, new FixedNameResolver("/custom/t7"), "/t7/whatever/x");

        Assert.Equal("/custom/t7", Assert.Single(names));
    }

    [Fact]
    public async Task Resolver_EmptyName_SkipSpan()
    {
        var mw = Create();

        var names = await Run(mw, new FixedNameResolver(""), "/t7/whatever/y");

        Assert.Empty(names);
    }

    [Fact]
    public async Task Prefix_FromConfig_AppliedImmediately()
    {
        var old = StarSetting.Current.TraceNamePrefixes;
        StarSetting.Current.TraceNamePrefixes = "/t8/api/order";
        try
        {
            var mw = Create();

            // 配置文件中的前缀(学习结果或手工调整)立即生效
            var names = await Run(mw, "/t8/api/order/history", "/t8/api/order/detail");

            Assert.All(names, e => Assert.Equal("/t8/api/order", e));
        }
        finally
        {
            StarSetting.Current.TraceNamePrefixes = old;
        }
    }

    [Fact]
    [DisplayName("404扫描探测_未命中路由终结点时抛弃埋点")]
    public async Task Abandon_UnmatchedRoute_DiscardsSpan()
    {
        var (mw, span) = CreateWithSpan();
        var ctx = CreateContext("/wp-login.php");

        // 路径不存在:没有命中任何终结点,状态码404
        ctx.Response.StatusCode = 404;
        await mw.Invoke(ctx);

        span.Verify(s => s.Abandon(), Times.Once);
        // 垃圾埋点不标记异常,避免额外产生异常埋点
        span.Verify(s => s.SetError(It.IsAny<Exception>(), It.IsAny<Object>()), Times.Never);
    }

    [Fact]
    [DisplayName("404业务响应_命中路由终结点时保留埋点")]
    public async Task Abandon_MatchedRoute_KeepsSpan()
    {
        var (mw, span) = CreateWithSpan();
        var ctx = CreateContext("/api/user/1");

        // 资源不存在:路由已命中,属于正常业务行为
        ctx.SetEndpoint(new Endpoint(static _ => Task.CompletedTask, EndpointMetadataCollection.Empty, "UserApi"));
        ctx.Response.StatusCode = 404;
        await mw.Invoke(ctx);

        span.Verify(s => s.Abandon(), Times.Never);
    }

    [Fact]
    [DisplayName("自定义抛弃规则_接管默认判定")]
    public async Task Abandon_CustomRule_TakesOver()
    {
        var (mw, span) = CreateWithSpan();
        var ctx = CreateContext("/api/user/1");

        // 命中终结点的405,默认规则不抛弃;自定义规则接管后抛弃
        ctx.SetEndpoint(new Endpoint(static _ => Task.CompletedTask, EndpointMetadataCollection.Empty, "UserApi"));
        ctx.Response.StatusCode = 405;
        TracerMiddleware.ShouldAbandon = static ctx => ctx.Response.StatusCode == 405;

        await mw.Invoke(ctx);

        span.Verify(s => s.Abandon(), Times.Once);
    }

    [Fact]
    [DisplayName("自定义抛弃规则_返回false时保留默认不抛弃的埋点")]
    public async Task Abandon_CustomRuleCanKeep()
    {
        var (mw, span) = CreateWithSpan();
        var ctx = CreateContext("/wp-login.php");

        ctx.Response.StatusCode = 404;
        TracerMiddleware.ShouldAbandon = static ctx => false;

        await mw.Invoke(ctx);

        span.Verify(s => s.Abandon(), Times.Never);
    }
}