个人学习分享

暂无。

静态链接 C# 到 Rust

最近 .Net 7 发布之后,因为带了 AOT 编译器,又爆发了一波热度,正好我最近有需求需要使用到这个功能,本文就记录下如何实现将 .Net 7 库编译成静态库,然后用 Rust 链接。

本文实现的是将一个非标准的 DES 算法编译成静态库,供 Rust 调用。该 DES 算法的 C# 实现在这里可以找到:https://github.com/fygroup/Security/blob/master/DES.cs

本文项目的目录结构为:

./call-net-from-rust-statically
  ├── des-lib
  │   ├── des-lib.csproj
  │   └── DES.cs
  ├── Cargo.toml
  ├── build.rs
  └── src
      └── main.rs

先创建好 call-net-from-rust-statically 目录:

mkdir call-net-from-rust-statically

C# 项目部分

首先创建项目:

cd call-net-from-rust-statically
dotnet new classlib -n des-lib

Class1.cs 重命名为 DES.cs,然后把上面链接中的 DES 类复制到 DES.cs 中,改下命名空间,再加上导出函数的代码,如下:

namespace des_lib;

using System.Runtime.InteropServices;

public class DES
{
  [UnmanagedCallersOnly(EntryPoint = "wtf_des_encrypt")]
  public static nint FFI_Encrypt(nint message, nint key)
  {
    var managedMessage = Marshal.PtrToStringUTF8(message);
    var managedKey = Marshal.PtrToStringUTF8(key);
    if (managedKey == null || managedMessage == null)
    {
      return nint.Zero;
    }
    var cipherText = EncryptDES(managedMessage, managedKey);
    return Marshal.StringToHGlobalAnsi(cipherText);
  }

  [UnmanagedCallersOnly(EntryPoint = "wtf_des_decrypt")]
  public static nint FFI_Decrypt(nint cipherMessage, nint key)
  {
    var managedCipherMessage = Marshal.PtrToStringUTF8(cipherMessage);
    var managedKey = Marshal.PtrToStringUTF8(key);
    if (managedKey == null || managedCipherMessage == null)
    {
      return nint.Zero;
    }
    var plainText = DecryptDES(managedCipherMessage, managedKey);
    return Marshal.StringToHGlobalAnsi(plainText);
  }

  [UnmanagedCallersOnly(EntryPoint = "wtf_des_free")]
  public static void FFI_FreeMemory(nint buffer)
  {
    Marshal.FreeHGlobal(buffer);
  }

  // 将原有 DES 类的内容放在这里。
}

其中 wtf_des_encryptwtf_des_decryptwtf_des_free 就是导出的加密、解密以及释放内存的方法。

配置项目的属性:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <NativeLib>Static</NativeLib>
    <PublishAot>true</PublishAot>
    <StripSymbols>true</StripSymbols>
    <SelfContained>true</SelfContained>
  </PropertyGroup>

</Project>

然后就可以用如下命令编译一下试试看:

cd des-lib
dotnet publish -r win-x64 -c Release

在构建完毕之后,会在 bin\Release\net7.0\win-x64\publish 目录下生成 des-lib.lib 文件。

Rust 项目部分

在上面的项目构建成功后,将会把 ilcompiler 包缓存,并可以在该目录 %USERPROFILE%/.nuget/packages/runtime.win-x64.microsoft.dotnet.ilcompiler/7.0.1/sdk 找到链接依赖的一些静态库(注意,版本号可能会变更)。

call-net-from-rust-statically 目录中创建 Rust 项目:

cd call-net-from-rust-statically
cargo init

先添加 windows 依赖,这是因为在链接的时候,.Net 运行时会依赖 Win32 API

cargo add windows

添加 build.rs,一定要注意修改 sdk_path 中的 ilcompiler 版本号(本文讲的是实现步骤,最终的代码我会把 des-lib 的构建也放在 build.rs 中,并从构建的输出中寻找这个版本号,而不需要写死):

use std::path::PathBuf;

fn main() {
    let user_profile: PathBuf = std::env::var("USERPROFILE").unwrap().into();
    let sdk_path: PathBuf = (user_profile)
        .join(".nuget\\packages\\runtime.win-x64.microsoft.dotnet.ilcompiler\\7.0.1\\sdk");
    let manifest_dir: PathBuf = std::env::var("CARGO_MANIFEST_DIR").unwrap().into();
    let des_lib_path = manifest_dir.join("des-lib");

    println!("cargo:rustc-link-arg=/INCLUDE:NativeAOT_StaticInitialization");
    println!("cargo:rustc-link-search={}", sdk_path.display());
    println!(
        "cargo:rustc-link-search={}\\bin\\Release\\net7.0\\win-x64\\publish",
        des_lib_path.display()
    );

    println!("cargo:rustc-link-lib=static=windows");
    println!("cargo:rustc-link-lib=static=bootstrapperdll");
    println!("cargo:rustc-link-lib=static=Runtime.WorkstationGC");
    println!("cargo:rustc-link-lib=static=System.Globalization.Native.Aot");
    println!("cargo:rustc-link-lib=static=des-lib");
}

接下来就是调用了,在 main.rs 中添加:

extern "C" {
    fn wtf_des_encrypt(message: *const u8, key: *const u8) -> *const u8;
    fn wtf_des_decrypt(cipher_text: *const u8, key: *const u8) -> *const u8;
    fn wtf_des_free(ptr: *const u8);
}

fn main() {
    let key = b"key\0";
    let cipher_text = unsafe { wtf_des_encrypt(b"message\0".as_ptr(), key.as_ptr()) };
    let cipher_text = unsafe { std::ffi::CStr::from_ptr(cipher_text as *const i8) };
    let plain_text = unsafe { wtf_des_decrypt(cipher_text.as_ptr() as _, key.as_ptr()) };
    let plain_text = unsafe { std::ffi::CStr::from_ptr(plain_text as *const i8) };
    println!("cipher_text: {}", cipher_text.to_str().unwrap());
    println!("plain_text: {}", plain_text.to_str().unwrap());

    unsafe {
        wtf_des_free(cipher_text.as_ptr() as _);
        wtf_des_free(plain_text.as_ptr() as _);
    }
}

最终版本

仓库地址:https://github.com/hamflx/call-net-from-rust-statically,在本文的基础增加了自动构建 C# 项目,自动查找 ilcompiler 的路径并链接。

使用 Rust 编写转发 DLL

本文实现一个转发 DLL:version.dll 转发到系统的 version.dll 上。另外要注意,本文代码仅供参考,不一定可以运行,需要做一定的修改,或直接查看最终实现:https://github.com/hamflx/forward-dll

首先用下面的命令查看系统的 version.dll 导出函数:

C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise>dumpbin /exports c:\windows\system32\version.dll
Microsoft (R) COFF/PE Dumper Version 14.29.30136.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file c:\windows\system32\version.dll

File Type: DLL

  Section contains the following exports for VERSION.dll

    00000000 characteristics
    927B71E6 time date stamp
        0.00 version
           1 ordinal base
          17 number of functions
          17 number of names

    ordinal hint RVA      name

          1    0 00001080 GetFileVersionInfoA
          2    1 00002190 GetFileVersionInfoByHandle
          3    2 00001DF0 GetFileVersionInfoExA
          4    3 00001040 GetFileVersionInfoExW
          5    4 00001010 GetFileVersionInfoSizeA
          6    5 00001E00 GetFileVersionInfoSizeExA
          7    6 00001050 GetFileVersionInfoSizeExW
          8    7 00001060 GetFileVersionInfoSizeW
          9    8 00001070 GetFileVersionInfoW
         10    9 00001E10 VerFindFileA
         11    A 00002360 VerFindFileW
         12    B 00001E20 VerInstallFileA
         13    C 00002F80 VerInstallFileW
         14    D          VerLanguageNameA (forwarded to KERNEL32.VerLanguageNameA)
         15    E          VerLanguageNameW (forwarded to KERNEL32.VerLanguageNameW)
         16    F 00001020 VerQueryValueA
         17   10 00001030 VerQueryValueW

  Summary

        1000 .data
        1000 .pdata
        2000 .rdata
        1000 .reloc
        1000 .rsrc
        3000 .text

C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise>

然后我们拿 GetFileVersionInfoSizeA 举例来写一个实例函数:

#![allow(unused)]
fn main() {
static mut RealGetFileVersionInfoSizeA: usize = 0;

#[no_mangle]
pub extern "system" fn GetFileVersionInfoSizeA() -> u32 {
    unsafe {
        std::arch::asm!(
            "jmp rax",
            in("rax") RealGetFileVersionInfoSizeA,
            options(nostack)
        );
    }
    1
}
}

这个函数其实也不能叫函数,因为它不会返回,直接跳转到目标函数地址,这个目标函数地址需要在 DllMain 中通过 LoadLibraryGetProcAddress 进行赋值:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "system" fn DllMain(_inst: isize, reason: u32, _: *const u8) -> u32 {
    if reason == 1 {
        let version_module = load_library("c:\\windows\\system32\\version.dll");
        unsafe { RealGetFileVersionInfoSizeA = get_proc_address(version_module, "GetFileVersionInfoSizeA") };
    }
    1
}
}

