前后端分离:WebAPI+Vue开发——远程数据请求axios_前后端分离前端axios远程访问-CSDN博客

mikel阅读(393)

来源: 前后端分离:WebAPI+Vue开发——远程数据请求axios_前后端分离前端axios远程访问-CSDN博客

前后端分离:WebAPI+Vue开发——远程数据请求axios

前后端分离:WebAPI+Vue开发——跨域设置

前后端分离:WebAPI+Vue开发——身份认证

本文没有Vue语法内容(Vue中文文档),只记录本人开发中遇到的难点

远程请求采用axios(axios中文文档,注意:IE11以下不支持axios)

ajax、axios、fetch之间的详细区别以及优缺点(https://blog.csdn.net/twodogya/article/details/80223508)

Get请求:

axios.get(‘http://api.abc.com/api/user’,{
param:{Id:132}
}).then(function(response){
console.log(response.data);
}).catch(function(error){
console.log(error);
});
Post请求:

axios.post(‘http://api.abc.com/api/user’,{
name:’frank’,
sex:’1′
}).then(function (response){
console.log(response.data)
}).catch(function(error){
console.log(error);
});
原始请求:

axios({
url:’http://api.abc.com/api/user’,
method:’post’,
responseType:’json’,
data:{
name:’frank’,
sex:’1′
}
}).then(function(response){
console.log(response.data);
}).catch(function(error){
console.log(error);
})
response的结构如下:

{
// 返回的数据
data: {},
// http状态码
status: 200,
//状态
statusText: ‘OK’,
// 返回结果的header头
headers: {},
//axios请求的配置内容
config: {}
}
全局默认值设置

axios.defaults.baseURL = ‘http://api.abc.com’;
设置之后,axios的远程请求,不用再具体到域名,直接用 url:’/api/user’即可,实际项目中建议把POST、GET方法进行封装,统一调用,如有更换其他远程请求方式的需求(如ajax)会很方便,封装如下:

//axios的Post方法封装
function POST(url, data, method) {
var tk = getCookie(‘token’);
if (tk) {
axios({
url: url,
method: ‘post’,
data: data,
headers: { ‘token’: tk }
}).then(function (response) {
if (response.data.ret == -2)//没有访问权限
{
location.href = ‘/’;
}
else if (response.data.ret == -1) {//程序错误
console.log(response.data.msg);
}
else {
method(response.data);
}
}).catch(function (error) {
console.log(error);
})
}
}
//axios的Get方法封装
function GET(url, data, method) {
var tk = getCookie(‘token’);
if (tk) {
axios({
url: url,
method: ‘get’,
data: data,
headers: { ‘token’: tk }
}).then(function (response) {
if (response.data.ret == -2)//没有访问权限
{
location.href = ‘/’;
}
else if (response.data.ret == -1) {//程序错误
console.log(response.data.msg);
}
else {
method(response.data);
}
}).catch(function (error) {
console.log(error);
})
}
}
GET和POST方法也可以封装到一个里边,method是一个回调函数,处理得到的数据;getCookie是自己写的cookie获取方法,此处的token类似sessionid,放在了请求头中,作为一个用户身份识别标识使用,用户身份识别后续再写;response.data.ret和response.msg是API接口中自定义的请求状态和提示信息
————————————————
版权声明:本文为CSDN博主「峰frank」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/fengkang511/article/details/82837701

.net Core3.1 WebAPI+Vue 前后端调用的一些问题 - wjbych - 博客园

mikel阅读(344)

来源: .net Core3.1 WebAPI+Vue 前后端调用的一些问题 – wjbych – 博客园

本人小白,如果你对此没啥了解,可以先搜索某个大神的教程看看:

Asp.netCore3.0 WebApi从0到1手摸手教你写【1】简单的webapi接口

说实话讲的真不错,只是我水平有限,看到实体类就晕了,只好将就着做个自己能用的就行了,至于怎么保存数据,以后再说。

需要的开发工具:

1、VS 2019 用以开发后端webapi

2、VS Code 用以前端开发,使用vue来做为前端框架,不懂的可以看看技术胖的教程,讲的很不错还免费,地址为 https://space.bilibili.com/165659472/channel/detail?cid=76803

3、Postman,用于调试你开发的接口,现在只有安装版了,可以在你没有使用vue时调试下所做的接口

 


 

一、控制器的路由设置问题

1、在项目中新建控制器,注释掉控制器中的路由

 

然后在startup.cs文件的Configureg事件中增加动态路由

 

 

复制代码
          app.UseEndpoints(endpoints =>
            {
                //endpoints.MapControllers();
                endpoints.MapControllerRoute(
                    name: "defalut",
                    pattern: "api/{controller=Home}/{action=Index}/{id?}"
                 );
            });
复制代码

 

二、项目中依赖项中包的安装问题

使用NuGet包管理器来安装所需要的包,如果你不会EF,不会LinQ,那么就不需要安装那些包了,我们可以使用以前的方法来保存数据到数据库的,我安装了三个

 

 

安装的时候比较慢,system.data.SQLClinet就是以前.net 4.0中我们用来保存数据到数据库时引用的包,现在需要单独引入。

 

三、Post数据传输时接口接收的问题

通过 Post来向接口传递数据后端接收一共有四种方法,可以参考下面这位大神写的

.NET Core WebAPI post参数传递时后端的接收方式

我们使用第一种方法:实体类,个人感觉容易理解。

在项目中新建一个类,用于存放定义的实体类,你也可以定义到控制器中。

 

在控制器中使用定义好的实体类

 

 四、使用postman测试接口

按F5启动你的项目,然后打开postman

 

就算你body中的json数据和后端定义的实体类不一致,后端也能接收到, 但是后端实体类中有的在json中一定要有。

 

五、使用Vue调用后端的接口

需要使用vue-cli来创建前端项目,如果你是单页面引用的话,那么在使用方法上会有所不同。

1、安装axios包

2、在main.js文件中引用axios

 

 

3、在vue文件中使用axios,为了方便使用,需要做个简单的封装。

 

 

注释掉的部分起的作用是把json转成字符串,因为所要调用的post接口参数就是json的,所以不需转换了

复制代码
 axiosPost:function(url,params){
          return new Promise((resolve, reject) => {
                  this.$axios({
                  url: url,
                  method: 'post',
                  data: params,
                  // transformRequest: [function(data) {
                  //     let ret = ''
                  //     for(let it in data) {
                  //         ret += encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&'
                  //     }
                  //     console.log(ret)
                  //     return ret
                  // }],
                  headers: {
                      'Content-Type':'application/json'
                  }
              })
              .then(res=>{
                  resolve(res.data);
              })
          });
      },
复制代码

4、在vue文件中使用封装好的axios

 

 

 

复制代码
login(){
          var params={
              user:this.yhm,
              pwd:this.pwd,
          };
          var url='http://192.168.3.111:5000/api/user/userlogin';
          this.axiosPost(url,params)
          .then(res=>{
              if (res===401){
                  //window.location.href='login.htm';
                  console.log(res);
              }else{
                  console.log(res);
              }
          })
          //return ''
      },
复制代码

res中就是返回的数据,在这里接收到以后可以自已处理一下。

5、跨域请求错误的处理

调用后发现出错了,

跨域请求错误: Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’

可以参照下面的这个贴子来进行处理

https://www.cnblogs.com/jidanfan/p/11177509.html

在后端webapi项目中的startup.cs文件的Configure事件中增加以下的代码

app.UseCors(cfg =>
{
cfg.AllowAnyOrigin(); //对应跨域请求的地址
cfg.AllowAnyMethod(); //对应请求方法的Method
cfg.AllowAnyHeader(); //对应请求方法的Headers
//cfg.AllowCredentials(); //对应请求的withCredentials 值
});

好了,到此一个最基本的前端调用后端的例子就做好了。

水平很差,不会写贴子,写的东西自己公司内部将就着能用用就行了

2023版:深度比较几种.NET Excel导出库的性能差异 - 百宝门园地 - 博客园

mikel阅读(308)

来源: 2023版:深度比较几种.NET Excel导出库的性能差异 – 百宝门园地 – 博客园

引言

背景和目的

本文介绍了几个常用的电子表格处理库,包括EPPlus、NPOI、Aspose.Cells和DocumentFormat.OpenXml,我们将对这些库进行性能测评,以便为开发人员提供实际的性能指标和数据。

下表将功能/特点、开源/许可证这两列分开,以满足需求:

功能 / 特点 EPPlus NPOI Aspose.Cells DocumentFormat.OpenXml
开源
许可证 MIT Apache 商业 MIT
支持的 Excel 版本 Excel 2007 及更高版本 Excel 97-2003 Excel 2003 及更高版本 Excel 2007 及更高版本

测评电脑配置

组件 规格
CPU 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz,2496 Mhz,4 个内核,8 个逻辑处理器
内存 40 GB DDR4 3200MHz
操作系统 Microsoft Windows 10 专业版
电源选项 已设置为高性能
软件 LINQPad 7.8.5 Beta
运行时 .NET 6.0.21

准备工作

使用Bogus库生成6万条标准化的测试数据。

void Main()
{
	string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "test-data.json");
	using var file = File.Create(path);
	using var writer = new Utf8JsonWriter(file, new JsonWriterOptions { Indented = true });
	var data = new Bogus.Faker<Data>()
		.RuleFor(x => x.Id, x => x.IndexFaker + 1)
		.RuleFor(x => x.Gender, x => x.Person.Gender)
		.RuleFor(x => x.FirstName, (x, u) => x.Name.FirstName(u.Gender))
		.RuleFor(x => x.LastName, (x, u) => x.Name.LastName(u.Gender))
		.RuleFor(x => x.Email, (x, u) => x.Internet.Email(u.FirstName, u.LastName))
		.RuleFor(x => x.BirthDate, x => x.Person.DateOfBirth)
		.RuleFor(x => x.Company, x => x.Person.Company.Name)
		.RuleFor(x => x.Phone, x => x.Person.Phone)
		.RuleFor(x => x.Website, x => x.Person.Website)
		.RuleFor(x => x.SSN, x => x.Person.Ssn())
		.GenerateForever().Take(6_0000)
		.Dump();
	JsonSerializer.Serialize(writer, data);
	Process.Start("explorer", @$"/select, ""{path}""".Dump());
}

Bogus输出结果

Id Gender FirstName LastName Email BirthDate Company Phone Website SSN
1 Male Antonio Paucek Antonio.Paucek@gmail.com 1987/10/31 5:46:50 Moen, Willms and Maggio (898) 283-1583 x88626 pamela.name 850-06-4706
2 Male Kurt Gerhold Kurt.Gerhold40@yahoo.com 1985/11/1 18:41:01 Wilkinson and Sons (698) 637-0181 x49124 cordelia.net 014-86-1757
3 Male Howard Hegmann Howard2@hotmail.com 1979/7/20 22:35:40 Kassulke, Murphy and Volkman (544) 464-9818 x98381 kari.com 360-23-1669
4 Female Rosemarie Powlowski Rosemarie.Powlowski48@hotmail.com 1964/5/18 1:35:45 Will Group 1-740-705-6482 laurence.net 236-10-9925
5 Female Eunice Rogahn Eunice84@gmail.com 1979/11/25 11:53:14 Rippin – Rowe (691) 491-2282 x3466 yvette.net 219-75-6886
……

创建公共类方便正式测评使用

void Main()
{
    string path = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json";
    LoadUsers(path).Dump();
}

List<User> LoadUsers(string jsonfile)
{
    string path = jsonfile;
    byte[] bytes = File.ReadAllBytes(path);
    return JsonSerializer.Deserialize<List<User>>(bytes);
}

IObservable<object> Measure(Action action, int times = 5)
{
    return Enumerable.Range(1, times).Select(i =>
    {
        var sw = Stopwatch.StartNew();

        long memory1 = GC.GetTotalMemory(true);
        long allocate1 = GC.GetTotalAllocatedBytes(true);
        {
            action();
        }
        long allocate2 = GC.GetTotalAllocatedBytes(true);
        long memory2 = GC.GetTotalMemory(true);

        sw.Stop();
        return new
        {
            次数 = i, 
            分配内存 = (allocate2 - allocate1).ToString("N0"),
            内存提高 = (memory2 - memory1).ToString("N0"), 
            耗时 = sw.ElapsedMilliseconds,
        };
    }).ToObservable();
}

