2024年7月

基于Chrome扩展的浏览器可信事件与网页离线PDF导出

Chrome
扩展是一种可以在浏览器中添加新功能和修改浏览器行为的软件程序,我们可以基于
Manifest
规范的
API
实现对于浏览器和
Web
页面在一定程度上的修改,例如广告拦截、代理控制等。
Chrome DevTools Protocol
则是
Chrome
浏览器提供的一套与浏览器进行交互的
API
,我们可以基于
DevTools
协议控制
Chromium
内核的浏览器进行各种操作,例如操作页面元素、模拟用户交互等。

描述

前段时间我们需要实现一个比较复杂的需求,经常做需求的同学都知道,很多功能并不是可以按步就班地实现的,在某些情况下例如要跨部门甚至无法联系合作的情况下,单方面跨系统完成一些事情就可能需要动用不同寻常的方法。当然具体需求的内容不是很方便表达,所以在这里我们就替代为其他方面的需求展开文章的叙述,虽然实现的目的不一样,但是最终想要表达的技术方案是类似的。

因此在这里假设我们的背景变成了另一个故事,前段时间语雀进行了商业化,对于用户文章的数量和分享都做了一些限制,那么此时我们可能希望将现在已经写过的文档内容抽离出来,将其放在
GitHub
或者其他软件中作备份或分享等。那么此时问题来了,熟悉富文本的同学都知道,我们在语雀上存储的文档都是
JSON
文件而不是
MarkDown
等,会存在固定的私有格式,因此我们可能需要对其先进行一遍解析,而调用语雀的
OpenAPI
所需要的
Personal Token
是需要超级会员的,因此我们可能只能走比较常用的
Cookie
以及私有格式的解析方案,或者自动化操作
Puppeteer
模拟导出文档也是可行的。

那么有没有更加通用的方案可以参考,熟悉富文本的同学还知道,由于富文本需要实现
DOM
与选区
MODEL
的映射,因此生成的
DOM
结构通常会比较复杂,而当我们从文档中复制内容到剪贴板时,我们会希望这个结构是更规范化的,以便粘贴到其他平台例如飞书、
Word
等时会有更好的解析。因此我们便可以借助这一点来获取更加通用的方案,毕竟通过
HTML
解析成
MarkDown
等格式社区有很多完善的方法而不需要我们自行解析了,此外由于我们是通过
HTML
来描述内容,对于文档的内容完整性保持的会更好一些,自行解析的情况下可能会由于复杂的嵌套内容需要不断完善解析程序。

当然在这里只是平替了一下需求,前边我们也提到了背景是假设出来的,而由这个背景则延伸出了我们文章要聊的解决方案,如果真的是针对于语雀的这个迁移问题,在批量处理内容的情况下还是自行解析
JSON
会更方便一些。那么我们可以继续沿着提取
HTML
内容的思路处理数据,首先我们需要考虑如何获取这个
HTML
内容,最简单的方案就是我们通过读取
Node.innerHTML
属性来获取
DOM
结构,那么问题来了,在语雀当中有大量的
ne
开头的标签,以及大量的
ne
属性值来表达样式,以简单的文本与加粗为例,其
HTML
内容是这样的,其实语雀还算比较简单的结构,如果是飞书的表达则更加复杂。

<!-- 语雀 -->
<ne-p id="u5aec73be" data-lake-id="u5aec73be">
  <ne-text id="u1e4a00ce">123</ne-text>
  <ne-text id="ucc026ff4" ne-bold="true">123</ne-text>
  <span class="ne-viewer-b-filler" ne-filler="block"><br></span>
</ne-p>

<!-- 飞书 -->
 <div class="block docx-text-block" data-block-type="text" data-block-id="2" data-record-id="doxcns7E9SHaX2Xft1XweL0Mqwth">
  <div class="text-block-wrapper">
    <div class="text-block">
      <div class="zone-container text-editor non-empty" data-zone-id="2" data-zone-container="*" data-slate-editor="true" contenteditable="true">
        <div class="ace-line" data-node="true" dir="auto">
          <span data-string="true" class=" author-0087753711195911211" data-leaf="true">123</span>
          <span data-string="true" style="font-weight:bold;" class=" author-0087753711195911211" data-leaf="true">123</span>
          <span data-string="true" data-enter="true" data-leaf="true">&ZeroWidthSpace;</span>
        </div>
      </div>
    </div>
  </div>
</div>

可以看出来,我们取得这样的
HTML
解析起来相对成本还是比较高的,而如果我们以上述的剪贴板思路,也就是富文本通常会对复制的内容作
Normalize
处理,那么我们可以通过剪贴板事件来获取这个规范化的内容,然后再进行处理
HTML
,这里的
HTML
内容就会规范很多,那么同样也会便于我们处理数据。在这里实际上通常还会有私有类型的数据,这里就是我们选中部分取得的渲染
Fragment
,通常是用来在编辑器内部粘贴处理数据无损化还原使用的,如果对于数据格式非常熟悉的话解析这部分内容也是可以的,只是并没有比较高的通用性。

<!-- 语雀 -->
<div class="lake-content" typography="classic">
  <p id="u5aec73be" class="ne-p" style="margin: 0; padding: 0; min-height: 24px">
    <span class="ne-text">123</span>
    <strong><span class="ne-text">123</span></strong>
  </p>
</div>

<!-- 飞书 -->
<div data-page-id="doxcnTYldMboJldT2Mc2wXfervv6vqc" data-docx-has-block-data="false">
  <div class="ace-line ace-line old-record-id-doxcnsBUassFNud1XwL1vMgth">
    123<strong>123</strong>
  </div>
</div>

那么我们就可以继续沿着这个思路,以复制出的的内容为基准解析
HTML
格式解析内容,而实际上说了这么多我们最需要解决的问题是如何自动化提取内容,由此就引出了我们今天要聊的
Chrome
拓展与
Chrome DevTools Protocol
协议,当我们成功解决了内容问题之后,接下来将内容格式转换为其他格式社区就有很多成熟的方案了。文中涉及的相关代码都在
https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/chrome-debugger
中,在这里为了方便处理演示
DEMO
,我们的事件触发全部都是
DOM0
级的事件绑定形式。

JavaScript事件

既然我们的目标是自动操作浏览器执行复制操作,那么可供自动化操作的选择有很多例如
Selenium

Puppeteer
,都是可以考虑的方案。在这里我们考虑比较轻量的解决方案,不需要安装
WebDriver
等依赖环境,并且可以直接安装在用户本身的浏览器中开箱即用,基于这些考虑则使用
Chrome
扩展来帮我们实现目标是比较好的选择。并且
Chrome
扩展程序可以帮我们在
Web
页面中直接注入脚本,实现相关功能也会更加方便,关于使用扩展程序实现复杂的功能注入可以参考之前的文章,在这里就不重复叙述了。

那么接下来我们就需要考虑一下如何触发页面的
OnCopy
事件,试想一下此时我们的目的有两个,首先是让编辑器本身提取内容并规范化,其次是让转换后的内容写入剪贴板,那么实现的方式就很明确了,我们只需要主动在页面上触发
SelectAll

Copy
命令即可,那么接下来我们就可以在控制台中测试这两个命令的使用。

document.execCommand("selectAll");
const res = document.execCommand("copy");
console.log(res); // true

当我们手动在控制台执行命令的时候,可以发现页面上的内容已经被选中并且复制到了剪贴板中,那么接下来我们就可以将这两个命令封装到一个函数中,然后通过
Content Script
注入到页面中,这样我们就可以在页面上直接调用这个函数就可以了。然而当我们真正借助
Chrome
扩展实现这个功能的时候,会发现页面能够正常全部选中,但是剪贴板的内容却是上次的内容,也就是本次复制并没有真正执行成功。

这实际上是由于浏览器的安全策略导致的,由于浏览器为了加强安全性,限制了一些可能会影响用户隐私的
API
,只有在用户的直接操作下才能运行,也就是相当于执行
Copy
命令只有在用户主动激活上下文中才可以正常触发,与之类似的就是当我们在
Js
中主动执行点击事件例如
Node.click()
时,其对于浏览器来说是不可信的,在事件触发时会携带
isTrusted
属性,只有用户主动触发的事件才会为
true
。因此我们在控制台中执行的命令被认为是浏览器的可信命令,是用户主动触发的事件,而在扩展中执行的不是用户主动触发的事件,进而命令执行失败。

那么为什么我们在控制台的命令就可以正常执行呢,实际上这是因为我们在执行控制台的命令时,会需要点击回车键来执行代码,注意这个回车键是我们主动触发的,因此浏览器会将我们执行的
Js
代码认为是可信的,所以我们可以正常执行
Copy
命令。而如果我们在执行代码时将其加入延时,例如我们延时
5s
再执行命令,此时我们就可以发现即使是同样的代码同样在控制台执行就无法写入剪贴板,
document.execCommand("copy")
的返回值就是命令是否执行成功,在
5s
的延时下我们得到的返回值就是
false
,我们可以同样在控制台中执行代码来获取命令执行状态,在这里也可以不断调整延时的时间来观察执行结果,例如将其设置为
2s
就可以获得
true
的返回值。

