2024年10月


继音视频播放器ExoPlayer之后,谷歌又推出了音视频转换器Transformer,要在音视频加工领域施展拳脚。根据Android开发者官网介绍:Jetpack Media3是Android媒体库的新家,可让App呈现丰富的视听体验。Media3提供了一个简单的架构,能够基于设备功能开展自定义与可靠性优化,可以解决媒体部分的碎片化问题。

Transformer作为Media3架构中的转换组件,可以用于编辑加工音视频,包括在不同格式之间转换编码与修改媒体内容,例如从较长的视频剪辑片段、或者应用自定义的滤镜效果,以及其他音视频编辑操作等等。
谷歌官方也提供了Transformer的代码应用例子,示例源码的托管地址为https://github.com/androidx/media/tree/release/demos,托管页面打开之后访问transformer目录,即可找到Transformer组件的实际运用模块代码。
因为Transformer被Media3囊括在内,所以它对运行环境要求颇高,具体环境要求参见之前的文章《使用Media3的Exoplayer播放网络视频》。准备好了开发环境之后,再按照以下描述引入Media3的ExoPlayer库,详细步骤说明如下。

一、修改模块的build.gradle

在build.gradle的dependencies节点内部补充下面的导包语句,把Transformer用到的相关库都加进来。

implementation "androidx.media3:media3-transformer:1.4.0"
implementation "androidx.media3:media3-effect:1.4.0"
implementation "androidx.media3:media3-common:1.4.0"

二、活动页面代码增加Transformer的处理代码

首先创建音视频的时间处理效果,比如下面代码构建了一个剪辑视频片段的媒体项目,准备把视频文件的第10秒到第20秒单独剪辑出来。

MediaItem.ClippingConfiguration clippingConfiguration =
  new MediaItem.ClippingConfiguration.Builder()
    .setStartPositionMs(10_000) // start at 10 seconds
    .setEndPositionMs(20_000) // end at 20 seconds
    .build();
MediaItem mediaItem = new MediaItem.Builder()
    .setUri(mVideoUri)
    .setClippingConfiguration(clippingConfiguration)
    .build();

接着创建音视频的空间处理效果,包括旋转、缩放视频画面等等,并对上一步的媒体项目运用指定的空间效果。创建代码如下:

ScaleAndRotateTransformation rotateEffect =
  new ScaleAndRotateTransformation.Builder()
    //.setRotationDegrees(90f)
    .setScale(0.5f, 0.5f)
    .build();
Effects effects = new Effects(
    ImmutableList.of(),
    ImmutableList.of(rotateEffect)
);
EditedMediaItem editedMediaItem =
  new EditedMediaItem.Builder(mediaItem)
    .setEffects(effects)
    .build();

然后按照以下代码构建转换器对象,指定输出视频格式为H265,输出音频格式为AAC,并且监听转换操作的结束事件和失败事件。构建代码如下:

Transformer transformer = new Transformer.Builder(this)
    .setVideoMimeType(MimeTypes.VIDEO_H265)
    .setAudioMimeType(MimeTypes.AUDIO_AAC)
    .addListener(new Transformer.Listener() {
        @Override
        public void onCompleted(Composition composition, ExportResult exportResult) {
            Toast.makeText(mContext, "转换成功", Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onError(Composition composition, ExportResult exportResult, ExportException exportException) {
            Toast.makeText(mContext, "转换失败", Toast.LENGTH_SHORT).show();
            Log.d(TAG, "exportException: "+exportException.toString());
        }
    })
    .build();

最后填写输出文件的保存路径,调用转换器对象的start方法,开始执行音视频转换动作。转换代码如下:

String outputPath = mPath + DateUtil.getNowDateTime() + ".mp4";
transformer.start(editedMediaItem, outputPath);

编译运行App,即可在真机上选取视频文件并执行对应的编辑加工操作。

更多详细的FFmpeg开发知识参见
《FFmpeg开发实战:从零基础到短视频上线》
一书。

很久很久没有写过博客了, 正好最近园子又挣得一线生机, 必须得凑个热闹水一篇. 事情是这样的, 在今年的早些时候, 把公司的一部分api服务器的.net版本从6升级到了8, 毕竟6马上就是EOL了(.NET6 TLS 到2024年11月12日). 没成想在升级完的3个月后竟然触发了一个.NET8 runtime JIT 的BUG, 而且是在代码没有任何改动的情况下. 也是离奇他妈给离奇开门, 离奇到家了, 下面就给大家说说这个BUG发现和发生的过程.

我干了什么?

正如上面所说, 在今年的早些时候把一部分api服务器从NET6升级到了NET8.

出现了什么问题?

在所有升级到NET8的十几个是API项目(基于服务scope/流量等原因切分了大约有10几个项目, 但是公共的功能都是通过共享的lib发布的)中, 只有其中一个项目在某些时候的其中的某一个/或少数几个部署实例(AWS ECS Task)会一直报一个业务错误(出现的时间/个数等都不定). 表现的现象就是AES解密(这一部分是所有项目公共使用的)后的plaintext总是会丢失一部分字符. 而且这个实例一旦出现这个BUG, 后续所有的业务执行到这个AES解密的时候都会出现丢失字符.

初步的检查和怀疑?

review 代码过后没有发现问题, 单元测试等也都一直pass的, 所以对目前的逻辑代码实现的怀疑初步排除. 简化后的代码如下

        public int Decrypt(byte[] buffer, string key, string iv, out byte[] decryptedData)
        {
            int decryptedByteCount = 0;
            decryptedData = new byte[buffer.Length];

            using var aes = Aes.Create();
            aes.Mode = CipherMode.CBC;
            aes.KeySize = 16;
            aes.Padding = PaddingMode.Zeros;
            
            var instPwdArray = Encoding.ASCII.GetBytes(key);
            var instSaltArray = Encoding.ASCII.GetBytes(iv);

            using (var decryptor = aes.CreateDecryptor(instPwdArray, instSaltArray))
            {
                using (var memoryStream = new MemoryStream(buffer))
                {
                    using (var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read))
                    {
                        int read;
                        do
                        {
                            read = cryptoStream.Read(decryptedData, decryptedByteCount, decryptedData.Length - decryptedByteCount);
                            decryptedByteCount += read;
                        } while (read != 0);
                    }
                }
            }
            while (decryptedData[decryptedByteCount - 1] == 0 && decryptedByteCount > 0)
            {
                decryptedByteCount--;
            }

            return decryptedByteCount;
        }

BUG具体的现象就是这个方法解密后返回明文的byte count会和预期的不一致, 即代码中的decryptedByteCount. 比如明文明明是10个字符, 结果返回的decryptedByteCount确是8. 调用方会使用这个decryptedByteCount来读取明文.

同时在线上出现这个BUG的时候(2次)采集到的host信息上有一个相似的特征, 那就是CPU都是AMD EPYC 系列CPU, 而恰好在这不久前我们在另外 .NET8 服务上发生了一个
.NET8 GC 在AMD EPYC CPU的异常问题
, 心想着该不会又是类似的问题把, 都已经准备给dotnet/rumtime team 提ticket了, 结果ticket写到一半, 线上又出现了这个问题, 而且采集到的host信息里面多了intel的CPU. 哦豁, 此路不通.

话说 AMD EPYC 的 BUG 前前后后也折腾了好几天, 可以考虑下次在水一篇, 就不再这个JIT BUG的博文中占用更多文字了.

行动: 增加debug代码进行线上调试?

在这个核心方法的更外层调用的地方增加检测代码, 如果检测到触发了这个BUG, 就执行这个方法的复制出来的另外一个方法(增加了更多的调试信息, 例如记录每个参数/变量的值等), 然后, 我们就信心满满的将增加了调试代码的版本发布到线上, 就等着这个BUG出现, 然后抓住它! 更是和客户保证一切尽在掌握, 在下班前就能修复这个BUG.
结果: 调试代码上线后, 这个BUG就再也没有被触发过了. 而我就再也没有下班了. 最后鉴于这个BUG影响范围不大, 而且又没有再次发生, 因此就此打住(毕竟研发资源还是很昂贵的)

2个月后的某一天, 与BUG再次不期而遇!

在即将要下班的档口, 同事找过来和说: "我的朋友(新疆口音), 我在我这边一个项目上调用了基础类库的AES解密, 遇到了一个丢失明文的问题, 你能帮我看看吗?", 这个BUG在他的项目上表现比在我这边还更离谱, 我这边只是解密后的明文丢失最后几个字符, 而他这边则是丢得值剩下一个字符, 就是不管啥密文(同一个规则,长度相同)解密后都只剩下一个字符(比如 AAA 加密后是BBB, 解密 BBB后就都返回"A"了, 即期待decryptedByteCount返回3, 结果都返回了1). 而且他这边的项目触发这个BUG更频繁. 上线10几分钟就能被触发. 和我的同事初步了解后得到了一个重要的线索, 那就是频繁执行的时候更容易触发BUG, 于是我和我的同事说 :"我的朋友(新疆口音), 这会儿我下班了, 你先回滚到上一个版本了. 我们明天继续"

紧跟着, 第二天深入调查, 终是找到了"它"

书接昨天, 昨天我们捕获了一个重要的信息, 那就是频繁的执行就频繁的触发这个BUG, 于是我们在本地写个死循环来调用这个AES解密方法, 果然不出所料, 短时间内, 执行超过大约1万次的时候, 这个BUG总是能触发. 而且随着测试的进行, 越来越多的信息被掌握. 例如: 这个BUG只在release模式下才能被触发, 核心方法的代码要一行不改才能触发. 显然, "它" 也越来越清楚的浮出了水面, 那就是和 runtime JIT 的编译优化有关系.
我们要知道dotnet的一个方法在运行多次之后, runtime会根据调用的时间/次数等信息认定一个方法是不是热点方法, 而进行一次或多次的编译优化, 以提供执行性能. 而我们在本地触发BUG前后做了dump, 从分析dump来看也证实了这个BUG确实是因为JIT二次优化造成的.
在BUG触发前, dump中这个方法的版本是 00007ffe03122e70 (QuickJitted), 而BUG出现后 这个方法的版本是 Current CodeAddr: 00007ffe03134620(OptimizedTier1)

> ip2md 00007FFE0312323A
MethodDesc:   00007ffe031de8d0
Method Name:          [......].Decrypt(Byte[], System.String, System.String, Byte[] ByRef)
Class:                00007ffe031ef318
MethodTable:          00007ffe031de918
mdToken:              000000000600036B
Module:               00007ffe031dd740
IsJitted:             yes
Current CodeAddr:     00007ffe03122e70
Version History:
  ILCodeVersion:      0000000000000000
  ReJIT ID:           0
  IL Addr:            0000018f23a1c864
     CodeAddr:           00007ffe03122e70  (QuickJitted)
     NativeCodeVersion:  0000000000000000
> ip2md 00007FFE03134AE3
MethodDesc:   00007ffe031de8d0
Method Name:          [......].Decrypt(Byte[], System.String, System.String, Byte[] ByRef)
Class:                00007ffe031ef318
MethodTable:          00007ffe031de918
mdToken:              000000000600036B
Module:               00007ffe031dd740
IsJitted:             yes
Current CodeAddr:     00007ffe03134620
Version History:
  ILCodeVersion:      0000000000000000
  ReJIT ID:           0
  IL Addr:            0000018f23a1c864
     CodeAddr:           00007ffe03129cb0  (QuickJitted + Instrumented)
     NativeCodeVersion:  0000018F0054A360
     CodeAddr:           00007ffe03134620  (OptimizedTier1)
     NativeCodeVersion:  0000018F02253050
     CodeAddr:           00007ffe03122e70  (QuickJitted)
     NativeCodeVersion:  0000000000000000

调查到这里, 我们毫不犹豫的把调查的信息发给了dotnet/runtime team, 等待他们的深入调查. 而我们也就此打住, 为什么打住呢? 实在是! 汇编代码一点都看不了! 看不了一点! 优化前 1千多行, 优化后 3千多. 在加之JIT的资料属实不多, 无从下手.
runtime team 也非常给力, 不出一个小时他们就重现了这个BUG, 并找到了
根源
在等待runtime team将这个fix向后移植到NET8的时间内, 我们也要进行修复以避免触发这个BUG, 而修复方案也非常简单, 只要打破这个陷入BUG的语法即可, 最终我们将代码改为了如下结构

 using (var aes = Aes.Create())
 {
     aes.Mode = CipherMode.CBC;
     ......
 }
 while (decryptedData[decryptedByteCount - 1] == 0 && decryptedByteCount > 0)

后话

如果我们能采集/监控足够多的信息,并分析在BUG发生前后的变化, 也许能更快的找到问题的根源, 例如在这个例子中, 如果我们采集/监控了了JIT的分层编译事件,就很有可能能在更早的时间线上解决这个问题. 更可以引用AI来分析事件前后的差异,给出建议. 好期待这样的产品.

相关连接

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:文长

前言

在 Web IDE 中,控制台中展示日志是至关重要的功能。Monaco Editor 作为一个强大的代码编辑器,提供了丰富的功能和灵活的 API ,支持为内容进行“装饰”,非常适合用来构建日志展示器。如下图:
file

除了实时日志外,还有一些需要查看历史日志的场景。如下图:
file

Monarch

Monarch
是 Monaco Editor 自带的一个语法高亮库,通过它,我们可以用类似 Json 的语法来实现自定义语言的语法高亮功能。这里不做过多的介绍,只介绍在本文中使用到的那部分内容.

一个语言定义基本上就是描述语言的各种属性的
JSON
值,部分通用属性如下:

  • tokenizer
    (必填项,带状态的对象)这个定义了
    tokenization
    的规则。 Monaco Editor 中用于定义语言语法高亮和解析的一个核心组件。它的主要功能是将输入的代码文本分解成一个个的 token,以便于编辑器能够根据这些 token 进行语法高亮、错误检查和其他编辑功能。
  • ignoreCase
    (可选项,默认值:
    false
    )语言是否大小写不敏感?
    tokenizer
    (分词器)中的正则表达式使用这个属性去进行大小写(不)敏感匹配,以及
    case
    场景中的测试。
  • brackets
    (可选项,括号定义的数组)
    tokenizer
    使用这个来轻松的定义大括号匹配,更多信息详见
    @brackets

    bracket
    部分。每个方括号定义都是一个由3个元素或对象组成的数组,描述了
    open左大括号

    close右大括号

    token令牌
    类。默认定义如下:
[ ['{','}','delimiter.curly'],
['[',']','delimiter.square'],
['(',')','delimiter.parenthesis'],
['<','>','delimiter.angle'] ]

tokenizer

tokenizer 属性描述了如何进行词法分析,以及如何将输入转换成 token ,每个 token 都会被赋予一个 css 类名,用于在编辑器中渲染,内置的 css token 包括:

identifier         entity           constructor
operators          tag              namespace
keyword            info-token       type
string             warn-token       predefined
string.escape      error-token      invalid
comment            debug-token
comment.doc        regexp
constant           attribute

delimiter .[curly,square,parenthesis,angle,array,bracket]
number    .[hex,octal,binary,float]
variable  .[name,value]
meta      .[content]

当然也可以自定义 css token,通过以下方式将自定义的 css token 注入。

editor.defineTheme("vs", {
  base: "vs",
  inherit: true,
  rules: [
    {
      token: "token-name",
      foreground: "#117700",
    }
  ],
  colors: {},
});

一个 tokenizer 由一个描述状态的对象组成。tokenizer 的初始状态由 tokenizer 定义的第一个状态决定。这句话什么意思呢?查看下方例子,
root
就是 tokenizer 定义的第一个状态,就是初始状态。同理,如果把
afterIf

root
两个状态调换位置,那么
afterIf
就是初始状态。

monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            // 初始状态的规则
            [/\d+/, 'number'], // 识别数字
            [/\w+/, 'keyword'], // 识别关键字
            // 转移到下一个状态
            [/^if$/, { token: 'keyword', next: 'afterIf' }],
        ],
        afterIf: [
            // 处理 if 语句后的内容
            [/\s+/, ''], // 忽略空白
            [/[\w]+/, 'identifier'], // 识别标识符
            // 返回初始状态
            [/;$/, { token: '', next: 'root' }],
        ]
    }
});