如果每个函数都这么写,那是相当的麻烦,因此,我们可以写一个宏,并把加载目标 dll 的真实地址封装到结构体的方法里面,这样在 DllMain 时直接调用即可:

#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! forward_dll {
    ($lib:expr, $name:ident, $($proc:ident)*) => {
        static mut $name: forward_dll::DllForwarder<{ forward_dll::count!($($proc)*) }> = forward_dll::DllForwarder {
            lib_name: $lib,
            target_functions_address: [
                0;
                forward_dll::count!($($proc)*)
            ],
            target_function_names: [
                $(stringify!($proc),)*
            ]
        };
        forward_dll::define_function!($name, 0, $($proc)*);
    };
}

#[macro_export]
macro_rules! define_function {
    ($name:ident, $index:expr, ) => {};
    ($name:ident, $index:expr, $proc:ident $($procs:ident)*) => {
        #[no_mangle]
        pub extern "system" fn $proc() -> u32 {
            unsafe {
                std::arch::asm!(
                    "jmp rax",
                    in("rax") $name.target_functions_address[$index],
                    options(nostack)
                );
            }
            1
        }
        forward_dll::define_function!($name, ($index + 1), $($procs)*);
    };
}

/// DLL 转发类型的具体实现。该类型不要自己实例化,应调用 forward_dll 宏生成具体的实例。
pub struct DllForwarder<const N: usize> {
    pub target_functions_address: [usize; N],
    pub target_function_names: [&'static str; N],
    pub lib_name: &'static str,
}

impl<const N: usize> DllForwarder<N> {
    /// 将所有函数的跳转地址设置为对应的 DLL 的同名函数地址。
    pub fn forward_all(&mut self) -> ForwardResult<()> {
        let load_module_dir = "C:\\Windows\\System32\\";
        let module_full_path = format!("{}{}", load_module_dir, self.lib_name);
        let module_handle = get_module_handle(module_full_path.as_str())?;

        for index in 0..self.target_functions_address.len() {
            let addr_in_remote_module =
                get_proc_address_by_module(module_handle, self.target_function_names[index])?;
            self.target_functions_address[index] = addr_in_remote_module as *const usize as usize;
        }

        Ok(())
    }
}

forward_dll::forward_dll!(
    "C:\\Windows\\system32\\version.dll",
    DLL_VERSION_FORWARDER,
    GetFileVersionInfoA
    GetFileVersionInfoByHandle
    GetFileVersionInfoExA
    GetFileVersionInfoExW
    GetFileVersionInfoSizeA
    GetFileVersionInfoSizeExA
    GetFileVersionInfoSizeExW
    GetFileVersionInfoSizeW
    GetFileVersionInfoW
    VerFindFileA
    VerFindFileW
    VerInstallFileA
    VerInstallFileW
    VerLanguageNameA
    VerLanguageNameW
    VerQueryValueA
    VerQueryValueW
);

// 在 DllMain 中调用:
// unsafe { DLL_VERSION_FORWARDER.forward_all() };
}

这就完成了 version.dll 的转发。可参考通过该方法实现的一个小工具:https://github.com/hamflx/huawei-pc-manager-bootstrap

法二

如果我们不希望通过 DllMain 来初始化怎么办?我们可以在跳板函数里面加载目标函数地址,为了保证寄存器和栈上数据的状态,我们单独写一个加载函数,并从跳板里面调用过去,由编译器来帮我们保证寄存器的状态。

通过该函数拿到目标函数地址后将其返回,那么目标函数地址就存储在 rax 上,然后再跳转到 rax 上:

#![allow(unused)]
fn main() {
pub extern "system" fn $proc() -> u32 {
    unsafe {
        std::arch::asm!(
            "push rcx",
            "push rdx",
            "push r8",
            "push r9",
            "push r10",
            "push r11",
            options(nostack)
        );
        std::arch::asm!(
            "sub rsp, 28h",
            "call rax",
            "add rsp, 28h",
            in("rax") forward_dll::default_jumper,
            in("rcx") std::concat!($lib, "\0").as_ptr() as usize,
            in("rdx") std::concat!(std::stringify!($proc), "\0").as_ptr() as usize,
            options(nostack)
        );
        std::arch::asm!(
            "pop r11",
            "pop r10",
            "pop r9",
            "pop r8",
            "pop rdx",
            "pop rcx",
            "jmp rax",
            options(nostack)
        );
    }
    1
}
}

然后我们实现一个 forward_dll::default_jumper 方法:

#![allow(unused)]
fn main() {
/// 默认的跳板,如果没有执行初始化操作,则进入该函数。
pub fn default_jumper(
    lib_name: *const u8,
    func_name: *const u8,
) -> usize {
    let module_handle = unsafe { LoadLibraryA(lib_name) };
    if module_handle != 0 {
        let addr = unsafe { GetProcAddress(module_handle, func_name) };
        // 这里调用了 FreeLibrary 释放目标模块,实际使用需要在其他地方持有目标模块的句柄,防止被释放。
        unsafe { FreeLibrary(module_handle) };
        return addr.map(|addr| addr as usize).unwrap_or(exit_fn as usize);
    }

    exit_fn as usize
}
}

vite 兼容性踩坑记录

vite 提供的快速启动开发模式服务器的特性非常好用,不过,有时候线上出了兼容性问题,你还无法快速定位到问题的根源,那就需要低版本的浏览器来进行开发测试,然而……

想要在低版本浏览器上运行项目,首先要在浏览器上打开项目,因为我们项目采用了可选链,导致连项目都打不开。