setTimeout(() => {
  document.execCommand("selectAll");
  const res = document.execCommand("copy");
  console.log(res); // false
}, 5000);

我们暂且先放开需要用户主动激活的可信事件问题不谈,到后边再继续聊这个问题的解决方案。那么我们除了需要测试
OnCopy
事件之外,同样需要测试一下
OnPaste
的事件,不要忘记当我们执行了
OnCopy
提取内容之后,这部分内容实际上还是存在于剪贴板之中的,我们还需要将其提取出来。那么在执行下面的代码之后,我们可以发现
OnPaste

OnCopy
的策略还是不一样,即使是在用户的主动操作下,并且我们此时并没有延时执行,但是其结果依然是
false
,并且
document
绑定的事件也没有触发。

document.onpaste = console.log;
const res = document.execCommand("paste");
console.log(res); // false

那么会不会是因为我们没有在
input
或者
textarea
中执行
paste
命令的原因,我们同样可以测试下这个问题。我们可以通过创建一个
input
元素,然后将其插入到
body
中,然后将焦点移动到这个
input
元素上,然后执行
paste
命令,然而我们仍然无法成功执行命令,而且我们执行
focus
的时候会发现并没有光标的出现,

const input = document.createElement("input");
input.setAttribute("style", "position:fixed; top:0; right: 0");
document.body.appendChild(input);
input.focus();
const res = document.execCommand("paste");
console.log(res); // false

那么是不是还有其他原因会造成这个问题呢,在前边我们经过
OnCopy
部分的测试,可以得知在用户主动触发可信事件之后一段时间内的事件都是可信的,但是浏览器的安全策略中还有焦点方面的考量。在某些操作中焦点必须要在
document
上,否则操作不会正常执行,与之对应的异常就是
DOMException: Document is not focused.
,而此时我们的焦点是在控制台
Console
面板上的,这里同样可能存在不可控的问题。因此我们需要在这
2s
的执行延时中将焦点转移到
document
上,也就是需要点击
body
中任意元素,当然直接点击
input
也是可行的,然而即使这样我们也没有办法执行
paste

const input = document.createElement("input");
input.setAttribute("style", "position:fixed; top:0; right: 0");
document.body.appendChild(input);
setTimeout(() => {
  input.focus();
  const res = document.execCommand("paste");
  console.log(res); // false
}, 2000);

实际上在经过查阅文档可以知道
document.execCommand("paste")

Web Content
中实际上已经是被禁用的,然而这个命令还是可以执行的,我们后边会继续聊到。在现代浏览器中我们还有
navigator.clipboard API
来操作剪贴板,
navigator.clipboard.read
可以实现有限的剪贴板内容读取,调用这个
API
时会出现明确的调用授权提示,主动授权对于用户隐私是没有问题的,只是在自动化场景下可能需要多出一步授权操作。

此外,我们提到了
navigator.clipboard
是有限的剪贴板内容读取,那么这个有限是指什么呢,实际上这个有限是指只能读取特定的类型,例如
text/plain

text/html

image/png
等常见的类型,而对于私有类型的数据则是无法读取的,例如我们在语雀中复制的
text/ne-inode Fragment
数据,这部分数据是无法通过
navigator.clipboard.read
来读取的,通过执行下面的代码并授权之后可以发现并没有任何输出。

setTimeout(() => {
  navigator.clipboard.read().then(res => {
    for (const item of res) {
      item.getType("text/ne-inode").then(console.log).catch(() => null)
    }
  });
}, 2000);

我们实际上也可以通过遍历
navigator.clipboard
的内容来获得剪贴板的内容,同样的我们也只能获取
text/plain

text/html

image/png
等常见的规范
MIME-Type
类型。而这
2s
的耗时则是之前提到过的另一个限制,我们必须要在执行下面的代码之后将焦点移动到
document
上,否则控制台则会抛出
DOMException: Document is not focused.
异常,同样也不会出现授权弹窗。

setTimeout(() => {
  navigator.clipboard.read().then(res => {
    for (const item of res) {
      const types = item.types;
      for (const type of types) {
        item.getType(type).then(data => {
          const reader = new FileReader();
          reader.readAsText(data, "utf-8");
          reader.onload = () => {
            console.info(type, reader.result);
          };
        });
      }
    }
  });
}, 2000);

那么我们可以设想一个问题,富文本编辑器中如果只是写数据的时候写入了自定义的
MIME-Type
类型,那么我们在剪贴板中应该如何读取呢。实际上这还是得回归到我们的
OnPaste
事件上,我们借助于
navigator.clipboard API
是无法读取这部分自定义
key
值的,虽然我们可以将其写入到复制出的
HTML
的某个节点作为
attributes
然后再读取,这样是可以但是没必要,我们可以直接在
OnPaste
事件中通过
clipboardData
获取更加完整的相关数据,我们可以获取比较完整的类型了,这个方法同样也可以用于在浏览器中方便地调试剪贴板的内容。

const input = document.createElement("input");
input.style.position = "fixed";
input.style.top = "100px";
input.style.right = "10px";
input.style.zIndex = "999999";
input.style.width = "200px";
input.placeholder = "Read Clipboard On Paste";
input.addEventListener("paste", event => {
  const clipboardData = event.clipboardData || window.clipboardData;
  for (const item of clipboardData.items) {
    console.log("%c" + item.type, "background-color: #165DFF; color: #fff; padding: 3px 5px;");
    console.log(item.kind === "file" ? item.getAsFile() : clipboardData.getData(item.type));
  }
});
document.body.appendChild(input);

DevToolsProtocol

在前边我们抛出了需要用户主动激活触发的可信事件问题,那么在部分我们就需要解决这个问题。首先我们需要解决的问题是如何将代码注入到页面中,当然这个问题我们已经说过多次了,就是借助于
Chrome
扩展将脚本注入即可。那么即使我们能够注入脚本,执行的代码仍然不是用户主动激活的事件,无法突破浏览器的安全限制,那么这时候就需要请出我们的
Chrome DevTools Protocol
协议了。

熟悉
E2E
的同学都知道,
DevToolsProtocol
协议是
Chrome
浏览器提供的一套与浏览器进行交互的
API
,无论是
Selenium

Puppeteer

Playwright
都是基于这个协议来实现的。我们甚至可以基于这个协议主动实现
F12
的调试面板,也就是说当前在
F12
开发者工具能够实现的功能我们都可以基于这个协议实现,而且其
API
也不仅仅只有调试面板的功能实现,并且诸如
chrome://inspect
等调试程序也可以通过这个协议来完成。

那么在这里就有新的问题了,如果我们采用
Selenium

Puppeteer
等方案就需要用户安装
WebDriver
或者
Node
等依赖项,不能做到让用户开箱即用,那么在这个时候我们就需要将目光转向
chrome.debugger
了。
Chrome.debugger API
可以作为
Chrome
的远程调试协议的另一种传输方式,使用
chrome.debugger
可以连接到一个或多个标签页来监控网络交互、调试
JavaScript
、修改
DOM

CSS
等等,对我们来说最重要的是这个
API
是可以在
Chrome
扩展中调用的,这样我们就可以做到开箱即用的应用程序。

那么接下来我们就来处理
OnCopy
的事件,因为
chrome.debugger
必须要在
worker
中进行,而我们的控制启动的按钮则是定义在
Popup
中的,所以我们就需要进行
Popup -> Worker
的事件通信,关于
Chrome
扩展的通信方案可以在之前的文章中找到,也可以在前边提到的仓库中找到,在这里就不过多叙述了。那么此时我们就需要在扩展中查询当前活跃的标签页,然后需要过滤下当前活跃标签的协议,例如
chrome://
协议的连接我们不会进行处理,然后在符合条件的情况下我们将
tabId
传递下去。

cross.tabs
  .query({ active: true, currentWindow: true })
  .then(tabs => {
    const tab = tabs[0];
    const tabId = tab && tab.id;
    const tabURL = tab && tab.url;
    if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {
      return void 0;
    }
    return tabId;
  })

那么接下来我们就需要将协议控制持续挂载到当前活跃的
Tab
页上,当我们将扩展挂载
debugger
之后,会在用户的界面上提示我们的扩展已经开始调试此浏览器,这其实也是浏览器的一种安全策略,因为
debugger
的权限实在是太高了,给予用户可取消的操作还是非常有必要的。那么当挂载之后,我们就可以通过
chrome.debugger.sendCommand
来发送命令,例如我们可以通过
Input.dispatchKeyEvent
来模拟按键事件,在这里我们就需要借助按键的事件来发送
selectAll
命令,实际上发送命令这一环节是可以通过任何按键的发送来实现的,只不过为了符合实际操作我们选择了
Ctrl+A
的组合键。

chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
  type: "keyDown",
  modifiers: 4,
  keyCode: 65,
  key: "a",
  code: "KeyA",
  windowsVirtualKeyCode: 65,
  nativeVirtualKeyCode: 65,
  isSystemKey: true,
  commands: ["selectAll"],
});

需要注意的是经过前边的按键事件发送之后,我们此时执行的事件就会是可信的,通过
DevToolsProtocol
的模拟按键事件对于浏览器来说是完全可信的,等同于用户主动触发的事件。那么接下来就可以直接通过
Eval
执行
document.execCommand("copy")
命令了,这里我们可以通过
Runtime.evaluate
来执行
Js
代码,当执行完毕后,我们就需要将
debugger
卸载出当前活跃的标签页。在我们提供的
DEMO
中,为了对齐之前直接用
Js
执行的操作,我们同样也会延时
5s
再执行操作,此时可以发现我们的代码是可以正常将内容写到剪贴板里的,也就是我们成功执行了
Copy
命令。

chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
  expression: "const res = document.execCommand('copy'); console.log(res);",
})
.then(() => {
  chrome.debugger.detach({ tabId });
});

那么同样的接下来我们就研究在
DevToolsProtocol
中的
OnPaste
事件,那么首先我们并不在权限清单中声明
clipboardRead
权限,这是在
Chrome
扩展程序权限清单中的读剪贴板权限,紧接着我们延续之前的代码在
debugger
中执行
document.execCommand("paste")
,可以发现执行的结果是
false
,这表示即使在可信的条件下,执行
paste
仍然是无法取得结果的。那么如果我们在
permissions
中声明了
clipboardRead
,会可以发现仍然是
false
,这说明在用户脚本
Inject Script
下执行
document.execCommand("paste")
是无法取得效果的。

chrome.debugger
  .attach({ tabId }, "1.2")
  .then(() =>
    chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
      type: "keyDown",
      // ...
    })
  )
  .then(() => {
    return chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
      expression:
        "document.onpaste = console.log; const res = document.execCommand('paste'); console.log(res);",
      });
  })
  .finally(() => {
    chrome.debugger.detach({ tabId });
  });

那么我们继续保持不在清单中声明
clipboardRead
权限,尝试用
DevToolsProtocol
的方式执行
document.execCommand("paste")
,也就是在模拟按键时将命令发送出去。此时我们可以发现是可以正常触发事件的,这里实际上就同样表明了通过
DevToolsProtocol
协议直接执行事件是完全以用户主动触发的形式来进行的,其本身就是可信的事件源。

chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {
  type: "keyDown",
  modifiers: 4,
  keyCode: 86,
  key: "v",
  code: "KeyV",
  windowsVirtualKeyCode: 86,
  nativeVirtualKeyCode: 86,
  isSystemKey: true,
  commands: ["paste"],
});

紧接着我们简单更改一下先前在用户态执行的
Js
事件操作,将执行的
copy
命令改为
paste
命令,也就是在
Content Script
部分执行
document.execCommand("paste")
,此时仍然是会返回
false
,说明我们的命令执行并没有成功。那么别忘了此时我们还没有声明清单中的
clipboardRead
权限,而当我们在清单中声明权限之后,再次执行
document.execCommand("paste")
,发现此时的结果是
true
并且可以正常触发事件。

document.onpaste = console.log;
case PCBridge.REQUEST.COPY_ALL: {
  const res = document.execCommand("paste");
  console.log(res);
  break;
}

而如果我们更进一步,继续保持清单中的
clipboardRead
权限声明,将事件传递到
Inject Script
中执行,可以发现即使是在声明了权限的情况下,
document.execCommand("paste")
返回的结果仍然是
false
,并且无法触发我们绑定的事件,这也印证了之前我们说的在
Inject Script
下执行
paste
命令是无法正常触发的,进而我们可以明确
clipboardRead
权限是需要我们在
Content Script
中使用的。而对于
navigator.clipboard API
即使在权限清单中声明权限的情况下 仍然还需要主动授权。

// Content Script
case PCBridge.REQUEST.COPY_ALL: {
  document.dispatchEvent(new CustomEvent("custom-event"));
  break;
}

// Inject Script
document.onpaste = console.log;
document.addEventListener("custom-event", () => {
  const res = document.execCommand("paste");
  console.log(res);
});

网页离线PDF导出

在前段时间刷社区的时候发现有不少用户希望能够将网页保存为
PDF
文件,方便作为快照保存以供离线阅读,因此在这里也顺便聊一下相关实现方案,而实际上在这里也属于
Web
页面内容的提取,与我们上文聊的剪贴板操作本质上是类似的功能。那么在浏览器中我们当然可以通过
Ctrl + P

PDF
打印出来,然而通过打印的方式或者生成图片的方式导出的
PDF
文件就存在一些问题:

  • 导出的
    PDF
    必须指定纸张大小,不能随意设定纸张大小,例如当想将页面导出为单页
    PDF
    的情况下就难以实现。
  • 导出
    PDF
    时必须要弹出选择对话框,不能够静默导出并自动下载,这对于想要同时导出多个
    Tab
    页的批量场景不够友好。
  • 导出的
    PDF
    不会自动携带
    Outline
    ,也就是
    PDF
    的目录书签大纲,需要后续主动使用
    pdf-lib
    等工具来生成。
  • 导出时必须要全页面打印,页面本身可能没有定义
    @media print
    样式预设,希望实现局部打印时会有些困难。
  • 如果想在打印
    PDF
    前批量自定义样式,则需要为每个页面单独注入样式,这样的操作显然不适用于批量场景。
  • 如果通过类似于
    HTML2Canvas
    的方式将页面转换为图片再转换为
    PDF
    ,则会导致图片体积过大且文本不能选中的问题。

那么在这里我们可以借助
Chrome DevTools Protocol
协议来实现这个功能,实际上
DevTools Protocol
协议中有一个
Page.printToPDF
方法,这也是常用的
Node
服务端将
HTML
转换为
PDF
的常用方法,当然借助
PDFKit
等工具直接绘制生成
PDF
也是可行的,只不过成本很高。
Page.printToPDF
方法可以将当前页面导出为
PDF
文件,并且可以实现静默导出并自动下载,也可以实现自定义纸张大小,同时也可以实现
Outline
的生成,这个方法的使用也是非常简单的,只需要传递一个
PDF
的配置对象即可。

那么在调用方法之前,我们同样需要查询当前活跃的活动窗口,当然直接选择当前
Window
下的所有窗口也是可行的,此时需要注意权限清单中的
tabs

activeTab
权限的声明,同样的在这里我们仍然需要过滤
chrome://
等协议,只处理
http://

https://

file://
协议的内容。

cross.tabs
  .query({ active: true, currentWindow: true })
  .then(tabs => {
    const tab = tabs[0];
    const tabId = tab && tab.id;
    const tabURL = tab && tab.url;
    if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {
      return void 0;
    }
    return tabId;
  })

接下来我们就可以根据
TabId
挂载
debugger
,前边提到了我们是希望将页面导出为单页
PDF
的,因此我们就需要将页面的高度和宽度取得,此时我们可以通过
Page.getLayoutMetrics
方法来获取页面的布局信息,这个方法会返回一个
LayoutMetrics
对象,其中包含了页面的宽度、高度、滚动高度等信息。然而当然我们也可以通过通信的方式将消息传递到
Content Script
中得到页面的宽高信息,在这里我们采用更加简单的方式,通过执行
Runtime.evaluate
的方式,获取得到的返回值,这样我们可以灵活地取得更多的数据,当然也可以灵活地控制页面内容,例如在滚动容器不是
window
的情况下就需要我们注入代码获取宽高以及控制打印范围。

chrome.debugger
  .attach({ tabId }, "1.3")
  .then(() => {
    return chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
      expression:
        "JSON.stringify({width: document.body.clientWidth, height: document.body.scrollHeight})",
    });
  })

那么接下来我们就需要根据页面的宽高信息来设置
PDF
的配置对象,在这里需要注意的是我们通过
document
取得的宽高信息是像素大小,而在
Page.printToPDF
中的
paperWidth

paperHeight
是以
inch
为单位的,因此我们需要将其转换为
inch
单位,根据
CSS
规范
1px = 1/96th of 1 inch
,我们通常可以认为
1px = 1/96 inch
而不受设备物理像素的影响。此外,我们可以指定一些配置,当前我们输出的
PDF
只会包含第一页的内容,同时会包含背景颜色、生成文档大纲的配置,并且还有
Header

Footer
等配置选项,我们可以根据实际需求来设置输出格式,需要注意的是
generateDocumentOutline
是实验性的配置,在比较新的
Chrome
版本中才被支持。