如何获取 tokenizer 定义的第一个状态呢?

class MonarchTokenizer {
  ...
  public getInitialState(): languages.IState {
    const rootState = MonarchStackElementFactory.create(null, this._lexer.start!);
    return MonarchLineStateFactory.create(rootState, null);
  }
  ...
}

通过 getInitialState 获取初始的一个状态,通过代码可以看到 确认哪个是初始状态是通过 this._lexer.start 这个属性。这个属性又是怎么被赋值的呢?

function compile() {
  ...
  for (const key in json.tokenizer) {
    if (json.tokenizer.hasOwnProperty(key)) {
      if (!lexer.start) {
        lexer.start = key;
      }
  
      const rules = json.tokenizer[key];
      lexer.tokenizer[key] = new Array();
      addRules('tokenizer.' + key, lexer.tokenizer[key], rules);
    }
	}
  ...
}

在 compile 解析 setMonarchTokensProvider 传入的语言定义对象时,会将读取出来的第一个 key 作为初始状态。可能会有疑问,就一定能保证在定义对象时,写入的第一个属性,在读取时一定第一个被读出吗?

在 JavaScript 中,对象属性的顺序有一些特定的规则:

  1. 整数键:如果属性名是一个整数(如
    "1"

    "2"
    等),这些属性会按照数值的升序排列。
  2. 字符串键:对于非整数的字符串键,属性的顺序是按照它们被添加到对象中的顺序。
  3. Symbol 键:如果属性的键是 Symbol 类型,这些属性会按照它们被添加到对象中的顺序。

因此,当使用
for...in
循环遍历对象的属性时,属性的顺序如下:

  • 首先是所有整数键,按升序排列。
  • 然后是所有字符串键,按添加顺序排列。
  • 最后是所有 Symbol 键,按添加顺序排列。

看个例子:

file

上述例子可以看出,“1”、“2”虽然被写在了后面,但仍然会被排序优先输出,其后才是字符串键根据添加顺序输出。所以,尽可能不要使用整数键去定义状态名。

当 tokenizer 处于某种状态时,只有那个状态的规则才能匹配。所有规则是按顺序进行匹配的,当匹配到第一个规则时,它的 action 将被用来确定 token 的类型。不会再使用后面的规则进行尝试,因此,以一种最有效的方式排列规则是很重要的。比如空格和标识符优先。

