前言
小明是个单纯的 .NET
开发,一天大哥叫住他,安排了一项任务:
“小明,分析一下我们 超牛逼
网站上个月的所有 AWS ELB
流量日志,这些日志保存在 AWS S3
上,你分析下,看哪个 API
的响应时间中位数最长。”
“对了,别用 Excel
,哥给你写好了一段 Python
脚本,可以自动解析统计一个 AWS ELB
文件的日志,你可以利用一下。”
“好的✌,大哥真厉害!”。
小明看了一下,然后傻眼了,在管理控制台中,九月份 AWS ELB
日志文件翻了好几页都没翻完,大概算算,大概有 1000
个文件不止。想想自己又不懂 Python
,又不是搞数据分析专业出身的,这个“看似简单”的工作完不成,这周怕是陪不了女朋友,搞不好还要 996.ICU
,小明几乎要流下了没有技术的泪水……
不怕!会.NET就行!
要完成这项工作,光老老实实将文件从管理控制台下载到本地,估计都够喝一壶。若小明稍机灵点,他可能会找到 AWS S3
的文件管理器,然后……发现只有付费版才有批量下载功能。
其实要完成这项工作,只需做好两项基本任务即可:
从
AWS S3
下载9月份的所有ELB
日志聚合并分析这1000多个日志文件,然后按响应时间中位数倒排序
AWS资源
能在管理控制台上看到的 AWS
资源, AWS
都提供了各语言的 SDK
可供操作(可在 SDK
上操作的东西,如批量下载,反倒不一定能在界面上看到)。SDK
支持多种语言,其中(显然)也包括 .NET
。
对于 AWS S3
的访问, Amazon
提供的 NuGet
包叫:AWSSDK.S3
,在 VisualStudio
中下载并安装,即可运行本文的示例。
要使用 AWSSDK.S3
,首先需要实例化一个 AmazonS3Client
,并传入 aws access key
、 aws secret key
、 AWS
区域等参数:
var credentials = new BasicAWSCredentials( Util.GetPassword("aws_live_access_key"), Util.GetPassword("aws_live_secret_key"));
var s3 = new AmazonS3Client(credentials, RegionEndpoint.USEast1);
注意:本文的所有代码全部共享这一个
s3
的实例。因为根据文档,AmazonS3Client
实例是设计为线程安全的。
在下载 AWS S3
的文件(对象)之前,首先需要知道有哪些对象可供下载,可通过 ListObjectsV2Async
方法列出某个 bucket
的文件列表。注意该方法是分页的,经我的测试,无论 MaxKeys
参数设置多大,该接口最多一次性返回 1000
条数据,但这显然不够,因此需要循环分页去拿。
分页时该响应对象中包含了 NextContinuationToken
和 IsTruncated
属性,如果 IsTruncated=true
,则 NextContinuationToken
必定有值,此时下次调用 ListObjectsV2Async
时的请求参数传入 NextContinuationToken
即可实现分页获取 S3
文件列表的功能。
这个过程说起来有点绕,但感谢 C#
提供了 yield
关键字来实现 协程-coroutine
,代码写起来非常简单:
IEnumerable<List<S3Object>> Load201909SuperCoolData(AmazonS3Client s3)
{ ListObjectsV2Response response = null; do { response = s3.ListObjectsV2Async(new ListObjectsV2Request { BucketName = "supercool-website", Prefix = "AWSLogs/1383838438/elasticloadbalancing/us-east-1/2019/09", ContinuationToken = response?.NextContinuationToken, MaxKeys = 100, }).Result; yield return response.S3Objects; } while (response.IsTruncated);
}
注意:
Prefix
为前缀,AWS ELB
日志都会按时间会有一个前缀模式,从文件列表中找到这一模式后填入该参数。
接下来就简单了,通过 GetObjectAsync
方法即可下载某个对象,要直接分析,最好先转换为字符串,拿到文件流 stream
后,最简单的方式是使用 StreamReader
将其转换为字符串:
IEnumerable<string> ReadS3Object(AmazonS3Client s3, S3Object x)
{ using GetObjectResponse obj = s3.GetObjectAsync(x.BucketName, x.Key).Result; using var reader = new StreamReader(obj.ResponseStream); while (!reader.EndOfStream) { yield return reader.ReadLine(); }
}
注意:
GetObjectAsync
方法返回的GetObjectResponse
类实现了IDisposable
接口,因为它的ResponseStream
实际上是非托管资源,需要单独释放。因此需要使用using
关键字来实现资源的正确释放。可以直接调用
StreamReader.ReadToEnd()
方法直接获取全部字符串,然后再通过Split
将字符串按行分隔,但这样会浪费大量内存,影响性能。
这时一般会将这个 stream
缓存到本地磁盘以供慢慢分析,但也可以一鼓作气直接将该 stream
转换为字符串直接分析。本文将采取后者做法。
分析1000多个文件
每个 ELB
日志文件的格式如下:
2019-08-31T23:08:36.637570Z SUPER-COOLELB 10.0.2.127:59737 10.0.3.142:86 0.000038 0.621249 0.000041 200 200 6359 291 "POST http://super-coolelb-10086.us-east-1.elb.amazonaws.com:80/api/Super/Cool HTTP/1.1" "-" - -
2019-08-31T23:28:36.264848Z SUPER-COOLELB 10.0.3.236:54141 10.0.3.249:86 0.00004 0.622208 0.000045 200 200 6359 291 "POST http://super-coolelb-10086.us-east-1.elb.amazonaws.com:80/api/Super/Cool HTTP/1.1" "-" - -
可见该日志有一定格式, Amazon
提供了该日志的详细文档中文说明:https://docs.aws.amazon.com/zh_cn/elasticloadbalancing/latest/application/load-balancer-access-logs.html#access-log-entry-format
根据文档,这种日志可以通过按简单的空格分隔来解析,但后面的 RequestInfo
和 UserAgent
字段稍微麻烦点,这种可以使用 正则表达式
来实现比较精致的效果:
public static LogEntry Parse(string line)
{ MatchCollection s = Regex.Matches(line, @"[\""].+?[\""]|[^ ]+"); string[] requestInfo = s[11].Value.Replace("\"", "").Split(' '); return new { Timestamp = DateTime.Parse(s[0].Value), ElbName = s[1].Value, ClientEndpoint = s[2].Value, BackendEndpoint = s[3].Value, RequestTime = decimal.Parse(s[4].Value), BackendTime = decimal.Parse(s[5].Value), ResponseTime = decimal.Parse(s[6].Value), ElbStatusCode = int.Parse(s[7].Value), BackendStatusCode = int.Parse(s[8].Value), ReceivedBytes = long.Parse(s[9].Value), SentBytes = long.Parse(s[10].Value), Method = requestInfo[0], Url = requestInfo[1], Protocol = requestInfo[2], UserAgent = s[12].Value.Replace("\"", ""), SslCypher = s[13].Value, SslProtocol = s[14].Value, };
}
LINQ
数据下载好了,解析也成功了,这时即可通过强大的 LINQ
来进行分析。这里将用到以下的操作符:
SelectMany
数据“打平”(和js
数组的.flatMap
方法类似)Select
数据转换(和js
数组的.map
方法类似)GroupBy
数据分组
首先,通过 AWSSDK
的 ListObjectsV2Async
方法,获取的是文件列表,可以通过 .SelectMany
方法将多个下载批次“打平”:
Load201909SuperCoolData(s3) .SelectMany(x => x)
然后通过 Select
,将单个文件 Key
下载并读为字符串:
Load201909SuperCoolData(s3) .SelectMany(x => x) .SelectMany(x => ReadS3Object(s3, x))
然后再通过 Select
,将文件每一行日志转换为一条 .NET
对象:
Load201909SuperCoolData(s3) .SelectMany(x => x) .SelectMany(x => ReadS3Object(s3, x)) .Select(LogEntry.Parse)
有了 .NET
对象,即可利用 LINQ
进行愉快地分析了,如小明需要求,只需加一个 GroupBy
和 Select
,即可求得根据 Url
分组的响应时间中位数,然后再通过 OrderByDescending
即按该数字排序,最后通过 .Dump
显示出来:
Load201909SuperCoolData(s3) .SelectMany(x => x) .SelectMany(x => ReadS3Object(s3, x)) .Select(LogEntry.Parse) .GroupBy(x => x.Url) .Select(x => new { Url = x.Key, Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2) }) .OrderByDescending(x => x.Median) .Dump();
运行效果如下:
多线程下载
解析和分析都在内存中进行,因此本代码的瓶颈在于下载速度。
上文中的代码是串行、单线程下载,带宽利用率低,下载速度慢。可以改成并行、多线程下载,以提高带宽利用率。
传统的多线程需要非常大的功力,需要很好的技巧才能完成。但 .NET4.0
发布了 ParallelLINQ
,只需极少的代码改动,即可享受到多线程的便利。在这里,只需将在第二个 SelectMany
后加上一个 AsParallel()
,即可瞬间获取多线程下载优势:
Load201909SuperCoolData(s3) .SelectMany(x => x) .AsParallel() // 重点 .SelectMany(x => ReadS3Object(s3, x)) .Select(LogEntry.Parse) .GroupBy(x => x.Url) .Select(x => new { Url = x.Key, Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2) }) .OrderByDescending(x => x.Median) .Dump();
注意:写
AsParallel()
的位置有讲究,这取决于你对性能瓶颈的把控。总的来说:
太靠后了不行,因为
AsParallel
之前的语句都是串行的;靠前了也不行,因为靠前的代码往往数据量还没扩大,并行没意义;
扩展
到了这一步,如果小明足够机灵,其实还能再扩展扩展,将平均值,总响应时间一并求出来,改动代码也不大,只需将下方那个 Select
改成如下即可:
.Select(x => new { Url = x.Key, Median = x.OrderBy(x => x.BackendTime).ElementAt(x.Count() / 2), Avg = x.Average(x => x.BackendTime), Sum = x.Sum(x => x.BackendTime), })
运行效果如下:
总结
看来并不需要 python
,有了 .NET
和 LINQ
两大法宝,看来小明周末又可以陪女朋友了?