const value = res.result.value as string;
const rect = TSON.parse<{ width: number; height: number }>(value);
return chrome.debugger.sendCommand({ tabId }, "Page.printToPDF", {
  paperHeight: rect ? rect.height / 96 : undefined,
  paperWidth: rect ? rect.width / 96 : undefined,
  pageRanges: "1",
  printBackground: true,
  generateDocumentOutline: true,
});

那么在生成完毕后,我们接下来就需要将其下载到设备中,触发下载的方法又很多,例如可以将数据传递到页面中通过
a
标签触发下载。在扩展程序中实际上提供了
chrome.downloads.download
方法,这个方法可以直接下载文件到设备中,并且虽然传递数据参数名字为
url
,但是实际上并不会受到链接长度/字符数的限制,通过传递
Base64
编码的数据可以实现大量数据下载,只要注意在权限清单中声明权限即可。那么在下载完成之后,我们同样就可以将
debugger
分离当前
Tab
页,这样就完成了整个
PDF
导出的过程。

const base64 = res.data as string;
chrome.downloads
  .download({ url: "data:application/pdf;base64," + base64 });
  .finally(() => {
    chrome.debugger.detach({ tabId });
  });

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://chromedevtools.github.io/devtools-protocol/
https://github.com/microsoft/playwright/issues/29417
https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
https://developer.chrome.google.cn/docs/extensions/reference/api/debugger?hl=zh-cn
https://stackoverflow.com/questions/71005817/how-does-pixels-relate-to-screen-size-in-css
https://chromewebstore.google.com/detail/just-one-page-pdf/fgbhbfdgdlojklkbhdoilkdlomoilbpl

上次编写了《
LUAgent服务器端工具
》这个应用,然后里面需要新启动一个线程去对文件进行上传到FTP服务器,但是新线程里无法对应用主线程UI的内容进行更改,所以就需要在线程里设置主UI线程里控件信息的方法,于是就有了此博文。此文记录的是一种高级用法。

为了实际的使用,笔者将线程操作放在独立的类当中,其它窗体为独立的,然后在线程操作里运行然后更新窗体控件的内容。(主线程负责UI窗体等的显示,操作线程处理计算等等,然后更新主线程里的UI控件显示信息)

1、
项目目录;

2、
源码介绍;

1) 同步代码;

2) 操作代码;

3) 操作过程;

4) 更新控件的函数;

3、
运行界面;

4、
使用介绍;

1) 先在操作类里定义同步变量;

2) 在窗体里添加更改控件的代码;

3) 添加更改对象,里面定义需要的字段;

4) 添加操作代码,里面对控件显示信息进行更新;

5、
源码下载;

https://download.csdn.net/download/lzhdim/89496026

6、
其它建议;

这个例子挺简单的,里面的代码是高级用法,请需要的读者进行复用代码。

一:背景

1. 讲故事

早就听说过有什么
网络边缘计算
,这次还真给遇到了,有点意思,问了下 chatgpt 这是干嘛的 ?

网络边缘计算是一种计算模型,它将计算能力和数据存储位置从传统的集中式数据中心向网络边缘的用户设备、传感器和其他物联网设备移动。这种模型的目的是在接近数据生成源头的地方提供更快速的计算和数据处理能力,从而减少数据传输延迟并提高服务质量。网络边缘计算使得在设备本地进行数据处理和决策成为可能,同时也有助于减轻对中心数据中心的网络流量和负载。

看到.NET还有这样的应用场景还是挺欣慰的,接下来就来分析下这个dump到底是怎么回事?

二:WinDbg 分析

1. 为什么会卡死

不同程序的卡死有不同的分析方式,所以要先鉴别下程序的类型以及主线程的调用栈即可,参考如下:


0:000> !eeversion
5.0.721.25508
5.0.721.25508 @Commit: 556582d964cc21b82a88d7154e915076f6f9008e
Server mode with 64 gc heaps
SOS Version: 8.0.10.10501 retail build

0:000> k
 # Child-SP          RetAddr               Call Site
00 0000ffff`e0dddac0 0000fffd`c194c30c     libpthread_2_28!pthread_cond_wait+0x238
...
18 (Inline Function) --------`--------     libcoreclr!RunMain::$_0::operator()::{lambda(Param *)#1}::operator()+0x14c [/__w/1/s/src/coreclr/src/vm/assembly.cpp @ 1536] 
19 (Inline Function) --------`--------     libcoreclr!RunMain::$_0::operator()+0x188 [/__w/1/s/src/coreclr/src/vm/assembly.cpp @ 1538] 
1a 0000ffff`e0dde600 0000fffd`c153e860     libcoreclr!RunMain+0x298 [/__w/1/s/src/coreclr/src/vm/assembly.cpp @ 1538] 
...
20 0000ffff`e0dded10 0000fffd`c1bf7800     libhostpolicy!corehost_main+0xc0 [/root/runtime/src/installer/corehost/cli/hostpolicy/hostpolicy.cpp @ 409] 
21 (Inline Function) --------`--------     libhostfxr!execute_app+0x2c0 [/root/runtime/src/installer/corehost/cli/fxr/fx_muxer.cpp @ 146] 
22 (Inline Function) --------`--------     libhostfxr!<unnamed-namespace>::read_config_and_execute+0x3b4 [/root/runtime/src/installer/corehost/cli/fxr/fx_muxer.cpp @ 520] 
23 0000ffff`e0ddeeb0 0000fffd`c1bf6840     libhostfxr!fx_muxer_t::handle_exec_host_command+0x57c [/root/runtime/src/installer/corehost/cli/fxr/fx_muxer.cpp @ 1001] 
24 0000ffff`e0ddf000 0000fffd`c1bf4090     libhostfxr!fx_muxer_t::execute+0x2ec
25 0000ffff`e0ddf130 0000aaad`c9e1d22c     libhostfxr!hostfxr_main_startupinfo+0xa0 [/root/runtime/src/installer/corehost/cli/fxr/hostfxr.cpp @ 50] 
26 0000ffff`e0ddf200 0000aaad`c9e1d468     dotnet!exe_start+0x36c [/root/runtime/src/installer/corehost/corehost.cpp @ 239] 
27 0000ffff`e0ddf370 0000fffd`c1c63fe0     dotnet!main+0x90 [/root/runtime/src/installer/corehost/corehost.cpp @ 302] 
28 0000ffff`e0ddf3b0 0000aaad`c9e13adc     libc_2_28!_libc_start_main+0xe0
29 0000ffff`e0ddf4e0 00000000`00000000     dotnet!start+0x34

从卦中的指标来看,这是一个 Linux 上部署的 Web网站,既然是网站的卡死,那就要关注各个线程都在做什么。

2. 线程都在干嘛

以我多年的分析经验,绝大多数都是由于
线程饥饿
或者说
线程池耗尽
导致的,首先我们看下线程池的情况。


0:000> !t
ThreadCount:      365
UnstartedThread:  0
BackgroundThread: 354
PendingThread:    0
DeadThread:       10
Hosted Runtime:   no
                                                                                                            Lock  
 DBG   ID     OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1    31eaf 0000AAADF267C600  2020020 Preemptive  0000000000000000:0000000000000000 0000aaadf26634b0 -00001 Ukn 
...
 423  363    36d30 0000FFDDB4000B20  1020220 Preemptive  0000000000000000:0000000000000000 0000aaadf26634b0 -00001 Ukn (Threadpool Worker) 
 424  364    36d31 0000FFDDA8000B20  1020220 Preemptive  0000000000000000:0000000000000000 0000aaadf26634b0 -00001 Ukn (Threadpool Worker) 
 425  365    36d32 0000FFDDAC000B20  1020220 Preemptive  0000000000000000:0000000000000000 0000aaadf26634b0 -00001 Ukn (Threadpool Worker) 

0:000> !tp
Using the Portable thread pool.

CPU utilization:  9%
Workers Total:    252
Workers Running:  236
Workers Idle:     13
Worker Min Limit: 64
Worker Max Limit: 32767

Completion Total:   0
Completion Free:    0
Completion MaxFree: 128
Completion Current Limit: 0
Completion Min Limit:     64
Completion Max Limit:     1000

从卦中看当前有 365 个托管线程,这个算多吗?对于64core 来说,这个线程其实算是正常,训练营里的朋友都知道,server版的gc仅gc线程就有
64*2=128
个,接下来再看一个指标就是当前是否存在任务积压? 可以使用
!ext tpq
命令,参考输出如下:


0:000> !ext tpq
global work item queue________________________________

local per thread work items_____________________________________

从卦中看当前没有任务积压,这就有点反经验了。

3. 真的不是线程饥饿吗

最后一招比较彻底,就是看各个线程栈都在做什么,可以使用
~*e !clrstack
命令。

这不看不知道,一看吓一跳,有 193 个线程在
Task.Result
上等待,这玩意太经典了,然后从上面的调用栈
UIUpdateTimer_Elapsed
来看,貌似是一个定时器导致的,接下来我就好奇这代码是怎么写的?