如何定义一个状态?

每个状态定义为一个用于匹配输入的规则数组,规则可以有如下形式:

  • [regex, action]
    {regex: regex, action: action}形式的简写。
  • [regex, action, next]
    { regex: regex, action: action{ next: next} }形式的简写。
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            // [regex, action]
            [/\d+/, 'number'], 
            /** 
             * [regex, action, next]
             * [/\w+/, { token: 'keyword', next: '@pop' }] 的简写
             */
            [/\w+/, 'keyword', '@pop'], 
        ]
    }
});

regex 是正则表达式,action 分为以下几种:

  • string
    { token: string } 的简写
  • [action, ..., actionN]
    多个 action 组成的数组。这仅在正则表达式恰好由 N 个组(即括号部分)组成时才允许。举个例子:
[/(\d)(\d)(\d)/, ['string', 'string', 'string']
  • { token: tokenClass }
    这个 tokenClass 可以是内置的 css token,也可以是自定义的 token。同时,还规定了一些特殊的 token 类:
    • "@rematch"
      备份输入并重新调用 tokenizer 。这只在状态发生变化时才有效(或者我们进入了无限的递归),所以这个通常和 next 属性一起使用。例如,当你处于特定的 tokenizer 状态,并想要在看到某些结束标记时退出,但是不想在处于该状态时使用它们,就可以使用这个。例如:
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            [/\d+/, 'number', 'word'],
        ],
        word: [
            [/\d/, '@rematch', '@pop'],
            [/[^\d]+/, 'string']
        ]
    }
});

这个 language 的状态流转图是怎么样的呢?

file

可以看出,在定义一个状态时,应保证状态存在出口即没有定义转移到其他状态的规则),否则可能会导致死循环,不断的使用状态内的规则去匹配。

  • "@pop"
    弹出 tokenizer 栈以返回到之前的状态。
  • "@push"
    推入当前状态,并在当前状态中继续。
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
    root: [
      // 当匹配到开始标记时,推送新的状态
      [/^\s*function\b/, { token: 'keyword', next: '@function' }],
    ],
    function: [
      // 在 function 状态下的匹配规则
      [/^\s*{/, { token: 'delimiter.bracket', next: '@push' }],
      [/[^}]+/, 'statement'],
      [/^\s*}/, { token: 'delimiter.bracket', next: '@pop' }],
    ],
  }
});
- $n  

匹配输入的第n组,或者是$0代表这个匹配的输入。
- $Sn
状态的第 n 个部分,比如,状态 @tag.foo,用 $S0 代表整个状态名(即 tag.foo ),$S1 返回 tag,$S2 返回 foo 。

实时日志

在本篇文章中,Monaco Editor 的使用就不再提及,不是本文的重点。利用 Monaco Editor 实现日志查看器主要是为了让不同的类型的日志有不同的高亮主题。

实时日志中,存在不同的日志类型,如:info、error、warning 等。

/**
 * 日志构造器
 * @param {string} log 日志内容
 * @param {string} type 日志类型
 */
export function createLog(log: string, type = '') {
    let now = moment().format('HH:mm:ss');
    if (process.env.NODE_ENV == 'test') {
        now = 'test';
    }
    return `[${now}] <${type}> ${log}`;
}

根据日志可以看出,每条日志都是
[xx:xx:xx]
开头,紧跟着
<日志类型>
,后面的是日志内容。(日志类型:info 、error、warning。)

注册一个自定义语言
realTimeLog
作为实时日志的一个
language

这里规则也很简单,在 root 中设置了两条解析规则,分别是匹配日志日期和日志类型。在匹配到对应的日志类型后,给匹配到的内容打上
token
,然后通过
next
携带匹配的引用标识( $1 表示正则分组中的第1组)进入下一个状态
consoleLog
,在状态
consoleLog
中,匹配日志内容,并打上
token
,直到遇见终止条件(日志日期)。

import { languages } from "monaco-editor/esm/vs/editor/editor.api";
import { LanguageIdEnum } from "./constants";

languages.register({ id: LanguageIdEnum.REALTIMELOG });

languages.setMonarchTokensProvider(LanguageIdEnum.REALTIMELOG, {
  keywords: ["error", "warning", "info", "success"],
  date: /\[[0-9]{2}:[0-9]{2}:[0-9]{2}\]/,
  tokenizer: {
    root: [
      [/@date/, "date-token"],
      [
        /<(\w+)>/,
        {
          cases: {
            "$1@keywords": { token: "$1-token", next: "@log.$1" },
            "@default": "string",
          },
        },
      ],
    ],
    log: [
      [/@date/, { token: "@rematch", next: "@pop" }],
      [/.*/, { token: "$S2-token" }],
    ],
  },
});

// ===== 日志样式 =====
export const realTimeLogTokenThemeRules = [
  {
    token: "date-token",
    foreground: "#117700",
  },
  {
    token: "error-token",
    foreground: "#ff0000",
    fontStyle: "bold",
  },
  {
    token: "info-token",
    foreground: "#999977",
  },
  {
    token: "warning-token",
    foreground: "#aa5500",
  },
  {
    token: "success-token",
    foreground: "#669600",
  },
];

状态流转图:

file

普通日志

普通日志与实时日志有些许不同,他是的日志类型是不展示出来的,没有一个
起始/结束
标识符供
Monarch
高亮规则匹配。所以需要一个在文本中不展示,又能作为
起始/结束
的标识符。

也确实存在这么一个东西,不占宽度,又能被匹配——“零宽字符”。

零宽字符(Zero Width Characters)是指在文本中占用零宽度的字符,通常用于特定的文本处理或编码目的。它们在视觉上不可见,但在程序处理中可能会产生影响。

利用零宽字符创建不同日志类型的标识。

// 使用零宽字符作为不同类型的日志标识
// U+200B
const ZeroWidthSpace = '';
// U+200C
const ZeroWidthNonJoiner = '‌';
// U+200D
const ZeroWidthJoiner = '‍';
// 不同类型日志的起始 / 结束标识,用于 Monarch 语法文件的解析
const jobTag = {
    info: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthSpace}`,
    warning: `${ZeroWidthNonJoiner}${ZeroWidthSpace}${ZeroWidthNonJoiner}`,
    error: `${ZeroWidthJoiner}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,
    success: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,
};