尝试配置 config.esbuild.targetes2015,ts 文件是可以被正确编译了,但是 vue 文件仍然不行,然后我去搜索,发现了好多人都有这个问题,但是貌似没有一个能给出很方便的解决方案的。在 GitHub 上有个 issue 给出了 PR,但是这个 PR 没有被合并(issue:https://github.com/vitejs/vite/issues/5222,pr:https://github.com/vitejs/vite/pull/5652)。

根据上面的 pr 我们可以看到 plugin-vue 的源代码,发现 vue 中的 ts 是不检查 config.esbuild.target 选项的,如下图:

transformWithEsBuild

所以我首先尝试的方法就是按照 PR 所述直接修改 node_modules 里的文件,改完之后发现 vue 中的可选链被编译了,但是 ts 中的可选链仍然没有被编译,这个时候再搭配一下 config.esbuild.target 就能把两边问题都给解决了,不过这个方法要修改 node_modules 不太好。

后来换了 @rollup/plugin-babel 插件,配置完也不行,研究了下源代码,对于 ts 项目,需要配置 extensions 才行:

export const DEFAULT_EXTENSIONS: ['.js', '.jsx', '.es6', '.es', '.mjs'];

const unpackOptions = ({
  extensions = babel.DEFAULT_EXTENSIONS,
  // rollup uses sourcemap, babel uses sourceMaps
  // just normalize them here so people don't have to worry about it
  sourcemap = true,
  sourcemaps = true,
  sourceMap = true,
  sourceMaps = true,
  ...rest
} = {}) => {
  return {
    extensions,
    plugins: [],
    sourceMaps: sourcemap && sourcemaps && sourceMap && sourceMaps,
    ...rest,
    caller: {
      name: '@rollup/plugin-babel',
      ...rest.caller
    }
  };
};

配置如下:

import { resolve } from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import babel from '@rollup/plugin-babel'

export default defineConfig({
  plugins: [
    vue(),
    babel({
      extensions: ['.ts', '.js', '.jsx', '.es6', '.es', '.mjs'],
      plugins: [
        '@babel/plugin-proposal-optional-chaining',
        '@babel/plugin-proposal-nullish-coalescing-operator'
      ]
    })
  ]
})

一眼看过去发现应该还是不行,因为缺少 .vue 文件的处理,试了一下果然不行,不过,扩展名里加 .vue 的话会报错,一般来说 .vue 文件编译之后会是 js,但是 .vue 里面如果包含了样式,会单独提取出来作为一个虚拟的文件,通过查询参数 type=style 来读取,这里以 babel 来转译样式文件当然报错。

看了下 @rollup/plugin-babel 的代码,发现还有 include/exclude/filter 选项可以使用,与扩展名之间是且的关系:

const userDefinedFilter =
  typeof customFilter === 'function' ? customFilter : createFilter(include, exclude);
filter = (id) => extensionRegExp.test(stripQuery(id).bareId) && userDefinedFilter(id);

所以,我们只要限定一下,只转义以 .vue 为后缀的文件就行了:

import { resolve } from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import babel from '@rollup/plugin-babel'

export default defineConfig({
  plugins: [
    vue(),
    babel({
      include: [
        /\.vue$/,
        /\.ts$/
      ],
      extensions: ['.vue', '.ts', '.js'],
      plugins: [
        '@babel/plugin-proposal-optional-chaining',
        '@babel/plugin-proposal-nullish-coalescing-operator'
      ]
    })
  ]
})

最后结果如下:

转译结果

为了解决 chrome 的滚动条 bug(另外再吐槽下,最近 chrome 频繁升级导致的 bug 真的是不少),先得把 vite 不支持 chrome 老版本的问题解决……

前端通过 ffmpeg 库播放视频

仓库地址:https://github.com/hamflx/ffmpeg-fe

先看一下 chrome 支持的视频格式与解码器:

Codec and Container Support

其中 H.265 并不在一般的 Chrome 上支持,有时候我们需要播放一些不常用的格式,那么我们就可以将 ffmpeg 库编译为 WebAssembly 以支持这些格式。

ffmpeg 是一个音视频处理的通用库,我们需要用 C++ 写一个调用 ffmpeg 的解码程序,该程序与 JS 进行通信,取得视频数据,并调用 ffmpeg 解码,将解码后的程序送给 JS,由 JS 调用 WebGL 渲染。

程序结构

我这里以流的形式来取视频数据、解码,在播放器一侧在 requestAnimationFrame 中拉取视频数据:

时序图

构建 ffmpeg

首先需要把 ffmpeg 编译成几个库,后续我们的 C++ 解码程序就可以调用这个库里的方法,构建命令:

CPPFLAGS="-D_POSIX_C_SOURCE=200112 -D_XOPEN_SOURCE=600" \
emconfigure ./configure \
    --prefix=$(pwd)/lib \
    --cc="emcc" \
    --cxx="em++" \
    --ar="emar" \
    --ranlib="emranlib" \
    --target-os=none \
    --enable-cross-compile \
    --enable-lto \
    --cpu=generic \
    --arch=x86_64 \
    --disable-asm \
    --disable-inline-asm \
    --disable-programs \
    --disable-avdevice \
    --disable-doc \
    --disable-swresample \
    --disable-postproc  \
    --disable-avfilter \
    --disable-pthreads \
    --disable-w32threads \
    --disable-os2threads \
    --disable-network \
    --disable-logging \
    --disable-everything \
    --enable-gpl \
    --enable-version3 \
    --enable-static \
    --enable-demuxers \
    --enable-parsers \
    --enable-decoder=pcm_mulaw \
    --enable-decoder=pcm_alaw \
    --enable-decoder=adpcm_ima_smjpeg \
    --enable-protocol=file \
    --enable-protocol=pipe \
    --enable-decoder=h264 \
    --enable-decoder=hevc

make && make install

以上构建命令成功后,将会得到下面的文件:

lib*.a

编写解码程序

通过 ffmpeg 的自定义流,来实现一个 read_packet 方法,该方法内部调用 js 的异步方法取得视频流,以此作为输入。程序主循环中不断尝试调用 avcodec_receive_frame 解码,当数据不足时,通过 av_read_frameavcodec_send_packet 将数据发送给 ffmpeg 的解码器。

自定义输入流如下:

int nBufferSize = 32768;
unsigned char *pReadBuffer = (unsigned char*)av_malloc(nBufferSize);
if (pReadBuffer == NULL)
{
  return DECODER_ERROR::AV_ERROR;
}

AVIOContext *pIoCtx = avio_alloc_context(pReadBuffer, nBufferSize, 0, (void*)this, DecoderReadPacket, NULL, NULL);
if (pIoCtx == NULL)
{
  return DECODER_ERROR::AV_ERROR;
}

m_pFmtCtx = avformat_alloc_context();
m_pFmtCtx->pb = pIoCtx;
m_pFmtCtx->flags = AVFMT_FLAG_CUSTOM_IO;

int ret;
while ((ret = avformat_open_input(&m_pFmtCtx, NULL, NULL, NULL)) == AVERROR(EAGAIN))
{
}
if (ret)
{
  return DECODER_ERROR::AV_ERROR;
}

int Decoder::ReadPacket(void *opaque, uint8_t*buf, int buf_size)
{
  emscripten::val packet = m_jsUpstream.call<emscripten::val>("next", buf_size).await();
  emscripten::val data = packet["data"];
  emscripten::val done = packet["done"];
  if (done.as<bool>())
  {
    return 0;
  }

  const auto nPacketLength = data["length"].as<unsigned>();
  if (nPacketLength > buf_size)
  {
    printf("==> nPacketLength > buf_size\n");
  }

  emscripten::val memoryView{emscripten::typed_memory_view(nPacketLength, buf)};
  memoryView.call<void>("set", data.call<emscripten::val>("slice", 0, nPacketLength));

  return nPacketLength;
}

程序主循环解码(其实是在 JS 里调用 NextC++ 的)如下:

while ((ret = avcodec_receive_frame(m_pCodecCtx, m_pFrame)) == AVERROR(EAGAIN))
{
  while (1)
  {
    ret = av_read_frame(m_pFmtCtx, m_pPacketFrame);
    if (ret == 0)
    {
      if (m_pPacketFrame->stream_index == m_iVideoStream)
      {
        break;
      }
      av_packet_unref(m_pPacketFrame);
      continue;
    }

    printf("==> av_read_frame error: %s\n", av_err2str(ret));
    av_packet_unref(m_pPacketFrame);

    if (ret == AVERROR_EOF)
    {
      result.set("status", (int)DECODER_ERROR::END_OF_FILE);
      return result;
    }
    if (ret != 0)
    {
      printf("av_read_frame failed: %s\n", av_err2str(ret));
      result.set("status", (int)DECODER_ERROR::AV_READ_FRAME);
      return result;
    }
  }

  ret = avcodec_send_packet(m_pCodecCtx, m_pPacketFrame);
  av_packet_unref(m_pPacketFrame);

  if (ret != 0)
  {
    printf("==> avcodec_send_packet error: %s\n", av_err2str(ret));
    result.set("status", (int)DECODER_ERROR::AVCODEC_SEND_PACKET);
    return result;
  }
}

Makefile

all: ../web/ff.js

rebuild: clean all

CC = emcc
CFLAGS = -O3 -I../lib/include
LIBOBJS := ../lib/lib/libavcodec.a ../lib/lib/libavutil.a ../lib/lib/libavformat.a
EMCCFLAGS = -gsource-map -g --bind -s ASYNCIFY -s WASM=1 -s ALLOW_TABLE_GROWTH=1 -s ALLOW_MEMORY_GROWTH=1 -s FILESYSTEM=0 -s ASSERTIONS=1

../web/ff.js: ff.cpp $(LIBOBJS)
 $(CC) $(EMCCFLAGS) $(CFLAGS) -o $@ $^

fflib: build-ffmpeg.sh
 sh build-ffmpeg.sh

clean:
 rm -f ff.js ff.wasm

web

首先要实现一个视频来源对象,主要逻辑就是在调用 next 方法时,将视频数据返回,同时,如果数据太多,就先缓存着:

async function beginReadPacket() {
  let cachedSize = 0
  /**

* @type {Uint8Array[]}
   */
  const cachedChunks = []
  const reader = (await fetch('test.mkv')).body.getReader()
  const combineChunks = size => {
    let resultSize = 0
    const result = new Uint8Array(size)
    while (resultSize < size) {
      const chunk = cachedChunks.shift()
      if (chunk.length + resultSize > size) {
        const needSize = size - resultSize
        result.set(chunk.slice(0, needSize), resultSize)
        resultSize += needSize
        cachedChunks.unshift(chunk.slice(needSize))
        break
      } else {
        result.set(chunk, resultSize)
        resultSize += chunk.length
      }
    }
    cachedSize -= result.length
    return result
  }
  return async size => {
    while (cachedSize < size) {
      const { done, value } = await reader.read()
      if (done) {
        if (!cachedSize) return { done }
        return { data: combineChunks(cachedSize) }
      }
      cachedChunks.push(value)
      cachedSize += value.length
    }
    return { data: combineChunks(size) }
  }
}

然后就是在 requestAnimationFrame 中调用 decoder.next 来拉去视频帧了,就不写了。当然,最后还有一步是拿解码后的 YUV 数据丢给 WebGL 渲染,具体就不展开了。

问题

虽然最开始是因为 H.265 无法播放,才去做这个东西的,但是吧,做完之后发现,就这样还真不一定能播放 mp4 封装的 H.265 视频。

对于 mp4 格式的文件信息 moov box 有可能是在文件最后的,我这里实现的方法是取视频流,一部分一部分的解码,就会导致取不到视频信息。所以对于这种视频文件,要么是把视频数据取完整了,一股脑塞进 ffmpeg 取识别,或者也可以把他的 moov box 放到前面,或者直接指定解码器参数也可以吧。

下面的命令将 mp4 视频的 moov box 放前面:

ffmpeg -i test.mkv -c:v libx265 -preset ultrafast -c:a copy -movflags faststart test.mp4

参考资料

又一个 WordPress 博客的初始配置

站点结构:nginx => WordPress

配置 HTTPS

# 生成公私钥对
ssh-keygen ...

# 生成证书申请请求
openssl req -new -sha256 -key cert.key -subj "/C=CN/ST=Anhui/L=Wuhu/O=whit/CN=hamflx.cn" \
    -reqexts SAN -config <(cat /etc/pki/tls/openssl.cnf \
    <(printf "[SAN]\nsubjectAltName=DNS:hamflx.cn,DNS:*.hamflx.cn")) \
    >cert.csr

# 申请通配符证书
docker run -it --rm \
    -e DP_Id=DP_Id \
    -e DP_Key=DP_Key \
    -v $PWD:/acme.sh \
    neilpang/acme.sh \
    --signcsr \
    --csr /acme.sh/cert.csr \
    --dns dns_dp

强制流量从 http 重定向到 httpsnginx 的配置:

server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name _;
  return 301 https://$host$request_uri;
}