分析上面的代码之后,我发现它是和
Linux Shell
窗口进行命令交互,不知道为何 Shell 没有响应导致代码在这里卡死。

4. 为什么线程池没有积压

相信有很多朋友对这个反经验的东西很好奇为什么请求没有积压在线程池,其实这个考验的是你对 PortableThreadPool 的底层了解,这里我就简单说一下吧。

  1. 在 ThreadPool 中有一个 GateThread 线程是专门给线程池动态注入线程的,参考代码如下:

private static class GateThread
{
    private static void GateThreadStart()
    {
        while (true)
        {
            bool wasSignaledToWake = DelayEvent.WaitOne((int)delayHelper.GetNextDelay(tickCount));

            WorkerThread.MaybeAddWorkingWorker(threadPoolInstance);
        }
    }
}

  1. 一旦有人调用了 Task.Result 代码,内部会主动唤醒 DelayEvent 事件,告诉 GateThread 赶紧通过 MaybeAddWorkingWorker 方法给我注入新的线程,参考代码如下:

private bool SpinThenBlockingWait(int millisecondsTimeout, CancellationToken cancellationToken)
{
    bool flag3 = ThreadPool.NotifyThreadBlocked();

}
internal static bool NotifyThreadBlocked()
{
    if (UsePortableThreadPool)
    {
        return PortableThreadPool.ThreadPoolInstance.NotifyThreadBlocked();
    }
    return false;
}
public bool NotifyThreadBlocked()
{
    GateThread.Wake(this);
}

上面这种主动唤醒的机制是 C# 版 PortableThreadPool 做的优化来缓解线程饥饿的,这里有一个重点就是它
只能缓解
,换句话说如果上游太猛了还是会有请求积压的,但为什么这里没有积压呢? 很显然上游不猛呗,那如何眼见为实呢? 这就需要看 timer 的周期数即可,到当前的线程栈上给扒出来。


0:417> !DumpObj /d 0000ffee380757f8
Name:        System.Timers.Timer
MethodTable: 0000fffd4ab24030
EEClass:     0000fffd4ad6e140
Size:        88(0x58) bytes
File:        /home/user/env/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.ComponentModel.TypeConverter.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
0000fffd4c947498  400001c        8 ...ponentModel.ISite  0 instance 0000000000000000 _site
0000000000000000  400001d       10 ....EventHandlerList  0 instance 0000000000000000 _events
0000fffd479195d8  400001b       98        System.Object  0   static 0000000000000000 s_eventDisposed
0000fffd47926f60  400000e       40        System.Double  1 instance 3000.000000 _interval
0000fffd4791fb10  400000f       48       System.Boolean  1 instance                1 _enabled
0000fffd4791fb10  4000010       49       System.Boolean  1 instance                0 _initializing
0000fffd4791fb10  4000011       4a       System.Boolean  1 instance                0 _delayedEnable
0000fffd4ab241d8  4000012       18 ...apsedEventHandler  0 instance 0000ffee3807aae8 _onIntervalElapsed
0000fffd4791fb10  4000013       4b       System.Boolean  1 instance                1 _autoReset
0000fffd4c944ea0  4000014       20 ...SynchronizeInvoke  0 instance 0000000000000000 _synchronizingObject
0000fffd4791fb10  4000015       4c       System.Boolean  1 instance                0 _disposed
0000fffd49963e28  4000016       28 ...m.Threading.Timer  0 instance 0000ffee38098dc8 _timer
0000fffd48b90a30  4000017       30 ...ing.TimerCallback  0 instance 0000ffee3807aaa8 _callback
0000fffd479195d8  4000018       38        System.Object  0 instance 0000ffee38098db0 _cookie

从卦中看当前是 3s 为一个周期,这就能解释为什么线程池没有积压的底层原因了。

三:总结

这个卡死事故还是蛮好解决的,如果有一些经验直接用
dotnet-counter
也是能搞定的,重点在于这是一个 Linux的dump,同时又是 .NET上的一个很好玩的场景,故此分享出来。

图片名称

问题

最近碰到一个 case,一台主机上,部署了多个实例。之前使用的是 MySQL 8.0,启动时没有任何问题。但升级到 MySQL 8.4 后,部分实例在启动时出现了以下错误。

[Warning] [MY-012582] [InnoDB] io_setup() failed with EAGAIN. Will make 5 attempts before giving up.
[Warning] [MY-012583] [InnoDB] io_setup() attempt 1.
[Warning] [MY-012583] [InnoDB] io_setup() attempt 2.
[Warning] [MY-012583] [InnoDB] io_setup() attempt 3.
[Warning] [MY-012583] [InnoDB] io_setup() attempt 4.
[Warning] [MY-012583] [InnoDB] io_setup() attempt 5.
[ERROR] [MY-012584] [InnoDB] io_setup() failed with EAGAIN after 5 attempts.
[ERROR] [MY-012954] [InnoDB] Cannot initialize AIO sub-system
[ERROR] [MY-012930] [InnoDB] Plugin initialization aborted with error Generic error.
[ERROR] [MY-010334] [Server] Failed to initialize DD Storage Engine
[ERROR] [MY-010020] [Server] Data Dictionary initialization failed.
[ERROR] [MY-010119] [Server] Aborting
[System] [MY-010910] [Server] /usr/local/mysql/bin/mysqld: Shutdown complete (mysqld 8.4.0)  MySQL Community Server - GPL.
[System] [MY-015016] [Server] MySQL Server - end.

下面我们来分析下这个报错的具体原因及解决方法。

定位过程

首先搜索下这个报错是在哪个文件产生的。

# grep "io_setup() failed" -r /usr/src/mysql-8.4.0
/usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:          ib::warn(ER_IB_MSG_757) << "io_setup() failed with EAGAIN."
/usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:            << "io_setup() failed with EAGAIN after "

接着分析该文件中产生报错的具体函数。

// storage/innobase/os/os0file.cc
bool AIO::linux_create_io_ctx(ulint max_events, io_context_t *io_ctx) {
  ssize_t n_retries = 0;
  for (;;) {
    memset(io_ctx, 0x0, sizeof(*io_ctx));
    int ret = io_setup(max_events, io_ctx);

    if (ret == 0) {
      /* Success. Return now. */
      return (true);
    }
    switch (ret) {
      case -EAGAIN:
        if (n_retries == 0) {
          /* First time around. */
          ib::warn(ER_IB_MSG_757) << "io_setup() failed with EAGAIN."
                                     " Will make "
                                  << OS_AIO_IO_SETUP_RETRY_ATTEMPTS
                                  << " attempts before giving up.";
        }
        if (n_retries < OS_AIO_IO_SETUP_RETRY_ATTEMPTS) {
          ++n_retries;
          ib::warn(ER_IB_MSG_758) << "io_setup() attempt " << n_retries << ".";
          std::this_thread::sleep_for(OS_AIO_IO_SETUP_RETRY_SLEEP);
          continue;
        }

        /* Have tried enough. Better call it a day. */
        ib::error(ER_IB_MSG_759)
            << "io_setup() failed with EAGAIN after "
            << OS_AIO_IO_SETUP_RETRY_ATTEMPTS << " attempts.";
        break;
        ...
    }
    ib::info(ER_IB_MSG_762) << "You can disable Linux Native AIO by"
                               " setting innodb_use_native_aio = 0 in my.cnf";

    break;
  }
  return (false);
}

可以看到,错误信息主要是在执行
io_setup
,产生 EAGAIN 错误时打印的。

函数中的
io_setup
是一个 Linux 系统调用,用于初始化一个异步 I/O (AIO) 上下文(context)。异步 I/O(AIO)允许程序在发出 I/O 操作请求后继续执行其他工作,而不是等待操作完成。
io_setup
是 Linux 内核提供的异步 I/O 接口,通常用于高性能应用程序和数据库系统,以实现非阻塞 I/O 操作。max_events 指定了这个异步 I/O 上下文可以处理的最大并发 I/O 请求数。
io_setup
执行成功时会返回 0,失败时则返回 -1,并通过 errno 表示具体错误。

当返回的错误是 EAGAIN 时,则意味着指定的 max_events 超过了系统允许的最大异步 I/O (AIO) 事件数。

系统允许创建的最大异步 I/O 事件数是在
/proc/sys/fs/aio-max-nr
中定义的,默认值跟系统有关,通常是 65536。

所以,解决方法找到了,直接调整
/proc/sys/fs/aio-max-nr
的值即可。

# echo 1048576 > /proc/sys/fs/aio-max-nr

注意这种只是临时修改,系统重启就会失效。如果要永久修改,需调整 /etc/sysctl.conf。

# vim /etc/sysctl.conf
fs.aio-max-nr=1048576
# sysctl -p

问题解决了,接下来我们分析下同一台主机,为什么之前的 MySQL 8.0 没问题,升级到 MySQL 8.4 就报错了呢?

这个时候,就需要分析函数中 max_events 的生成逻辑了。

堆栈信息