class User
{
    public int Id { get; set; }
    public int Gender { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public DateTime BirthDate { get; set; }
    public string Company { get; set; }
    public string Phone { get; set; }
    public string Website { get; set; }
    public string SSN { get; set; }
}

代码解释

1、上面的代码单位是字节 (bytes)

2 、其中IObservable(System.IObservable)是用于处理事件流的接口,它实现了观察者模式。它表示一个可观察的序列,可以产生一系列的事件,并允许其他对象(观察者)来订阅和接收这些事件。IObservable 适用于动态的、实时的事件流处理,允许观察者以异步方式接收事件,可以用于响应式编程、事件驱动的编程模型等。

3、GC.GetTotalAllocatedBytes(true) 获取分配内存大小
GC.GetTotalMemory(true) 获取占用内存大小

性能测评

EPPlus

string path = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json";
List<User> users = LoadUsers(path);

Measure(() =>
{
    Export(users, Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\export.epplus.xlsx");
}).Dump("EPPlus");

void Export<T>(List<T> data, string path)
{
    using var stream = File.Create(path);
    using var excel = new ExcelPackage(stream);
    ExcelWorksheet sheet = excel.Workbook.Worksheets.Add("Sheet1");
    PropertyInfo[] props = typeof(User).GetProperties();
    for (var i = 0; i < props.Length; ++i)
    {
        sheet.Cells[1, i + 1].Value = props[i].Name;
    }
    for (var i = 0; i < data.Count; ++i)
    {
        for (var j = 0; j < props.Length; ++j)
        {
            sheet.Cells[i + 2, j + 1].Value = props[j].GetValue(data[i]);
        }
    }
    excel.Save();
}

输出结果

EPPlus (6.2.8) (2023/8/15)输出结果

次数ΞΞ 分配内存ΞΞ 内存提高ΞΞ 耗时ΞΞ
1 454,869,176 970,160 2447
2 440,353,488 176 1776
3 440,062,264 0 1716
4 440,283,584 0 1750
5 440,653,264 0 1813

EPPlus (4.5.3.2)(2019/6/16)输出结果

次数ΞΞ 分配内存ΞΞ 内存提高ΞΞ 耗时ΞΞ
1 963,850,944 192,048 2765
2 509,450,792 600 1897
3 509,872,160 424 1920
4 509,858,576 424 1989
5 509,651,512 424 2076

由此看出 相比2019,到了2023年EPPlus的性能得到了略微的提升

NPOI

示例代码一:XSSFWorkbook

List<User> users = LoadUsers(Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json");

Measure(() =>
{
    Export(users, Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\export.npoi.xlsx");
}).Dump("NPOI");

void Export<T>(List<T> data, string path)
{
    IWorkbook workbook = new XSSFWorkbook();
    ISheet sheet = workbook.CreateSheet("Sheet1");

    var headRow = sheet.CreateRow(0);
    PropertyInfo[] props = typeof(User).GetProperties();
    for (var i = 0; i < props.Length; ++i)
    {
        headRow.CreateCell(i).SetCellValue(props[i].Name);
    }
    for (var i = 0; i < data.Count; ++i)
    {
        var row = sheet.CreateRow(i + 1);
        for (var j = 0; j < props.Length; ++j)
        {
            row.CreateCell(j).SetCellValue(props[j].GetValue(data[i]).ToString());
        }
    }

    using var file = File.Create(path);
    workbook.Write(file);
	workbook.Close();
}

输出结果

NPOI (2.6.1)(2023/7/12)输出结果

次数ΞΞ 分配内存 内存提高 耗时ΞΞ
1 1,589,285,792 567,272 5549
2 1,577,028,664 96 7043
3 1,577,398,488 48 8107
4 1,576,360,696 -90,512 9336
5 1,576,226,688 -3,120 8289

NPOI (2.4.1)(2018/12/18)输出结果

次数ΞΞ 分配内存 内存提高 耗时ΞΞ
1 1,648,548,696 526,824 6947
2 1,633,685,136 120 7921
3 1,634,033,296 24 8864
4 1,634,660,176 -90,200 8945
5 1,634,205,368 -2,584 8078

示例代码二:SXSSFWorkbook

List<User> users = LoadUsers(Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json");

Measure(() =>
{
    Export(users, Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\export.npoi.xlsx");
}).Dump("NPOI");

void Export<T>(List<T> data, string path)
{
    IWorkbook workbook = new SXSSFWorkbook();
    ISheet sheet = workbook.CreateSheet("Sheet1");

    var headRow = sheet.CreateRow(0);
    PropertyInfo[] props = typeof(User).GetProperties();
    for (var i = 0; i < props.Length; ++i)
    {
        headRow.CreateCell(i).SetCellValue(props[i].Name);
    }
    for (var i = 0; i < data.Count; ++i)
    {
        var row = sheet.CreateRow(i + 1);
        for (var j = 0; j < props.Length; ++j)
        {
            row.CreateCell(j).SetCellValue(props[j].GetValue(data[i]).ToString());
        }
    }

    using var file = File.Create(path);
    workbook.Write(file);
	workbook.Close();
}

输出结果

NPOI (2.6.1)(2023/7/12)输出结果

次数 分配内存 内存提高 耗时
1 571,769,144 11,495,488 2542
2 482,573,584 96 5106
3 481,139,296 24 1463
4 481,524,384 48 1510
5 481,466,616 48 1493

NPOI (2.4.1)(2018/12/18)输出结果

次数 分配内存 内存提高 耗时
1 660,709,472 537,512 7808
2 650,060,376 8,128 8649
3 649,006,952 4,136 7064
4 649,267,920 -89,776 6973
5 649,955,024 48 6538

经过测试 发现SXSSFWorkbook 确实比XSSFWorkbook 性能好,有显著提升
由此看出 相比2018,到了2023年NPOI的性能得到了略微的提升

Aspose.Cells

Util.NewProcess = true;
List<User> users = LoadUsers(Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json");

SetLicense();

Measure(() =>
{
    Export(users, Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\export.aspose2.xlsx");
}, 5).Dump("Aspose");

void Export<T>(List<T> data, string path)
{
    using var excel = new Workbook();
    excel.Settings.MemorySetting = MemorySetting.Normal;
    excel.Settings.CheckExcelRestriction = false;
    Worksheet sheet = excel.Worksheets["Sheet1"];
    sheet.Cells.ImportCustomObjects(data, 0, 0, new ImportTableOptions
    {
        IsFieldNameShown = true, 
        DateFormat = "MM/DD/YYYY hh:mm:ss AM/PM", 
        ConvertNumericData = false, 
    });
    excel.Save(path);
}

void SetLicense()
{
    Stream stream = new MemoryStream(Convert.FromBase64String(@"密钥"));
    stream.Seek(0, SeekOrigin.Begin);
    new Aspose.Cells.License().SetLicense(stream);
}

输出结果

Aspose.Cells (23.8.0)(2023/8/9)输出结果

次数 分配内存 内存提高 耗时
1 443,025,112 3,471,984 2889
2 392,090,304 30,208 1863
3 391,419,072 -8 1716
4 392,041,144 24 1797
5 392,078,992 24 1689

Aspose.Cells (19.8.0)(2019/8/20)输出结果

次数 分配内存 内存提高 耗时
1 552,862,056 2,987,000 2913
2 508,337,872 49,776 1750
3 507,922,728 24 1933
4 507,949,584 24 1781
5 508,368,208 24 1773

由此看出 相比2019,到了2023年Aspose.Cells的性能还是一样差不多,只是内存占用减少了

DocumentFormat.OpenXml

List<User> users = LoadUsers(Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\test-data.json");

Measure(() =>
{
    Export(users, Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\export.openXml.xlsx");
}).Dump("OpenXML");

void Export<T>(List<T> data, string path)
{
    using SpreadsheetDocument excel = SpreadsheetDocument.Create(path, SpreadsheetDocumentType.Workbook);

    WorkbookPart workbookPart = excel.AddWorkbookPart();
    workbookPart.Workbook = new Workbook();

    WorksheetPart worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
    worksheetPart.Worksheet = new Worksheet(new SheetData());

    Sheets sheets = excel.WorkbookPart.Workbook.AppendChild<Sheets>(new Sheets());
    Sheet sheet = new Sheet
    {
        Id = excel.WorkbookPart.GetIdOfPart(worksheetPart),
        SheetId = 1,
        Name = "Sheet1"
    };
    sheets.Append(sheet);
    
    SheetData sheetData = worksheetPart.Worksheet.GetFirstChild<SheetData>();

    PropertyInfo[] props = typeof(User).GetProperties();
    {    // header
        var row = new Row() { RowIndex = 1 };
        sheetData.Append(row);
        row.Append(props.Select((prop, i) => new Cell
        {
            CellReference = ('A' + i - 1) + row.RowIndex.Value.ToString(),
            CellValue = new CellValue(props[i].Name),
            DataType = new EnumValue<CellValues>(CellValues.String),
        }));
    }
    sheetData.Append(data.Select((item, i) => 
    {
        var row = new Row { RowIndex = (uint)(i + 2) };
        row.Append(props.Select((prop, j) => new Cell
        {
            CellReference = ('A' + j - 1) + row.RowIndex.Value.ToString(),
            CellValue = new CellValue(props[j].GetValue(data[i]).ToString()),
            DataType = new EnumValue<CellValues>(CellValues.String),
        }));
        return row;
    }));
    excel.Save();
}

输出结果

DocumentFormat.OpenXml (2.20.0)(2023/4/7)输出结果

次数ΞΞ 分配内存 内存提高 耗时ΞΞ
1 614,013,080 421,552 3909
2 613,007,112 96 3487
3 613,831,672 104 3465
4 613,058,344 24 3650
5 613,161,096 24 3521

DocumentFormat.OpenXml (2.9.1)(2019/3/14)输出结果

次数ΞΞ 分配内存 内存提高 耗时ΞΞ
1 542,724,752 139,080 3504
2 542,478,208 96 2897
3 543,030,904 24 2826
4 542,247,544 24 2957
5 542,763,312 24 2941

由此看出 相比2019,到了2023年DocumentFormat.OpenXml的性能反而越差啦

结论和总结

结论一:如果你想找开源,(旧版本免费),(最新版收费)EPPlus 依旧是最佳选择

次数ΞΞ 分配内存ΞΞ 内存提高ΞΞ 耗时ΞΞ
1 454,869,176 970,160 2447
2 440,353,488 176 1776
3 440,062,264 0 1716
4 440,283,584 0 1750
5 440,653,264 0 1813

结论二:如果你想找速度快,很稳定,但收费的,Aspose.Cells 依旧是最佳选择

次数 分配内存 内存提高 耗时
1 443,025,112 3,471,984 2889
2 392,090,304 30,208 1863
3 391,419,072 -8 1716
4 392,041,144 24 1797
5 392,078,992 24 1689

总结:
1、EPPlus表现不错,内存和耗时在开源组中表现最佳
2、收费的Aspose.Cells表现最佳,内存占用最低,用时也最短

作者 => 百宝门瞿佑明

此文章是对此前《.NET骚操作》2019年写的文章的更新和扩展
https://www.cnblogs.com/sdflysha/p/20190824-dotnet-excel-compare.html

原文地址:https://blog.baibaomen.com/2023版:深度比较几种-net-excel导出库的性能差异/

vue3 + mark.js | 实现文字标注功能 - 杨芋可可 - 博客园

mikel阅读(303)

来源: vue3 + mark.js | 实现文字标注功能 – 杨芋可可 – 博客园

具体实现

新增

  • 1、监听鼠标抬起事件,通过window.getSelection()方法获取鼠标用户选择的文本范围或光标的当前位置。
  • 2、通过 选中的文字长度是否大于0window.getSelection().isCollapsed (返回一个布尔值用于描述选区的起始点和终止点是否位于一个位置,即是否框选了)来判断是否展示标签选择的弹窗。
  • 3、标签选择的弹窗采用 子绝父相 的定位方式,通过鼠标抬起的位置确认弹窗的 top 与 left 值。
const TAG_WIDTH = 280 //自定义最大范围,以保证不超过内容的最大宽度 const tagInfo = ref({ visible: false, top: 0, left: 0, }) const el = document.getElementById(‘text-container’) //鼠标抬起 el?.addEventListener(‘mouseup’, (e) => { const text = window?.getSelection()?.toString() || if (text.length > 0) { const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 tagInfo.value = { visible: true, top: e.offsetY + 40, left: left, } getSelectedTextData() } else { tagInfo.value.visible = false } //清空重选/取消数据 resetEditTag()
const selectedText = reactive({ start: 0, end: 0, content: , }) //获取选取的文字数据 const getSelectedTextData = () => { const select = window?.getSelection() as any console.log(‘selectselectselectselect’, select) const nodeValue = select.focusNode?.nodeValue const anchorOffset = select.anchorOffset const focusOffset = select.focusOffset const nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue) selectedText.content = select.toString() if (anchorOffset < focusOffset) { //从左到右标注 selectedText.start = nodeValueSatrtIndex + anchorOffset selectedText.end = nodeValueSatrtIndex + focusOffset } else { //从右到左 selectedText.start = nodeValueSatrtIndex + focusOffset selectedText.end = nodeValueSatrtIndex + anchorOffset } }

JavaScript操作光标和选区详情可参考文档:https://blog.51cto.com/u_14524391/3712814

  • 4、选中标签后,采用markjs的markRanges()方式去创建一个选中的元素并为其添加样式和绑定事件。
  • 5、定义一个响应式的文字列表,专门记录标记的内容,添加完元素后可追加一条已标记的数据。
import Mark from ‘mark.js’ import {ref} from ‘vue import { nanoid } from ‘nanoid‘ const selectedTextList = ref([]) const handleSelectLabel = (t) => { const marker = new Mark(document.getElementById(‘text-container‘)) const { tag_color, tag_name, tag_id } = t const markId = nanoid(10) marker.markRanges( [ { start: selectedText.start, //必填 length: selectedText.content.length, //必填 }, ], { className: ‘text-selected‘, element: ‘span‘, each: (element: any) => { //为元素添加样式和属性 element.setAttribute(‘id‘, markId) element.style.borderBottom = `2px solid ${t.tag_color}` //添加下划线 element.style.color = t.tag_color //绑定事件 element.onclick = function (e: any) { // } }, } ) selectedTextList.value.push({ tag_color, tag_name, tag_id, start: selectedText.start, end: selectedText.end, mark_content:selectedText.content, mark_id: markId, }) }

删除

image.png

点击已进行标记的文字————>重选/取消弹窗显示————>点击取消

如何判断点击的文字是否已标记,通过在创建的标记元素中绑定点击事件,触发则表示已标记。

  1. 在点击事件中记录该标记的相关内容,如颜色,文字,起始位置,以及唯一标识id(新建时给元素添加一个id属性,点击时即可通过e.target.id获取)
import { nanoid } from ‘nanoid’ //选择标签后 const markId = nanoid(10) marker.markRanges( [ { start: isReset ? editTag.value.start : selectedText.start, length: isReset ? editTag.value.content.length : selectedText.content.length, }, ], { className: ‘text-selected’, element: ‘span’, each: (element: any) => { element.setAttribute(‘id’, markId) //绑定事件 element.onclick = function (e: any) { e.preventDefault() if (!e.target.id) return const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any const { mark_content, tag_id, start, end } = item || {} editTag.value = { visible: true, top: e.offsetY + 40, left: e.offsetX, mark_id: e.target.id, content: mark_content || , tag_id: tag_id || , start: start, end: end, } tagInfo.value = { visible: false, top: e.offsetY + 40, left: left, } } }, } )
  1. 点击取消后,获取在此前记录的id,根据id查询相关的标记元素
  • 使用markjs.unmark()方法即可删除此元素。
  • 绑定的响应式数据,可使用findIndexsplice()删除
  1. 编辑弹窗隐藏
const handleCancel = () => { if (!editTag.value.mark_id) return const markEl = new Mark(document.getElementById(editTag.value.mark_id)) markEl.unmark() selectedTextList.value.splice( selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id), 1 ) tagInfo.value = { visible: false, top: 0, left: 0, } resetEditTag() } const resetEditTag = () => { editTag.value = { visible: false, top: 0, left: 0, mark_id: , content: , tag_id: , start: 0, end: 0, } }

重选

image.png

和取消的步骤一样,只不过在点击重选后,先弹出标签弹窗,选择标签后,需要先删除选中的元素,然后再新增一个标记元素。由于在标签选择,在标签选择中判断一下是否是重选,是重选的话就需删除后再创建元素,不是的话就代表是新增,直接新增标记元素(综上所述)。

const handleSelectLabel = (t: TTag) => { tagInfo.value.visible = false const { tag_color, tag_name, tag_id } = t const marker = new Mark(document.getElementById(‘text-container’)) const markId = nanoid(10) const isReset = selectedTextList.value?.map((j) => j.mark_id).includes(editTag.value.mark_id) ? 1 : 0 // 1:重选 0:新增 if (isReset) { //如若重选,则删除后再新增标签 const markEl = new Mark(document.getElementById(editTag.value.mark_id)) markEl.unmark() selectedTextList.value.splice( selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id), 1 ) } marker.markRanges( [ { start: isReset ? editTag.value.start : selectedText.start, length: isReset ? editTag.value.content.length : selectedText.content.length, }, ], { className: ‘text-selected’, element: ‘span’, each: (element: any) => { element.setAttribute(‘id’, markId) element.style.borderBottom = `2px solid ${t.tag_color}` element.style.color = t.tag_color element.style.userSelect = ‘none’ element.style.paddingBottom = ‘6px’ element.onclick = function (e: any) { e.preventDefault() if (!e.target.id) return const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any const { mark_content, tag_id, start, end } = item || {} editTag.value = { visible: true, top: e.offsetY + 40, left: e.offsetX, mark_id: e.target.id, content: mark_content || , tag_id: tag_id || , start: start, end: end, } tagInfo.value = { visible: false, top: e.offsetY + 40, left: left, } } }, } ) selectedTextList.value.push({ tag_color, tag_name, tag_id, start: isReset ? editTag.value.start : selectedText.start, end: isReset ? editTag.value.end : selectedText.end, mark_content: isReset ? editTag.value.content : selectedText.content, mark_id: markId, }) }

清空标记

image.png

const handleAllDelete = () => { selectedTextList.value = [] const marker = new Mark(document.getElementById(‘text-container’)) marker.unmark() }

完整代码

<script setup lang=“ts”> import { ref, onMounted, reactive } from ‘vue’ import Mark from ‘mark.js’ import { nanoid } from ‘nanoid’ type TTag = { tag_name: string tag_id: string tag_color: string } type TSelectText = { tag_id: string tag_name: string tag_color: string start: number end: number mark_content: string mark_id: string } const TAG_WIDTH = 280 const selectedTextList = ref<TSelectText[]>([]) const selectedText = reactive({ start: 0, end: 0, content: , }) const markContent = ref( ‘这是标注的内容有业绩还是我我很快就很快就开完如突然好几个地方各级很大功夫数据库二极管捍卫国家和我回家很晚十九世纪俄国激活工具和丈母娘环境和颠覆国家的高房价奥苏爱哦因为i以太网图的还是觉得好看啊空间函数调用加快速度还是饥渴的发货可是磕碰日俄和那那么会就开始开会的数据库和也会觉得讲故事的而黄金九二额呵呵三角函数的吧合乎实际的和尽快核实当升科技看交互的接口和送二ui为人开朗少女都被你们进货金额麦当娜表面上的’ ) const tagInfo = ref({ visible: false, top: 0, left: 0, }) const editTag = ref({ visible: false, top: 0, left: 0, mark_id: , content: , tag_id: , start: 0, end: 0, }) const tagList: TTag[] = [ { tag_name: ‘标签一’, tag_color: `#DE050CFF`, tag_id: ‘tag_id1’, }, { tag_name: ‘标签二’, tag_color: `#6ADE05FF`, tag_id: ‘tag_id2’, }, { tag_name: ‘标签三’, tag_color: `#DE058BFF`, tag_id: ‘tag_id3’, }, { tag_name: ‘标签四’, tag_color: `#9205DEFF`, tag_id: ‘tag_id4’, }, { tag_name: ‘标签五’, tag_color: `#DE5F05FF`, tag_id: ‘tag_id5’, }, ] const handleAllDelete = () => { selectedTextList.value = [] const marker = new Mark(document.getElementById(‘text-container’)) marker.unmark() } const handleCancel = () => { if (!editTag.value.mark_id) return const markEl = new Mark(document.getElementById(editTag.value.mark_id)) markEl.unmark() selectedTextList.value.splice( selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id), 1 ) tagInfo.value = { visible: false, top: 0, left: 0, } resetEditTag() } const handleReset = () => { editTag.value.visible = false tagInfo.value.visible = true } const handleSave = () => { console.log(‘标注的数据’, selectedTextList.value) } const handleSelectLabel = (t: TTag) => { const { tag_color, tag_name, tag_id } = t tagInfo.value.visible = false const marker = new Mark(document.getElementById(‘text-container’)) const markId = nanoid(10) const isReset = selectedTextList.value?.map((j) => j.mark_id).includes(editTag.value.mark_id) ? 1 : 0 // 1:重选 0:新增 if (isReset) { //如若重选,则删除后再新增标签 const markEl = new Mark(document.getElementById(editTag.value.mark_id)) markEl.unmark() selectedTextList.value.splice( selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id), 1 ) } marker.markRanges( [ { start: isReset ? editTag.value.start : selectedText.start, length: isReset ? editTag.value.content.length : selectedText.content.length, }, ], { className: ‘text-selected’, element: ‘span’, each: (element: any) => { element.setAttribute(‘id’, markId) element.style.borderBottom = `2px solid ${t.tag_color}` element.style.color = t.tag_color element.style.userSelect = ‘none’ element.style.paddingBottom = ‘6px’ element.onclick = function (e: any) { e.preventDefault() if (!e.target.id) return const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any const { mark_content, tag_id, start, end } = item || {} editTag.value = { visible: true, top: e.offsetY + 40, left: e.offsetX, mark_id: e.target.id, content: mark_content || , tag_id: tag_id || , start: start, end: end, } tagInfo.value = { visible: false, top: e.offsetY + 40, left: left, } } }, } ) selectedTextList.value.push({ tag_color, tag_name, tag_id, start: isReset ? editTag.value.start : selectedText.start, end: isReset ? editTag.value.end : selectedText.end, mark_content: isReset ? editTag.value.content : selectedText.content, mark_id: markId, }) } /** * 获取选取的文字数据 */ const getSelectedTextData = () => { const select = window?.getSelection() as any const nodeValue = select.focusNode?.nodeValue const anchorOffset = select.anchorOffset const focusOffset = select.focusOffset const nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue) selectedText.content = select.toString() if (anchorOffset < focusOffset) { //从左到右标注 selectedText.start = nodeValueSatrtIndex + anchorOffset selectedText.end = nodeValueSatrtIndex + focusOffset } else { //从右到左 selectedText.start = nodeValueSatrtIndex + focusOffset selectedText.end = nodeValueSatrtIndex + anchorOffset } } const resetEditTag = () => { editTag.value = { visible: false, top: 0, left: 0, mark_id: , content: , tag_id: , start: 0, end: 0, } } const drawMark = () => { //模拟后端返回的数据 const res = [ { start: 2, //必备 end: 6, tag_color: ‘#DE050CFF’, tag_id: ‘tag_id1’, tag_name: ‘标签一’, mark_content: ‘标注的内容’, mark_id: ‘mark_id1’, }, { start: 39, end: 41, tag_color: ‘#6ADE05FF’, tag_id: ‘tag_id2’, tag_name: ‘标签二’, mark_content: ‘二极管’, mark_id: ‘mark_id2’, }, { start: 58, end: 61, tag_color: ‘#DE058BFF’, tag_id: ‘tag_id3’, tag_name: ‘标签三’, mark_content: ‘激活工具’, mark_id: ‘mark_id3’, }, ] selectedTextList.value = res?.map((t) => ({ tag_id: t.tag_id, tag_name: t.tag_name, tag_color: t.tag_color, start: t.start, end: t.end, mark_content: t.mark_content, mark_id: t.mark_id, })) const markList = selectedTextList.value?.map((j) => ({ …j, start: j.start, //必备 length: j.end – j.start + 1, //必备 })) || [] const marker = new Mark(document.getElementById(‘text-container’)) markList?.forEach?.(function (m: any) { marker.markRanges([m], { element: ‘span’, className: ‘text-selected’, each: (element: any) => { element.setAttribute(‘id’, m.mark_id) element.style.borderBottom = `2px solid ${m.tag_color}` element.style.color = m.tag_color element.style.userSelect = ‘none’ element.style.paddingBottom = ‘6px’ element.onclick = function (e: any) { console.log(‘cccccc’, m) const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 editTag.value = { visible: true, top: e.offsetY + 40, left: e.offsetX, mark_id: m.mark_id, content: m.mark_content, tag_id: m.tag_id, start: m.start, end: m.end, } tagInfo.value = { visible: false, top: e.offsetY + 40, left: left, } } }, }) }) } //页面初始化 onMounted(() => { const el = document.getElementById(‘text-container’) //鼠标抬起 el?.addEventListener(‘mouseup’, (e) => { const text = window?.getSelection()?.toString() || if (text.length > 0) { const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX300 tagInfo.value = { visible: true, top: e.offsetY + 40, left: left, } getSelectedTextData() } else { tagInfo.value.visible = false } //清空重选/取消数据 resetEditTag() }) //从后端获取标注数据,进行初始化标注 drawMark() }) </script> <template> <header> <n-button type=“primary” :disabled=“selectedTextList.length == 0 ? true : false” ghost @click=“handleAllDelete” > 清空标记 </n-button> <n-button type=“primary” :disabled=“selectedTextList.length == 0 ? true : false” @click=“handleSave” > 保存 </n-button> </header> <main> <div id=“text-container” class=“text”> {{ markContent }} </div> <!– 标签选择 –> <div v-if=“tagInfo.visible && tagList.length > 0” :class=“[‘tag-box p-4 ‘]” :style=“{ top: tagInfo.top + ‘px’, left: tagInfo.left + ‘px’ }” > <div v-for=“i in tagList” :key=“i.tag_id” class=“tag-name” @click=“handleSelectLabel(i)”> <n-space> <p>{{ i.tag_name }}</p> <n-button v-if=“i.tag_id == editTag.tag_id” text type=“primary”></n-button> </n-space> <div :class=“[‘w-4 h-4’]” :style=“{ background: i.tag_color, }” ></div> </div> </div> <!– 重选/取消 –> <div v-if=“editTag.visible” class=“edit-tag” :style=“{ top: editTag.top + ‘px’, left: editTag.left + ‘px’ }” > <div class=“py-1 bg-gray-100 text-center” @click=“handleCancel”>取 消</div> <div class=“py-1 bg-gray-100 mt-2 text-center” @click=“handleReset”>重 选</div> </div> </main> </template> <style lang=“less” scoped> header { display: flex; justify-content: space-between; align-items: center; padding: 0 24px; height: 80px; border-bottom: 1px solid #e5e7eb; user-select: none; background: #fff; } main { background: #fff; margin: 24px; height: 80vh; padding: 24px; overflow-y: auto; position: relative; box-shadow: 0 3px 8px 0 rgb(0 0 0 / 13%); .text { color: #333; font-weight: 500; font-size: 16px; line-height: 50px; } .tag-box { position: absolute; z-index: 10; width: 280px; max-height: 40vh; overflow-y: auto; background: #fff; border-radius: 4px; box-shadow: 0 9px 28px 8px rgb(0 0 0 / 3%), 0 6px 16px 4px rgb(0 0 0 / 9%), 0 3px 6px2px rgb(0 0 0 / 20%); user-select: none; .tag-name { width: 100%; background: rgba(243, 244, 246, var(–tw-bg-opacity)); font-size: 14px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 4px 8px; margin-top: 8px; } .tag-name:nth-of-type(1) { margin-top: 0; } } .edit-tag { position: absolute; z-index: 20; padding: 16px; cursor: pointer; width: 100px; background: #fff; border-radius: 4px; box-shadow: 0 9px 28px 8px rgb(0 0 0 / 3%), 0 6px 16px 4px rgb(0 0 0 / 9%), 0 3px 6px2px rgb(0 0 0 / 20%); user-select: none; } ::selection { background: rgb(51 51 51 / 20%); } } </style>

结束语

目前功能实现比较简单,还有很多发挥的空间,先小小的记录一下,最后~,预祝大家,双节快乐!!

markjs

使用Vue轻松搞定表单,不在为表单烦恼 - 知乎

mikel阅读(389)

来源: 使用Vue轻松搞定表单,不在为表单烦恼 – 知乎

介绍

form-create 是一个可以通过 JSON 生成具有动态渲染、数据收集、验证和提交功能的表单生成器。并且支持生成任何 Vue 组件。结合内置17种常用表单组件和自定义组件,再复杂的表单都可以轻松搞定。

文档 | github | 在线示例

功能

  • 自定义组件
    • 可生成任何Vue组件
    • 自带数据验证
    • 轻松转换为表单组件
  • 通过 JSON 生成表单
  • 通过 Maker 生成表单
  • 强大的API,可快速操作表单
  • 双向数据绑定
  • 事件扩展
  • 局部更新
  • 数据验证
  • 栅格布局
  • 内置组件17种常用表单组件

对比 1.x

  • 速度更快
  • 体积更小
  • 更强大的全局配置
  • 自定义组件更容易扩展
  • 更容易支持第三方 UI 库
  • 更少的 bug

示例

通过 JSON 创建表单

动图封面

通过 API 操作表单

动图封面

@form-create包说明

使用

以element-ui版本为例介绍如何在项目中使用 form-create

安装

npm i @form-create/element-ui

挂载

全局注册

import formCreate form '@form-create/element-ui';

Vue.use(formCreate);

局部挂载

import formCreate form '@form-create/element-ui';

export default {
    components:{
        formCreate:formCreaet.$form()
    }
}

生成表单

<template>
    <form-create v-model="$f" :rule="rule" @on-submit="onSubmit"></form-create>
</template>
export default {
  data () {
    return {
     //表单实例对象
     $f:{},
     //表单生成规则
     rule:[
       {
          type:'input',
          field:'goods_name',
          title:'商品名称'
        },
        {
          type:'datePicker',
          field:'created_at',
          title:'创建时间'
        }
     ]
    };
  },
  methods:{
      onSubmit(formData){
          //TODO 提交表单
      }
  }
};

效果

实例对象 $f

可以通过 $f 快速操作表单,例如:

  • $f.hidden:隐藏指定组件
  • $f.validate:验证表单
  • $f.setValue:修改表单组件的值
  • $f.append:追加表单组件

自定义组件

生成

通过标签生成

{
    type:'el-button',
    name: 'btn',
    props:{
        type:'primary',
        field:'btn',
        loading:true
    },
    children:['加载中']
}

通过模板生成

{
    type:'template',
    name:'btn'
    template:'<el-button :loading="loading">{{text}}<el-button>',
    vm: new Vue({
      data:{
        loading:true,
        text:'加载中'
      }
    })
}

转换为表单组件

自定义组件转换为表单组件后,可通过$f.formData,$f.getValue,$f.setValue,$f.disabled等方法快速操作组件,达到和内置组件相同的效果

预定义

props

在自定义组件内部通过props接收一下属性

  • value 表单的值
  • disabled 组件的禁用状态

例如:

vm = Vue({
  props:{
   value:String,
   disabled:Boolean      
  }
})

input 事件

通过input事件更新组件内部的值

当组件值发生变化后,通过 input 事件更新值.例如:

vm.$emit('input',newValue);

挂载自定义组件

要生成的自定义组件必须通过Vue.component方法挂载到全局,或者通过formCreate.component方法挂载

例如:

formCreate.component('TestComponent',component);

或者

Vue.component('TestComponent',component);

生成

表单组件必须定义field属性

JSON

{
    type:'TestComponent',
    value:'test',
    field:'testField',
    title:'自定义组件'
}

Maker

formCreate.maker.create('TestComponent','testField','自定义组件').value('test')

示例

自定义计数器按钮组件,获取按钮点击数.该组件的功能和内置组件相同

formCreate.maker.template('<el-button @click="onClick" long :disabled="disabled">计数器-{{num}}</el-button>', new Vue({
  props:{
    //预定义
    disabled:Boolean,
    value:Number,
  },
  data: function () {
    return {
        num: this.value,
    }
  },
  watch:{
    value(n){
        this.num = n;
    }
  },
  methods: {
    onClick: function () {
        this.num++;
        //更新组件内部的值
        this.$emit('input',this.num);
    },
  },
}), 'tmp', '自定义 title').value(100).props('disabled',false)

完整示例

高拍仪拍照SDK开发(良田影像S300L|S500L) - 磊哥|www.javacn.site - 博客园

mikel阅读(320)

来源: 高拍仪拍照SDK开发(良田影像S300L|S500L) – 磊哥|www.javacn.site – 博客园

高拍仪拍照SDK开发下载地址:点击下载

本SDK适用于:良田影像S300L|S500L

高拍仪如图:

SDN开发包安装之后找到安装目录,如图:

大家找到各自需要的版本即可,需要注意的是如果需要上传图片到服务器的话,我已经写好了C#版的接收程序,代码如下:

 

复制代码
<%@ WebHandler Language="C#" Class="FileUpload" %>

using System;
using System.Web;

public class FileUpload : IHttpHandler
{
    public void ProcessRequest(HttpContext context)
    {
        context.Response.ContentType = "text/plain";
        HttpPostedFile uploadFile = context.Request.Files[0];
        string fileName = System.IO.Path.GetFileName(uploadFile.FileName);
        int fileSize = uploadFile.ContentLength;
        string fileExt = System.IO.Path.GetExtension(fileName).ToLower();
        string message = "";
        if (!(fileExt == ".png" || fileExt == ".gif" || fileExt == ".jpg" || fileExt == ".jpeg"))
        {
            message = "图片类型只能为gif,png,jpg,jpeg";
            return;
        }
        else
        {
            if (fileSize > (int)(500 * 1024))
            {
                message = "图片大小不能超过500KB";
                return;
            }
            else
            {
                try
                {
                    string directoryPath = context.Server.MapPath("~/TmpFile/");
                    if (!System.IO.Directory.Exists(directoryPath))//不存在这个文件夹就创建这个文件夹 
                    {
                        System.IO.Directory.CreateDirectory(context.Server.MapPath("~/TmpFile/"));
                    }
                    uploadFile.SaveAs(context.Server.MapPath("~/TmpFile/") + fileName);
                    message = fileName;
                }
                catch (Exception ex)
                {
                    message = ex.Message;
                    return;
                }
            }
        }
    }

    public bool IsReusable
    {
        get
        {
            return false;
        }
    }

}
复制代码

 

JavaScript调用上传服务器代码:

复制代码
function SetSelImgs() {
    var selImgs = ""; // 获取选中的图片
    for (var i = 0; i < EThumbnails.GetDisplayCount(); i++) {
        if (1 == EThumbnails.IsChecked(i)) {
            var fileName = EThumbnails.GetFilePath(i).replace("D:\\", "");
            ScanCtrl.UploadFileOfHTTP("http://xxx/FileUpload.ashx",
                EThumbnails.GetFilePath(i), fileName);
            selImgs += fileName + ",";
        }
    } document.getElementById("selImgs1").value = selImgs;
    if ("" == selImgs) {
        alert('请先选中上传图片!');
        return false;
    }
    return true;
}
复制代码

 

百度云下载地址:链接:https://pan.baidu.com/s/1wUkJow9Xn2VL2FXMz9itHQ 密码:nn0v

良田高拍仪集成vue项目 - 蝈民党 - 博客园

mikel阅读(542)

来源: 良田高拍仪集成vue项目 – 蝈民党 – 博客园

一、硬件及开发包说明:

产品型号为良田高拍仪S1800A3,集成b/s系统,适用现代浏览器,图片使用BASE64数据。开发包的bin文件下的video.flt文件需要和高拍仪型号的硬件id对应,这个可以找厂家要然后替换.

开发包地址:https://github.com/xufeikko/webIm/blob/master/%E8%B7%A8%E6%B5%8F%E8%A7%88%E5%99%A8%E6%8E%A7%E4%BB%B6SDK%20V2.3.0.rar

开发包文件目录:

demo文件:samples->JavaScript

qwebchannel.js文件的改写

 

二、控件运行原理

控件大部分功能在后台运行,前端通过websocket传输所选操作的信号或者获取来自后台程序的信息,并展示。控件必须在有网条件下才能正常运行,通过websocket可以访问同一局域网的高拍仪设备(前提是连接高拍仪的电脑有运行后台程序)。

(开包中也有夸浏览器控件sdk使用说明)

后台运行程序:

 

三、vue中使用

a.导入js文件

b.mounted内初始化函数,baseUrl路径不能更改(详情请看跨浏览器sdk使用文档)

c.创建websocket连接

四、测试

连接并拍照成功

注意:本开发需要更新替换bin目录下video.flt配置文件     github:https://github.com/xufeikko/webIm/blob/master/video.flt

RabbitMQ保姆级教程最佳实践 - 佛祖让我来巡山 - 博客园

mikel阅读(347)

来源: RabbitMQ保姆级教程最佳实践 – 佛祖让我来巡山 – 博客园

一、消息队列介绍

1、消息队列概念

1、MQ全称为Message Queue,消息队列(MQ)是⼀种应⽤程序对应⽤程序的通信⽅法。
应⽤程序通过读写出⼊队列的消息(针对应⽤程序的数据)来通信,⽽⽆需专⽤连接来
链接它们。
2、消息传递指的是程序之间通过在消息中发送数据进⾏通信,⽽不是通过直接调⽤彼此来
通信,直接调⽤通常是⽤于诸如远程过程调⽤的技术。

2、常⽤的消息队列产品

1、RabbitMQ 稳定可靠,数据⼀致,⽀持多协议,有消息确认,基于erlang语⾔
2、Kafka ⾼吞吐,⾼性能,快速持久化,⽆消息确认,⽆消息遗漏,可能会有有重复消息,依赖于zookeeper,成本⾼.
3、ActiveMQ 不够灵活轻巧,对队列较多情况⽀持不好.
4、RocketMQ 性能好,⾼吞吐,⾼可⽤性,⽀持⼤规模分布式,协议⽀持单⼀

⼆、RabbitMQ

1、RabbitMQ介绍

1、RabbitMQ是⼀个在AMQP基础上完成的,可复⽤的企业消息系统。他遵循MozillaPublic License开源协议。
2、AMQP,即Advanced Message Queuing Protocol, ⼀个提供统⼀消息服务的应⽤层标准
     ⾼级消息队列协议,是应⽤层协议的⼀个开放标准,为⾯向消息的中间件设计。基于此协议
     的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同的开发语⾔等
     条件的限制。Erlang中的实现有 RabbitMQ等。
3、主要特性:
  • 保证可靠性 :使⽤⼀些机制来保证可靠性,如持久化、传输确认、发布确认
  • 灵活的路由功能
  • 可伸缩性:⽀持消息集群,多台RabbitMQ服务器可以组成⼀个集群
  • ⾼可⽤性 :RabbitMQ集群中的某个节点出现问题时队列仍然可⽤
  • ⽀持多种协议
  • ⽀持多语⾔客户端
  • 提供良好的管理界⾯
  • 提供跟踪机制:如果消息出现异常,可以通过跟踪机制分析异常原因
  • 提供插件机制:可通过插件进⾏多⽅⾯扩展

2、RabbitMQ安装和配置

具体参考:https://note.youdao.com/s/MKn2Jr8c

3、RabbitMQ逻辑结构

三、RabbitMQ⽤户管理

RabbitMQ默认提供了⼀个guests账号,但是此账号不能⽤作远程登录,也就是不能在管理系统的登录;我们可以创建⼀个新的账号并授予响应的管理权限来实现远程登录

1、逻辑结构

⽤户
虚拟主机
队列

2、⽤户管理

2.1、命令⾏⽤户管理

1、在linux中使⽤命令⾏创建⽤户

## 进⼊到rabbit_mq的sbin⽬录
cd /usr/local/rabbitmq_server-3.7.0/sbin
## 新增⽤户
./rabbitmqctl add_user ytao admin123

2、设置⽤户级别

## ⽤户级别:
## 1.administrator 可以登录控制台、查看所有信息、可以对RabbitMQ进⾏管理
## 2.monitoring 监控者 登录控制台、查看所有信息
## 3.policymaker 策略制定者 登录控制台、指定策略
## 4.managment 普通管理员 登录控制台
./rabbitmqctl set_user_tags ytao administrator

2.2、管理系统进⾏⽤户管理

管理系统登录:访问http://localhost:15672/

四、RabbitMQ⼯作⽅式

RabbitMQ提供了多种消息的通信⽅式—⼯作模式  https://www.rabbitmq.com/getstarted.html
消息通信是由两个⻆⾊完成:消息⽣产者(producer)和 消息消费者(Consumer)

1、简单模式

⼀个队列只有⼀个消费者

2、⼯作模式

多个消费者监听同⼀个队列

3、订阅模式

⼀个交换机绑定多个消息队列,每个消息队列有⼀个消费者监听

4、路由模式

⼀个交换机绑定多个消息队列,每个消息队列都由⾃⼰唯⼀的key,每个消息队列有⼀个消费者监听

五、RabbitMQ交换机和队列管理

1、创建队列

2、创建交换机

3、交换机绑定队列

六、在普通的Maven应⽤中使⽤MQ

1、简单模式

1.1、消息⽣产者

1、创建Maven项⽬

2、添加RabbitMQ连接所需要的依赖

复制代码
<!-- https://mvnrepository.com/artifact/com.rabbitmq/amqp-client -->
<dependency>
 <groupId>com.rabbitmq</groupId>
 <artifactId>amqp-client</artifactId>
 <version>4.10.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-log4j12 -->
<dependency>
 <groupId>org.slf4j</groupId>
 <artifactId>slf4j-log4j12</artifactId>
 <version>1.7.25</version>
 <scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commonslang3 -->
<dependency>
 <groupId>org.apache.commons</groupId>
 <artifactId>commons-lang3</artifactId>
 <version>3.9</version>
</dependency>
复制代码

3、在resources⽬录下创建log4j.properties

log4j.rootLogger=DEBUG,A1 log4j.logger.com.taotao = DEBUG
log4j.logger.org.mybatis = DEBUG
log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n

4、创建MQ连接工具类

复制代码
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
public class ConnectionUtil {
    public static Connection getConnection() throws IOException,
    TimeoutException {
        //1.创建连接⼯⼚
        ConnectionFactory factory = new ConnectionFactory();
        //2.在⼯⼚对象中设置MQ的连接信息
        (ip,port,virtualhost,username,password)
         factory.setHost("47.96.11.185");
        factory.setPort(5672);
        factory.setVirtualHost("host1");
        factory.setUsername("ytao");
        factory.setPassword("admin123");
        //3.通过⼯⼚对象获取与MQ的链接
        Connection connection = factory.newConnection();
        return connection;
    }
}
复制代码

5、消息⽣产者发送消息

复制代码
import com.qfedu.mq.utils.ConnectionUtil;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
public class SendMsg {
    public static void main(String[] args) throws Exception{
        String msg = "Hello HuangDaoJun!";
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        //定义队列(使⽤Java代码在MQ中新建⼀个队列)
        //参数1:定义的队列名称
        //参数2:队列中的数据是否持久化(如果选择了持久化)
        //参数3: 是否排外(当前队列是否为当前连接私有)
        //参数4:⾃动删除(当此队列的连接数为0时,此队列会销毁(⽆论队列中是否还有数据))
        //参数5:设置当前队列的参数
        //channel.queueDeclare("queue7",false,false,false,null);
        //参数1:交换机名称,如果直接发送信息到队列,则交换机名称为""
        //参数2:⽬标队列名称
        //参数3:设置当前这条消息的属性(设置过期时间 10)
        //参数4:消息的内容
        channel.basicPublish("","queue1",null,msg.getBytes());
        System.out.println("发送:" + msg);
        channel.close();
        connection.close();
    }
}
复制代码

1.2、消息消费者

1、创建Maven项⽬
2、添加依赖
3、log4j.properties
4、ConnetionUtil.java
5、消费者消费消息
复制代码
import com.qfedu.mq.utils.ConnectionUtil;
import com.rabbitmq.client.*;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
public class ReceiveMsg {
    public static void main(String[] args) throws IOException, TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("接收:"+msg);
            }
        };
        channel.basicConsume("queue1",true,consumer);
    }
}
复制代码

2、⼯作模式

⼀个发送者多个消费者

2.1、发送者

复制代码
public class SendMsg {
    public static void main(String[] args) throws Exception{
        System.out.println("请输⼊消息:");
        Scanner scanner = new Scanner(System.in);
        String msg = null;
        while(!"quit".equals(msg = scanner.nextLine())){
            Connection connection = ConnectionUtil.getConnection();
            Channel channel = connection.createChannel();
            channel.basicPublish("","queue2",null,msg.getBytes());
            System.out.println("发送:" + msg);
            channel.close();
            connection.close();
        }
    }
}
复制代码

2.2、消费者1

复制代码
public class ReceiveMsg {
    public static void main(String[] args) throws Exception {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer1接收:"+msg);
                if("wait".equals(msg)){
                    try {
                        Thread.sleep(10000);
                    }
                    catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        channel.basicConsume("queue2",true,consumer);
    }
}
复制代码

2.3、消费者2

复制代码
public class ReceiveMsg {
    public static void main(String[] args) throws IOException,
    TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer2接收:"+msg);
            }
        };
        channel.basicConsume("queue2",true,consumer);
    }
}
复制代码

3、订阅模式

1、发送者 发送消息到交换机

复制代码
public class SendMsg {
    public static void main(String[] args) throws Exception{
        System.out.println("请输⼊消息:");
        Scanner scanner = new Scanner(System.in);
        String msg = null;
        while(!"quit".equals(msg = scanner.nextLine())){
            Connection connection = ConnectionUtil.getConnection();
            Channel channel = connection.createChannel();
            channel.basicPublish("ex1","",null,msg.getBytes());
            System.out.println("发送:" + msg);
            channel.close();
            connection.close();
        }
    }
}
复制代码

2、消费者1

复制代码
public class ReceiveMsg1 {
    public static void main(String[] args) throws Exception {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer1接收:"+msg);
                if("wait".equals(msg)){
                    try {
                        Thread.sleep(10000);
                    }
                    catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        channel.basicConsume("queue3",true,consumer);
    }
}
复制代码

3、消费者2

复制代码
public class ReceiveMsg2 {
    public static void main(String[] args) throws IOException,
    TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer2接收:"+msg);
            }
        }
        ;
        channel.basicConsume("queue4",true,consumer);
    }
}
复制代码

4、路由模式

1、发送者 发送消息到交换机

复制代码
public class SendMsg {
    public static void main(String[] args) throws Exception{
        System.out.println("请输⼊消息:");
        Scanner scanner = new Scanner(System.in);
        String msg = null;
        while(!"quit".equals(msg = scanner.nextLine())){
            Connection connection = ConnectionUtil.getConnection();
            Channel channel = connection.createChannel();
            if(msg.startsWith("a")){
                channel.basicPublish("ex2","a",null,msg.getBytes());
            } else if(msg.startsWith("b")){
                channel.basicPublish("ex2","b",null,msg.getBytes());
            }
            System.out.println("发送:" + msg);
            channel.close();
            connection.close();
        }
    }
}
复制代码

2、消费者1

复制代码
public class ReceiveMsg1 {
    public static void main(String[] args) throws Exception {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer1接收:"+msg);
                if("wait".equals(msg)){
                    try {
                        Thread.sleep(10000);
                    }
                    catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        channel.basicConsume("queue5",true,consumer);
    }
}
复制代码

3、消费者2

复制代码
public class ReceiveMsg2 {
    public static void main(String[] args) throws IOException, TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                //body就是从队列中获取的数据
                String msg = new String(body);
                System.out.println("Consumer2接收:"+msg);
            }
        };
        channel.basicConsume("queue6",true,consumer);
    }
}
复制代码

七、在SpringBoot应⽤中使⽤MQ

SpringBoot应⽤可以完成⾃动配置及依赖注⼊——可以通过Spring直接提供与MQ的连接对象

1、消息⽣产者

1、创建SpringBoot应⽤,添加依赖

2、配置application.yml

复制代码
server:
  port: 9001
spring:
  application:
    name: producer
  rabbitmq:
    host: 47.96.11.185
    port: 5672
    virtual-host: host1
    username: ytao
    password: admin123
复制代码

3、发送消息

复制代码
@Service
public class TestService {
    @Resource
    private AmqpTemplate amqpTemplate;
    public void sendMsg(String msg){
        //1. 发送消息到队列
        amqpTemplate.convertAndSend("queue1",msg);
        //2. 发送消息到交换机(订阅交换机)
        amqpTemplate.convertAndSend("ex1","",msg);
        //3. 发送消息到交换机(路由交换机)
        amqpTemplate.convertAndSend("ex2","a",msg);
    }
}
复制代码

2、消息消费者

1、创建项⽬添加依赖
2、配置yml
3、接收消息
复制代码
@Service
//@RabbitListener(queues = {"queue1","queue2"})
@RabbitListener(queues = "queue1")
public class ReceiveMsgService {
    @RabbitHandler
    public void receiveMsg(String msg){
        System.out.println("接收MSG:"+msg);
    }
}
复制代码

⼋、使⽤RabbitMQ传递对象

RabbitMQ是消息队列,发送和接收的都是字符串/字节数组类型的消息

1、使⽤序列化对象

要求:
传递的对象实现序列化接⼝
传递的对象的包名、类名、属性名必须⼀致

 

1、消息提供者

复制代码
@Service
public class MQService {
    @Resource
    private AmqpTemplate amqpTemplate;
    public void sendGoodsToMq(Goods goods){
        //消息队列可以发送 字符串、字节数组、序列化对象
        amqpTemplate.convertAndSend("","queue1",goods);
    }
}
复制代码

2、消息消费者

复制代码
@Component
@RabbitListener(queues = "queue1")
public class ReceiveService {
    @RabbitHandler
    public void receiveMsg(Goods goods){
        System.out.println("Goods---"+goods);
    }
}
复制代码

2、使⽤序列化字节数组

要求:
  传递的对象实现序列化接⼝
  传递的对象的包名、类名、属性名必须⼀致

 

1、消息提供者

复制代码
@Service
public class MQService {
    @Resource
    private AmqpTemplate amqpTemplate;
    public void sendGoodsToMq(Goods goods){
        //消息队列可以发送 字符串、字节数组、序列化对象
        byte[] bytes = SerializationUtils.serialize(goods);
        amqpTemplate.convertAndSend("","queue1",bytes);
    }
}
复制代码

2、消息消费者

复制代码
@Component
@RabbitListener(queues = "queue1")
public class ReceiveService {
    @RabbitHandler
    public void receiveMsg(byte[] bs){
        Goods goods = (Goods) SerializationUtils.deserialize(bs);
        System.out.println("byte[]---"+goods);
    }
}
复制代码

3、使⽤JSON字符串传递

要求:对象的属性名⼀直

 

1、消息提供者

复制代码
@Service
public class MQService {
    @Resource
    private AmqpTemplate amqpTemplate;
    public void sendGoodsToMq(Goods goods) throws JsonProcessingException {
        //消息队列可以发送 字符串、字节数组、序列化对象
        ObjectMapper objectMapper = new ObjectMapper();
        String msg = objectMapper.writeValueAsString(goods);
        amqpTemplate.convertAndSend("","queue1",msg);
    }
}
复制代码

2、消息消费者

复制代码
@Component
@RabbitListener(queues = "queue1")
public class ReceiveService {
    @RabbitHandler
    public void receiveMsg(String msg) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        Goods goods = objectMapper.readValue(msg,Goods.class);
        System.out.println("String---"+msg);
    }
}
复制代码

九、基于Java的交换机与队列创建

我们使⽤消息队列,消息队列和交换机可以通过管理系统完成创建,也可以在应⽤程序中通过Java代码来完成创建

1、普通Maven项⽬交换机及队列创建

1、使⽤Java代码新建队列

复制代码
//1.定义队列 (使⽤Java代码在MQ中新建⼀个队列)
//参数1:定义的队列名称
//参数2:队列中的数据是否持久化(如果选择了持久化)
//参数3: 是否排外(当前队列是否为当前连接私有)
//参数4:⾃动删除(当此队列的连接数为0时,此队列会销毁(⽆论队列中是否还有数据))
//参数5:设置当前队列的参数
channel.queueDeclare("queue7",false,false,false,null);
复制代码

2、新建交换机

//定义⼀个“订阅交换机”
channel.exchangeDeclare("ex3", BuiltinExchangeType.FANOUT);
//定义⼀个“路由交换机”
channel.exchangeDeclare("ex4", BuiltinExchangeType.DIRECT);

3、绑定队列到交换机

//绑定队列
//参数1:队列名称
//参数2:⽬标交换机
//参数3:如果绑定订阅交换机参数为"",如果绑定路由交换机则表示设置队列的key
channel.queueBind("queue7","ex4","k1");
channel.queueBind("queue8","ex4","k2");

2、SpringBoot应⽤中通过配置完成队列的创建

复制代码
@Configuration
public class RabbitMQConfiguration {
    //声明队列
    @Bean
    public Queue queue9(){
        Queue queue9 = new Queue("queue9");
        //设置队列属性
        return queue9;
    }
    @Bean
    public Queue queue10(){
        Queue queue10 = new Queue("queue10");
        //设置队列属性
        return queue10;
    }
    //声明订阅模式交换机
    @Bean
    public FanoutExchange ex5(){
        return new FanoutExchange("ex5");
    }
    //声明路由模式交换机
    @Bean
    public DirectExchange ex6(){
        return new DirectExchange("ex6");
    }
    //绑定队列
    @Bean
    public Binding bindingQueue9(Queue queue9, DirectExchange ex6){
        return BindingBuilder.bind(queue9).to(ex6).with("k1");
    }
    @Bean
    public Binding bindingQueue10(Queue queue10, DirectExchange ex6){
        return BindingBuilder.bind(queue10).to(ex6).with("k2");
    }
}
复制代码

⼗、消息的可靠性

消息的可靠性:从 ⽣产者发送消息 —— 消息队列存储消息 —— 消费者消费消息 的整个过程中消息的安全性及可控性。
  • ⽣产者
  • 消息队列
  • 消费者

1、RabbitMQ事务

RabbitMQ事务指的是基于客户端实现的事务管理,当在消息发送过程中添加了事务,处理效率降低⼏⼗倍甚⾄上百倍
复制代码
Connection connection = RabbitMQUtil.getConnection(); //connection 表示与 host1的连接
Channel channel = connection.createChannel();
channel.txSelect();//开启事务
try{
    channel.basicPublish("ex4", "k1", null, msg.getBytes());
    channel.txCommit();//提交事务
}
catch (Exception e){
    channel.txRollback();//事务回滚
}
finally{
    channel.close();
    connection.close();
}
复制代码

2、RabbitMQ消息确认和return机制

1、消息确认机制:确认消息提供者是否成功发送消息到交换机
2、return机制:确认消息是否成功的从交换机分发到队列

2.1、普通Maven项⽬的消息确认

1、普通confirm⽅式

//1.发送消息之前开启消息确认
channel.confirmSelect();
channel.basicPublish("ex1", "a", null, msg.getBytes());
//2.接收消息确认
Boolean b = channel.waitForConfirms();
System.out.println("发送:" +(b?"成功":"失败"));

2、批量confirm⽅式

复制代码
//1.发送消息之前开启消息确认
channel.confirmSelect();
//2.批量发送消息
for (int i=0 ; i<10 ; i++){
    channel.basicPublish("ex1", "a", null, msg.getBytes());
}
//3.接收批量消息确认:发送的所有消息中,如果有⼀条是失败的,则所有消息发送直接失败,抛出IO异常
Boolean b = channel.waitForConfirms();
复制代码

3、异步confirm⽅式

复制代码
//发送消息之前开启消息确认
channel.confirmSelect();
//批量发送消息
for (int i=0 ; i<10 ; i++){
    channel.basicPublish("ex1", "a", null, msg.getBytes());
}
//假如发送消息需要10s,waitForConfirms会进⼊阻塞状态
//boolean b = channel.waitForConfirms();
//使⽤监听器异步confirm
channel.addConfirmListener(new ConfirmListener() {
    //参数1: long l 返回消息的表示
    //参数2: boolean b 是否为批量confirm
    public void handleAck(long l, Boolean b) throws IOException {
        System.out.println("~~~~~消息成功发送到交换机");
    }
    public void handleNack(long l, Boolean b) throws IOException {
        System.out.println("~~~~~消息发送到交换机失败");
    }
}
);
复制代码

2.2、普通Maven项⽬的return机制

1、添加return监听器
2、发送消息是指定第三个参数为true
3、由于监听器监听是异步处理,所以在消息发送之后不能关闭channel
复制代码
String msg = "Hello HuangDaoJun!";
Connection connection = ConnectionUtil.getConnection();
//相当于JDBC操作的数据库连接
Channel channel = connection.createChannel();
//相当于JDBC操作的statement
//return机制:监控交换机是否将消息分发到队列
channel.addReturnListener(new ReturnListener() {
    public void handleReturn(int i, String s, String s1, String s2,AMQP.BasicProperties basicProperties,byte[] bytes) throws IOException {
        //如果交换机分发消息到队列失败,则会执⾏此⽅法(⽤来处理交换机分发消息到队列失败的情况)
        System.out.println("*****"+i);//标识
        System.out.println("*****"+s);//
        System.out.println("*****"+s1);//交换机名
        System.out.println("*****"+s2);//交换机对应的队列的key
        System.out.println("*****"+new String(bytes));//发送的消息
    }
}
);
//发送消息
//channel.basicPublish("ex2", "c", null, msg.getBytes());
channel.basicPublish("ex2", "c", true, null, msg.getBytes());
复制代码

2.3、在SpringBoot应⽤实现消息确认与return监听

1、配置application.yml,开启消息确认和return监听

spring:
 rabbitmq:
    publisher-confirm-type: simple ## 开启消息确认模式
    publisher-returns: true ##使⽤return监听机制

2、创建confirm和return监听

2.1、消息确认

复制代码
@Component
public class MyConfirmListener implements
RabbitTemplate.ConfirmCallback {
    @Autowired
    private AmqpTemplate amqpTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate;
    @PostConstruct
    public void init(){
        rabbitTemplate.setConfirmCallback(this);
    }
    @Override
    public void confirm(CorrelationData correlationData, Boolean b, String s) {
        //参数b 表示消息确认结果
        //参数s 表示发送的消息
        if(b){
            System.out.println("消息发送到交换机成功!");
        } else{
            System.out.println("消息发送到交换机失败!");
            amqpTemplate.convertAndSend("ex4","",s);
        }
    }
}
复制代码

2.2、return机制

复制代码
@Component
public class MyReturnListener implements RabbitTemplate.ReturnsCallback
{
    @Autowired
    private AmqpTemplate amqpTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate;
    @PostConstruct
    public void init(){
        rabbitTemplate.setReturnsCallback(this);
    }
    @Override
    public void returnedMessage(ReturnedMessage returnedMessage) {
        System.out.println("消息从交换机分发到队列失败");
        String exchange = returnedMessage.getExchange();
        String routingKey = returnedMessage.getRoutingKey();
        String msg = returnedMessage.getMessage().toString();
        amqpTemplate.convertAndSend(exchange,routingKey,msg);
    }
}
复制代码

3、RabbitMQ消费者⼿动应答

复制代码
@Component
@RabbitListener(queues="queue01")
public class Consumer1 {
    @RabbitHandler
    public void process(String msg,Channel channel, Message message) throws IOException {
        try {
            System.out.println("get msg1 success msg = "+msg);
            /**
         * 确认⼀条消息:<br>
         * channel.basicAck(deliveryTag, false); <br>
         * deliveryTag:该消息的index <br>
         * multiple:是否批量.true:将⼀次性ack所有⼩于deliveryTag的消息 <br>
       */
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            //消费者处理出了问题,需要告诉队列信息消费失败
            /**
         * 拒绝确认消息:<br>
         * channel.basicNack(long deliveryTag, boolean multiple, boolean requeue) ; <br>
         * deliveryTag:该消息的index<br>
         * multiple:是否批量.true:将⼀次性拒绝所有⼩于deliveryTag的消息。<br>
         * requeue:被拒绝的是否重新⼊队列 <br>
       */
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
            System.err.println("get msg1 failed msg = "+msg);
        }
    }
}
复制代码

4、消息消费的幂等性问题

消息消费的幂等性——多次消费的执⾏结果时相同的 (避免重复消费)
解决⽅案:处理成功的消息setnx到redis

⼗⼀、延迟机制

1、延迟队列

1、延迟队列——消息进⼊到队列之后,延迟指定的时间才能被消费者消费
2、AMQP协议和RabbitMQ队列本身是不⽀持延迟队列功能的,但是可以通过TTL(Time To Live)特性模拟延迟队列的功能
3、TTL就是消息的存活时间。RabbitMQ可以分别对队列和消息设置存活时间

1、在创建队列的时候可以设置队列的存活时间,当消息进⼊到队列并且在存活时间内没有消费者消费,则此消息就会从当前队列被移除;
2、创建消息队列没有设置TTL,但是消息设置了TTL,那么当消息的存活时间结束,也会被移除;
3、当TTL结束之后,我们可以指定将当前队列的消息转存到其他指定的队列

2、使⽤延迟队列实现订单⽀付监控

1、实现流程图

2、创建交换机和队列

⼗⼆、消息队列作⽤/使⽤场景总结

1、解耦

场景说明:⽤户下单之后,订单系统要通知库存系统

2、异步

场景说明:⽤户注册成功之后,需要发送注册邮件及注册短信提醒

3、消息通信

场景说明:应⽤系统之间的通信,例如聊天室

4、流量削峰

场景说明:秒杀业务

5、⽇志处理

场景说明:系统中⼤量的⽇志处理

 

4款免费且实用的.NET反编译工具 - 追逐时光者 - 博客园

mikel阅读(373)

来源: 4款免费且实用的.NET反编译工具 – 追逐时光者 – 博客园

4款免费且实用的.NET反编译工具 

反编译工具的作用

.NET反编译工具能够将已经编译好的.NET程序集转换为易于理解的源代码,它们可以帮助开发人员恢复丢失的源代码、理解和分析第三方组件dll、学习其他人的代码、更好的查找修复 bug 或进行逆向工程等(注意:请在法律允许范围内合理使用)。

ILSpy

ILSpy是一款免费、开源的 .NET 反编译工具,能够将已编译的 .NET 程序集转换为易于阅读和理解的源代码。

 

dnSpy

dnSpy是一个功能强大的调试器和.NET程序集编辑器,可用于在没有源代码的情况下编辑和调试程序集。(dnSpy已经该代码库已于2020年12月21日被所有者归档为只读状态,但这并不影响您继续使用 dnSpy 工具本身。)

JetBrains dotPeek

dotPeek 是一款基于 ReSharper 捆绑反编译器的免费独立工具。它可以可靠地将任意 .NET 程序集反编译为对等的 C# 或 IL 代码。这款反编译器支持包括库 (.dll)、可执行文件 (.exe) 和 Windows 元数据文件 (.winmd) 在内的多种格式。

JustDecompile

JustDecompile是一款功能强大的.NET反编译工具,可帮助您恢复丢失的源代码或查看程序集的内部结构。

总结

这四款免费的.NET反编译工具都非常不错,选择哪一款最终取决于每个人的偏好和需求,我个人主要使用ILSpy比较多,当然你假如不确定哪一款适合自己的话不妨都尝试一下,选择一款自己觉得最顺手的。请记住,合法使用这些工具并保护他人的知识产权是至关重要的。

优秀项目和框架精选

该项目已收录到C#/.NET/.NET Core优秀项目和框架精选中,关注优秀项目和框架精选能让你及时了解C#、.NET和.NET Core领域的最新动态和最佳实践,提高开发效率和质量。坑已挖,欢迎大家踊跃提交PR,自荐(让优秀的项目和框架不被埋没🤞)。

https://github.com/YSGStudyHards/DotNetGuide/blob/main/docs/DotNet/DotNetProjectPicks.md

加入DotNetGuide技术交流群

1、提供.NET开发者分享自己优质文章的群组和获取更多全面的C#/.NET/.NET Core学习资料、视频、文章、书籍,社区组织,工具和常见面试题资源,帮助大家更好地了解和使用 .NET技术。
2、在这个群里,开发者们可以分享自己的项目经验、遇到的问题以及解决方案,倾听他人的意见和建议,共同成长与进步。
3、可以结识更多志同道合的开发者,甚至可能与其他开发者合作完成有趣的项目。通过这个群组,我们希望能够搭建一个积极向上、和谐友善的.NET技术交流平台,为广大.NET开发者带来更多的价值。

从内核世界透视 mmap 内存映射的本质(原理篇) - bin的技术小屋 - 博客园

mikel阅读(386)

来源: 从内核世界透视 mmap 内存映射的本质(原理篇) – bin的技术小屋 – 博客园

之前有不少读者给笔者留言,希望笔者写一篇文章介绍下 mmap 内存映射相关的知识体系,之所以迟迟没有动笔,是因为 mmap 这个系统调用看上去简单,实际上并不简单,可以说是非常复杂的一个系统调用。

如果想要给大家把 mmap 背后的技术本质,正确地,清晰地还原出来,还是有一定难度的,因为 mmap 这一个系统调用就能撬动起整个内存管理系统,文件系统,页表体系,缺页中断等一大片的背景知识,涉及到的知识面广且繁杂。

幸运的是这一整套的背景知识,笔者已经在 《聊聊 Linux 内核》 系列文章中为大家详细介绍过了,所以现在是时候开始动笔了,不过大家不需要担心,虽然涉及到的背景知识比较多,但是在后面的相关章节里,笔者还会为大家重新交代。

image

在上一篇文章 《一步一图带你构建 Linux 页表体系》 中,笔者为大家介绍了内存映射最为核心的内容 —— 页表体系。通过一步一图的方式为大家展示了整个页表体系的演进过程,并在这个过程中逐步揭开了整个页表体系的全貌。

image

本文的内容依然是内存映射相关的内容,这一次笔者会带着大家围绕页表这个最为核心的体系,在页表的外围进行内存映射相关知识的介绍,核心目的就是彻底为大家还原内存映射背后的技术本质,由浅入深地给大家讲透彻,弄明白。

在我们正式开始今天的内容之前,笔者想首先抛出几个问题给大家思考,建议大家带着这几个问题来阅读接下来的内容,我们共同来将这些迷雾一层一层地慢慢拨开,直到还原出内存映射的本质。

  1. 既然我们是在讨论虚拟内存与物理内存的映射,那么首先你得有虚拟内存,你也得有物理内存吧,在这个基础之上,才能讨论两者之间的映射,而物理内存是怎么来的,笔者已经通过前边文章 《深入理解 Linux 物理内存分配全链路实现》 介绍的非常清楚了,那虚拟内存是怎么来的呢 ?内核分配虚拟内存的过程是怎样的呢?
  2. 我们知道内存映射是按照物理内存页为单位进行的,而在内存管理中,内存页主要分为两种:一种是匿名页,另一种是文件页,这一点笔者已经在 《一步一图带你深入理解 Linux 物理内存管理》 一文中反复讲过很多次了。根据物理内存页的类型分类,内存映射自然也分为两种:一种是虚拟内存对匿名物理内存页的映射,另一种是虚拟内存对文件页的映射。关于文件映射,大家或多或少在网上看到过这样的论述——” 通过内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来完成磁盘文件的读写 “。关于这个论述,如果对内存管理和文件系统不熟悉的同学,可能感到这句话非常的神奇,会有这样的一个疑问,内存就是内存啊,磁盘上的文件就是文件啊,这是两个完全不同的东西,为什么说读写内存就相当于读写磁盘上的文件呢 ?内存文件映射在内核中到底发生了什么 ?我们经常谈到的内存映射,到底映射的是什么?
  3. 在上篇文章中笔者只是为大家展示了整个页表体系的全貌,以及页表体系一步一步的演进过程,但是在进程被创建出来之后,内核也仅是会为进程分配一张全局页目录表 PGD(Page Global Directory)而已,此时进程虚拟内存空间中只存在一张顶级页目录表,而在上图中所展示的四级页表体系中的上层页目录 PUD(Page Upper Directory),中间页目录 PMD(Page Middle Directory)以及一级页表是不存在的,那么上图展示的这个页表完整体系是在什么时候,又是如何被一步一步构建出来的呢?

本文的主旨就是围绕上述这几个问题来展开的,那么从何谈起呢 ?笔者想了一下,还是应该从我们最为熟悉的,在用户态经常接触到的内存映射系统调用 mmap 开始聊起~~~

1. 详解内存映射系统调用 mmap

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

// 内核文件:/arch/x86/kernel/sys_x86_64.c
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
		unsigned long, prot, unsigned long, flags,
		unsigned long, fd, unsigned long, off)

mmap 内存映射里所谓的内存其实指的是虚拟内存,在调用 mmap 进行匿名映射的时候(比如进行堆内存的分配),是将进程虚拟内存空间中的某一段虚拟内存区域与物理内存中的匿名内存页进行映射,当调用 mmap 进行文件映射的时候,是将进程虚拟内存空间中的某一段虚拟内存区域与磁盘中某个文件中的某段区域进行映射。

而用于内存映射所消耗的这些虚拟内存位于进程虚拟内存空间的哪里呢 ?

笔者在之前的文章《一步一图带你深入理解 Linux 虚拟内存管理》 中曾为大家详细介绍过进程虚拟内存空间的布局,在进程虚拟内存空间的布局中,有一段叫做文件映射与匿名映射区的虚拟内存区域,当我们在用户态应用程序中调用 mmap 进行内存映射的时候,所需要的虚拟内存就是在这个区域中划分出来的。

image

在文件映射与匿名映射这段虚拟内存区域中,包含了一段一段的虚拟映射区,每当我们调用一次 mmap 进行内存映射的时候,内核都会在文件映射与匿名映射区中划分出一段虚拟映射区出来,这段虚拟映射区就是我们申请到的虚拟内存。

那么我们申请的这块虚拟内存到底有多大呢 ?这就用到了 mmap 系统调用的前两个参数:

  • addr : 表示我们要映射的这段虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),但是这个参数只是给内核的一个暗示,内核并非一定得从我们指定的 addr 虚拟内存地址上划分虚拟内存区域,内核只不过在划分虚拟内存区域的时候会优先考虑我们指定的 addr,如果这个虚拟地址已经被使用或者是一个无效的地址,那么内核则会自动选取一个合适的地址来划分虚拟内存区域。我们一般会将 addr 设置为 NULL,意思就是完全交由内核来帮我们决定虚拟映射区的起始地址。
  • length :从进程虚拟内存空间中的什么位置开始划分虚拟内存区域的问题解决了,那么我们要申请的这段虚拟内存有多大呢 ? 这个就是 length 参数的作用了,如果是匿名映射,length 参数决定了我们要映射的匿名物理内存有多大,如果是文件映射,length 参数决定了我们要映射的文件区域有多大。

addr,length 必须要按照 PAGE_SIZE(4K) 对齐。

image

如果我们通过 mmap 映射的是磁盘上的一个文件,那么就需要通过参数 fd 来指定要映射文件的描述符(file descriptor),通过参数 offset 来指定文件映射区域在文件中偏移。

image

在内存管理系统中,物理内存是按照内存页为单位组织的,在文件系统中,磁盘中的文件是按照磁盘块为单位组织的,内存页和磁盘块大小一般情况下都是 4K 大小,所以这里的 offset 也必须是按照 4K 对齐的。

而在文件映射与匿名映射区中的这一段一段的虚拟映射区,其实本质上也是虚拟内存区域,它们和进程虚拟内存空间中的代码段,数据段,BSS 段,堆,栈没有任何区别,在内核中都是 struct vm_area_struct 结构来表示的,下面我们把进程空间中的这些虚拟内存区域统称为 VMA。

进程虚拟内存空间中的所有 VMA 在内核中有两种组织形式:一种是双向链表,用于高效的遍历进程 VMA,这个 VMA 双向链表是有顺序的,所有 VMA 节点在双向链表中的排列顺序是按照虚拟内存低地址到高地址进行的。

另一种则是用红黑树进行组织,用于在进程空间中高效的查找 VMA,因为在进程虚拟内存空间中不仅仅是只有代码段,数据段,BSS 段,堆,栈这些虚拟内存区域 VMA,尤其是在数据密集型应用进程中,文件映射与匿名映射区里也会包含有大量的 VMA,进程的各种动态链接库所映射的虚拟内存在这里,进程运行过程中进行的匿名映射,文件映射所需要的虚拟内存也在这里。而内核需要频繁地对进程虚拟内存空间中的这些众多 VMA 进行增,删,改,查。所以需要这么一个红黑树结构,方便内核进行高效的查找。

// 进程虚拟内存空间描述符
struct mm_struct {
    // 串联组织进程空间中所有的 VMA  的双向链表 
    struct vm_area_struct *mmap;  /* list of VMAs */
    // 管理进程空间中所有 VMA 的红黑树
    struct rb_root mm_rb;
}

// 虚拟内存区域描述符
struct vm_area_struct {
    // vma 在 mm_struct->mmap 双向链表中的前驱节点和后继节点
    struct vm_area_struct *vm_next, *vm_prev;
    // vma 在 mm_struct->mm_rb 红黑树中的节点
    struct rb_node vm_rb;
}

image

上图中的文件映射与匿名映射区里边其实包含了大量的 VMA,这里只是为了清晰的给大家展示虚拟内存在内核中的组织结构,所以只画了一个大的 VMA 来表示文件映射与匿名映射区,这一点大家需要知道。

mmap 系统调用的本质是首先要在进程虚拟内存空间里的文件映射与匿名映射区中划分出一段虚拟内存区域 VMA 出来 ,这段 VMA 区域的大小用 vm_start,vm_end 来表示,它们由 mmap 系统调用参数 addr,length 决定。

struct vm_area_struct {
    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address */
}

随后内核会对这段 VMA 进行相关的映射,如果是文件映射的话,内核会将我们要映射的文件,以及要映射的文件区域在文件中的 offset,与 VMA 结构中的 vm_file,vm_pgoff 关联映射起来,它们由 mmap 系统调用参数 fd,offset 决定。

struct vm_area_struct {
    struct file * vm_file;      /* File we map to (can be NULL). */
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

另外由 mmap 在文件映射与匿名映射区中映射出来的这一段虚拟内存区域同进程虚拟内存空间中的其他虚拟内存区域一样,也都是有权限控制的。

image

比如上图进程虚拟内存空间中的代码段,它是与磁盘上 ELF 格式可执行文件中的 .text section(磁盘文件中各个区域的单元组织结构)进行映射的,存放的是程序执行的机器码,所以在可执行文件与进程虚拟内存空间进行文件映射的时候,需要指定代码段这个虚拟内存区域的权限为可读(VM_READ),可执行的(VM_EXEC)。

数据段也是通过文件映射进来的,内核会将磁盘上 ELF 格式可执行文件中的 .data section 与数据段映射起来,在映射的时候需要指定数据段这个虚拟内存区域的权限为可读(VM_READ),可写(VM_WRITE)。

与代码段和数据段不同的是,BSS段,堆,栈这些虚拟内存区域并不是从磁盘二进制可执行文件中加载的,它们是通过匿名映射的方式映射到进程虚拟内存空间的。

BSS 段中存放的是程序未初始化的全局变量,这段虚拟内存区域的权限是可读(VM_READ),可写(VM_WRITE)。

堆是用来描述进程在运行期间动态申请的虚拟内存区域的,所以堆也会具有可读(VM_READ),可写(VM_WRITE)权限,在有些情况下,堆也具有可执行(VM_EXEC)的权限,比如 Java 中的字节码存储在堆中,所以需要可执行权限。

栈是用来保存进程运行时的命令行参,环境变量,以及函数调用过程中产生的栈帧的,栈一般拥有可读(VM_READ),可写(VM_WRITE)的权限,但是也可以设置可执行(VM_EXEC)权限,不过出于安全的考虑,很少这么设置。

而在文件映射与匿名映射区中的情况就变得更加复杂了,因为文件映射与匿名映射区里包含了数量众多的 VMA,尤其是在数据密集型应用进程里更是如此,我们每调用一次 mmap ,无论是匿名映射也好还是文件映射也好,都会在文件映射与匿名映射区里产生一个 VMA,而通过 mmap 映射出的这段 VMA 中的相关权限和标志位,是由 mmap 系统调用参数里的 prot,flags 决定的,最终会映射到虚拟内存区域 VMA 结构中的 vm_page_prot,vm_flags 属性中,指定进程对这块虚拟内存区域的访问权限和相关标志位。

除此之外,进程运行过程中所依赖的动态链接库 .so 文件,也是通过文件映射的方式将动态链接库中的代码段,数据段映射进文件映射与匿名映射区中。

struct vm_area_struct {
    /*
     * Access permissions of this VMA.
     */
    pgprot_t vm_page_prot;
    unsigned long vm_flags; 
}

我们可以通过 mmap 系统调用中的参数 prot 来指定其在进程虚拟内存空间中映射出的这段虚拟内存区域 VMA 的访问权限,它的取值有如下四种:

#define PROT_READ	0x1		/* page can be read */
#define PROT_WRITE	0x2		/* page can be written */
#define PROT_EXEC	0x4		/* page can be executed */
#define PROT_NONE	0x0		/* page can not be accessed */
  • PROT_READ 表示该虚拟内存区域背后映射的物理内存是可读的。
  • PROT_WRITE 表示该虚拟内存区域背后映射的物理内存是可写的。
  • PROT_EXEC 表示该虚拟内存区域背后映射的物理内存所存储的内容是可以被执行的,该内存区域内往往存储的是执行程序的机器码,比如进程虚拟内存空间中的代码段,以及动态链接库通过文件映射的方式加载进文件映射与匿名映射区里的代码段,这些 VMA 的权限就是 PROT_EXEC 。
  • PROT_NONE 表示这段虚拟内存区域是不能被访问的,既不可读写,也不可执行。用于实现防范攻击的 guard page。如果攻击者访问了某个 guard page,就会触发 SIGSEV 段错误。除此之外,指定 PROT_NONE 还可以为进程预先保留这部分虚拟内存区域,虽然不能被访问,但是当后面进程需要的时候,可以通过 mprotect 系统调用修改这部分虚拟内存区域的权限。

mprotect 系统调用可以动态修改进程虚拟内存空间中任意一段虚拟内存区域的权限。

image

我们除了要为 mmap 映射出的这段虚拟内存区域 VMA 指定访问权限之外,还需要为这段映射区域 VMA 指定映射方式,VMA 的映射方式由 mmap 系统调用参数 flags 决定。内核为 flags 定义了数量众多的枚举值,下面笔者将一些非常重要且核心的枚举值为大家挑选出来并解释下它们的含义:

#define MAP_FIXED   0x10        /* Interpret addr exactly */
#define MAP_ANONYMOUS   0x20        /* don't use a file */

#define MAP_SHARED  0x01        /* Share changes */
#define MAP_PRIVATE 0x02        /* Changes are private */

前边我们介绍了 mmap 系统调用的 addr 参数,这个参数只是我们给内核的一个暗示并非是强制性的,表示我们希望内核可以根据我们指定的虚拟内存地址 addr 处开始创建虚拟内存映射区域 VMA。

但如果我们指定的 addr 是一个非法地址,比如 [addr , addr + length] 这段虚拟内存地址已经存在映射关系了,那么内核就会自动帮我们选取一个合适的虚拟内存地址开始映射,但是当我们在 mmap 系统调用的参数 flags 中指定了 MAP_FIXED, 这时参数 addr 就变成强制要求了,如果 [addr , addr + length] 这段虚拟内存地址已经存在映射关系了,那么内核就会将这段映射关系 unmmap 解除掉映射,然后重新根据我们的要求进行映射,如果 addr 是一个非法地址,内核就会报错停止映射。

操作系统对于物理内存的管理是按照内存页为单位进行的,而内存页的类型有两种:一种是匿名页,另一种是文件页。根据内存页类型的不同,内存映射也自然分为两种:一种是虚拟内存对匿名物理内存页的映射,另一种是虚拟内存对文件页的也映射,也就是我们常提到的匿名映射和文件映射。

当我们将 mmap 系统调用参数 flags 指定为 MAP_ANONYMOUS 时,表示我们需要进行匿名映射,既然是匿名映射,fd 和 offset 这两个参数也就没有了意义,fd 参数需要被设置为 -1 。当我们进行文件映射的时候,只需要指定 fd 和 offset 参数就可以了。

而根据 mmap 创建出的这片虚拟内存区域背后所映射的物理内存能否在多进程之间共享,又分为了两种内存映射方式:

  • MAP_SHARED 表示共享映射,通过 mmap 映射出的这片内存区域在多进程之间是共享的,一个进程修改了共享映射的内存区域,其他进程是可以看到的,用于多进程之间的通信。
  • MAP_PRIVATE 表示私有映射,通过 mmap 映射出的这片内存区域是进程私有的,其他进程是看不到的。如果是私有文件映射,那么多进程针对同一映射文件的修改将不会回写到磁盘文件上

这里介绍的这些 flags 参数枚举值是可以相互组合的,我们可以通过这些枚举值组合出如下几种内存映射方式。

2. 私有匿名映射

MAP_PRIVATE | MAP_ANONYMOUS 表示私有匿名映射,我们常常利用这种映射方式来申请虚拟内存,比如,我们使用 glibc 库里封装的 malloc 函数进行虚拟内存申请时,当申请的内存大于 128K 的时候,malloc 就会调用 mmap 采用私有匿名映射的方式来申请堆内存。因为它是私有的,所以申请到的内存是进程独占的,多进程之间不能共享。

这里需要特别强调一下 mmap 私有匿名映射申请到的只是虚拟内存,内核只是在进程虚拟内存空间中划分一段虚拟内存区域 VMA 出来,并将 VMA 该初始化的属性初始化好,mmap 系统调用就结束了。这里和物理内存还没有发生任何关系。在后面的章节中大家将会看到这个过程。

当进程开始访问这段虚拟内存区域时,发现这段虚拟内存区域背后没有任何物理内存与其关联,体现在内核中就是这段虚拟内存地址在页表中的 PTE 项是空的。

image

或者 PTE 中的 P 位为 0 ,这些都是表示虚拟内存还未与物理内存进行映射。

image

关于页表相关的知识,不熟悉的读者可以回顾下笔者之前的文章 《一步一图带你构建 Linux 页表体系》

这时 MMU 就会触发缺页异常(page fault),这里的缺页指的就是缺少物理内存页,随后进程就会切换到内核态,在内核缺页中断处理程序中,为这段虚拟内存区域分配对应大小的物理内存页,随后将物理内存页中的内容全部初始化为 0 ,最后在页表中建立虚拟内存与物理内存的映射关系,缺页异常处理结束。

当缺页处理程序返回时,CPU 会重新启动引起本次缺页异常的访存指令,这时 MMU 就可以正常翻译出物理内存地址了。

image

mmap 的私有匿名映射除了用于为进程申请虚拟内存之外,还会应用在 execve 系统调用中,execve 用于在当前进程中加载并执行一个新的二进制执行文件:

#include <unistd.h>

int execve(const char* filename, const char* argv[], const char* envp[])

参数 filename 指定新的可执行文件的文件名,argv 用于传递新程序的命令行参数,envp 用来传递环境变量。

既然是在当前进程中重新执行一个程序,那么当前进程的用户态虚拟内存空间就没有用了,内核需要根据这个可执行文件重新映射进程的虚拟内存空间。

既然现在要重新映射进程虚拟内存空间,内核首先要做的就是删除释放旧的虚拟内存空间,并清空进程页表。然后根据 filename 打开可执行文件,并解析文件头,判断可执行文件的格式,不同的文件格式需要不同的函数进行加载。

linux 中支持多种可执行文件格式,比如,elf 格式,a.out 格式。内核中使用 struct linux_binfmt 结构来描述可执行文件,里边定义了用于加载可执行文件的函数指针 load_binary,加载动态链接库的函数指针 load_shlib,不同文件格式指向不同的加载函数:

static struct linux_binfmt elf_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_elf_binary,
	.load_shlib	= load_elf_library,
	.core_dump	= elf_core_dump,
	.min_coredump	= ELF_EXEC_PAGESIZE,
};
static struct linux_binfmt aout_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_aout_binary,
	.load_shlib	= load_aout_library,
};

在 load_binary 中会解析对应格式的可执行文件,并根据文件内容重新映射进程的虚拟内存空间。比如,虚拟内存空间中的 BSS 段,堆,栈这些内存区域中的内容不依赖于可执行文件,所以在 load_binary 中采用私有匿名映射的方式来创建新的虚拟内存空间中的 BSS 段,堆,栈。

image

BSS 段虽然定义在可执行二进制文件中,不过只是在文件中记录了 BSS 段的长度,并没有相关内容关联,所以 BSS 段也会采用私有匿名映射的方式加载到进程虚拟内存空间中。

3. 私有文件映射

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们在调用 mmap 进行内存文件映射的时候可以通过指定参数 flags 为 MAP_PRIVATE,然后将参数 fd 指定为要映射文件的文件描述符(file descriptor)来实现对文件的私有映射。

假设现在磁盘上有一个名叫 file-read-write.txt 的磁盘文件,现在多个进程采用私有文件映射的方式,从文件 offset 偏移处开始,映射 length 长度的文件内容到各个进程的虚拟内存空间中,调用完 mmap 之后,相关内存映射内核数据结构关系如下图所示:

为了方便描述,我们指定映射长度 length 为 4K 大小,因为文件系统中的磁盘块大小为 4K ,映射到内存中的内存页刚好也是 4K 。

image

当进程打开一个文件的时候,内核会为其创建一个 struct file 结构来描述被打开的文件,并在进程文件描述符列表 fd_array 数组中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符。

image

而 struct file 结构是和进程相关的( fd 的作用域也是和进程相关的),即使多个进程打开同一个文件,那么内核会为每一个进程创建一个 struct file 结构,如上图中所示,进程 1 和 进程 2 都打开了同一个 file-read-write.txt 文件,那么内核会为进程 1 创建一个 struct file 结构,也会为进程 2 创建一个 struct file 结构。

每一个磁盘上的文件在内核中都会有一个唯一的 struct inode 结构,inode 结构和进程是没有关系的,一个文件在内核中只对应一个 inode,inode 结构用于描述文件的元信息,比如,文件的权限,文件中包含多少个磁盘块,每个磁盘块位于磁盘中的什么位置等等。

// ext4 文件系统中的 inode 结构
struct ext4_inode {
   // 文件权限
  __le16  i_mode;    /* File mode */
  // 文件包含磁盘块的个数
  __le32  i_blocks_lo;  /* Blocks count */
  // 存放文件包含的磁盘块
  __le32  i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
};

那么什么是磁盘块呢 ?我们可以类比内存管理系统,Linux 是按照内存页为单位来对物理内存进行管理和调度的,在文件系统中,Linux 是按照磁盘块为单位对磁盘中的数据进行管理的,它们的大小均是 4K 。

如下图所示,磁盘盘面上一圈一圈的同心圆叫做磁道,磁盘上存储的数据就是沿着磁道的轨迹存放着,随着磁盘的旋转,磁头在磁道上读写硬盘中的数据。而在每个磁盘上,会进一步被划分成多个大小相等的圆弧,这个圆弧就叫做扇区,磁盘会以扇区为单位进行数据的读写。每个扇区大小为 512 字节。

image

而在 Linux 的文件系统中是按照磁盘块为单位对数据读写的,因为每个扇区大小为 512 字节,能够存储的数据比较小,而且扇区数量众多,这样在寻址的时候比较困难,Linux 文件系统将相邻的扇区组合在一起,形成一个磁盘块,后续针对磁盘块整体进行操作效率更高。

只要我们找到了文件中的磁盘块,我们就可以寻址到文件在磁盘上的存储内容了,所以使用 mmap 进行内存文件映射的本质就是建立起虚拟内存区域 VMA 到文件磁盘块之间的映射关系 。

image

调用 mmap 进行内存文件映射的时候,内核首先会在进程的虚拟内存空间中创建一个新的虚拟内存区域 VMA 用于映射文件,通过 vm_area_struct->vm_file 将映射文件的 struct flle 结构与虚拟内存映射关联起来。

struct vm_area_struct {
    struct file * vm_file;      /* File we map to (can be NULL). */
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

根据 vm_file->f_inode 我们可以关联到映射文件的 struct inode,近而关联到映射文件在磁盘中的磁盘块 i_block,这个就是 mmap 内存文件映射最本质的东西

站在文件系统的视角,映射文件中的数据是按照磁盘块来存储的,读写文件数据也是按照磁盘块为单位进行的,磁盘块大小为 4K,当进程读取磁盘块的内容到内存之后,站在内存管理系统的视角,磁盘块中的数据被 DMA 拷贝到了物理内存页中,这个物理内存页就是前面提到的文件页。

根据程序的时间局部性原理我们知道,磁盘文件中的数据一旦被访问,那么它很有可能在短期内被再次访问,所以为了加快进程对文件数据的访问,内核会将已经访问过的磁盘块缓存在文件页中。

一个文件包含多个磁盘块,当它们被读取到内存之后,一个文件也就对应了多个文件页,这些文件页在内存中统一被一个叫做 page cache 的结构所组织。

每一个文件在内核中都会有一个唯一的 page cache 与之对应,用于缓存文件中的数据,page cache 是和文件相关的,它和进程是没有关系的,多个进程可以打开同一个文件,每个进程中都有有一个 struct file 结构来描述这个文件,但是一个文件在内核中只会对应一个 page cache。

文件的 struct inode 结构中除了有磁盘块的信息之外,还有指向文件 page cache 的 i_mapping 指针。

struct inode {
    struct address_space	*i_mapping;
}

page cache 在内核中是使用 struct address_space 结构来描述的:

struct address_space {
    // 这里就是 page cache。里边缓存了文件的所有缓存页面
    struct radix_tree_root  page_tree; 
}

关于 page cache 的详细介绍,感兴趣的读者可以回看下 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 一文中的 “5. 页高速缓存 page cache” 小节。

当我们理清了内存系统和文件系统这些核心数据结构之间的关联关系之后,现在再来看,下面这幅 mmap 私有文件映射关系图是不是清晰多了。

image

page cache 在内核中是使用基树 radix_tree 结构来表示的,这里我们只需要知道文件页是挂在 radix_tree 的叶子结点上,radix_tree 中的 root 节点和 node 节点是文件页(叶子节点)的索引节点就可以了。

当多个进程调用 mmap 对磁盘上同一个文件进行私有文件映射的时候,内核只是在每个进程的虚拟内存空间中创建出一段虚拟内存区域 VMA 出来,注意,此时内核只是为进程申请了用于映射的虚拟内存,并将虚拟内存与文件映射起来,mmap 系统调用就返回了,全程并没有物理内存的影子出现。文件的 page cache 也是空的,没有包含任何的文件页。

当任意一个进程,比如上图中的进程 1 开始访问这段映射的虚拟内存时,CPU 会把虚拟内存地址送到 MMU 中进行地址翻译,因为 mmap 只是为进程分配了虚拟内存,并没有分配物理内存,所以这段映射的虚拟内存在页表中是没有页表项 PTE 的。

image

随后 MMU 就会触发缺页异常(page fault),进程切换到内核态,在内核缺页中断处理程序中会发现引起缺页的这段 VMA 是私有文件映射的,所以内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。

struct vm_area_struct {
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

static inline struct page *find_get_page(struct address_space *mapping,
     pgoff_t offset)
{
   return pagecache_get_page(mapping, offset, 0, 0);
}

如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。

随后会通过 address_space_operations 重定义的 readpage 激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页。

static const struct address_space_operations ext4_aops = {
    .readpage       = ext4_readpage
}

现在文件中映射的内容已经加载进 page cache 了,此时物理内存才正式登场,在缺页中断处理程序的最后一步,内核会为映射的这段虚拟内存在页表中创建 PTE,然后将虚拟内存与 page cache 中的文件页通过 PTE 关联起来,缺页处理就结束了,但是由于我们指定的私有文件映射,所以 PTE 中文件页的权限是只读的。

image

当内核处理完缺页中断之后,mmap 私有文件映射在内核中的关系图就变成下面这样:

image

此时进程 1 中的页表已经建立起了虚拟内存与文件页的映射关系,进程 1 再次访问这段虚拟内存的时候,其实就等于直接访问文件的 page cache。整个过程是在用户态进行的,不需要切态。

现在我们在将视角切换到进程 2 中,进程 2 和进程 1 一样,都是采用 mmap 私有文件映射的方式映射到了同一个文件中,虽然现在已经有了物理内存了(通过进程 1 的缺页产生),但是目前还和进程 2 没有关系。

因为进程 2 的虚拟内存空间中这段映射的虚拟内存区域 VMA,在进程 2 的页表中还没有 PTE,所以当进程 2 访问这段映射虚拟内存时,同样会产生缺页中断,随后进程 2 切换到内核态,进行缺页处理,这里和进程 1 不同的是,此时被映射的文件内容已经加载到 page cache 中了,进程 2 只需要创建 PTE ,并将 page cache 中的文件页与进程 2 映射的这段虚拟内存通过 PTE 关联起来就可以了。同样,因为采用私有文件映射的原因,进程 2 的 PTE 也是只读的。

现在进程 1 和进程 2 都可以根据各自虚拟内存空间中映射的这段虚拟内存对文件的 page cache 进行读取了,整个过程都发生在用户态,不需要切态,更不需要拷贝,因为虚拟内存现在已经直接映射到 page cache 了。

image

虽然我们采用的是私有文件映射的方式,但是进程 1 和进程 2 如果只是对文件映射部分进行读取的话,文件页其实在多进程之间是共享的,整个内核中只有一份。

但是当任意一个进程通过虚拟映射区对文件进行写入操作的时候,情况就发生了变化,虽然通过 mmap 映射的时候指定的这段虚拟内存是可写的,但是由于采用的是私有文件映射的方式,各个进程页表中对应 PTE 却是只读的,当进程对这段虚拟内存进行写入的时候,MMU 会发现 PTE 是只读的,所以会产生一个写保护类型的缺页中断,写入进程,比如是进程 1,此时又会陷入到内核态,在写保护缺页处理中,内核会重新申请一个内存页,然后将 page cache 中的内容拷贝到这个新的内存页中,进程 1 页表中对应的 PTE 会重新关联到这个新的内存页上,此时 PTE 的权限变为可写。

image

从此以后,进程 1 对这段虚拟内存区域进行读写的时候就不会再发生缺页了,读写操作都会发生在这个新申请的内存页上,但是有一点,进程 1 对这个内存页的任何修改均不会回写到磁盘文件上,这也体现了私有文件映射的特点,进程对映射文件的修改,其他进程是看不到的,并且修改不会同步回磁盘文件中。

进程 2 对这段虚拟映射区进行写入的时候,也是一样的道理,同样会触发写保护类型的缺页中断,进程 2 陷入内核态,内核为进程 2 新申请一个物理内存页,并将 page cache 中的内容拷贝到刚为进程 2 申请的这个内存页中,进程 2 页表中对应的 PTE 会重新关联到新的内存页上, PTE 的权限变为可写。

image

这样一来,进程 1 和进程 2 各自的这段虚拟映射区,就映射到了各自专属的物理内存页上,而且这两个内存页中的内容均是文件中映射的部分,他们已经和 page cache 脱离了。

进程 1 和进程 2 对各自虚拟内存区的修改只能反应到各自对应的物理内存页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中,这就是私有文件映射的核心特点

我们可以利用 mmap 私有文件映射这个特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。

image

因为同一份代码,也就是同一份二进制可执行文件可以运行多个进程,而代码段对于多进程来说是只读的,没有必要为每个进程都保存一份,多进程之间共享这一份代码就可以了,正好私有文件映射的读共享特点可以满足我们的这个需求。

对于数据段来说,虽然它是可写的,但是我们需要的是多进程之间对数据段的修改相互之间是不可见的,而且对数据段的修改不能回写到磁盘上的二进制文件中,这样当我们利用这个可执行文件在启动一个进程的时候,进程看到的就是数据段初始化未被修改的状态。 mmap 私有文件映射的写时复制(copy on write)以及修改不会回写到映射文件中等特点正好也满足我们的需求。

这一点我们可以在负责加载 elf 格式的二进制可执行文件并映射到进程虚拟内存空间的 load_elf_binary 函数,以及负责加载 a.out 格式可执行文件的 load_aout_binary 函数中可以看出。

static int load_elf_binary(struct linux_binprm *bprm)
{
   // 将二进制文件中的 .text .data section 私有映射到虚拟内存空间中代码段和数据段中
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
        elf_prot, elf_flags, total_size);
}

static int load_aout_binary(struct linux_binprm * bprm)
{
        ............ 省略 .............
        // 将 .text 采用私有文件映射的方式映射到进程虚拟内存空间的代码段
        error = vm_mmap(bprm->file, N_TXTADDR(ex), ex.a_text,
            PROT_READ | PROT_EXEC,
            MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
            fd_offset);

        // 将 .data 采用私有文件映射的方式映射到进程虚拟内存空间的数据段
        error = vm_mmap(bprm->file, N_DATADDR(ex), ex.a_data,
                PROT_READ | PROT_WRITE | PROT_EXEC,
                MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
                fd_offset + ex.a_text);

        ............ 省略 .............
}

4. 共享文件映射

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们通过将 mmap 系统调用中的 flags 参数指定为 MAP_SHARED , 参数 fd 指定为要映射文件的文件描述符(file descriptor)来实现对文件的共享映射。

共享文件映射其实和私有文件映射前面的映射过程是一样的,唯一不同的点在于私有文件映射是读共享的,写的时候会发生写时复制(copy on write),并且多进程针对同一映射文件的修改不会回写到磁盘文件上。

而共享文件映射因为是共享的,多个进程中的虚拟内存映射区最终会通过缺页中断的方式映射到文件的 page cache 中,后续多个进程对各自的这段虚拟内存区域的读写都会直接发生在 page cache 上。

因为映射文件的 page cache 在内核中只有一份,所以对于共享文件映射来说,多进程读写都是共享的,由于多进程直接读写的是 page cache ,所以多进程对共享映射区的任何修改,最终都会通过内核回写线程 pdflush 刷新到磁盘文件中。

下面这幅是多进程通过 mmap 共享文件映射之后的内核数据结构关系图:

image

同私有文件映射方式一样,当多个进程调用 mmap 对磁盘上的同一个文件进行共享文件映射的时候,内核中的处理都是一样的,也都只是在每个进程的虚拟内存空间中,创建出一段用于共享映射的虚拟内存区域 VMA 出来,随后内核会将各个进程中的这段虚拟内存映射区与映射文件关联起来,mmap 共享文件映射的逻辑就结束了。

唯一不同的是,共享文件映射会在这段用于映射文件的 VMA 中标注是共享映射 —— MAP_SHARED

struct vm_area_struct {
    // MAP_SHARED 共享映射
    unsigned long vm_flags; 
}

在 mmap 共享文件映射的过程中,内核同样不涉及任何的物理内存分配,只是分配了一段虚拟内存,在共享映射刚刚建立起来之后,文件对应的 page cache 同样是空的,没有包含任何的文件页。

由于 mmap 只是在各个进程中分配了虚拟内存,没有分配物理内存,所以在各个进程的页表中,这段用于文件映射的虚拟内存区域对应的页表项 PTE 是空的,当任意进程对这段虚拟内存进行访问的时候(读或者写),MMU 就会产生缺页中断,这里我们以上图中的进程 1 为例,随后进程 1 切换到内核态,执行内核缺页中断处理程序。

同私有文件映射的缺页处理一样,内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中。

然后调用 readpage 激活块设备驱动从磁盘中读取映射的文件内容,用读取到的内容填充新分配的内存页,现在物理内存有了,最后一步就是在进程 1 的页表中建立共享映射的这段虚拟内存与 page cache 中缓存的文件页之间的关联。

这里和私有文件映射不同的地方是,私有文件映射由于是私有的,所以在内核创建 PTE 的时候会将 PTE 设置为只读,目的是当进程写入的时候触发写保护类型的缺页中断进行写时复制 (copy on write)。

共享文件映射由于是共享的,PTE 被创建出来的时候就是可写的,所以后续进程 1 在对这段虚拟内存区域写入的时候不会触发缺页中断,而是直接写入 page cache 中,整个过程没有切态,没有数据拷贝。

image

现在我们在切换到进程 2 的视角中,虽然现在文件中被映射的这部分内容已经加载进物理内存页,并被缓存在文件的 page cache 中了。但是现在进程 2 中这段虚拟映射区在进程 2 页表中对应的 PTE 仍然是空的,当进程 2 访问这段虚拟映射区的时候依然会产生缺页中断。

当进程 2 切换到内核态,处理缺页中断的时候,此时进程 2 通过 vm_area_struct->vm_pgoff 在 page cache 查找文件页的时候,文件页已经被进程 1 加载进 page cache 了,进程 2 一下就找到了,就不需要再去磁盘中读取映射内容了,内核会直接为进程 2 创建 PTE (由于是共享文件映射,所以这里的 PTE 也是可写的),并插入到进程 2 页表中,随后将进程 2 中的虚拟映射区通过 PTE 与 page cache 中缓存的文件页映射关联起来。

image

现在进程 1 和进程 2 各自虚拟内存空间中的这段虚拟内存区域 VMA,已经共同映射到了文件的 page cache 中,由于文件的 page cache 在内核中只有一份,它是和进程无关的,page cache 中的内容发生的任何变化,进程 1 和进程 2 都是可以看到的。

重要的一点是,多进程对各自虚拟内存映射区 VMA 的写入操作,内核会根据自己的脏页回写策略将修改内容回写到磁盘文件中。

内核提供了以下六个系统参数,来供我们配置调整内核脏页回写的行为,这些参数的配置文件存在于 proc/sys/vm 目录下:

image