配置 HTTP 2.0

server {
    listen       443 default_server ssl http2;
    listen  [::]:443 default_server ssl http2;
    server_name  www.hamflx.cn hamflx.cn;

    ssl_certificate     /hamflx.cn/fullchain.cer;
    ssl_certificate_key /hamflx.cn/cert.key;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;

        # 注意这里一定要加,不然在登录的时候会一直重定向。
        proxy_set_header   X-Forwarded-Proto $scheme;

        proxy_pass https://www.hamflx.cn;
    }
}

配置邮箱

这里并不使用 WP-SMTP 类似的插件,而是通过“My Custom Functions”插件来实现这个功能。这个插件与邮箱功能无关,但是可以插入代码到 WordPress,这里只需要写一个邮箱功能初始化的函数插入到 WordPress 即可。

但是启用邮箱之后,激活邮件和重置密码邮件中的链接点击都无效,这是 WordPress 的一个 BUG。代码中的 h_mail_filter 函数即是为解决这个问题的。

在开始之前你需要一个具有 SMTP 功能的邮件服务器用来发送邮件,如果没有可以使用 QQ 邮箱。

配置清单

My Custom Functions 插件中的 Settings 页面中加入如下代码:

function h_override_mail_from_name($email) {
    return '幻梦';
}

function h_override_mail_from($email) {
    return 'service@hamflx.cn';
}

function h_mail_filter($args) {
    $args['message'] = preg_replace("/<(.*?)>/", "$1", $args['message']);
    return $args;
}

function h_mail_smtp($phpmailer) {
    $phpmailer->IsSMTP();
    $phpmailer->SMTPAuth = true;
    $phpmailer->Port = 465;
    $phpmailer->SMTPSecure = "ssl";
    $phpmailer->Host = "smtp.qq.com";
    $phpmailer->Username = "service@hamflx.cn";
    $phpmailer->Password = "Your Token";
}

add_filter('wp_mail_from_name', 'h_override_mail_from_name');
add_filter('wp_mail_from', 'h_override_mail_from');
add_filter('wp_mail', 'h_mail_filter');

add_action("phpmailer_init", "h_mail_smtp");

启用 QQ 邮箱 SMTP 功能

若使用 QQ 邮箱,则需要启用 QQ 邮箱的 SMTP 功能并生成授权码(作为 SMTP 登录时的密码)。

QQ 邮箱的设置页面中进入到“账户”选项卡,找到下图的配置,启用“POP3/SMTP”服务、生成授权码。

参考资料

记一次内存泄漏的排查过程

JavaScript 是有垃圾回收机制的,一般不太需要考虑资源释放的问题。然而,即使有垃圾回收兜底,但是代码写的太过于奔放,仍然存在不小的问题。这篇文章就以 ******** 排查出的问题做个简单介绍(注:******** 为内部项目名称,下同)。

检查是否存在内存泄漏

有用户反馈在 ******** 使用过程中,偶尔会出现浏览器崩溃的问题,然后在一次发版本的过程中,**** 发现了内存占用达到了 1GB 程度,如下图(截图是后来截的,嫌麻烦就点到 579MB,多点几次总是能到 1GB 的 😏):

内存占用

不过这 579MB 是正常内存占用,还是内存泄漏了呢?很简单,如果在使用过程中,内存占用未增加,或增加的都能在一段时间后(GC 执行了之后)正常释放掉,都是没有问题,但是如果在使用的过程中,内存一直增长而从未减少,即存在内存泄漏。

通过开发者工具里面的 Memory 面板,即可看到内存占用,使用 Heap snapshot 工具对内存占用做快照,在一顿猛烈的操作之前以及之后,分别做快照,然后对比一下就可以知道有没有内存泄漏了。

******** 举例详细说下步骤:

  1. 弄清楚你要排查哪一步可能存在内存泄漏,比如我需要排查 AB 页面来回切换时,是否存在内存泄漏;
  2. 先打开 ******** 的 B 页面等待加载完毕,因为如果你切换快了,可能也会造成内存泄漏(比方说,在接口返回后添加事件),这里要一步步排查,就是控制变量法);
  3. 然后切换到 A 页面,做快照之前要把目标页面加载一次,因为“冷启动”也是要消耗资源的;
  4. 再切换回 B 页面,在 Memory 面板中选择 Heap snapshot 然后点 Take snapshot 做初始的快照(做快照之前要点击一次 GC 按钮,参照下图);
  5. 点击 A 页面 => 点击 B 页面 => 点击 A 页面 => 点击 B 页面,反正就是一顿操作就是了,注意手速不要把其他不确定因素引入进来;
  6. 最后停留在做初始内存快照的页面,这里是 B 页面,然后再做一个内存快照(别忘了先点 GC 按钮);
  7. 对比一下两个数字就好了,如果没有太大差距的话,显然是没有内存泄漏的(当然,为了避免误判,你可以多重复几次第 5 步的操作),否则,存在内存泄漏。

Memory 面板

下面是 ******** 的(当然是点了很多很多次才会有这个数据的 🤣):

内存快照

排查问题根源

内存快照

拿出上面步骤的第二个快照(图片里只有一个是因为我把第一个删了),展开 a(就是 VueComponent,经验),可以看到第一个 a 的“三围”:

  • Distance 14 被引用的深度,表示由 GC 根(比如 window 对象、DOM 数根节点)到该对象之间最短的引用数量。
  • Shallow Size 浅层大小,该对象本身的大小。
  • Reatined Size 保留大小,表示如果当前对象被释放后,所有的从 GC 根无法达到的对象的总的大小,简单点说,释放了这个对象,能释放多少内存。

我们主要关注 DistanceReatined Size,前者如果较大,我们可能就要关注一下这个对象是不是有问题。比如第一个 a 对象,鼠标悬浮 a @8001491 后,能看到该对象的数据:

内存快照-第一个a对象

发现它是一个 Vue 组件的实例,鼠标悬浮到 $el 的值上面,如果这个元素仍然在页面上且是可见的,它就会像审查元素时一样覆盖一层蓝色矩形区域,显然这个没有(可以再展开其属性,看看 isConnectedclassNameinnerHTML 了解具体是哪里的逻辑,以及是否从 DOM 树上移除了,这里的这个实例是一个表格组件)。而它的 Retained Size 则表示了,如果把这个对象给释放了,可以释放约 1MB 2% 的内存。

点击这个对象可以看到由该对象到 GC 根的引用关系:

内存快照

这张图说明:这个表格组件是另一个 aVueComponent)组件的 $parent 属性,即作为另一个组件的父组件而被引用了,串联起来就是,这个表格组件被子组件引用了,子组件又被子组件引用了而子组件又被子组件引用了,而这个子组件又被一个方法引用了(这个方法使用 bind 绑定了这个组件),这个方法又被作为一个 DOM 元素的事件被引用了,而这个 DOM 元素又被 InternalNode 引用了,这个 InternalNode 属于 HTMLDocument

即,这个表格里面有个组件添加了事件,但是组件销毁的时候没有把事件移除,所以,这个元素泄漏了,导致整个表格泄漏了。

将鼠标悬浮到 bound_this in native_bind() @7606219 上,可以看到:

内存快照

找到了这个被添加而没有被移除的事件叫 handleBlur。OK,你现在是不是想全局搜索 handleBlur 方法了?

内存快照

还真搜到了。。。不过,这样太麻烦了,可以看看这个 bind 绑定的到底是哪个组件,也就是鼠标悬浮这个上面的那一条记录 $parent in a @9400441,可以看到:

内存快照

通过审查这个对象的 $el 可以知道,这是一个 el-popover 组件,也就是说 el-popover 监听了 handleBlur 事件,但是却没有移除,打开 element 仓库在 packages\popover\src\main.vue 中搜索 handleBlur

内存快照

可以看到,只添加了该事件,而没有移除该事件。

修复内存泄漏问题

Google 搜索 el-popover 内存泄漏,然后复制、粘贴,over。不对,搜索结果第一条是:

el-popover leaking memory · Issue #2561 · ElemeFE/element ...

2017 年的 Issue,点开一看,对应的 PR 已经合并了,而且是点击事件,不是 focusout 事件,难道是定位错了?其实用 el-tooltip 内存泄漏el-popover memory leak 为关键字搜索是可以搜索到 [Bug Report] Memory leak at el-tooltip cleanup,提这个 bug 的仁兄还提了两个 PR 修复 el-tooltip,还有位仁兄也提了 PR 修复 el-tooltipel-popover,然而。。。