之后的编写语法高亮规则,与实时日志相同。

import { languages } from "monaco-editor/esm/vs/editor/editor.api";
import { LanguageIdEnum } from "./constants";

languages.register({ id: LanguageIdEnum.NORMALLOG });

languages.setMonarchTokensProvider(LanguageIdEnum.NORMALLOG, {
  info: /\u200b\u200c\u200b/,
  warning: /\u200c\u200b\u200c/,
  error: /\u200d\u200c\u200d/,
  success: /\u200b\u200c\u200d/,
  tokenizer: {
    root: [
      [/@success/, { token: "success-token", next: "@log.success" }],
      [/@error/, { token: "error-token", next: "@log.error" }],
      [/@warning/, { token: "warning-token", next: "@log.warning" }],
      [/@info/, { token: "info-token", next: "@log.info" }],
    ],
    log: [
      [
        /@info|@warning|@error|@success/,
        { token: "$S2-token", next: "@pop" },
      ],
      [/.*/, { token: "$S2-token" }],
    ],
  },
});

// ===== 日志样式 =====
export const normalLogTokenThemeRules = [
  {
    token: "error-token",
    foreground: "#BB0606",
    fontStyle: "bold",
  },
  {
    token: "info-token",
    foreground: "#333333",
    fontStyle: "bold",
  },
  {
    token: "warning-token",
    foreground: "#EE9900",
  },
  {
    token: "success-token",
    foreground: "#669600",
  },
];

状态流转图:

file

其他

在 Monaco Editor 中支持
a
元素

Monaco Editor 本身是不支持在内容中插入 HTML 元素的,原生只支持对链接进行高亮,并且支持
cmd + 点击
打开链接。但仍可能会存在需要实现类似
a
元素的效果。

另辟蹊径,查找 Monaco Editor 的 API 后,linkProvider 也许可以大致满足,但仍有不足。

以下是介绍:

在 Monaco Editor 中,linkProvider 是一个用于提供链接功能的接口。它允许开发者为编辑器中的特定文本或代码片段提供链接,当用户悬停或点击这些链接时,可以执行特定的操作,比如打开文档、跳转到定义等。

具体用法:

const linkProvider = {
    provideLinks: function(model, position) {
        // 返回链接数组
        return [
            {
                range: new monaco.Range(1, 1, 1, 5), // 链接的范围
                url: 'https://example.com', // 链接的 URL
                tooltip: '点击访问示例' // 悬停提示
            }
        ];
    }
};
monaco.languages.registerLinkProvider('javascript', linkProvider);

它是针对已注册的语言进行注册,不会影响到其他语言。在文本内容发生变化时就会触发
provideLinks

根据这个 API 想到一个思路:

  1. 在生成文本时,在需要展示为 a 元素的地方使用
    #link#${JSON.stringify(attrs)}#link#
    包裹,attrs 是一个对象,其中包含了 a 元素的
    attribute
  2. 在文本内容传递给 Monaco Editor 之前,解析文本的内容,利用正则将
    a 元素标记
    匹配出来,使用
    attrs

    链接文本
    替换
    标记文本
    ,并记录替换后
    链接文本
    在文本内容中的索引位置。利用 Monaco Editor 的
    getPositionAt
    获取链接文本在编辑器中的位置(起始/结束行列信息),生成
    Range
  3. 使用一个容器收集对应的日志中的
    Link
    信息。在通过 linkProvider 将编辑器中对应的
    链接文本
    识别为链接高亮。
  4. 给 editor 实例绑定点击事件
    onMouseDown
    ,如果点击的内容位置在收集的 Link 中时,触发对外提供的自定义链接点击事件。

根据这一思路进行实现:

  1. 生成 a 元素标记。
interface IAttrs {
    attrs: Record<string, string>;
    props: {
        innerHTML: string;
    };
}
/**
 *
 * @param attrs
 * @returns
 */
export function createLinkMark(attrs: IAttrs) {
    return `#link#${JSON.stringify(attrs)}#link#`;
}
  1. 解析文本内容
getLinkMark(value: string, key?: string) {
    if (!value) return value;
    const links: ILink[] = [];

    const logRegexp = /#link#/g;
    const splitPoints: any[] = [];
    let indexObj = logRegexp.exec(value);
    /**
     * 1. 正则匹配相应的起始 / 结束标签 #link# , 两两为一组
     */
    while (indexObj) {
        splitPoints.push({
            index: indexObj.index,
            0: indexObj[0],
            1: indexObj[1],
        });
        indexObj = logRegexp.exec(value);
    }

    /**
     * 2. 根据步骤 1 获取的 link 标记范围,处理日志内容,并收集 link 信息
     */
      /** l为起始标签,r为结束标签 */
    let l = splitPoints.shift();
    let r = splitPoints.shift();
    /** 字符串替换中移除字符个数 */
    let cutLength = 0;
    let processedString = value;
    /** link 信息集合 */
    const collections:[number, number, string, ILink['attrs']][] = [];
    while (l && r) {
      const infoStr = value.slice(l.index + r[0].length, r.index);
      const info = JSON.parse(infoStr);
      /**
       * 手动补一个空格是由于后面没有内容,导致点击链接后面的空白处,光标也是在链接上的,
       * 导致当前的range也在link的range中,触发自定义点击事件
       */
      const splitStr = info.props.innerHTML + ' ';

      /** 将 '#link#{"attrs":{"href":"xxx"},"props":{"innerHTML":"logDownload"}}#link#' 替换为 innerHTML 中的文本 */
      processedString =
          processedString.slice(0, l.index - cutLength) +
          splitStr +
          processedString.slice(r.index + r[0].length - cutLength);
      collections.push([
          /** 链接的开始位置 */
          l.index - cutLength,
          /** 链接的结束位置 */
          l.index + splitStr.length - cutLength - 1,
          /** 链接地址 */
          info.attrs.href,
          /** 工作流中应用,点击打开子任务tab */
          info.attrs,
      ]);

      /** 记录文本替换过程中,替换文本和原文本的差值 */
      cutLength += infoStr.length - splitStr.length + r[0].length * 2;
      l = splitPoints.shift();
      r = splitPoints.shift();
    }
  
    /**
     * 3. 处理收集的 link 信息
     */
    const model = editor.createModel(processedString, 'xxx');
    for (const [start, end, url, attrs] of collections) {
        const startPosition = model.getPositionAt(start);
        const endPosition = model.getPositionAt(end);

        links.push({
            range: new Range(
                startPosition.lineNumber,
                startPosition.column,
                endPosition.lineNumber,
                endPosition.column
            ),
            url,
            attrs,
        });
    }
    model.dispose();

    return processedString;
}
  1. 使用一个容器存储解析出来的 link