  • dirty_writeback_centisecs 内核参数的默认值为 500。单位为 0.01 s。也就是说内核默认会每隔 5s 唤醒一次 flusher 线程来执行相关脏页的回写。
  • drity_background_ratio :当脏页数量在系统的可用内存 available 中占用的比例达到 drity_background_ratio 的配置值时,内核就会唤醒 flusher 线程异步回写脏页。默认值为:10。表示如果 page cache 中的脏页数量达到系统可用内存的 10% 的话,就主动唤醒 flusher 线程去回写脏页到磁盘。
  • dirty_background_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_background_bytes。内核就会唤醒 flusher 线程异步回写脏页。默认为:0。
  • dirty_ratio : dirty_background_* 相关的内核配置参数均是内核通过唤醒 flusher 线程来异步回写脏页。下面要介绍的 dirty_* 配置参数,均是由用户进程同步回写脏页。表示内存中的脏页太多了,用户进程自己都看不下去了,不用等内核 flusher 线程唤醒,用户进程自己主动去回写脏页到磁盘中。当脏页占用系统可用内存的比例达到 dirty_ratio 配置的值时,用户进程同步回写脏页。默认值为:20 。
  • dirty_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_bytes。用户进程同步回写脏页。默认值为:0。
  • 内核为了避免 page cache 中的脏页在内存中长久的停留,所以会给脏页在内存中的驻留时间设置一定的期限,这个期限可由前边提到的 dirty_expire_centisecs 内核参数配置。默认为:3000。单位为:0.01 s。也就是说在默认配置下,脏页在内存中的驻留时间为 30 s。超过 30 s 之后,flusher 线程将会在下次被唤醒的时候将这些脏页回写到磁盘中。

关于脏页回写详细的内容介绍,感兴趣的读者可以回看下 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 一文中的 “13. 内核回写脏页的触发时机” 小节。

根据 mmap 共享文件映射多进程之间读写共享(不会发生写时复制)的特点,常用于多进程之间共享内存(page cache),多进程之间的通讯。

5. 共享匿名映射

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们通过将 mmap 系统调用中的 flags 参数指定为 MAP_SHARED | MAP_ANONYMOUS,并将 fd 参数指定为 -1 来实现共享匿名映射,这种映射方式常用于父子进程之间共享内存,父子进程之间的通讯。注意,这里需要和大家强调一下是父子进程,为什么只能是父子进程,笔者后面再给大家解答。

在笔者介绍完 mmap 的私有匿名映射,私有文件映射,以及共享文件映射之后,共享匿名映射看似就非常简单了,由于不对文件进行映射,所以它不涉及到文件系统相关的知识,而且又是共享的,多个进程通过将自己的页表指向同一个物理内存页面不就实现共享匿名映射了吗?

image

看起来简单,实际上并没有那么简单,甚至可以说共享匿名映射是 mmap 这四种映射方式中最为复杂的,为什么这么说的 ?我们一起来看下共享匿名映射的映射过程。

首先和其他几种映射方式一样,mmap 只是负责在各个进程的虚拟内存空间中划分一段用于共享匿名映射的虚拟内存区域而已,这点笔者已经强调过很多遍了,整个映射过程并不涉及到物理内存的分配。

当多个进程调用 mmap 进行共享匿名映射之后,内核只不过是为每个进程在各自的虚拟内存空间中分配了一段虚拟内存而已,由于并不涉及物理内存的分配,所以这段用于映射的虚拟内存在各个进程的页表中对应的页表项 PTE 都还是空的,如下图所示:

image

当任一进程,比如上图中的进程 1 开始访问这段虚拟映射区的时候,MMU 会产生缺页中断,进程 1 切换到内核态,开始处理缺页中断逻辑,在缺页中断处理程序中,内核为进程 1 分配一个物理内存页,并创建对应的 PTE 插入到进程 1 的页表中,随后用 PTE 将进程 1 的这段虚拟映射区与物理内存映射关联起来。进程 1 的缺页处理结束,从此以后,进程 1 就可以读写这段共享映射的物理内存了。

image

现在我们把视角切换到进程 2 中,当进程 2 访问它自己的这段虚拟映射区的时候,由于进程 2 页表中对应的 PTE 为空,所以进程 2 也会发生缺页中断,随后切换到内核态处理缺页逻辑。

当进程 2 开始处理缺页逻辑的时候,进程 2 就懵了,为什么呢 ?原因是进程 2 和进程 1 进行的是共享映射,所以进程 2 不能随便找一个物理内存页进行映射,进程 2 必须和 进程 1 映射到同一个物理内存页面,这样才能共享内存。那现在的问题是,进程 2 面对着茫茫多的物理内存页,进程 2 怎么知道进程 1 已经映射了哪个物理内存页 ?

内核在缺页中断处理中只能知道当前正在缺页的进程是谁,以及发生缺页的虚拟内存地址是什么,内核根据这些信息,根本无法知道,此时是否已经有其他进程把共享的物理内存页准备好了。

这一点对于共享文件映射来说特别简单,因为有文件的 page cache 存在,进程 2 可以根据映射的文件内容在文件中的偏移 offset,从 page cache 中查找是否已经有其他进程把映射的文件内容加载到文件页中。如果文件页已经存在 page cache 中了,进程 2 直接映射这个文件页就可以了。

struct vm_area_struct {
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

static inline struct page *find_get_page(struct address_space *mapping,
     pgoff_t offset)
{
   return pagecache_get_page(mapping, offset, 0, 0);
}

由于共享匿名映射并没有对文件映射,所以其他进程想要在内存中查找要进行共享的内存页就非常困难了,那怎么解决这个问题呢 ?

既然共享文件映射可以轻松解决这个问题,那我们何不借鉴一下文件映射的方式 ?

共享匿名映射在内核中是通过一个叫做 tmpfs 的虚拟文件系统来实现的,tmpfs 不是传统意义上的文件系统,它是基于内存实现的,挂载在 dev/zero 目录下。

当多个进程通过 mmap 进行共享匿名映射的时候,内核会在 tmpfs 文件系统中创建一个匿名文件,这个匿名文件并不是真实存在于磁盘上的,它是内核为了共享匿名映射而模拟出来的,匿名文件也有自己的 inode 结构以及 page cache。

在 mmap 进行共享匿名映射的时候,内核会把这个匿名文件关联到进程的虚拟映射区 VMA 中。这样一来,当进程虚拟映射区域与 tmpfs 文件系统中的这个匿名文件映射起来之后,后面的流程就和共享文件映射一模一样了。

struct vm_area_struct {
    struct file * vm_file;      /* File we map to (can be NULL). */
}

最后,笔者来回答下在本小节开始处抛出的一个问题,就是共享匿名映射只适用于父子进程之间的通讯,为什么只能是父子进程呢 ?

因为当父进程进行 mmap 共享匿名映射的时候,内核会为其创建一个匿名文件,并关联到父进程的虚拟内存空间中 vm_area_struct->vm_file 中。但是这时候其他进程并不知道父进程虚拟内存空间中关联的这个匿名文件,因为进程之间的虚拟内存空间都是隔离的。

子进程就不一样了,在父进程调用完 mmap 之后,父进程的虚拟内存空间中已经有了一段虚拟映射区 VMA 并关联到匿名文件了。这时父进程进行 fork() 系统调用创建子进程,子进程会拷贝父进程的所有资源,当然也包括父进程的虚拟内存空间以及父进程的页表。

long _do_fork(unsigned long clone_flags,
       unsigned long stack_start,
       unsigned long stack_size,
       int __user *parent_tidptr,
       int __user *child_tidptr,
       unsigned long tls)
{
              ......... 省略 ..........
     struct pid *pid;
     struct task_struct *p;

              ......... 省略 ..........
    // 拷贝父进程的所有资源
     p = copy_process(clone_flags, stack_start, stack_size,
         child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

             ......... 省略 ..........
}

当 fork 出子进程的时候,这时子进程的虚拟内存空间和父进程的虚拟内存空间完全是一模一样的,在子进程的虚拟内存空间中自然也有一段虚拟映射区 VMA 并且已经关联到匿名文件中了(继承自父进程)。

现在父子进程的页表也是一模一样的,各自的这段虚拟映射区对应的 PTE 都是空的,一旦发生缺页,后面的流程就和共享文件映射一样了。我们可以把共享匿名映射看作成一种特殊的共享文件映射方式。

6. 参数 flags 的其他枚举值

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

在前边的几个小节中,笔者为大家介绍了 mmap 系统调用参数 flags 最为核心的三个枚举值:MAP_ANONYMOUS,MAP_SHARED,MAP_PRIVATE。随后我们通过这三个枚举值组合出了四种内存映射方式:私有匿名映射,私有文件映射,共享文件映射,共享匿名映射。

到现在为止,笔者算是把 mmap 内存映射的核心原理及其在内核中的映射过程给大家详细剖析完了,不过参数 flags 的枚举值在内核中并不只是上述三个,除此之外,内核还定义了很多。在本小节的最后,笔者为大家挑了几个相对重要的枚举值给大家做一些额外的补充,这样能够让大家对 mmap 内存映射有一个更加全面的认识。

#define MAP_LOCKED	0x2000		/* pages are locked */
#define MAP_POPULATE		0x008000	/* populate (prefault) pagetables */
#define MAP_HUGETLB		0x040000	/* create a huge page mapping */

经过前面的介绍我们知道,mmap 仅仅只是在进程虚拟内存空间中划分出一段用于映射的虚拟内存区域 VMA ,并将这段 VMA 与磁盘上的文件映射起来而已。整个映射过程并不涉及物理内存的分配,更别说虚拟内存与物理内存的映射了,这些都是在进程访问这段 VMA 的时候,通过缺页中断来补齐的。

如果我们在使用 mmap 系统调用的时候设置了 MAP_POPULATE ,内核在分配完虚拟内存之后,就会马上分配物理内存,并在进程页表中建立起虚拟内存与物理内存的映射关系,这样进程在调用 mmap 之后就可以直接访问这段映射的虚拟内存地址了,不会发生缺页中断。

但是当系统内存资源紧张的时候,内核依然会将 mmap 背后映射的这块物理内存 swap out 到磁盘中,这样进程在访问的时候仍然会发生缺页中断,为了防止这种现象,我们可以在调用 mmap 的时候设置 MAP_LOCKED

在设置了 MAP_LOCKED 之后,mmap 系统调用在为进程分配完虚拟内存之后,内核也会马上为其分配物理内存并在进程页表中建立虚拟内存与物理内存的映射关系,这里内核还会额外做一个动作,就是将映射的这块物理内存锁定在内存中,不允许它 swap,这样一来映射的物理内存将会一直停留在内存中,进程无论何时访问这段映射内存都不会发生缺页中断。

MAP_HUGETLB 则是用于大页内存映射的,在内核中关于物理内存的调度是按照物理内存页为单位进行的,普通物理内存页大小为 4K。但在一些对于内存敏感的使用场景中,我们往往期望使用一些比普通 4K 更大的页。

因为这些巨型页要比普通的 4K 内存页要大很多,而且这些巨型页不允许被 swap,所以遇到缺页中断的情况就会相对减少,由于减少了缺页中断所以性能会更高。

另外,由于巨型页比普通页要大,所以巨型页需要的页表项要比普通页要少,页表项里保存了虚拟内存地址与物理内存地址的映射关系,当 CPU 访问内存的时候需要频繁通过 MMU 访问页表项获取物理内存地址,由于要频繁访问,所以页表项一般会缓存在 TLB 中,因为巨型页需要的页表项较少,所以节约了 TLB 的空间同时降低了 TLB 缓存 MISS 的概率,从而加速了内存访问。

7. 大页内存映射

在 64 位 x86 CPU 架构 Linux 的四级页表体系下,系统支持的大页尺寸有 2M,1G。我们可以在 /sys/kernel/mm/hugepages 路径下查看当前系统所支持的大页尺寸:

image

要想在应用程序中使用 HugePage,我们需要在内核编译的时候通过设置 CONFIG_HUGETLBFS 和 CONFIG_HUGETLB_PAGE 这两个编译选项来让内核支持 HugePage。我们可以通过 cat /proc/filesystems 命令来查看当前内核中是否支持 hugetlbfs 文件系统,这是我们使用 HugePage 的基础。

image

因为 HugePage 要求的是一大片连续的物理内存,和普通内存页一样,巨型大页里的内存必须是连续的,但是随着系统的长时间运行,内存页被频繁无规则的分配与回收,系统中会产生大量的内存碎片,由于内存碎片的影响,内核很难寻找到大片连续的物理内存,这样一来就很难分配到巨型大页。

所以这就要求内核在系统启动的时候预先为我们分配好足够多的大页内存,这些大页内存被内核管理在一个大页内存池中,大页内存池中的内存全部是专用的,专门用于巨型大页的分配,不能用于其他目的,即使系统中没有使用巨型大页,这些大页内存就只能空闲在那里,另外这些大页内存都是被内核锁定在内存中的,即使系统内存资源紧张,大页内存也不允许被 swap。而且内核大页池中的这些大页内存使用完了就完了,大页池耗尽之后,应用程序将无法再使用大页。

既然大页内存池在内核启动的时候就需要被预先创建好,而创建大页内存池,内核需要首先知道内存池中究竟包含多少个 HugePage,每个 HugePage 的尺寸是多少 。我们可以将这些参数在内核启动的时候添加到 kernel command line 中,随后内核在启动的过程中就可以根据 kernel command line 中 HugePage 相关的参数进行大页内存池的创建。下面是一些 HugePage 相关的核心 command line 参数含义:

  • hugepagesz : 用于指定大页内存池中 HugePage 的 size,我们这里可以指定 hugepagesz=2M 或者 hugepagesz=1G,具体支持多少种大页尺寸由 CPU 架构决定。
  • hugepages:用于指定内核需要预先创建多少个 HugePage 在大页内存池中,我们可以通过指定 hugepages=256 ,来表示内核需要预先创建 256 个 HugePage 出来。除此之外 hugepages 参数还可以有 NUMA 格式,用于告诉内核需要在每个 NUMA node 上创建多少个 HugePage。我们可以通过设置 hugepages=0:1,1:2 ... 来指定 NUMA node 0 上分配 1 个 HugePage,在 NUMA node 1 上分配 2 个 HugePage。

image

  • default_hugepagesz:用于指定 HugePage 默认大小。各种不同类型的 CPU 架构一般都支持多种 size 的 HugePage,比如 x86 CPU 支持 2M,1G 的 HugePage。arm64 支持 64K,2M,32M,1G 的 HugePage。这么多尺寸的 HugePage 我们到底该使用哪种尺寸呢 ? 这时就需要通过 default_hugepagesz 来指定默认使用的 HugePage 尺寸。

以上为大家介绍的是在内核启动的时候(boot time)通过向 kernel command line 指定 HugePage 相关的命令行参数来配置大页,除此之外,我们还可以在系统刚刚启动之后(run time)来配置大页,因为系统刚刚启动,所以系统内存碎片化程度最小,也是一个配置大页的时机:

image

在 /proc/sys/vm 路径下有两个系统参数可以让我们在系统 run time 的时候动态调整当前系统中 default size (由 default_hugepagesz 指定)大小的 HugePage 个数。

  • nr_hugepages 表示当前系统中 default size 大小的 HugePage 个数,我们可以通过 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令来动态增大或者缩小 HugePage (default size )个数。
  • nr_overcommit_hugepages 表示当系统中的应用程序申请的大页个数超过 nr_hugepages 时,内核允许在额外申请多少个大页。当大页内存池中的大页个数被耗尽时,如果此时继续有进程来申请大页,那么内核则会从当前系统中选取多个连续的普通 4K 大小的内存页,凑出若干个大页来供进程使用,这些被凑出来的大页叫做 surplus_hugepage,surplus_hugepage 的个数不能超过 nr_overcommit_hugepages。当这些 surplus_hugepage 不在被使用时,就会被释放回内核中。nr_hugepages 个数的大页则会一直停留在大页内存池中,不会被释放,也不会被 swap。

nr_hugepages 有点像 JDK 线程池中的 corePoolSize 参数,(nr_hugepages + nr_overcommit_hugepages) 有点像线程池中的 maximumPoolSize 参数。

以上介绍的是修改默认尺寸大小的 HugePage,另外,我们还可以在系统 run time 的时候动态修改指定尺寸的 HugePage,不同大页尺寸的相关配置文件存放在 /sys/kernel/mm/hugepages 路径下的对应目录中:

image

如上图所示,当前系统中所支持的大页尺寸相关的配置文件,均存放在对应 hugepages-hugepagesize 格式的目录中,下面我们以 2M 大页为例,进入到 hugepages-2048kB 目录下,发现同样也有 nr_hugepages 和 nr_overcommit_hugepages 这两个配置文件,它们的含义和上边介绍的一样,只不过这里的是具体尺寸的 HugePage 相关配置。

我们可以通过如下命令来动态调整系统中 2M 大页的个数:

echo HugePageNum > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

同理在 NUMA 架构的系统下,我们可以在 /sys/devices/system/node/node_id 路径下修改对应 numa node 节点中的相应尺寸 的大页个数:

echo HugePageNum > /sys/devices/system/node/node_id/hugepages/hugepages-2048kB/nr_hugepages

现在内核已经支持了大页,并且我们从内核的 boot time 或者 run time 配置好了大页内存池,我们终于可以在应用程序中来使用大页内存了,内核给我们提供了两种方式来使用 HugePage:

  • 一种是本文介绍的 mmap 系统调用,需要在 flags 参数中设置 MAP_HUGETLB。另外内核提供了额外的两个枚举值来配合 MAP_HUGETLB 一起使用,它们分别是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。
    • MAP_HUGETLB | MAP_HUGE_2MB 用于指定我们需要映射的是 2M 的大页。
    • MAP_HUGETLB | MAP_HUGE_1GB 用于指定我们需要映射的是 1G 的大页。
    • MAP_HUGETLB 表示按照 default_hugepagesz 指定的默认尺寸来映射大页。
  • 另一种是 SYSV 标准的系统调用 shmget 和 shmat。

本小节我们主要介绍 mmap 系统调用使用大页的方式:

int main(void)
{
	addr = mmap(addr, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
	return 0;
}

MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePage

当我们通过 mmap 设置了 MAP_HUGETLB 进行大页内存映射的时候,这个映射过程和普通的匿名映射一样,同样也是首先在进程的虚拟内存空间中划分出一段虚拟映射区 VMA 出来,同样不涉及物理内存的分配,不一样的地方是,内核在分配完虚拟内存之后,会在大页内存池中为映射的这段虚拟内存预留好大页内存,相当于是把即将要使用的大页内存先锁定住,不允许其他进程使用。这些被预留好的 HugePage 个数被记录在上图中的 resv_hugepages 文件中。

当进程在访问这段虚拟内存的时候,同样会发生缺页中断,随后内核会从大页内存池中将这部分已经预留好的 resv_hugepages 分配给进程,并在进程页表中建立好虚拟内存与 HugePage 的映射。关于进程页表如何映射内存大页的详细内容,感兴趣的同学可以回看下之前的文章 《一步一图带你构建 Linux 页表体系》

image

由于这里我们调用 mmap 映射的是 HugePage ,所以系统调用参数中的 addr,length 需要和大页尺寸进行对齐,在本例中需要和 2M 进行对齐。

前边也提到了 MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,只能支持匿名映射的方式来使用 HugePage。那如果我们想使用 mmap 对文件进行大页映射该怎么办呢 ?

这就用到了前面提到的 hugetlbfs 文件系统:

image

hugetlbfs 是一个基于内存的文件系统,类似前边介绍的 tmpfs 文件系统,位于 hugetlbfs 文件系统下的所有文件都是被大页支持的,也就说通过 mmap 对 hugetlbfs 文件系统下的文件进行文件映射,默认都是用 HugePage 进行映射。

hugetlbfs 下的文件支持大多数的文件系统操作,比如:open , close , chmod , read 等等,但是不支持 write 系统调用,如果想要对 hugetlbfs 下的文件进行写入操作,那么必须通过文件映射的方式将 hugetlbfs 中的文件通过大页映射进内存,然后在映射内存中进行写入操作。

所以在我们使用 mmap 系统调用对 hugetlbfs 下的文件进行大页映射之前,首先需要做的事情就是在系统中挂载 hugetlbfs 文件系统到指定的路径下。

mount -t hugetlbfs -o uid=,gid=,mode=,pagesize=,size=,min_size=,nr_inodes= none /mnt/huge

上面的这条命令用于将 hugetlbfs 挂载到 /mnt/huge 目录下,从此以后只要是在 /mnt/huge 目录下创建的文件,背后都是由大页支持的,也就是说如果我们通过 mmap 系统调用对 /mnt/huge 目录下的文件进行文件映射,缺页的时候,内核分配的就是内存大页。

只有在 hugetlbfs 下的文件进行 mmap 文件映射的时候才能使用大页,其他普通文件系统下的文件依然只能映射普通 4K 内存页。

mount 命令中的 uid 和 gid 用于指定 hugetlbfs 根目录的 owner 和 group。

pagesize 用于指定 hugetlbfs 支持的大页尺寸,默认单位是字节,我们可以通过设置 pagesize=2M 或者 pagesize=1G 来指定 hugetlbfs 中的大页尺寸为 2M 或者 1G。

size 用于指定 hugetlbfs 文件系统可以使用的最大内存容量是多少,单位同 pagesize 一样。

min_size 用于指定 hugetlbfs 文件系统可以使用的最小内存容量是多少。

nr_inodes 用于指定 hugetlbfs 文件系统中 inode 的最大个数,决定该文件系统中最大可以创建多少个文件。

当 hugetlbfs 被我们挂载好之后,接下来我们就可以直接通过 mmap 系统调用对挂载目录 /mnt/huge 下的文件进行内存映射了,当缺页的时候,内核会直接分配大页,大页尺寸是 pagesize

int main(void)
{
    fd = open(“/mnt/huge/test.txt”, O_CREAT|O_RDWR);
    addr=mmap(0,MAP_LENGTH,PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
    return 0;
}

这里需要注意是,通过 mmap 映射 hugetlbfs 中的文件的时候,并不需要指定 MAP_HUGETLB。而我们通过 SYSV 标准的系统调用 shmget 和 shmat 以及前边介绍的 mmap ( flags 参数设置 MAP_HUGETLB)进行大页申请的时候,并不需要挂载 hugetlbfs。

在内核中一共支持两种类型的内存大页,一种是标准大页(hugetlb pages),也就是上面内容所介绍的使用大页的方式,我们可以通过命令 grep Huge /proc/meminfo 来查看标准大页在系统中的使用情况:

image

和标准大页相关的统计参数含义如下:

HugePages_Total 表示标准大页池中大页的个数。HugePages_Free 表示大页池中还未被使用的大页个数(未被分配)。

HugePages_Rsvd 表示大页池中已经被预留出来的大页,这个预留大页是什么意思呢 ?我们知道 mmap 系统调用只是为进程分配一段虚拟内存而已,并不会分配物理内存,当 mmap 进行大页映射的时候也是一样。不同之处在于,内核为进程分配完虚拟内存之后,还需要为进程在大页池中预留好本次映射所需要的大页个数,注意此时只是预留,还并未分配给进程,大页池中被预留好的大页不能被其他进程使用。这时 HugePages_Rsvd 的个数会相应增加,当进程发生缺页的时候,内核会直接从大页池中把这些提前预留好的大页内存映射到进程的虚拟内存空间中。这时 HugePages_Rsvd 的个数会相应减少。系统中真正剩余可用的个数其实是 HugePages_Free - HugePages_Rsvd

HugePages_Surp 表示大页池中超额分配的大页个数,这个概念其实笔者前面在介绍 nr_overcommit_hugepages 参数的时候也提到过,nr_overcommit_hugepages 参数表示最多能超额分配多少个大页。当大页池中的大页全部被耗尽的时候,也就是 /proc/sys/vm/nr_hugepages 指定的大页个数全部被分配完了,内核还可以超额为进程分配大页,超额分配出的大页个数就统计在 HugePages_Surp 中。

Hugepagesize 表示系统中大页的默认 size 大小,单位为 KB。

Hugetlb 表示系统中所有尺寸的大页所占用的物理内存总量。单位为 KB。

内核中另外一种类型的大页是透明大页 THP (Transparent Huge Pages),这里的透明指的是应用进程在使用 THP 的时候完全是透明的,不需要像使用标准大页那样需要系统管理员对系统进行显示的大页配置,在应用程序中也不需要向标准大页那样需要显示指定 MAP_HUGETLB, 或者显示映射到 hugetlbfs 里的文件中。

透明大页的使用对用户完全是透明的,内核会在背后为我们自动做大页的映射,透明大页不需要像标准大页那样需要提前预先分配好大页内存池,透明大页的分配是动态的,由内核线程 khugepaged 负责在背后默默地将普通 4K 内存页整理成内存大页给进程使用。但是如果由于内存碎片的因素,内核无法整理出内存大页,那么就会降级为使用普通 4K 内存页。但是透明大页这里会有一个问题,当碎片化严重的时候,内核会启动 kcompactd 线程去整理碎片,期望获得连续的内存用于大页分配,但是 compact 的过程可能会引起 sys cpu 飙高,应用程序卡顿。

透明大页是允许 swap 的,这一点和标准大页不同,在内存紧张需要 swap 的时候,透明大页会被内核默默拆分成普通 4K 内存页,然后 swap out 到磁盘。

透明大页只支持 2M 的大页,标准大页可以支持 1G 的大页,透明大页主要应用于匿名内存中,可以在 tmpfs 文件系统中使用。

在我们对比完了透明大页与标准大页之间的区别之后,我们现在来看一下如何使用透明大页,其实非常简单,我们可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 配置文件来选择开启或者禁用透明大页:

image

  • always 表示系统全局开启透明大页 THP 功能。这意味着每个进程都会去尝试使用透明大页。
  • never 表示系统全局关闭透明大页 THP 功能。进程将永远不会使用透明大页。
  • madvise 表示进程如果想要使用透明大页,需要通过 madvise 系统调用并设置参数 advice 为 MADV_HUGEPAGE 来建议内核,在 addr 到 addr+length 这片虚拟内存区域中,需要使用透明大页来映射。
#include <sys/mman.h>

int madvise(void addr, size_t length, int advice);

一般我们会首先使用 mmap 先映射一段虚拟内存区域,然后通过 madvise 建议内核,将来在缺页的时候,需要为这段虚拟内存映射透明大页。由于背后需要通过内核线程 khugepaged 来不断的扫描整理系统中的普通 4K 内存页,然后将他们拼接成一个大页来给进程使用,其中涉及内存整理和回收等耗时的操作,且这些操作会在内存路径中加锁,而 khugepaged 内核线程可能会在错误的时间启动扫描和转换大页的操作,造成随机不可控的性能下降。

另外一点,透明大页不像标准大页那样是提前预分配好的,透明大页是在系统运行时动态分配的,在内存紧张的时候,透明大页和普通 4K 内存页的分配过程一样,有可能会遇到直接内存回收(direct reclaim)以及直接内存整理(direct compaction),这些操作都是同步的并且非常耗时,会对性能造成非常大的影响。

前面在 cat /proc/meminfo 命令中显示的 AnonHugePages 就表示透明大页在系统中的使用情况。另外我们可以通过 cat /proc/pid/smaps | grep AnonHugePages 命令来查看某个进程对透明大页的使用情况。

总结

本文笔者从五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:

  1. 私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
  2. 私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。
  3. 共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。
  4. 共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。
  5. 大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。

在我们清楚了原理之后,笔者会在下篇文章为大家继续详细介绍 mmap 在内核中的源码实现,感谢大家收看到这里,我们下篇文章见~