为了修复这个问题,而不克隆 element 仓库,我们可以重写对应的方法,这里我拿 el-tooltip 举例,原版的 el-tooltipmounted 代码如下:

export default {
    // code ...

    mounted() {
        this.referenceElm = this.$el;
        if (this.$el.nodeType === 1) {
            this.$el.setAttribute('aria-describedby', this.tooltipId);
            this.$el.setAttribute('tabindex', this.tabindex);
            on(this.referenceElm, 'mouseenter', this.show);
            on(this.referenceElm, 'mouseleave', this.hide);
            on(this.referenceElm, 'focus', () => {
                if (!this.$slots.default || !this.$slots.default.length) {
                    this.handleFocus();
                    return;
                }
                const instance = this.$slots.default[0].componentInstance;
                if (instance && instance.focus) {
                    instance.focus();
                } else {
                    this.handleFocus();
                }
            });
            on(this.referenceElm, 'blur', this.handleBlur);
            on(this.referenceElm, 'click', this.removeFocusing);
        }
    },

    // code ...

    destroyed() {
        const reference = this.referenceElm;
        if (reference.nodeType === 1) {
            off(reference, 'mouseenter', this.show);
            off(reference, 'mouseleave', this.hide);
            // 这里移除了 handleBlur 方法,但是前面添加的不是这个方法。。。
            off(reference, 'focus', this.handleFocus);
            off(reference, 'blur', this.handleBlur);
            off(reference, 'click', this.removeFocusing);
        }
    }
}

通过如下方法重新实现它的相关方法:

import { Tooltip } from 'element-ui'
import { on } from 'element-ui/lib/utils/dom'

Tooltip.methods.handleFocus = function () {
  if (!this.$slots.default || !this.$slots.default.length) {
    this.doFocus()
    return
  }
  const instance = this.$slots.default[0].componentInstance
  if (instance && instance.focus) {
    instance.focus()
  } else {
    this.doFocus()
  }
}

Tooltip.methods.doFocus = function () {
  this.focusing = true
  this.show()
}

Tooltip.mounted = function () {
  this.referenceElm = this.$el
  if (this.$el.nodeType === 1) {
    this.$el.setAttribute('aria-describedby', this.tooltipId)
    // 这行代码在 Element 上的提交日志是为了无障碍访问,这将导致测试提一些 bug(点击空白处关闭详情页后,文字的 tooltip 仍然显示)。
    // this.$el.setAttribute('tabindex', 0)
    on(this.referenceElm, 'mouseenter', this.show)
    on(this.referenceElm, 'mouseleave', this.hide)
    on(this.referenceElm, 'focus', this.handleFocus)
    on(this.referenceElm, 'blur', this.handleBlur)
    on(this.referenceElm, 'click', this.removeFocusing)
  }
}

export default {
  install () {

  }
}

即,将原来直接添加的事件,写成 handleFocus 方法(原来也有这个方法,故把原来的 handleFocus 方法改为 doFocus 方法,然后在 handleFocus 方法里调用,这样其 destroyed 里面释放的事件就是添加的事件了。

其他问题

通过同样的方式,还可以找到定时器未释放、点击事件未释放之类的问题,但都是项目内的代码,比较好解决就不展开了。

成果

内存快照

深入思考

不知道大家有没有注意,搜索中文的 el-popover 内存泄漏没有搜索到想要的结果,其实不移除事件一般也是可以的,而且不会造成内存泄漏,所以一般情况下比较难以发现这个问题。

现代浏览器都是用 标记-清除 算法或基于其改进的算法来实现的垃圾回收。其原理是从 GC 根开始,遍历所有引用的对象,并将其标记,当算法遍历完所有对象后,那些未被标记的对象将会被清除。

Vue.js 会在组件被销毁的时候,释放其引用,那么其引用的元素,以及元素引用的事件都将成为无根浮萍,会被 GC 回收。

再次审查上文中的表格组件,可以发现 V8EventListenerDetached HTMLElement 引用,而 Detached HTMLElement 又被 InternalNode 引用,这个 InternalNode 才是它没有被释放的真正原因。

至于这个 InternalNode 究竟是什么,我们 N 期之后再说(N → ∞)。

参考资料

增强 Vue 项目的智能感知

本文说的类型支持仅仅是 VS CodeIntelliSense 功能,并非静态类型语言的类型检查,所以对开发的效率提升不大,但是可以提高项目的可维护性,即所谓代码即文档 CaaDCode as a Documentation -_-)。

VS CodeIntelliSense 功能

使用过 IDE 与静态类型语言的同学可能很熟悉了,比如 VS/C# 的自动导入命名空间、Code Refactor(重命名属性/方法/类名等标识符,并自动修改所有的引用)、转到定义(转到声明、查找引用)甚至代码段提取(提取一段代码作为一个函数,并自动将依赖的变量作为参数输入)等各种强大的功能。

JS 是一个动态类型的语言,为了支持上述功能,VS Code 团队开发了一个插件,名为 Visual Studio IntelliCode,最早该插件是作为一个外部扩展,后来直接作为内部扩展,可以直接使用。

以下摘自官网描述:

IntelliSense is a general term for a variety of code editing features including: code completion, parameter info, quick info, and member lists. IntelliSense features are sometimes called by other names such as "code completion", "content assist", and "code hinting."

蹩脚翻译如下:

智能感知是一系列编码特性的统称,包括自动补全、参数提示、快捷信息以及成员提示。……

特别注意:在成员提示被关闭的情况下可以使用快捷键 Ctrl+Space 重新打开成员提示(极大的可能与 Windows 系统默认的输入法切换键冲突导致失效,此时可以考虑更换快捷键),与 Ctrl+Shift+Space 打开参数提示。

配置 jsconfig.json

首先要说的是 jsconfig.json 文件。用 vue 脚手架生成的项目中,并无该文件,且一般情况下没有该文件也会有 IntelliSense 功能。

这里配置 jsconfig.json 的必要原因是:

  1. 无该文件会导致项目内的 .d.ts 文件不一定被加载;
  2. 对于 Vue 项目来说,导入时使用 @ 是很常见的行为,但是这将会导致 IntelliSense 无法识别。

一个常见的 jsconfig.json 配置如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
  "include": [
    "./src/**/*.d.ts",
    "./src/**/*.js",
    "./src/**/*.vue"
  ]
}

这个 jsconfig.json 包含了对导入时 @ 的解析以及扫描项目 src 目录及其子目录下的 .d.ts.js.vue 文件,以建立类型、引用等相关信息(转到定义、查找引用以及自动导入的关键)。

配置完成后重新加载(Ctrl+Shift+P 输入 Reload Window 并回车)即可。此时输入 import '@/' 后会有路径提示(如果没有可能是还没有加载完成,耐心等待一会儿 -.-)。

导入提示

值得注意的是,导入 SFC 文件时,文件的路径必须添加 .vue 后缀:

import FieldEdit from '@/components/FieldEdit.vue'

这样的话你就可以将光标移动到组件名称上按下 F12 键以导航到组件的定义,否则是无法导航到组件定义的,你会看到这样的提示:

导航失败

另外,配置完成后,还可以在 <template> 标签中得到自定义组件的补全提示,如果没有导入,甚至可以在敲回车补全时自动导入:

导航失败

至于提示的组件的命名方式可以在此处配置,具体每个选项都有描述就不说了:

规则配置

JSDoc 注释

即使用注释来说明代码。在 VS Code 中可以通过在 js 类型的文件中输入 /** 来触发 JSDoc 代码片段,以下是一些实例:

/**
 * 引用数据额外的信息发生了变化的事件。
 */
const EVENT_ISSUE_INFO_CHANGE = 'issue-info-change'

/**
 * 所属的应用类型 ID。
 */
const ISSUE_TYPE = {
  /** 需求。 */
  REQUIREMENT: 1,
  /** 任务。 */
  TASK: 2
}

/**
 * 将一个字符串反过来。
 * @param {string} str 需要反转的字符串。
 * @returns {string} 反转后的字符串。
 */
function reverseString (str) {
  return str.split('').reverse().join('')
}

这种注释可以提供如下的效果,在成员提示的列表中可以显示注释内容,即做到文档的效果(同时鼠标悬浮时也会有文档的提示):

成员提示

函数声明

可以看到,第二张图标注了传入参数 str 的类型,在输入 str.sp 时会给予成员提示,敲回车键后即会自动完成。

类型声明文件

类型声明文件是一个 .d.ts 文件,使用过 Type Script 的同学应该不陌生,在使用 Type Script 开发库项目时,如果需要发布到 npm 仓库,则需要编译成 js 发布,并可以用 tsc 生成对应的类型声明文件,以供使用者参照。

对于没有 Type ScriptVue 项目,仍然可以使用 .d.ts 文件带来的部分便利,虽然需要手写 .d.ts 文件。

一个 .d.ts 文件的例子如下:

/**
 * 应用数据的模型。
 */