下面是
AIO::linux_create_io_ctx
函数被调用的堆栈信息。

#0  AIO::linux_create_io_ctx (max_events=256, io_ctx=0x7fffe02d1500)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:2559
#1  0x0000000004db1649 in AIO::init_linux_native_aio (this=0x7fffe02d1d70)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:6139
#2  0x0000000004db16ed in AIO::init (this=0x7fffe02d1d70)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:6167
#3  0x0000000004db1826 in AIO::create (id=LATCH_ID_OS_AIO_IBUF_MUTEX, n=256, n_segments=1)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:6200
#4  0x0000000004db1a2b in AIO::start (n_per_seg=256, n_readers=64, n_writers=4)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:6254
#5  0x0000000004db261a in os_aio_init (n_readers=64, n_writers=4)
    at /usr/src/mysql-8.4.0/storage/innobase/os/os0file.cc:6514
#6  0x0000000004ee6b4d in srv_start (create_new_db=false)
    at /usr/src/mysql-8.4.0/storage/innobase/srv/srv0start.cc:1743
#7  0x0000000004bdfc92 in innobase_init_files (dict_init_mode=DICT_INIT_CHECK_FILES, tablespaces=0x7fffe77bf720)
    at /usr/src/mysql-8.4.0/storage/innobase/handler/ha_innodb.cc:5744
#8  0x0000000004bf0e22 in innobase_ddse_dict_init (dict_init_mode=DICT_INIT_CHECK_FILES, tables=0x7fffe77bf740, 
    tablespaces=0x7fffe77bf720) at /usr/src/mysql-8.4.0/storage/innobase/handler/ha_innodb.cc:13133
#9  0x00000000049153b8 in dd::bootstrap::DDSE_dict_init (thd=0xb7ce6b0, dict_init_mode=DICT_INIT_CHECK_FILES, 
    version=80300) at /usr/src/mysql-8.4.0/sql/dd/impl/bootstrap/bootstrapper.cc:746
#10 0x0000000004916195 in dd::bootstrap::restart_dictionary (thd=0xb7ce6b0)
    at /usr/src/mysql-8.4.0/sql/dd/impl/bootstrap/bootstrapper.cc:907
#11 0x0000000003814e54 in bootstrap::handle_bootstrap (arg=0x7fffffffcf20)
    at /usr/src/mysql-8.4.0/sql/bootstrap.cc:340
#12 0x0000000005792a92 in pfs_spawn_thread (arg=0xb7ece50) at /usr/src/mysql-8.4.0/storage/perfschema/pfs.cc:3051
#13 0x00007ffff7bc6ea5 in start_thread () from /lib64/libpthread.so.0
#14 0x00007ffff5ff0b0d in clone () from /lib64/libc.so.6

堆栈中的重点是 #6 的
srv_start
函数,这个函数会调用
os_aio_init
来初始化异步 I/O 系统。