const value = `这是一串带链接的文本:${createLinkMark({
  props: {
    innerHTML: '链接a'
  },
  attrs: {
    href: 'http://www.abc.com'
  }
})}`
const links = getLinkMark(value)
  1. 利用存储的 links 注册 LinkProvider
languages.registerLinkProvider('taskLog', {
    provideLinks() {
        return { links: links || [] };
    },
});
  1. 绑定自定义事件
    在点击 editor 中的内容时都会触发
    onMouseDown
    ,在其中可以获取当前点击位置的
    Range
    信息,循环遍历收集的所有 Link,判断当前点击位置的
    Range
    是否在其中。
    containsRange
    方法可以判断一个
    Range
    是否在另一个
    Range
    中。
useEffect(() => {
  const disposable = logEditorInstance.current?.onMouseDown((e) => {
      const curRange = e.target.range;
      if (curRange) {
        const link = links.find((e) => {
          return (e.range as Range)?.containsRange(curRange);
        });
        if (link) {
          onLinkClick?.(link);
        }
      }
    });

  return () => {
    disposable?.dispose();
  };
}, [logEditorInstance.current]);

缺点:在日志实时打印时,出现链接不会立马高亮,需要等一会才会变成链接高亮。

file

参考

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

从Windows 11 23H2升级至24H2后,Git操作提示文件所有权错误的3种有效解决方案

在升级至 Windows 11 24H2 后,使用
git add
等命令时,可能会遇到如下错误提示:

Error: libgit2 returned: repository path 'D:/repo/it-tools' is not owned by current user.
To add an exception for this directory, call:
git config --global --add safe.directory 'D:/repo/it-tools'

这个问题是由于 Windows 更新升级后,目录的所有权发生了变化,Git的安全机制不允许当前用户操作该仓库。

本文将介绍三种解决方案,并解释为什么推荐第一种方案。

错误原因分析

Git 从 2.35 版本引入了
safe.directory
机制,旨在防止未授权用户操作不属于自己的Git仓库。当系统升级或权限变更时,可能导致 Git 无法识别当前用户对某些目录的所有权,从而拒绝操作。

解决方案一:调整目录所有权(推荐)

步骤

  1. 以管理员身份运行 命令提示符 或 PowerShell。

  2. 运行以下命令,将目录的所有权重新分配给当前用户:

    takeown /f "D:/repo" /r /d y
    
  3. 重新分配所有权后,Git 将能够识别当前用户的权限,恢复对仓库的正常操作。

推荐理由

  • 一次性解决问题
    :此方法直接从文件系统权限入手,彻底解决了所有与该目录相关的权限问题。
  • 适用范围广
    :不仅限于 Git 操作,任何需要访问该目录的程序都会恢复正常。
  • 无安全隐患
    :只允许当前用户操作,符合系统安全策略,且不会影响系统其他部分的安全性。

解决方案二:针对单个仓库配置安全目录

步骤

  1. 打开 命令提示符 或 PowerShell。

  2. 运行以下命令,将该仓库路径添加到Git的安全目录列表中:

    git config --global --add safe.directory 'D:/repo/it-tools'
    
  3. 该命令将允许当前用户在该特定目录中正常操作 Git 仓库。

适用场景

  • 单仓库使用场景
    :如果问题只影响一个仓库,这个方法是快速有效的解决方案。
  • 无需更改系统权限
    :此方法不涉及更改文件系统的权限,仅影响 Git 的操作。

解决方案三:针对所有目录配置安全目录

步骤

  1. 打开 命令提示符 或 PowerShell。

  2. 运行以下命令,将所有目录标记为安全:

    git config --global --add safe.directory '*'
    
  3. 这将告诉 Git 信任所有目录,无论其所有权如何。

为什么不推荐此方法?

  • 安全风险高
    :该命令将所有目录都标记为安全目录,意味着任何用户都可以对系统中的任何 Git 仓库进行操作,极大地增加了安全风险,尤其是在多用户系统中。
  • 不符合精细化权限管理的原则
    :此方法直接忽略了 Git 的安全检查机制,虽然解决了权限问题,但不建议在生产环境或需要高安全性的系统中使用。

总结

在升级至 Windows 11 24H2 后,
git add
等命令操作可能会遇到权限相关的问题。

推荐使用
解决方案一
,即通过
takeown
命令更改目录所有权,从根本上解决问题。


解决方案二
适用于单仓库的快速解决方案,
解决方案三
虽然能解决问题,但由于其安全性较低,不推荐在多用户或安全敏感的环境中使用。

最后,请根据你的实际需求,选择适合的方案来解决问题。

主页

微信公众号:密码应用技术实战
博客园首页:
https://www.cnblogs.com/informatics/
GIT地址:
https://github.com/warm3snow

简介

从2009年比特币的诞生,区块链技术已经发展了十多年,区块链技术的应用也从最初的数字货币扩展到金融、供应链、医疗、物联网等多个领域。区块链技术的核心是去中心化、不可篡改、匿名性等特性,其中匿名性是区块链技术的重要特性之一,它可以保护用户的隐私,防止用户的交易信息被泄露。在区块链技术中,匿名地址是用户的重要身份标识,用户可以通过匿名地址进行资产托管、资产转账等操作,而不用暴露自己的真实身份。

在比特币中过,用户可以通过私钥生成公钥,再通过公钥生成比特币地址,然后通过比特币地址进行交易,而不用暴露自己的真实身份。虽然比特币地址(如:1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2)具有一定的匿名性,但是比特币地址是公开的,通过大数据手段,可以将该地址相关的交易信息进行关联,只要有一个交易地址被关联到了用户的真实身份,那么用户的所有交易信息都会被暴露,因此比特币匿名性较差。