declare type IssueModel = {
  /**
   * 应用数据的属性字典。
   */
  dataMap: {
    [key in IssuePropertyNames]: IssueProperty
  }

  /**
   * 可编辑的属性名称列表。
   */
  editablePropertyList: string[]
}

/**
 * 应用数据的数据属性名称列表。
 */
declare type IssuePropertyNames =
  'id' |
  'issuetype' |
  'key' |
  'project' |
  'summary' |
  'description'

/**
 * 应用数据的属性定义。
 */
declare interface IssueProperty {
  /**
   * 属性的名称。
   */
  name: string

  /**
   * 属性值的可读形式。
   */
  label: string

  /**
   * 属性值。
   */
  value: PropertyValue
}

/**
 * 属性值的类型。
 */
declare type PropertyValue = boolean | number | string | boolean[] | number[] | string[]

上述代码并不会生成 js 代码,仅仅在开发期间被编辑器或集成开发环境识别以用作代码自动完成、成员提示等功能。在数据结构较为复杂时,可以利用类型声明文件作为文档以提高项目的可维护性。

虽然类型声明文件不参与 js 代码的执行,但是仍然可以利用类型声明文件影响到 js 代码的编写,将上述类型声明文件保存后,即可在 js 代码中以 JSDoc 的形式引用:

/**
 * 应用数据模型。
 * @type {IssueModel}
 */
const model = {
  dataMap: {
    issuetype: {
      value: ISSUE_TYPE.REQUIREMENT
    }
  }
}

console.log(model.dataMap.issuetype.value)

效果如下:

对象成员

对象成员

SFC 的成员类型

<template>
  <div>
    <input v-model="value" @blur="handleInputBlur" />
  </div>
</template>

<script>
export default {
  props: {
    /**
     * 应用数据模型。
     * @type {import('vue').PropOptions<IssueModel>}
     */
    model: {
      type: Object,
      required: true
    },
    /**
     * 需要编辑的字段。
     * @type {import('vue').PropOptions<IssuePropertyNames>}
     */
    property: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      value: this.model.dataMap[this.property].value,
      /**
       * @type {IssueModel}
       */
      other: {}
    }
  },
  methods: {
    /**
     * 获取属性值。
     * @param {IssueModel} issue 应用数据模型。
     * @param {IssuePropertyNames} property 属性名称。
     * @returns {PropertyValue} 属性值。
     */
    getPropertyValue (issue, property) {
      issue.dataMap[property].value
    },
    handleInputBlur () {
      this.$emit('submit', this.model, this.property, this.value)
    }
  }
}
</script>

上面分别标记了 propsdatamethods 的数据类型、参数及返回值,在引用这些属性、变量时,将会有自动完成提示、参数信息等:

对象成员

对象成员

自动导入功能

在引用一些常用的导出符号时,如果这个符号没有被导入,则可以在打开的成员提示中找到这个标识符,并键入回车完成自动补全,这个时候会自动导入这个标识符:

对象成员

在提示信息中说明了 Import 'ISSUE_TYPE' from module "@/constatns/issueType",键入回车即可自动导入该标识符。

vuedraggable 使用问题记录

  • 本文引用的 Sortable.js 源码 commit id 为:1dff5e1
  • 本文引用的 vuedraggable 源码 commit id 为:2fd91d6

vuedraggable 官方地址:https://sortablejs.github.io/Vue.Draggable/

vuedraggable 是一个封装了 Sortable.jsvue 组件,将拖动同步到对应的数据模型上,提供了 Sortable.js 的所有特性。

但是 vuedraggable 提供的 vue 组件对动态绑定的属性并不能较好的兼容,会引发一些莫名奇妙的问题。

考虑一个典型的拖动场景,有待办、进行及完成三个泳道,每个泳道里面有若干卡片,待办中的卡片可以拖动到进行的泳道,反之进行的卡片则无法拖动到待办的泳道,若要在拖动时,高亮显示可以放置的泳道,则可以在 start 事件记录下拖动的卡片,通过双向绑定设置是否可以放置的类名 is-droppable

<!-- is-droppable 用于设置拖动时,可以放置的样式,在拖动过程中,isLaneDroppable 将会返回 true,否则为 false -->
<div :class="{ 'is-droppable': isLaneDroppable(item) }">
    <Draggable :sort="false">
        <div></div>
    </Draggable>
</div>

在这种 draggable 组件的父级元素绑定了动态属性的情况下,会导致 sort 属性不生效,初步猜测是 DOM 结构更新后,Sortable.js 未能匹配上,导致 sort 的判断失效。

因此,在使用 draggable 时,最好不要在拖动的过程中,修改动态绑定的属性的值,避免拖动时更新 DOM

避免使用双向绑定,同时再配合 start 事件,在该事件中找到所有可以放置的泳道,并通过直接操作 DOM 的形式,给可以放置的元素添加 is-droppable 类名可以解决该问题。

在该场景上考虑另一个问题,若需要在拖动时,当前鼠标悬停的、可放置的泳道上设置一个有别于可放置的样式(比方说,可放置是虚线边框,悬停的是实现边框且有背景色),基于先前的经验避开使用双向绑定的方法,直接使用 move 事件操作对应的 DOM,添加 is-target 类名。

此时会有一个新的问题,当 :sort="false" 触发时(即,鼠标移动到原泳道但非卡片原位置,将会触发拖动的重置,此时卡片的占位符将会回到拖动前的位置),将不会触发 move 事件,即,悬停高亮将没办法更新,还是为原先设置的元素高亮。

检查 Sortable.js 源码的 1287-1306 行:

if (revert) {
    parentEl = rootEl; // actualization
    capture();

    this._hideClone();

    //@ts-ignore
    dragOverEvent("revert");

    //@ts-ignore
    if (!Sortable.eventCanceled) {
        if (nextEl) {
        rootEl.insertBefore(dragEl, nextEl);
        } else {
        rootEl.appendChild(dragEl);
        }
    }

    return completed(true);
}

可知,在拖动被重置时,会触发 revert 事件,检查 dragOverEvent 函数的定义可知,这是一个在插件上触发的事件,而非 Sortable.js 实例事件,因此,可以编写一个插件,监听该事件,并将该事件在实例上触发,便可以解决该问题。

检查 vuedraggable 源代码的第 197-228 行:

mounted() {
    // ...

    !("draggable" in options) && (options.draggable = ">*");
    this._sortable = new Sortable(this.rootContainer, options);
    this.computeIndexes();
},

可以使用以下的方法获取内部的 Sortable.js 引用:

import Vue from 'vue'
import Draggable from 'vuedraggable'

const Tmp = Vue.extend(Draggable)
const tmp = new Tmp().$mount()
const Sortable = tmp._sortable.constructor

参考 on-spill 插件,可以编写出如下的插件:

function RevertEventPlugin () {}

RevertEventPlugin.prototype = {
  constructor: RevertEventPlugin,
  revertGlobal ({ dispatchSortableEvent }) {
    dispatchSortableEvent('revert')
  }
}

Object.assign(RevertEventPlugin, {
  pluginName: 'revertEventPlugin'
})

将以上两部封装为一个模块,用于替代 vuedraggable 模块:

/*
 * 描述:返回一个可以触发 revert 事件的 vuedraggable 组件
 * 文件名:src/utils/vuedraggableWithRevert.js
 */

import Vue from 'vue'
import Draggable from 'vuedraggable'

function RevertEventPlugin () {}

RevertEventPlugin.prototype = {
  constructor: RevertEventPlugin,
  revertGlobal ({ dispatchSortableEvent }) {
    dispatchSortableEvent('revert')
  }
}

Object.assign(RevertEventPlugin, {
  pluginName: 'revertEventPlugin'
})

const getDraggableWithRevertEvent = () => {
  const Tmp = Vue.extend(Draggable)
  const tmp = new Tmp().$mount()
  const Sortable = tmp._sortable.constructor
  Sortable.mount(RevertEventPlugin)
  return Draggable
}

export default getDraggableWithRevertEvent()

在使用时,直接将 import Draggable from 'vuedraggable' 替换为 import Draggable from './src/utils/vuedraggableWithRevert' 即可,若需要监听 revert 事件,添加一个具有 onRevert 属性的 options

<Draggable
    :sort="false"
    :options="{ onRevert: handleCardItemRevert }"
/>
</Draggable>

玩饥荒联机版日志

搭建独立服务器

  1. 安装 SteamCMD。首先创建 steam 用户,然后下载 SteamCMDhttps://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz 解压安装,安装位置任意,详细步骤及依赖关系推荐参阅此处。

  2. 运行 steamcmd.sh

  3. 登陆并安装服务器:

    login anonymous
    force_install_dir /home/steam/dstserver
    app_update 343050
    validate
    quit
    
  4. 配置服务器。可在 Windows 上使用图形界面启动具有洞穴的服务器,然后清除该存档记录并将其复制到 /home/steam/.klei/DoNotStarveTogether 目录中即可。也可以先将存档复制到上述目录,然后使用下述脚本清除存档:

    # 其中 /home/steam/.klei/DoNotStarveTogether/GlxinWorld 为世界存档目录
    rm -rf /home/steam/.klei/DoNotStarveTogether/GlxinWorld/{Master,Caves}/save
    
  5. 启动服务器。

    # 必须进入该目录,否则可能会无法加载
    # 其中 Cluster_1 改为对应的存档名称
    cd /home/steam/dstserver/bin
    ./dontstarve_dedicated_server_nullrenderer -console -cluster Cluster_1 -shard Caves
    ./dontstarve_dedicated_server_nullrenderer -console -cluster Cluster_1 -shard Master
    