// storage/innobase/srv/srv0start.cc
dberr_t srv_start(bool create_new_db) {
  ...
  if (!os_aio_init(srv_n_read_io_threads, srv_n_write_io_threads)) {
    ib::error(ER_IB_MSG_1129);

    return (srv_init_abort(DB_ERROR));
  }
...

调用
os_aio_init
时,会传递两个参数:srv_n_read_io_threads 和 srv_n_write_io_threads。这两个参数实际上对应的就是 MySQL 中的 innodb_read_io_threads 和 innodb_write_io_threads,这两个参数分别用来表示 InnoDB 中用于读操作、写操作的 I/O 线程数。

如果初始化失败,会打印
ER_IB_MSG_1129
错误。

ER_IB_MSG_1129
是一个预定义的错误代码,对应的错误信息是在
share/messages_to_error_log.txt
中定义的。

ER_IB_MSG_1129
  eng "Cannot initialize AIO sub-system"

所以,错误日志中看到的
[ERROR] [MY-012954] [InnoDB] Cannot initialize AIO sub-system
其实就是在这里打印的。

有的童鞋可能猜到了,异步 I/O 系统初始化失败与 innodb_read_io_threads 和 innodb_write_io_threads 的设置有关,事实也确实如此。

下面,我们分析下 MySQL 启动过程中需要初始化多少个异步 I/O 请求。

MySQL 启动过程中需要初始化多少个异步 I/O 请求?

异步 I/O 的初始化主要是在
AIO::linux_create_io_ctx
中进行的,接下来,我们分析下
AIO::linux_create_io_ctx
的调用场景:

场景1:AIO::is_linux_native_aio_supported

该函数用来判断系统是否支持 AIO。

bool AIO::is_linux_native_aio_supported() {
  ...
  if (!linux_create_io_ctx(1, &io_ctx)) {
    return (false);
  }
  ...
}

这里只会初始化 1 个异步 I/O 请求。

场景2:AIO::init_linux_native_aio

该函数是用来初始化 Linux 原生异步 I/O 的。

dberr_t AIO::init_linux_native_aio() {
  ...
  ulint max_events = slots_per_segment();
  for (ulint i = 0; i < m_n_segments; ++i, ++ctx) {
    if (!linux_create_io_ctx(max_events, ctx)) {
      return (DB_IO_ERROR);
    }
  }
  return (DB_SUCCESS);
}

函数中的 m_n_segments 是需要创建的异步 I/O (AIO) 上下文的数量,max_events 是每个异步 I/O (AIO) 上下文支持的最大并发 I/O 请求数。所以,这个函数会初始化 m_n_segments * max_events 个异步 I/O 请求。

在 MySQL 的启动过程中,
AIO::is_linux_native_aio_supported
只被调用一次,而
AIO::init_linux_native_aio
则会被调用三次,分别用于 insert buffer 线程、读线程和写线程的初始化。

这两个函数都是在
AIO::start
中调用的。

// storage/innobase/os/os0file.cc
bool AIO::start(ulint n_per_seg, ulint n_readers, ulint n_writers) {
#if defined(LINUX_NATIVE_AIO)
  /* Check if native aio is supported on this system and tmpfs */
  if (srv_use_native_aio && !is_linux_native_aio_supported()) {
    ib::warn(ER_IB_MSG_829) << "Linux Native AIO disabled.";
    srv_use_native_aio = false;
  }
#endif /* LINUX_NATIVE_AIO */
  ...
  if (0 < n_extra) {
    ...
    s_ibuf = create(LATCH_ID_OS_AIO_IBUF_MUTEX, n_per_seg, 1);
   ...
  }
  ...
  s_reads =
      create(LATCH_ID_OS_AIO_READ_MUTEX, n_readers * n_per_seg, n_readers);
  ...
  s_writes =
      create(LATCH_ID_OS_AIO_WRITE_MUTEX, n_writers * n_per_seg, n_writers);

  ...
  return true;
}

函数中的 n_per_seg 实际上就是 max_events。

n_per_seg 等于 8 * OS_AIO_N_PENDING_IOS_PER_THREAD,因为 OS_AIO_N_PENDING_IOS_PER_THREAD 是个常量,值为 32,所以 n_per_seg 等于 256。


AIO::init_linux_native_aio
中的 m_n_segments 实际上表示的是线程的数量:对于 insert buffer 线程,线程数为 1;对于读操作线程,线程数为 n_readers;对于写操作线程,线程数为 n_writers。

怎么知道 m_n_segments 就是线程的数量?

关键是在创建 AIO 对象时,会调用 AIO 的构造函数,而构造函数中的 m_slots 又决定了 max_events 的值。

AIO *AIO::create(latch_id_t id, ulint n, ulint n_segments) {
  ...
  AIO *array =
      ut::new_withkey<AIO>(UT_NEW_THIS_FILE_PSI_KEY, id, n, n_segments);
  ...
}

AIO::AIO(latch_id_t id, ulint n, ulint segments)
    : m_slots(n),
      m_n_segments(segments),
...
  
[[nodiscard]] ulint slots_per_segment() const {
  return (m_slots.size() / m_n_segments);
}

以读线程为例,
AIO::create
中的 n 等于 n_readers * n_per_seg,n_segments 等于 n_readers。

在初始化 AIO 对象时,n_readers * n_per_seg 将赋值给 m_slots,n_readers 将赋值给 m_n_segments。

所以
AIO::init_linux_native_aio
中的 max_events = slots_per_segment() = m_slots.size() / m_n_segments = n_readers * n_per_seg / n_readers = n_per_seg。

计算公式

基于上面的分析,我们可以推论出 MySQL 在启动过程中需要初始化的异步 I/O 请求数的计算公式。

(1 + innodb_read_io_threads + innodb_write_io_threads) * 256 + 1

最后一个 1 是判断系统是否支持 AIO。

验证

下面通过一个具体的案例来验证下上面的计算公式是否正确。

首先通过
/proc/sys/fs/aio-nr
查看当前系统中已分配的异步 I/O 请求的数量。

# cat /proc/sys/fs/aio-nr
4866

接着,启动一个 MySQL 8.4 实例,启动命令中显式设置 innodb_read_io_threads 和 innodb_write_io_threads。

# /usr/local/mysql8.4/bin/mysqld --defaults-file=/etc/my_3308.cnf --innodb-read-io-threads=64 --innodb-write-io-threads=4 &

实例启动后,再次查看
/proc/sys/fs/aio-nr

# cat /proc/sys/fs/aio-nr
22531

两个数之间的差值是 17665。

按照之前的公式计算,也是 17665,完全吻合。

(1 + 64 + 4) * 256 + 1 = 17665

为什么 MySQL 8.4 启动会报错呢?

因为 innodb_read_io_threads 的默认值在 MySQL 8.4 中发生了变化。

在 MySQL 8.4 之前,innodb_read_io_threads 默认为 4,而在 MySQL 8.4 中,innodb_read_io_threads 默认等于主机逻辑 CPU 的一半,最小是 4,最大是 64。

static MYSQL_SYSVAR_ULONG(
    read_io_threads, srv_n_read_io_threads,
    PLUGIN_VAR_RQCMDARG | PLUGIN_VAR_READONLY,
    "Number of background read I/O threads in InnoDB.", nullptr, nullptr,
    std::clamp(std::thread::hardware_concurrency() / 2, 4U, 64U), 1, 64, 0);

不巧,问题 case 主机的逻辑 CPU 是 128 核,所以就导致了 innodb_read_io_threads 等于 64。

这就意味着,在
/proc/sys/fs/aio-max-nr
等于 65536(默认值)的情况下,该主机上只能启动 3(65536/17665) 个 MySQL 8.4 实例。

结论

  1. MySQL 在启动时,如果出现
    io_setup() failed with EAGAIN
    错误,可适当增加
    /proc/sys/fs/aio-max-nr
    的值。
  2. MySQL 在启动过程中需要初始化的异步 I/O 请求数等于
    (1 + innodb_read_io_threads + innodb_write_io_threads) * 256 + 1
  3. innodb_read_io_threads 的默认值在 MySQL 8.4 中发生了变化,建议在配置文件中显式指定。

参考资料

io_setup(2) — Linux manual page:
https://www.man7.org/linux/man-pages/man2/io_setup.2.html

最近在看项目,看到别人使用Rougamo框架,好奇花了点时间仔细研究了,在这里记录一下。

0. 静态编织 Aop

首先,我们先了解什么是Aop? Aop
是指面向切面编程 (Aspect Oriented Programming),而所谓的切面,可以认为是具体拦截的某个业务点。

我们常用的aop框架是 AspectCore,他是属于动态代理,也就是发生在运行时期间对代码进行“修改”。

Rougamo、Fody 是属于静态编织,是指
在编译阶段将代码修改或额外的功能直接嵌入到程序集中,这个过程发生在源代码被编译成可执行文件或库之前。这意味着,一旦编译完成,插入的代码就已经是程
序集的一部分,无需在运行时再进行额外的操作。

1. Rougamo 肉夹馍

Rougamo 是一个开源项目,github:
https://github.com/inversionhourglass/Rougamo
,他是通过Fody ->  Mono.Cecil 的方式实现静态编织 实现Aop功能。

创建控制台程序,Nuget安装 Rougamo.Fody

[AttributeUsage(AttributeTargets.Method)]public classLoggingAttribute : MoAttribute
{
public override voidOnEntry(MethodContext context)
{
Console.WriteLine(
"执行方法 {0}() 开始,参数:{1}.", context.Method.Name,
JsonConvert.SerializeObject(context.Arguments));
}
public override voidOnException(MethodContext context)
{
Console.WriteLine(
"执行方法 {0}() 异常,{1}.", context.Method.Name, context.Exception.Message);
}
public override voidOnExit(MethodContext context)
{
Console.WriteLine(
"执行方法 {0}() 结束.", context.Method.Name);
}
public override voidOnSuccess(MethodContext context)
{
Console.WriteLine(
"执行方法 {0}() 成功.", context.Method.Name);
}
}
internal classProgram
{
static void Main(string[] args)
{
Add(
1, 2);
AddAsync(
1, 2);
Divide(
1, 2);
}

[Logging]
static int Add(int a, int b) => a +b;

[Logging]
static Task<int> AddAsync(int a, int b) => Task.FromResult(a +b);

[Logging]
static decimal Divide(decimal a, decimal b) => a /b;
}

运行后会自动创建FodyWeavers.xsd 和 FodyWeavers.xml

<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
  <xs:element name="Weavers">
    <xs:complexType>
      <xs:all>
        <xs:element name="Rougamo" minOccurs="0" maxOccurs="1" type="xs:anyType" />
      </xs:all>
      <xs:attribute name="VerifyAssembly" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="VerifyIgnoreCodes" type="xs:string">
        <xs:annotation>
          <xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="GenerateXsd" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
    </xs:complexType>
  </xs:element>
</xs:schema>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Rougamo />
</Weavers>

下面是运行结果

这时候我们可以看到 增加了LoggingAttribute 特性的方法在运行前、运行成功、运行结束 执行了 OnEntry(MethodContext context) 、OnSuccess(MethodContext context)、OnExit(MethodContext context) 方法,这时我们打开ILSpy工具,看看实际运行的代码

internal classProgram
{
private static void Main(string[] args)
{
Add(
1, 2);
AddAsync(
1, 2);
Divide(1m, 2m);
}

[DebuggerStepThrough]
private static int Add(int a, intb)
{
LoggingAttribute loggingAttribute
= newLoggingAttribute();
IMo[] mos
= new IMo[1] { loggingAttribute };
MethodContext methodContext
= new MethodContext(null, typeof(Program), MethodBase.GetMethodFromHandle((RuntimeMethodHandle)/*OpCode not supported: LdMemberToken*/, typeof(Program).TypeHandle), isAsync: false, isIterator: false, mosNonEntryFIFO: false, mos, new object[2] { a, b });
loggingAttribute.OnEntry(methodContext);
int result = default(int);if(methodContext.ReturnValueReplaced)
{
result
= (int)methodContext.ReturnValue;
loggingAttribute.OnExit(methodContext);
returnresult;
}
if(methodContext.RewriteArguments)
{
a
= (int)methodContext.Arguments[0];
b
= (int)methodContext.Arguments[1];
}
bool flag = default(bool);do{try{while (true)
{
try{
flag
= false;
result
=$Rougamo_Add(a, b);
}
catch(Exception exception)
{
methodContext.Exception
=exception;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnException(methodContext);
if (methodContext.RetryCount > 0)
{
continue;
}
if(methodContext.ExceptionHandled)
{
result
= (int)methodContext.ReturnValue;break;
}
throw;
}
break;
}
}
finally{if (methodContext.HasException ||methodContext.ExceptionHandled)
{
gotoIL_0160;
}
methodContext.ReturnValue
=result;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnSuccess(methodContext);
if (methodContext.RetryCount <= 0)
{
if(methodContext.ReturnValueReplaced)
{
result
= (int)methodContext.ReturnValue;
}
gotoIL_0160;
}
flag
= true;gotoend_IL_00fc;
IL_0160:
loggingAttribute.OnExit(methodContext);
end_IL_00fc:;
}
}
while(flag);returnresult;
}

[DebuggerStepThrough]
private static Task<int> AddAsync(int a, intb)
{
LoggingAttribute loggingAttribute
= newLoggingAttribute();
IMo[] mos
= new IMo[1] { loggingAttribute };
MethodContext methodContext
= new MethodContext(null, typeof(Program), MethodBase.GetMethodFromHandle((RuntimeMethodHandle)/*OpCode not supported: LdMemberToken*/, typeof(Program).TypeHandle), isAsync: false, isIterator: false, mosNonEntryFIFO: false, mos, new object[2] { a, b });
loggingAttribute.OnEntry(methodContext);
Task
<int> result = default(Task<int>);if(methodContext.ReturnValueReplaced)
{
result
= (Task<int>)methodContext.ReturnValue;
loggingAttribute.OnExit(methodContext);
returnresult;
}
if(methodContext.RewriteArguments)
{
a
= (int)methodContext.Arguments[0];
b
= (int)methodContext.Arguments[1];
}
bool flag = default(bool);do{try{while (true)
{
try{
flag
= false;
result
=$Rougamo_AddAsync(a, b);
}
catch(Exception exception)
{
methodContext.Exception
=exception;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnException(methodContext);
if (methodContext.RetryCount > 0)
{
continue;
}
if(methodContext.ExceptionHandled)
{
result
= (Task<int>)methodContext.ReturnValue;break;
}
throw;
}
break;
}
}
finally{if (methodContext.HasException ||methodContext.ExceptionHandled)
{
gotoIL_015b;
}
methodContext.ReturnValue
=result;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnSuccess(methodContext);
if (methodContext.RetryCount <= 0)
{
if(methodContext.ReturnValueReplaced)
{
result
= (Task<int>)methodContext.ReturnValue;
}
gotoIL_015b;
}
flag
= true;gotoend_IL_00fc;
IL_015b:
loggingAttribute.OnExit(methodContext);
end_IL_00fc:;
}
}
while(flag);returnresult;
}

[DebuggerStepThrough]
private static decimal Divide(decimal a, decimalb)
{
LoggingAttribute loggingAttribute
= newLoggingAttribute();
IMo[] mos
= new IMo[1] { loggingAttribute };
MethodContext methodContext
= new MethodContext(null, typeof(Program), MethodBase.GetMethodFromHandle((RuntimeMethodHandle)/*OpCode not supported: LdMemberToken*/, typeof(Program).TypeHandle), isAsync: false, isIterator: false, mosNonEntryFIFO: false, mos, new object[2] { a, b });
loggingAttribute.OnEntry(methodContext);
decimal result = default(decimal);if(methodContext.ReturnValueReplaced)
{
result
= (decimal)methodContext.ReturnValue;
loggingAttribute.OnExit(methodContext);
returnresult;
}
if(methodContext.RewriteArguments)
{
a
= (decimal)methodContext.Arguments[0];
b
= (decimal)methodContext.Arguments[1];
}
bool flag = default(bool);do{try{while (true)
{
try{
flag
= false;
result
=$Rougamo_Divide(a, b);
}
catch(Exception exception)
{
methodContext.Exception
=exception;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnException(methodContext);
if (methodContext.RetryCount > 0)
{
continue;
}
if(methodContext.ExceptionHandled)
{
result
= (decimal)methodContext.ReturnValue;break;
}
throw;
}
break;
}
}
finally{if (methodContext.HasException ||methodContext.ExceptionHandled)
{
gotoIL_0160;
}
methodContext.ReturnValue
=result;
methodContext.Arguments[
0] =a;
methodContext.Arguments[
1] =b;
loggingAttribute.OnSuccess(methodContext);
if (methodContext.RetryCount <= 0)
{
if(methodContext.ReturnValueReplaced)
{
result
= (decimal)methodContext.ReturnValue;
}
gotoIL_0160;
}
flag
= true;gotoend_IL_00fc;
IL_0160:
loggingAttribute.OnExit(methodContext);
end_IL_00fc:;
}
}
while(flag);returnresult;
}

[Logging]
private static int $Rougamo_Add(int a, intb)
{
return a +b;
}

[Logging]
private static Task<int> $Rougamo_AddAsync(int a, intb)
{
return Task.FromResult(a +b);
}

[Logging]
private static decimal $Rougamo_Divide(decimal a, decimalb)
{
return a /b;
}
}

从实际运行的代码我们可以看到,原先Add(int a, int b)方法中的执行内容被移动到 $Rougamo_Add方法中,而Add(int a, int b)方法先是new LoggingAttribute() 和 new Rougamo.Context.MethodContext() -> 执行了 loggingAttribute.OnEntry(methodContext); -> 在do{}while(bool) 执行了$Rougamo_Add(a, b); -> 在 exception 中执行了loggingAttribute.OnException(methodContext); -> 在 finally中执行了 loggingAttribute.OnSuccess(methodContext); 和 loggingAttribute.OnExit(methodContext);

注:do{}while(bool) 执行了$Rougamo_Add(a, b); 是因为 Rougamo 可以实现方法执行失败重试功能

至此我们明白了 Rougamo 实现 Aop功能是通过编译时修改IL代码,往代码增加对应的生命周期代码。那他为什么可以做到呢?其实是借用了Fody ->  Mono.Cecil 的方式。

代码如下:
https://gitee.com/Karl_Albright/csharp-demo/tree/master/FodyDemo/RougamoDemo

2. Fody ->  Mono.Cecil

Fody 是一个开源项目,github:
https://github.com/Fody/Fody
,相关教程文档在
https://github.com/Fody/Home/tree/master/pages

创建类库,选择netstandard2.0,命名为HelloWorld,Nuget安装 Fody 和 FodyPackaging

注:必须创建 netstandard2.0,因为FodyPackaging的目标是netstandard2.0,

在HelloWorld项目中,我们只放 HWAttribute类,继承于 Attribute。代码如下

[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Constructor | AttributeTargets.Method |AttributeTargets.Property)]public classHWAttribute : Attribute
{

}