随着区块链技术的发展,区块链用户隐私的研究也逐渐增多,其中Zcash、Monero等匿名币的研究较为深入,它们通过零知识证明、环签名、隐形地址等技术,实现了更好的匿名性和用户隐私。本文及系列文章将重点介绍
门罗币
的隐私保护技术,如:隐形地址、环签名、机密交易等。

术语介绍

椭圆曲线定义

椭圆曲线(Elliptic Curve)是一种数学曲线,它的一般代数表达形式为:

\[y^2 = x^3 + ax + b
\]

其中,
\(a, b\)
是椭圆曲线的参数,
\((x, y)\)
是椭圆曲线上的点。
在密码学中,椭圆曲线通常定义在有限域
\(F_p\)
上, 其中
\(p\)
是一个大素数。此时,
\(x\)

\(y\)
的取值范围是
\(0\)

\(p-1\)
。具体来说,
\(x\)

\(y\)
都是有限域
\(F_p\)
中的元素,满足椭圆曲线方程,这样的方程我们也称为
有限域上的椭圆曲线
。构造在椭圆曲线上的密码学我们称为
椭圆曲线密码学
,英文缩写为ECC(Elliptic Curve Cryptography)。

有限域上的椭圆曲线有以下几个重要的性质:

  • 单位元
    :椭圆曲线上的单位元
    \(O\)
    是一个特殊的点,它是椭圆曲线上的无穷远点,单位元
    \(O\)
    是椭圆曲线上的一个特殊点,它满足
    \(P + O = P\)

    \(P - P = O\)

    \(O + O = O\)
  • 逆元
    :椭圆曲线上的逆元是指给定一个点
    \(P\)
    ,求解满足
    \(Q = -P\)
    的点
    \(Q\)
    ,其中
    \(Q\)
    是椭圆曲线上的另一个点
  • 点加
    :椭圆曲线上的点加法是指给定两个点
    \(P, Q\)

    \(P \neq Q\)
    , 求解它们的和
    \(R = P + Q\)
    , 其中
    \(R\)
    是椭圆曲线上的另一个点
  • 生成元
    :椭圆曲线上的生成元是指给定一个点
    \(G\)
    ,通过不断地对
    \(G\)
    进行倍乘运算,可以得到椭圆曲线上的所有点
  • 倍乘
    :椭圆曲线上的点倍乘是指给定一个点
    \(P\)
    和一个整数
    \(k\)
    ,求解满足
    \(Q = kP = \sum_{i=1}^{k}P\)
    的点
    \(Q\)
    ,其中
    \(Q\)
    是椭圆曲线上的另一个点

离散对数困难问题
是指在有限域的椭圆曲线上,给定椭圆曲线上的一个点
\(P\)
和另一个点
\(Q\)
,求解满足
\(Q = aG\)

\(a\)
值。(该问题是一个困难问题,目前没有有效的解决方案,也是椭圆曲线加密算法的安全基础)

匿名性定义

隐私和匿名是电子现金最重要的方面。与传统银行不同的是,区块链交易(或者说点对点支付)旨在不依赖于第三方。特别是,T. Okamoto 和 K. Ohta 描述了理想电子现金的几个标准,其中包括“隐私:交易双方的关系必须对任何人不可追踪”。根据他们的描述,完全匿名的电子现金模型必须满足的两个属性:

  • 不可追踪性
    (Untraceability):对于每一笔 incoming 交易,所有可能的发送者都是等概率的。这意味着,对于任何两个 incoming 交易,无法证明它们是由同一个人发送的。
  • 不可链接性
    (Unlinkability):对于任何两笔 outgoing 交易,无法证明它们是发送给同一个人的。即对于任何两个 outgoing 交易,无法证明它们是由同一个人收款的。

不幸的是,比特币并不满足不可追踪性要求。由于网络参与者之间发生的所有交易都是公开的,任何交易都可以明确地追溯到唯一的来源和最终收款者。即使两个参与者以间接方式交换资金,经过精心设计的路径查找方法也会揭示出来源和最终收款者。
虽然比特币可以提供不可链接性,但是它并不是默认的。用户必须小心地处理他们的地址,以确保不会在不同的交易中使用相同的地址。这是因为,如果两个交易都使用相同的地址,那么这两个交易就可以被链接在一起,从而揭示出用户的身份。

术语定义

  • 公私钥对:公私钥对常见于非对称密码算法,在这些算法中,使用的密钥包含公钥和私钥。公钥公开,用于加密或签名验证;私钥保密,用于解密或签名。常见的非对称密码算法有RSA、ECC等。如在椭圆曲线中,
    \(Q = aG\)
    ,其中
    \(Q\)
    是公钥,
    \(a\)
    是私钥
  • 用户公私钥对/密钥对:用户公私钥对用
    \((a, Q)\)
    表示,其中
    \(a\)
    是用户的私钥,
    \(Q\)
    是用户的公钥。其中
    \((a, Q)\)
    被称为标准的椭圆曲线密钥对
  • 门罗币用户密钥对:在门罗币中,用户密钥对包含两个标准的椭圆曲线密钥对
    \((a, A)\)

    \((b, B)\)
    ,不是一般性,后文中称
    \((a, b)\)
    为用户私钥,称
    \((A, B)\)
    为用户公钥

门罗币之隐形地址

在比特币和以太坊等经典区块链系统中,用户可以在不同交易中使用不同的地址来保证交易的不可链接性

比特币交易模型
如下:

image

以上图为例,在比特币网络中,当Alice在给Bob进行2次转账时,使用了Bob不同的地址,对应不同的公私钥对。比特币交易具有如下特点:

  • 为了保证用户的隐私,Bob需要每次生成一个新的比特币地址(对应新的公私钥对),这样可以保证每次交易的地址不同,从而保证交易的隐私性
  • 比特币钱包需要维护一个地址池,每次生成一个新的地址,就需要将该地址加入到地址池中,这样会增加钱包的存储空间
  • 由于钱包存储空间有限,限制了用户生成地址的数量,从而不可避免导致在不同交易中使用相同的地址,无法保证地址的不可链接性
  • 不同的地址之间相互独立,无法关联,能够保证交易的不可链接性,从而保证用户的隐私性