Tip:有些系统(如:CentOS 7)不提供 libcurl-gnutls.so.4 那么将其链接到 libcurl.so.4 即可。

多世界并存:若要在一台服务器上运行多个饥荒服务器,则需要修改存档的 MasterCaves 目录中 server.iniserver_port 使其各不相同,且在 10998-11018 范围内。且每个存档的 cluster.inimaster_port 应唯一,该端口用于存档的地面与洞穴之间通信。 饥荒服务器在启动时同样会监听 127.0.0.1:10888/UDP 端口和一个非固定 TCP 端口,可能这些也需要修改。初步猜测该端口可能用于存档的地面与洞穴之间通信,多个存档应修改该端口。

跨服务器世界:即将洞穴服务器与地面服务器分离,将存档的 cluster.inimaster_ipmaster_port 配置为地面服务器的 IP 地址和端口即可。

一键启动脚本

其中变量按需修改,若不希望每次启动都检查更新,将脚本中检查更新的行注释即可。另外,最新版不购买可能导致搜素不到世界的问题,故可以按照下一节介绍安装 247691 版本。

#!/bin/bash

steamcmd_dir="$HOME/Steam"
install_dir="$HOME/dstserver"
cluster_name="GlxinWorld"
dontstarve_dir="$HOME/.klei/DoNotStarveTogether"

function fail()
{
        echo Error: "$@" >&2
        exit 1
}

function check_for_file(){
    if [ ! -e "$1" ]; then
            fail "Missing file: $1"
    fi
}

cd "$steamcmd_dir" || fail "Missing $steamcmd_dir directory!"
check_for_file "$steamcmd_dir/steamcmd.sh"
check_for_file "$dontstarve_dir/$cluster_name/cluster.ini"
check_for_file "$dontstarve_dir/$cluster_name/cluster_token.txt"
check_for_file "$dontstarve_dir/$cluster_name/Master/server.ini"
check_for_file "$dontstarve_dir/$cluster_name/Caves/server.ini"

# 检查更新,若不需要每次启动都检查更新,将其注释即可
~/Steam/steamcmd.sh +force_install_dir "$install_dir" +login anonymous +app_update 343050 validate +quit

check_for_file "$install_dir/bin"

cd "$install_dir/bin" || fail
run_shared=(./dontstarve_dedicated_server_nullrenderer)
run_shared+=(-console)
run_shared+=(-cluster "$cluster_name")
run_shared+=(-monitor_parent_process $$)

"${run_shared[@]}" -shard Caves  | sed 's/^/Caves:  /' &
"${run_shared[@]}" -shard Master | sed 's/^/Master: /'

下载指定版本

  1. 进入 SteamDB 查找游戏,记录 AppID
  2. 进入 Depots 找到对应平台,记录 DepotID
  3. 点击 DepotID => Manifests 找到需要的版本,记录 ManifestID
  4. Windows 下运行 steam://nav/console 进入控制台/Linux 下执行 steamcmd.sh
  5. 若是 Linux 用户,需要执行 login anonymous 登陆;若不是 Linux 用户则跳过。
  6. 执行 download_depot <AppID> <DepotID> <ManifestID> 进行下载。
  7. 执行 quit 退出。

若下载失败,可退出从第 4 步重新开始。实际测试发现,下载成功率非常低,故建议使用外网服务器,且具有不低的带宽。

上述过程得到 DST 的信息如下:

  • AppID: 343050
  • Linux DepotID: 343052
  • Latest Version ManifestID: 3637127330667398786
  • Version 247691 ManifestID: 6994825278996354537
  • SteamDB URL: https://steamdb.info/app/343050/

进入控制台(Win + R 运行):

steam://nav/console

下载 247691 版本游戏:

download_depot 343050 343052 6994825278996354537

Linux 可在控制台直接执行:

steamcmd.sh +login anonymous +download_depot 343050 343052 6994825278996354537 +quit

SteamCMD 根据游戏 depot id 进行存储,故一个游戏可下载多个 depot 对应的版本,但每个 depot 仅能下载一个 manifest 对应的版本。

参考资料

steam_commands SteamCMD – Valve Developer Community 饥荒联机版独立服务器搭建踩坑记录 – Blessing Studio 【社区指南翻译】如何下载旧版的游戏 – 平台研讨 – SteamCN 蒸汽动力 – 驱动正版游戏的引擎! 通过depot下载得到旧版游戏及一个衍生应用 – 平台研讨 – SteamCN 蒸汽动力 – 驱动正版游戏的引擎! Guide: How to download older versions of a game on Steam:Steam 饥荒联机独立服务器搭建教程(三):配置篇 | 天天の記事簿 Create UDP to TCP bridge with socat/netcat to relay control commands for vlc media-player – Unix & Linux Stack Exchange

Playing outside the LAN

对游戏端口 UDP 10998/10999 进行转发,可实现在外网进入内网的游戏服务器。其简单 python2 脚本如下:

agent_v3.py:

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

"""
Usage:
    agent.py -h | --help
    agent.py client [-p BASE_PORT]
    agent.py server [-p BASE_PORT]

Options:
    -h --help                       show this
    -p BASE_PORT, --port BASE_PORT  specify base port

Examples:
    agent.py client

"""


from threading import Thread
from docopt import docopt
from time import sleep
from socket import socket, AF_INET, SOCK_DGRAM

LOCALHOST_IP = '127.0.0.1'
MASTER_IP_ADDRESS = '172.18.135.5'
MASTER_PORT = 10999
CAVES_IP_ADDRESS = '172.18.135.5'
CAVES_PORT = 10998

BASE_TRANSFER_PORT = 10001

BUFFER_SIZE = 10485760


def remote_to_local(sock_local, sock_remote, addr, buffsize):
    while True:
        data = sock_remote.recv(buffsize)
        if len(data):
            sock_local.sendto(data, )
    pass


def local_to_remote(local, remote, buffsize):
    conn_dict = {}
    sock_local = socket(AF_INET, SOCK_DGRAM)
    sock_local.bind(local)
    sock_remote = None
    while True:
        data, addr = sock_local.recvfrom(buffsize)
        if addr in conn_dict:
            sock_remote = conn_dict[addr]
        else:
            sock_remote = socket(AF_INET, SOCK_DGRAM)
            sock_remote.connect(remote)
            Thread(target=remote_to_local, args=(sock_local, sock_remote, addr, buffsize))
        sock_remote.sendall(data)


def build_connection(local, remote, buffsize):
    thread = Thread(target=local_to_remote, args=(local, remote, buffsize))
    thread.setDaemon(True)
    thread.start()


def check_connection(conns):
    for conn in conns:
        if not conn['t_l2r'].isAlive():
            return False
        if not conn['t_r2l'].isAlive():
            return False
    return True


def main(client_mode, base_port):
    master_listen, master_remote = None, None
    caves_listen, caves_remote = None, None
    if client_mode:
        master_listen = ('0.0.0.0', MASTER_PORT)
        master_remote = (MASTER_IP_ADDRESS, base_port)
        caves_listen = ('0.0.0.0', CAVES_PORT)
        caves_remote = (CAVES_IP_ADDRESS, base_port + 1)
    else:
        master_listen = ('0.0.0.0', base_port)
        master_remote = (LOCALHOST_IP, MASTER_PORT)
        caves_listen = ('0.0.0.0', base_port + 1)
        caves_remote = (LOCALHOST_IP, CAVES_PORT)
    conns = []
    conns.append(build_connection(master_listen, master_remote, BUFFER_SIZE))
    conns.append(build_connection(caves_listen, caves_remote, BUFFER_SIZE))

    try:
        while check_connection(conns):
            sleep(1)
    except KeyboardInterrupt as e:
        print 'User interrupted'

    return None


if __name__ == '__main__':
    arguments = docopt(__doc__)
    main(arguments['client'], int(arguments['--port'] or BASE_TRANSFER_PORT))

脚本依赖 docopt。

forward_v1.py:

#!/usr/bin/python2
# coding: utf-8


import sys

from time import sleep
from Queue import Queue
from select import select
from socket import socket, AF_INET, SOCK_DGRAM, SOL_SOCKET, SO_REUSEADDR

# 512KB
BUFFER_SIZE = 524288

LEVEL_INFO = 0
LEVEL_ERROR = 1


def log(level, text):
    if level == LEVEL_ERROR:
        print text