再次创建类库,选择netstandard2.0,命名为HelloWorld.Fody,Nuget安装 FodyHelpers,引用HelloWorld类库

在HelloWorld.Fody项目中,我们只放ModuleWeaver类(类名是固定的,详情见Fody文档),继承于 BaseModuleWeaver。代码如下

usingFody;usingMono.Cecil;usingMono.Cecil.Cil;usingSystem;usingSystem.Collections.Generic;usingSystem.Linq;usingSystem.Reflection;namespaceHelloWorld.Fody
{
public partial classModuleWeaver : BaseModuleWeaver
{
public override voidExecute()
{
foreach (var type inModuleDefinition.Types)
{
foreach (var method intype.Methods)
{
var customerAttribute = method.CustomAttributes.FirstOrDefault(x => x.AttributeType.Name ==nameof(HWAttribute));if (customerAttribute != null)
{
ProcessMethod(method);
}
}
}
}
public override IEnumerable<string>GetAssembliesForScanning()
{
yield return "mscorlib";yield return "System";
}
private MethodInfo _writeLineMethod => typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) });private voidProcessMethod(MethodDefinition method)
{
//获取当前方法体中的第一个IL指令 var processor =method.Body.GetILProcessor();var current =method.Body.Instructions.First();//插入一个 Nop 指令,表示什么都不做 var first =Instruction.Create(OpCodes.Nop);
processor.InsertBefore(current, first);
current
=first;//构造 Console.WriteLine("Hello World") foreach (var instruction inGetInstructions(method))
{
processor.InsertAfter(current, instruction);
current
=instruction;
}
}
private IEnumerable<Instruction>GetInstructions(MethodDefinition method)
{
yield returnInstruction.Create(OpCodes.Nop);yield return Instruction.Create(OpCodes.Ldstr, "Hello World.");yield returnInstruction.Create(OpCodes.Call, ModuleDefinition.ImportReference(_writeLineMethod));
}
}
}

在代码中,我们遍历了所有类型的所有方法,如果方法标注了 HWAttribute特性,则增加 Console.WriteLine("Hello World."); 代码。

创建控制台应用程序,命名为HelloWorldFodyDemo,添加 HelloWorld 和 HelloWorld.Fody 项目引用,并且手动增加 WeaverFiles标签,目标是HelloWorld.Fody.dll

在控制台中,我们需要一个方法,方法上有 HWAttribute 特性就可以了,代码如下

internal classProgram
{
static void Main(string[] args)
{
Echo();
Console.ReadKey();
}

[HW]
public static voidEcho()
{
Console.WriteLine(
"Hello Fody.");
}
}

在控制台项目中,我们还需要 FodyWeavers.xml 和 FodyWeavers.xsd 文件,(我也是从上面Rougamo项目中复制的),内容如下

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
    <HelloWorld />
</Weavers>
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:element name="Weavers">
    <xs:complexType>
      <xs:all>
        <xs:element name="HelloWorld" minOccurs="0" maxOccurs="1" type="xs:anyType" />
      </xs:all>
      <xs:attribute name="VerifyAssembly" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="VerifyIgnoreCodes" type="xs:string">
        <xs:annotation>
          <xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="GenerateXsd" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
    </xs:complexType>
  </xs:element>
</xs:schema>

目前,文件结构如下

FodyDemo
|--- HelloWorld
|--- HWAttribute.cs
|--- HelloWorld.csproj
|--- HelloWorld.Fody
|--- HelloWorld.Fody.csproj
|--- ModuleWeaver.cs
|--- HelloWorldFodyDemo
|--- FodyWeavers.xml
|--- FodyWeavers.xsd
|--- HelloWorldFodyDemo.csproj
|--- Program.cs

代码如下:
https://gitee.com/Karl_Albright/csharp-demo/tree/master/FodyDemo

最后运行结果如下,很明显,HWAttribute生效了,我们成功的在Echo()方法前打印了Hello World。

我们再次打开ILSpy工具,得到的结果如图,代码增加了Console.WriteLine("Hello World.");行代码

4. Fody 有很多其他的“插件”,大家可以多试试

AutoProperties.Fody
: 这个外接程序为您提供了对自动属性的扩展控制,比如直接访问backing字段或拦截getter和setter。

PropertyChanged.Fody:
将属性通知添加到实现INotifyPropertyChanged的所有类。

InlineIL.Fody:
在编译时注入任意IL代码。

MethodDecorator.Fody:
通过IL重写编译时间装饰器模式。

NullGuard.Fody:
将空参数检查添加到程序集。

ToString.Fody:
给属性生成ToString()方法

Rougamo.Fody:
在编译时生效的AOP组件,类似于PostSharp。