门罗币使用了一种匿名地址技术,称为
隐形地址(stealth address)
,隐身地址与实际收款人的用户唯一公私钥绑定,但只有发送方和收款方知道两者之间的关联。对于用户的每一笔转账交易,收款方可以使用不同的隐形地址,从而保证了用户交易的
不可链接性

门罗币交易模型
如下:

image

以上图为例,在门罗币网络中,当Alice在给Bob进行2次转账时,使用了Bob不同的地址,对应不同的公私钥对,但这些不同的公私钥对都是由相同的密钥对派生得到。门罗币交易具有如下特点:

  • 门罗币用户只需要维护两个标准的公私钥对
    \((a, A)\)

    \((b, B)\)
    ,在每次收款时,基于用户的公私钥对生成一个隐形地址,用于收款。如:
    \(Q_1\)

    \(Q_2\)
    是基于用户公私钥对生成的临时公钥,对应私钥为
    \(a_1\)

    \(a_2\)
  • 隐形地址不需要事先生成和本地存储,只需要在收款时,基于用户的公私钥对生成一个隐形地址即可,降低了钱包的存储空间
  • 基于用户公私钥对可以生成多个隐形地址,每个隐形地址之间不可关联,从而保证了用户的隐私性

门罗币中的隐形地址是通过用户的公私钥对
\((a, A)\)

\((b, B)\)
生成的,通常用
\((a, b)\)
表示用户的私钥,用
\((A, B)\)
表示用户的公钥。隐形地址主要应用在门罗币
生成交易

接收交易
中,下面我们分别介绍隐形地址的生成和验证过程。

生成交易(转账)

image

  • Alice要向Bob转账, Alice首先生成随机数
    \(r\)
    , 并计算
    \(R = rG\)
    ,作为交易的一部分
  • Alice补充转账金额,如图中为
    \(1.5 XMR\)
    , 表示Alice向Bob转账
    \(1.5\)
    个门罗币
  • Bob私下提供给Alice自己的公钥
    \((A, B)\)
  • Alice基于Bob的公钥生成一个隐形地址
    \(P = H_s(rA)G + B\)
    , 其中
    \(H_s\)
    是哈希函数,
    \(G\)
    是椭圆曲线上的生成元
  • Alice对交易信息
    \((R, 1.5XMR, P)\)
    进行签名,生成签名
    \(S\)
    ,并将
    \((R, 1.5XMR, P, S)\)
    发送到门罗币网络中

值得注意的是,隐形地址
\(P\)
的随机性是由
\(r\)
决定的,而
\(r\)
是Alice生成的随机数,只有Alice知道,因此隐形地址的安全性依赖于Alice生成
\(r\)
的安全性

接收交易(收款)

image

  • Bob在区块链上查询到Alice的交易信息
    \((R, 1.5XMR, P, S)\)
    ,并验证签名的有效性
  • Bob基于自己的密钥
    \((a, B)\)
    ,计算
    \(P^{'} = H_s(aR)G + B\)
    ,并验证
    \(P \stackrel{?}{=} P^{'}\)
    ,如果验证通过,则表示该交易是Bob的收款交易
  • 同时当Bob需要花费该笔交易时,Bob可以使用自己的密钥
    \((a, b)\)
    ,计算
    \(x = H_s(aR) + b\)
    ,并使用
    \(x\)
    作为签名私钥,从而授权该交易

收款隐私性和正确性验证

  • 隐私性:需要注意的是,由于只有Bob知道自己的私钥
    \((a)\)
    ,因此只有Bob能够计算出
    \(H_s(aR)\)
    ,从而计算出
    \(P^{'}\)
    ,因此只有Bob能够确认该交易是自己的收款交易,保证了用户隐私性
  • 正确性验证如下:

\[P^{'} = H_s(aR)G + B
\]

\[= H_s(a(rG))G + B
\]

\[= H_s(r(aG))G + B
\]

\[= H_s(rA)G + B
\]

\[= P
\]

因此,
\(P^{'} = P\)
,验证通过,表示Bob能够正确验证该交易是自己的收款交易

支出正确性验证

Bob要能够花费该笔资产,需要保证自己有
\(P\)
对应的私钥。Bob可以根据自己的私钥
\((a, b)\)
,计算
\(x = H_s(aR) + b\)

支出正确性验证如下:

\[P = H_s(rA)G + B
\]

\[= H_s(r(aG))G + B
\]

\[= H_s(a(rG))G + B
\]

\[= H_s(aR)G + bG
\]

\[= (H_s(aR) + b)G
\]

\[= xG
\]

因此,
\(P = xG\)
,表示
\(x\)

\(P\)
对应的私钥,Bob可以使用
\(x\)
作为签名私钥,从而授权对应资产的支出

视图密钥和支出密钥

从Bob收款和支出的过程中,我们可以看到Bob收款时只需要用到
\((a, B)\)
, 而支出时需要用到
\((a, b)\)
,按照使用场景,一般将
\((a, B)\)
称为
视图密钥
,将
\((a, b)\)
称为
支出密钥

  • 视图密钥:用于收款,可以公开给监管结构或第三方,第三方可以通过视图密钥查看用户的交易记录,但是由于不知道全部的私钥
    \((a, b)\)
    ,因此无法花费用户的资产
  • 支出密钥:用于支出,只有用户自己知道,用于花费用户的资产

门罗币的隐形地址技术,为监管机构提供了一种有效的监管手段,既保护了用户的隐私,又满足了监管机构的监管需求。因此,在不少数字货币友好的国家和地区也开始尝试在主权数字货币中引入门罗币的隐形地址技术。

结语

隐形地址技术是门罗币的核心技术之一,实现了用户的隐私保护和监管需求。本文简单介绍了比特币和门罗币的交易模型,并详细介绍了门罗币的隐形地址技术,包括隐形地址的生成和验证过程。希望通过本文的介绍,读者能够了解门罗币的隐形地址技术,以及隐形地址技术的应用场景和优势。

下一篇文章将介绍门罗币的环签名技术,环签名技术是门罗币的另一个核心技术,它通过环签名技术,实现了交易的不可追踪性。

参考文献