def do_forwarding(options):
    sock_remote = socket(AF_INET, SOCK_DGRAM)
    sock_remote.setblocking(False)
    sock_remote.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock_remote.bind(options["bind"])

    connections = {}

    inputs = [sock_remote]
    outputs = []
    msg_queues = []

    while True:
        try:
            readable, writable, exceptional = select(inputs, outputs, [], 0.1)
        except KeyboardInterrupt:
            break
        if not (readable or writable or exceptional):
            continue
        for sock in readable:
            try:
                data, address = sock.recvfrom(BUFFER_SIZE)
            except Exception, ex:
                log(LEVEL_ERROR, 'Error: ' + repr(ex))
            if len(data) == 0:
                continue
            log(LEVEL_INFO, 'Received from: ' + repr(address))
            sleep
            if sock is sock_remote:
                if address in connections:
                    sock_out = connections[address]
                    log(LEVEL_INFO, 'Host already exists: ' + repr(address))
                else:
                    sock_out = socket(AF_INET, SOCK_DGRAM)
                    sock_out.connect(options["host"])
                    connections[address] = sock_out
                    inputs.append(sock_out)
                    log(LEVEL_INFO, 'Added remote host: ' + repr(address))
                addr_out = options["host"]
            else:
                for src_addr, src_sock in connections.iteritems():
                    if src_sock is sock:
                        addr_out = src_addr
                sock_out = sock_remote
            msg_queues.append({ "to": addr_out, "sock": sock_out, "data": data })
            outputs.append(sock_out)
            log(LEVEL_INFO, 'Add sock to output list: ' + repr(sock_out.getsockname()))
        for sock in writable:
            log(LEVEL_INFO, 'Sock want to send data: ' + repr(sock.getsockname()))
            remains_msgs = []
            for msg in msg_queues:
                if msg["sock"] is sock:
                    log(LEVEL_INFO, 'Hint message')
                    try:
                        sock.sendto(msg["data"], msg["to"])
                        log(LEVEL_INFO, 'Sent to: ' + repr(msg["to"]) + ' via: ' + repr(sock.getsockname()))
                    except Exception, ex:
                        log(LEVEL_ERROR, 'Error: ' + repr(ex))
                else:
                    remains_msgs.append(msg)
            msg_queues = remains_msgs
        if writable:
            outputs = []

    for addr, sock in connections.iteritems():
        sock.close()
    sock_remote.close()


def parse_arguments(args):
    if len(args) != 2:
        return None
    params = args[1].split(':')
    options = { "agent_port": 12345 }
    if len(params) == 3:
        options["bind"] = "127.0.0.1", int(params[0])
        options["host"] = params[1], int(params[2])
    elif len(params) == 4:
        options["bind"] = params[0], int(params[1])
        options["host"] = params[2], int(params[3])
    else:
        return None
    return options


def usage():
    print "Usage: forward.py [bind_ip:]bind_port:host_ip:host_port"
    exit(1)


def main():
    options = parse_arguments(sys.argv)
    if options:
        do_forwarding(options)
    else:
        usage()


if __name__ == '__main__':
    main()

Server 端监听需要转发的端口,并接受多条连接,建立表维护连接信息,在接收到数据时连带连接信息发送给连接到 Server 端的 Client 端。

问题

在启动 dontstarve_dedicated_server_nullrenderer 进程时,可能遇到如下错误:

  • [S_API FAIL] SteamAPI_Init() failed; SteamAPI_IsSteamRunning() failed. 忽略即可。

  • Segmentation fault (core dumped) 可能是游戏中的 steamclient.so 库文件与 SteamCMD 中的 steamclient.so 文件版本不匹配导致。删除该文件,并将 SteamCMD 中的该文件链接到游戏中该文件。

    # 将游戏中的 steamclient.so 备份为 steamclient.so.bak 后将 SteamCMD 中的 steamclient.so 链接到游戏中对应的位置
    # 下面命令假定 SteamCMD 安装在 /home/steam/Steam,饥荒联机版安装在 /home/steam/dstserver
    cd /home/steam/dstserver/bin/lib32 mv steamclient.so{,.bak}
    ln -s /home/steam/SteamCMD/linux32/steamclient.so /home/steam/dstserver/bin/lib32/steamclient.so
    # 若无法解决问题,则可以通过如下命令恢复
    mv /home/steam/dstserver/bin/lib32/steamclient.so.bak /home/steam/dstserver/bin/lib32/steamclient.so
    

    若运行上述命令解决问题后,则应将世界启动脚本中的更新指令移除。若不移除,游戏的更新、校验都会将上述修改覆盖,从而导致游戏无法运行。

    # 在脚本中找到下面这一行,并将其注释
    #~/Steam/steamcmd.sh +force_install_dir "$install_dir" +login anonymous +app_update 343050 validate +quit
    

Backup & Restore

Backup

# 进入存档目录
cd /home/steam/.klei/DoNotStarveTogether/GlxinWorld

# 建立存档备份目录
mkdir ../backup

# 执行备份
zip -r ../backup.$(date +%Y%m%d%H%M%S).zip .

Restore

# 进入存档目录
cd /home/steam/.klei/DoNotStarveTogether/GlxinWorld

# 列出存档列表,并找出需要恢复的存档
ls ../backup

# 恢复存档
find . -delete && unzip ../backup/backup.20180614173011.zip

Automatically backup

在基于 Systemd 的 Linux 系统上,创建如下两个文件实现每 3h 对存档做一次备份。由于在游戏运行状态中,存档目录会包含许多临时文件,

/usr/lib/systemd/system/dst-backup.service

[Unit]
Description=Don't Starve Together Backup

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'cd /home/steam/.klei/DoNotStarveTogether/GlxinWorld && zip -r ../backup/backup.$(date +%%Y%%m%%d%%H%%M%%S).zip .'
User=steam

/usr/lib/systemd/system/dst-auto-backup.timer

[Unit]
Description=Don't Starve Together Auto Backup

[Timer]
OnUnitActiveSec=3h
Unit=dst-backup.service

[Install]
WantedBy=multi-user.target

Chrome 时代的浏览器兼容性测试

对于这个标题,我想有一些人是很有疑惑的,对于 IE 时代来说,确实需要去研究浏览器的兼容性,但是对于浏览器中的老大 Chrome,我们需要考虑它的兼容性吗?

就我目前做的项目,目标用户都是使用 Chrome 浏览器,但是仍然在工作中碰到很多关于 Chrome 的问题,比如:

  • 用户反馈你们的软件开了一天就卡的不行,有时候半天不到就会崩溃,然后你吭哧吭哧去解决内存泄漏的点,然后自己测试发现没问题,但是一上线,客户又反馈卡卡卡(浏览器内存泄漏);
  • 用户机器上,表格的渲染有时缺一某一条边框,有时候有的边框特别粗;
  • 明明没有设置 z-index,或者设置了较小的 z-index,但是滚动到容器下面的时候,被遮住的那部分滚动条却突破了容器显示出来了;
  • 开发时没考虑 API 兼容性,用了 replaceAll,然后在用户机器上挂掉了;
  • 因为要嵌入第三方页面,考虑到 Chrome 变更了 SameSite 策略,默认为 None,需要兼容新旧版本的 Chrome
  • Chrome 删除了 /deep/ 的支持,导致项目中错误使用 /deep/ 的地方样式崩溃;
  • Chrome 在某个版本删除了 Event.path 的支持,然后用这个的地方也都崩了。

上述情况有些是属于 bug,有些是浏览器更新太快而开发人员知识没有跟上,还有些就是单纯的开发人员的失误。

Event.path 实际上是一个非标准属性,在 Chrome 100 上还可以正常使用,在 Chrome 101 的时候,如果你使用这个属性,会在 Issues 里输出一个 Deprecated Feature Used 的警告。然后在 Chrome 104 里就不能用了。然而你高强度开发的时候,真的会在意这个警告吗?

所以,浏览器的兼容性问题一直都在,只不过,从兼容 IE 变换为兼容老版本 Chrome 和兼容新版本 Chrome,以及兼容特定版本 Chrome

因此,你可能会需要频繁的下载不同版本的 Chrome 来做各种兼容性测试,这之前对我来说是个比较麻烦的事,我不喜欢通过第三方网站下载的安装包,通过官方 Chromium 给出的方法又太麻烦。

后来无意间看到这个仓库:google/fetchchromium,但是这个工具需要提供 revision,不能通过版本号下载,然后我就自己搞了一个 hamflx/fetchbrowser,可以通过给定版本号下载特定的 Chromium 浏览器,另外,也支持下载 Firefox

fetchbrowser

仓库地址:https://github.com/hamflx/fetchbrowser,欢迎 star (❁´◡`❁)。

安装方式:

irm https://raw.githubusercontent.com/hamflx/fetchbrowser/master/install.ps1 | iex

下载 Chromium 98

fb 98

注意:在特定平台第一次下载 Chromium 会比较慢,因为会联机查找版本信息,后续会使用缓存的数据。

下载 Chromium 109.0.5414.120

fb 109.0.5414.120

下载 Firefox 98

fb --firefox 98

Firefox 的支持可能会有问题,因为 Firefox 官方只提供了安装包的安装形式,这里是下载了官方的安装包后解压实现的。