Compare commits
15 Commits
write-asyn
...
fda4c01c22
Author | SHA1 | Date | |
---|---|---|---|
fda4c01c22 | |||
2b9c374e8c | |||
4df3b98e6d | |||
c293d2f6d7 | |||
132261831b | |||
043376c6d3 | |||
4682dacc79 | |||
383dd41695 | |||
7f3221fde9 | |||
05ea729950 | |||
dcad453eb1 | |||
dec2bc937a | |||
b9c44408ad | |||
e1c5362cf5 | |||
bdcfed5506 |
@@ -7,7 +7,7 @@ jobs:
|
||||
Build-Blog-Image:
|
||||
runs-on: archlinux
|
||||
steps:
|
||||
- uses: https://git.rrricardo.top/actions/checkout@v4
|
||||
- uses: https://mirrors.rrricardo.top/actions/checkout.git@v4
|
||||
name: Check out code
|
||||
with:
|
||||
lfs: true
|
||||
@@ -18,12 +18,16 @@ jobs:
|
||||
- name: Build docker image
|
||||
run: |
|
||||
cd YaeBlog
|
||||
docker build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
podman build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
- name: Workaround to make sure podman login succeed
|
||||
run: |
|
||||
mkdir /root/.docker
|
||||
- name: Login aliyun docker registry
|
||||
uses: https://git.rrricardo.top/actions/login-action@v3
|
||||
uses: https://mirrors.rrricardo.top/actions/podman-login.git@v1
|
||||
with:
|
||||
registry: registry.cn-beijing.aliyuncs.com
|
||||
username: 初冬的朝阳
|
||||
password: ${{ secrets.ALIYUN_PASSWORD }}
|
||||
auth_file_path: /etc/containers/auth.json
|
||||
- name: Push docker image
|
||||
run: docker push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
run: podman push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -484,4 +484,4 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
|
||||
# Tailwind auto-generated stylesheet
|
||||
output.css
|
||||
*.g.css
|
||||
|
41
YaeBlog.sln
41
YaeBlog.sln
@@ -1,41 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YaeBlog", "YaeBlog\YaeBlog.csproj", "{20438EFD-8DDE-43AF-92E2-76495C29233C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".gitea", ".gitea", "{9B5AAA29-37D8-454A-8D8F-3E6B6BCF38E6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ADBC3DA8-F65C-4B5D-A97A-DC351F8E6592}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.gitea\workflows\build.yaml = .gitea\workflows\build.yaml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{753B998C-1B9E-498F-B949-845CE86C4075}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.editorconfig = .editorconfig
|
||||
.gitattributes = .gitattributes
|
||||
.gitignore = .gitignore
|
||||
README.md = README.md
|
||||
LICENSE = LICENSE
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{ADBC3DA8-F65C-4B5D-A97A-DC351F8E6592} = {9B5AAA29-37D8-454A-8D8F-3E6B6BCF38E6}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
14
YaeBlog.slnx
Normal file
14
YaeBlog.slnx
Normal file
@@ -0,0 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/.gitea/" />
|
||||
<Folder Name="/.gitea/workflows/">
|
||||
<File Path=".gitea/workflows/build.yaml" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitattributes" />
|
||||
<File Path=".gitignore" />
|
||||
<File Path="LICENSE" />
|
||||
<File Path="README.md" />
|
||||
</Folder>
|
||||
<Project Path="YaeBlog/YaeBlog.csproj" />
|
||||
</Solution>
|
@@ -7,6 +7,4 @@ public interface IEssayScanService
|
||||
public Task<BlogContents> ScanContents();
|
||||
|
||||
public Task SaveBlogContent(BlogContent content, bool isDraft = true);
|
||||
|
||||
public Task<ImageScanResult> ScanImages();
|
||||
}
|
||||
|
21
YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs
Normal file
21
YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.CommandLine.Binding;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Services;
|
||||
|
||||
namespace YaeBlog.Commands.Binders;
|
||||
|
||||
public sealed class ImageCompressServiceBinder : BinderBase<ImageCompressService>
|
||||
{
|
||||
protected override ImageCompressService GetBoundValue(BindingContext bindingContext)
|
||||
{
|
||||
bindingContext.AddService(provider =>
|
||||
{
|
||||
IEssayScanService essayScanService = provider.GetRequiredService<IEssayScanService>();
|
||||
ILogger<ImageCompressService> logger = provider.GetRequiredService<ILogger<ImageCompressService>>();
|
||||
|
||||
return new ImageCompressService(essayScanService, logger);
|
||||
});
|
||||
|
||||
return bindingContext.GetRequiredService<ImageCompressService>();
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Commands.Binders;
|
||||
using YaeBlog.Components;
|
||||
using YaeBlog.Extensions;
|
||||
@@ -19,6 +21,7 @@ public sealed class YaeBlogCommand
|
||||
AddNewCommand(_rootCommand);
|
||||
AddPublishCommand(_rootCommand);
|
||||
AddScanCommand(_rootCommand);
|
||||
AddCompressCommand(_rootCommand);
|
||||
}
|
||||
|
||||
public Task<int> RunAsync(string[] args)
|
||||
@@ -94,22 +97,20 @@ public sealed class YaeBlogCommand
|
||||
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
|
||||
newCommand.AddArgument(filenameArgument);
|
||||
|
||||
newCommand.SetHandler(async (file, _, _, essayScanService) =>
|
||||
newCommand.SetHandler(async (file, blogOption, _, essayScanService) =>
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
if (contents.Posts.Any(content => content.FileName == file))
|
||||
if (contents.Posts.Any(content => content.BlogName == file))
|
||||
{
|
||||
Console.WriteLine("There exists the same title blog in posts.");
|
||||
return;
|
||||
}
|
||||
|
||||
await essayScanService.SaveBlogContent(new BlogContent
|
||||
{
|
||||
FileName = file,
|
||||
FileContent = string.Empty,
|
||||
Metadata = new MarkdownMetadata { Title = file, Date = DateTime.Now }
|
||||
});
|
||||
await essayScanService.SaveBlogContent(new BlogContent(
|
||||
new FileInfo(Path.Combine(blogOption.Value.Root, "drafts", file + ".md")),
|
||||
new MarkdownMetadata { Title = file, Date = DateTime.Now },
|
||||
string.Empty, true, [], []));
|
||||
|
||||
Console.WriteLine($"Created new blog '{file}.");
|
||||
}, filenameArgument, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(),
|
||||
@@ -126,15 +127,15 @@ public sealed class YaeBlogCommand
|
||||
BlogContents contents = await essyScanService.ScanContents();
|
||||
|
||||
Console.WriteLine($"All {contents.Posts.Count} Posts:");
|
||||
foreach (BlogContent content in contents.Posts.OrderBy(x => x.FileName))
|
||||
foreach (BlogContent content in contents.Posts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.FileName}");
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
|
||||
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.FileName))
|
||||
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.FileName}");
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
|
||||
}
|
||||
@@ -150,32 +151,39 @@ public sealed class YaeBlogCommand
|
||||
|
||||
command.SetHandler(async (_, _, essayScanService, removeOptionValue) =>
|
||||
{
|
||||
ImageScanResult result = await essayScanService.ScanImages();
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
List<BlogImageInfo> unusedImages = (from content in contents
|
||||
from image in content.Images
|
||||
where image is { IsUsed: false }
|
||||
select image).ToList();
|
||||
|
||||
if (result.UnusedImages.Count != 0)
|
||||
if (unusedImages.Count != 0)
|
||||
{
|
||||
Console.WriteLine("Found unused images:");
|
||||
Console.WriteLine("HINT: use '--rm' to remove unused images.");
|
||||
}
|
||||
|
||||
foreach (FileInfo image in result.UnusedImages)
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
Console.WriteLine($" - {image.FullName}");
|
||||
Console.WriteLine($" - {image.File.FullName}");
|
||||
}
|
||||
|
||||
if (removeOptionValue)
|
||||
{
|
||||
foreach (FileInfo image in result.UnusedImages)
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
image.Delete();
|
||||
image.File.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Used not existed images:");
|
||||
|
||||
foreach (FileInfo image in result.NotFoundImages)
|
||||
foreach (BlogContent content in contents)
|
||||
{
|
||||
Console.WriteLine($" - {image.FullName}");
|
||||
foreach (FileInfo file in content.NotfoundImages)
|
||||
{
|
||||
Console.WriteLine($"- {file.Name} in {content.BlogName}");
|
||||
}
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), removeOption);
|
||||
}
|
||||
@@ -193,7 +201,7 @@ public sealed class YaeBlogCommand
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
BlogContent? content = (from blog in contents.Drafts
|
||||
where blog.FileName == filename
|
||||
where blog.BlogName == filename
|
||||
select blog).FirstOrDefault();
|
||||
|
||||
if (content is null)
|
||||
@@ -202,14 +210,17 @@ public sealed class YaeBlogCommand
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置发布的时间
|
||||
content.Metadata.Date = DateTime.Now;
|
||||
|
||||
// 将选中的博客文件复制到posts
|
||||
await essayScanService.SaveBlogContent(content, isDraft: false);
|
||||
|
||||
// 复制图片文件夹
|
||||
DirectoryInfo sourceImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName));
|
||||
new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName));
|
||||
DirectoryInfo targetImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "posts", content.FileName));
|
||||
new(Path.Combine(blogOptions.Value.Root, "posts", content.BlogName));
|
||||
|
||||
if (sourceImageDirectory.Exists)
|
||||
{
|
||||
@@ -223,9 +234,30 @@ public sealed class YaeBlogCommand
|
||||
}
|
||||
|
||||
// 删除原始的文件
|
||||
FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName + ".md"));
|
||||
FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName + ".md"));
|
||||
sourceBlogFile.Delete();
|
||||
}, new BlogOptionsBinder(),
|
||||
new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), filenameArgument);
|
||||
}
|
||||
|
||||
private static void AddCompressCommand(RootCommand rootCommand)
|
||||
{
|
||||
Command command = new("compress", "Compress png/jpeg image to webp image to reduce size.");
|
||||
rootCommand.Add(command);
|
||||
|
||||
Option<bool> dryRunOption = new("--dry-run", description: "Dry run the compression task but not write.",
|
||||
getDefaultValue: () => false);
|
||||
command.AddOption(dryRunOption);
|
||||
|
||||
command.SetHandler(ImageCommandHandler,
|
||||
new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new LoggerBinder<ImageCompressService>(),
|
||||
new EssayScanServiceBinder(), new ImageCompressServiceBinder(), dryRunOption);
|
||||
}
|
||||
|
||||
private static async Task ImageCommandHandler(IOptions<BlogOptions> _, ILogger<EssayScanService> _1,
|
||||
ILogger<ImageCompressService> _2,
|
||||
IEssayScanService _3, ImageCompressService imageCompressService, bool dryRun)
|
||||
{
|
||||
await imageCompressService.Compress(dryRun);
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="YaeBlog.styles.css"/>
|
||||
<link rel="icon" href="images/favicon.ico"/>
|
||||
<link rel="stylesheet" href="globals.css"/>
|
||||
<link rel="stylesheet" href="output.css"/>
|
||||
<link rel="stylesheet" href="tailwind.g.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
|
18
YaeBlog/Extensions/AngleSharpExtensions.cs
Normal file
18
YaeBlog/Extensions/AngleSharpExtensions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using AngleSharp.Dom;
|
||||
|
||||
namespace YaeBlog.Extensions;
|
||||
|
||||
public static class AngleSharpExtensions
|
||||
{
|
||||
public static IEnumerable<IElement> EnumerateParentElements(this IElement element)
|
||||
{
|
||||
IElement? e = element.ParentElement;
|
||||
|
||||
while (e is not null)
|
||||
{
|
||||
IElement c = e;
|
||||
e = e.ParentElement;
|
||||
yield return c;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,12 +1,20 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public class BlogContent
|
||||
/// <summary>
|
||||
/// 单个博客文件的所有数据和元数据
|
||||
/// </summary>
|
||||
/// <param name="BlogFile">博客文件</param>
|
||||
/// <param name="Metadata">文件中的MD元数据</param>
|
||||
/// <param name="Content">文件内容</param>
|
||||
/// <param name="IsDraft">是否为草稿</param>
|
||||
/// <param name="Images">博客中使用的文件</param>
|
||||
public record BlogContent(
|
||||
FileInfo BlogFile,
|
||||
MarkdownMetadata Metadata,
|
||||
string Content,
|
||||
bool IsDraft,
|
||||
List<BlogImageInfo> Images,
|
||||
List<FileInfo> NotfoundImages)
|
||||
{
|
||||
public required string FileName { get; init; }
|
||||
|
||||
public required MarkdownMetadata Metadata { get; init; }
|
||||
|
||||
public required string FileContent { get; set; }
|
||||
|
||||
public bool IsDraft { get; set; } = false;
|
||||
public string BlogName => BlogFile.Name.Split('.')[0];
|
||||
}
|
||||
|
@@ -1,10 +1,15 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public sealed class BlogContents(ConcurrentBag<BlogContent> drafts, ConcurrentBag<BlogContent> posts)
|
||||
public record BlogContents(ConcurrentBag<BlogContent> Drafts, ConcurrentBag<BlogContent> Posts)
|
||||
: IEnumerable<BlogContent>
|
||||
{
|
||||
public ConcurrentBag<BlogContent> Drafts { get; } = drafts;
|
||||
IEnumerator<BlogContent> IEnumerable<BlogContent>.GetEnumerator()
|
||||
{
|
||||
return Posts.Concat(Drafts).GetEnumerator();
|
||||
}
|
||||
|
||||
public ConcurrentBag<BlogContent> Posts { get; } = posts;
|
||||
public IEnumerator GetEnumerator() => ((IEnumerable<BlogContent>)this).GetEnumerator();
|
||||
}
|
||||
|
44
YaeBlog/Models/BlogImageInfo.cs
Normal file
44
YaeBlog/Models/BlogImageInfo.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Text;
|
||||
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public record BlogImageInfo(FileInfo File, long Width, long Height, string MineType, byte[] Content, bool IsUsed)
|
||||
: IComparable<BlogImageInfo>
|
||||
{
|
||||
public int Size => Content.Length;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
|
||||
builder.AppendLine($"Blog image {File.Name}:");
|
||||
builder.AppendLine($"\tWidth: {Width}; Height: {Height}");
|
||||
builder.AppendLine($"\tSize: {FormatSize()}");
|
||||
builder.AppendLine($"\tImage Format: {MineType}");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public int CompareTo(BlogImageInfo? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return other.Size.CompareTo(Size);
|
||||
}
|
||||
|
||||
private string FormatSize()
|
||||
{
|
||||
double size = Size;
|
||||
if (size / 1024 > 3)
|
||||
{
|
||||
size /= 1024;
|
||||
|
||||
return size / 1024 > 3 ? $"{size / 1024}MB" : $"{size}KB";
|
||||
}
|
||||
|
||||
return $"{size}B";
|
||||
}
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public record struct ImageScanResult(List<FileInfo> UnusedImages, List<FileInfo> NotFoundImages);
|
@@ -1,6 +1,7 @@
|
||||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Extensions;
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Processors;
|
||||
@@ -20,20 +21,21 @@ public sealed class EssayStylesPostRenderProcessor : IPostRenderProcessor
|
||||
|
||||
ApplyGlobalCssStyles(document);
|
||||
BeatifyTable(document);
|
||||
BeatifyList(document);
|
||||
BeatifyInlineCode(document);
|
||||
|
||||
return essay.WithNewHtmlContent(document.DocumentElement.OuterHtml);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, string> _globalCssStyles = new()
|
||||
{
|
||||
{ "pre", "p-4 bg-slate-300 rounded-sm overflow-x-auto" },
|
||||
{ "pre", "p-4 bg-gray-100 rounded-sm overflow-x-auto" },
|
||||
{ "h2", "text-3xl font-bold py-4" },
|
||||
{ "h3", "text-2xl font-bold py-3" },
|
||||
{ "h4", "text-xl font-bold py-2" },
|
||||
{ "h5", "text-lg font-bold py-1" },
|
||||
{ "p", "p-2" },
|
||||
{ "img", "w-11/12 block mx-auto my-2 rounded-md shadow-md" },
|
||||
{ "ul", "list-disc pl-2" }
|
||||
};
|
||||
|
||||
private void ApplyGlobalCssStyles(IDocument document)
|
||||
@@ -99,4 +101,45 @@ public sealed class EssayStylesPostRenderProcessor : IPostRenderProcessor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeatifyList(IDocument document)
|
||||
{
|
||||
foreach (IElement ulElement in from e in document.All
|
||||
where e.LocalName == "ul"
|
||||
select e)
|
||||
{
|
||||
// 首先给<ul>元素添加样式
|
||||
ulElement.ClassList.Add("list-disc ml-10");
|
||||
|
||||
|
||||
foreach (IElement liElement in from e in ulElement.Children
|
||||
where e.LocalName == "li"
|
||||
select e)
|
||||
{
|
||||
// 修改<li>元素中的<p>元素样式
|
||||
// 默认的p-2间距有点太宽了
|
||||
foreach (IElement pElement in from e in liElement.Children
|
||||
where e.LocalName == "p"
|
||||
select e)
|
||||
{
|
||||
pElement.ClassList.Remove("p-2");
|
||||
pElement.ClassList.Add("p-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeatifyInlineCode(IDocument document)
|
||||
{
|
||||
// 选择不在<pre>元素内的<code>元素
|
||||
// 即行内代码
|
||||
IEnumerable<IElement> inlineCodes = from e in document.All
|
||||
where e.LocalName == "code" && e.EnumerateParentElements().All(p => p.LocalName != "pre")
|
||||
select e;
|
||||
|
||||
foreach (IElement e in inlineCodes)
|
||||
{
|
||||
e.ClassList.Add("bg-gray-100 inline p-1 rounded-xs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,8 @@ using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Processors;
|
||||
|
||||
public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
|
||||
public class ImagePostRenderProcessor(
|
||||
ILogger<ImagePostRenderProcessor> logger,
|
||||
IOptions<BlogOptions> options)
|
||||
: IPostRenderProcessor
|
||||
{
|
||||
@@ -29,22 +30,27 @@ public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
|
||||
if (attr is not null)
|
||||
{
|
||||
logger.LogDebug("Found image link: '{}'", attr.Value);
|
||||
attr.Value = GenerateImageLink(attr.Value, essay.FileName);
|
||||
attr.Value = GenerateImageLink(attr.Value, essay.FileName, essay.IsDraft);
|
||||
}
|
||||
}
|
||||
|
||||
return essay.WithNewHtmlContent(html.DocumentElement.OuterHtml);
|
||||
}
|
||||
|
||||
public string Name => nameof(ImagePostRenderProcessor);
|
||||
|
||||
private string GenerateImageLink(string filename, string essayFilename)
|
||||
private string GenerateImageLink(string filename, string essayFilename, bool isDraft)
|
||||
{
|
||||
// 如果图片路径中没有包含文件名
|
||||
// 则添加文件名
|
||||
if (!filename.Contains(essayFilename))
|
||||
{
|
||||
filename = Path.Combine(essayFilename, filename);
|
||||
}
|
||||
|
||||
filename = Path.Combine(_options.Root, "posts", filename);
|
||||
filename = isDraft
|
||||
? Path.Combine(_options.Root, "drafts", filename)
|
||||
: Path.Combine(_options.Root, "posts", filename);
|
||||
|
||||
if (!Path.Exists(filename))
|
||||
{
|
||||
|
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Imageflow.Bindings;
|
||||
using Imageflow.Fluent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Core.Exceptions;
|
||||
@@ -9,17 +11,30 @@ using YamlDotNet.Serialization;
|
||||
|
||||
namespace YaeBlog.Services;
|
||||
|
||||
public partial class EssayScanService(
|
||||
ISerializer yamlSerializer,
|
||||
IDeserializer yamlDeserializer,
|
||||
IOptions<BlogOptions> blogOptions,
|
||||
ILogger<EssayScanService> logger) : IEssayScanService
|
||||
public partial class EssayScanService : IEssayScanService
|
||||
{
|
||||
private readonly BlogOptions _blogOptions = blogOptions.Value;
|
||||
private readonly BlogOptions _blogOptions;
|
||||
private readonly ISerializer _yamlSerializer;
|
||||
private readonly IDeserializer _yamlDeserializer;
|
||||
private readonly ILogger<EssayScanService> _logger;
|
||||
|
||||
public EssayScanService(ISerializer yamlSerializer,
|
||||
IDeserializer yamlDeserializer,
|
||||
IOptions<BlogOptions> blogOptions,
|
||||
ILogger<EssayScanService> logger)
|
||||
{
|
||||
_yamlSerializer = yamlSerializer;
|
||||
_yamlDeserializer = yamlDeserializer;
|
||||
_logger = logger;
|
||||
_blogOptions = blogOptions.Value;
|
||||
RootDirectory = ValidateRootDirectory();
|
||||
}
|
||||
|
||||
private DirectoryInfo RootDirectory { get; }
|
||||
|
||||
public async Task<BlogContents> ScanContents()
|
||||
{
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
|
||||
return new BlogContents(
|
||||
await ScanContentsInternal(drafts, true),
|
||||
@@ -28,82 +43,92 @@ public partial class EssayScanService(
|
||||
|
||||
public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
|
||||
{
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
|
||||
FileInfo targetFile = isDraft
|
||||
? new FileInfo(Path.Combine(drafts.FullName, content.FileName + ".md"))
|
||||
: new FileInfo(Path.Combine(posts.FullName, content.FileName + ".md"));
|
||||
|
||||
if (!isDraft)
|
||||
{
|
||||
content.Metadata.Date = DateTime.Now;
|
||||
}
|
||||
? new FileInfo(Path.Combine(drafts.FullName, content.BlogName + ".md"))
|
||||
: new FileInfo(Path.Combine(posts.FullName, content.BlogName + ".md"));
|
||||
|
||||
if (targetFile.Exists)
|
||||
{
|
||||
logger.LogWarning("Blog {} exists, overriding.", targetFile.Name);
|
||||
_logger.LogWarning("Blog {} exists, overriding.", targetFile.Name);
|
||||
}
|
||||
|
||||
await using StreamWriter writer = targetFile.CreateText();
|
||||
|
||||
await writer.WriteAsync("---\n");
|
||||
await writer.WriteAsync(yamlSerializer.Serialize(content.Metadata));
|
||||
await writer.WriteAsync(_yamlSerializer.Serialize(content.Metadata));
|
||||
await writer.WriteAsync("---\n");
|
||||
|
||||
if (isDraft)
|
||||
if (string.IsNullOrEmpty(content.Content) && isDraft)
|
||||
{
|
||||
// 如果博客为操作且内容为空
|
||||
// 创建简介隔断符号
|
||||
await writer.WriteLineAsync("<!--more-->");
|
||||
}
|
||||
else
|
||||
{
|
||||
await writer.WriteAsync(content.FileContent);
|
||||
await writer.WriteAsync(content.Content);
|
||||
}
|
||||
|
||||
// 保存图片文件
|
||||
await Task.WhenAll(from image in content.Images
|
||||
select File.WriteAllBytesAsync(image.File.FullName, image.Content));
|
||||
}
|
||||
|
||||
private record struct BlogResult(
|
||||
FileInfo BlogFile,
|
||||
string BlogContent,
|
||||
List<BlogImageInfo> Images,
|
||||
List<FileInfo> NotFoundImages);
|
||||
|
||||
private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory, bool isDraft)
|
||||
{
|
||||
// 扫描以md结果的但是不是隐藏文件的文件
|
||||
// 扫描以md结尾且不是隐藏文件的文件
|
||||
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
|
||||
where file.Extension == ".md" && !file.Name.StartsWith('.')
|
||||
select file;
|
||||
|
||||
ConcurrentBag<(string, string)> fileContents = [];
|
||||
ConcurrentBag<BlogResult> fileContents = [];
|
||||
|
||||
await Parallel.ForEachAsync(markdownFiles, async (file, token) =>
|
||||
{
|
||||
using StreamReader reader = file.OpenText();
|
||||
fileContents.Add((file.Name, await reader.ReadToEndAsync(token)));
|
||||
string blogName = file.Name.Split('.')[0];
|
||||
string blogContent = await reader.ReadToEndAsync(token);
|
||||
ImageResult imageResult =
|
||||
await ScanImagePreBlog(directory, blogName,
|
||||
blogContent);
|
||||
|
||||
fileContents.Add(new BlogResult(file, blogContent, imageResult.Images, imageResult.NotfoundImages));
|
||||
});
|
||||
|
||||
ConcurrentBag<BlogContent> contents = [];
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
foreach ((string filename, string content) in fileContents)
|
||||
foreach (BlogResult blog in fileContents)
|
||||
{
|
||||
int endPos = content.IndexOf("---", 4, StringComparison.Ordinal);
|
||||
if (!content.StartsWith("---") || endPos is -1 or 0)
|
||||
int endPos = blog.BlogContent.IndexOf("---", 4, StringComparison.Ordinal);
|
||||
if (!blog.BlogContent.StartsWith("---") || endPos is -1 or 0)
|
||||
{
|
||||
logger.LogWarning("Failed to parse metadata from {}, skipped.", filename);
|
||||
_logger.LogWarning("Failed to parse metadata from {}, skipped.", blog.BlogFile.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
string metadataString = content[4..endPos];
|
||||
string metadataString = blog.BlogContent[4..endPos];
|
||||
|
||||
try
|
||||
{
|
||||
MarkdownMetadata metadata = yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
|
||||
logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, filename);
|
||||
MarkdownMetadata metadata = _yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
|
||||
_logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, blog.BlogFile.Name);
|
||||
|
||||
contents.Add(new BlogContent
|
||||
{
|
||||
FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..],
|
||||
IsDraft = isDraft
|
||||
});
|
||||
contents.Add(new BlogContent(blog.BlogFile, metadata, blog.BlogContent[(endPos + 3)..], isDraft,
|
||||
blog.Images, blog.NotFoundImages));
|
||||
}
|
||||
catch (YamlException e)
|
||||
{
|
||||
logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", filename, e);
|
||||
_logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", blog.BlogFile.Name, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,99 +136,96 @@ public partial class EssayScanService(
|
||||
return contents;
|
||||
}
|
||||
|
||||
public async Task<ImageScanResult> ScanImages()
|
||||
private record struct ImageResult(List<BlogImageInfo> Images, List<FileInfo> NotfoundImages);
|
||||
|
||||
private async Task<ImageResult> ScanImagePreBlog(DirectoryInfo directory, string blogName, string content)
|
||||
{
|
||||
BlogContents contents = await ScanContents();
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
MatchCollection matchResult = ImagePattern.Matches(content);
|
||||
DirectoryInfo imageDirectory = new(Path.Combine(directory.FullName, blogName));
|
||||
|
||||
List<FileInfo> unusedFiles = [];
|
||||
List<FileInfo> notFoundFiles = [];
|
||||
Dictionary<string, bool> usedImages = imageDirectory.Exists
|
||||
? imageDirectory.EnumerateFiles().ToDictionary(file => file.FullName, _ => false)
|
||||
: [];
|
||||
List<FileInfo> notFoundImages = [];
|
||||
|
||||
ImageScanResult draftResult = await ScanUnusedImagesInternal(contents.Drafts, drafts);
|
||||
ImageScanResult postResult = await ScanUnusedImagesInternal(contents.Posts, posts);
|
||||
|
||||
unusedFiles.AddRange(draftResult.UnusedImages);
|
||||
notFoundFiles.AddRange(draftResult.NotFoundImages);
|
||||
unusedFiles.AddRange(postResult.UnusedImages);
|
||||
notFoundFiles.AddRange(postResult.NotFoundImages);
|
||||
|
||||
return new ImageScanResult(unusedFiles, notFoundFiles);
|
||||
}
|
||||
|
||||
private static Task<ImageScanResult> ScanUnusedImagesInternal(IEnumerable<BlogContent> contents,
|
||||
DirectoryInfo root)
|
||||
{
|
||||
ConcurrentBag<FileInfo> unusedImage = [];
|
||||
ConcurrentBag<FileInfo> notFoundImage = [];
|
||||
|
||||
Parallel.ForEach(contents, content =>
|
||||
foreach (Match match in matchResult)
|
||||
{
|
||||
MatchCollection result = ImagePattern.Matches(content.FileContent);
|
||||
DirectoryInfo imageDirectory = new(Path.Combine(root.FullName, content.FileName));
|
||||
string imageName = match.Groups[1].Value;
|
||||
|
||||
Dictionary<string, bool> usedDictionary;
|
||||
// 判断md文件中的图片名称中是否包含文件夹名称
|
||||
// 例如 blog-1/image.png 或者 image.png
|
||||
// 如果不带文件夹名称
|
||||
// 默认添加同博客名文件夹
|
||||
FileInfo usedFile = imageName.Contains(blogName)
|
||||
? new FileInfo(Path.Combine(directory.FullName, imageName))
|
||||
: new FileInfo(Path.Combine(directory.FullName, blogName, imageName));
|
||||
|
||||
if (imageDirectory.Exists)
|
||||
if (usedImages.TryGetValue(usedFile.FullName, out _))
|
||||
{
|
||||
usedDictionary = (from file in imageDirectory.EnumerateFiles()
|
||||
select new KeyValuePair<string, bool>(file.FullName, false)).ToDictionary();
|
||||
usedImages[usedFile.FullName] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
usedDictionary = [];
|
||||
notFoundImages.Add(usedFile);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match match in result)
|
||||
{
|
||||
string imageName = match.Groups[1].Value;
|
||||
List<BlogImageInfo> images = (await Task.WhenAll((from pair in usedImages
|
||||
select GetImageInfo(new FileInfo(pair.Key), pair.Value)).ToArray())).ToList();
|
||||
|
||||
FileInfo usedFile = imageName.Contains(content.FileName)
|
||||
? new FileInfo(Path.Combine(root.FullName, imageName))
|
||||
: new FileInfo(Path.Combine(root.FullName, content.FileName, imageName));
|
||||
return new ImageResult(images, notFoundImages);
|
||||
}
|
||||
|
||||
if (usedDictionary.TryGetValue(usedFile.FullName, out _))
|
||||
{
|
||||
usedDictionary[usedFile.FullName] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFoundImage.Add(usedFile);
|
||||
}
|
||||
}
|
||||
private static async Task<BlogImageInfo> GetImageInfo(FileInfo file, bool isUsed)
|
||||
{
|
||||
byte[] image = await File.ReadAllBytesAsync(file.FullName);
|
||||
|
||||
foreach (KeyValuePair<string, bool> pair in usedDictionary.Where(p => !p.Value))
|
||||
{
|
||||
unusedImage.Add(new FileInfo(pair.Key));
|
||||
}
|
||||
});
|
||||
if (file.Extension is ".jpg" or ".jpeg" or ".png")
|
||||
{
|
||||
ImageInfo imageInfo =
|
||||
await ImageJob.GetImageInfoAsync(MemorySource.Borrow(image), SourceLifetime.NowOwnedAndDisposedByTask);
|
||||
|
||||
return Task.FromResult(new ImageScanResult(unusedImage.ToList(), notFoundImage.ToList()));
|
||||
return new BlogImageInfo(file, imageInfo.ImageWidth, imageInfo.ImageWidth, imageInfo.PreferredMimeType,
|
||||
image, isUsed);
|
||||
}
|
||||
|
||||
return new BlogImageInfo(file, 0, 0, file.Extension switch
|
||||
{
|
||||
"svg" => "image/svg",
|
||||
"avif" => "image/avif",
|
||||
_ => string.Empty
|
||||
}, image, isUsed);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\!\[.*?\]\((.*?)\)")]
|
||||
private static partial Regex ImagePattern { get; }
|
||||
|
||||
private void ValidateDirectory(string root, out DirectoryInfo drafts, out DirectoryInfo posts)
|
||||
|
||||
private DirectoryInfo ValidateRootDirectory()
|
||||
{
|
||||
root = Path.Combine(Environment.CurrentDirectory, root);
|
||||
DirectoryInfo rootDirectory = new(root);
|
||||
DirectoryInfo rootDirectory = new(Path.Combine(Environment.CurrentDirectory, _blogOptions.Root));
|
||||
|
||||
if (!rootDirectory.Exists)
|
||||
{
|
||||
throw new BlogFileException($"'{root}' is not a directory.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}' is not a directory.");
|
||||
}
|
||||
|
||||
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
|
||||
return rootDirectory;
|
||||
}
|
||||
|
||||
private void ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts)
|
||||
{
|
||||
if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
|
||||
{
|
||||
throw new BlogFileException($"'{root}/drafts' not exists.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}/drafts' not exists.");
|
||||
}
|
||||
|
||||
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
|
||||
if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
|
||||
{
|
||||
throw new BlogFileException($"'{root}/posts' not exists.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}/posts' not exists.");
|
||||
}
|
||||
|
||||
drafts = new DirectoryInfo(Path.Combine(root, "drafts"));
|
||||
posts = new DirectoryInfo(Path.Combine(root, "posts"));
|
||||
drafts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "drafts"));
|
||||
posts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "posts"));
|
||||
}
|
||||
}
|
||||
|
108
YaeBlog/Services/ImageCompressService.cs
Normal file
108
YaeBlog/Services/ImageCompressService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Imageflow.Fluent;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Core.Exceptions;
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Services;
|
||||
|
||||
public sealed class ImageCompressService(IEssayScanService essayScanService, ILogger<ImageCompressService> logger)
|
||||
{
|
||||
private record struct CompressResult(BlogImageInfo ImageInfo, byte[] CompressContent);
|
||||
|
||||
public async Task<List<BlogImageInfo>> ScanUsedImages()
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
List<BlogImageInfo> originalImages = (from content in contents.Posts.Concat(contents.Drafts)
|
||||
from image in content.Images
|
||||
where image.IsUsed
|
||||
select image).ToList();
|
||||
|
||||
originalImages.Sort();
|
||||
|
||||
return originalImages;
|
||||
}
|
||||
|
||||
public async Task Compress(bool dryRun)
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
// 筛选需要压缩的图片
|
||||
// 即图片被博客使用且是jpeg/png格式
|
||||
List<BlogContent> needCompressContents = (from content in contents
|
||||
where content.Images.Any(i => i is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" })
|
||||
select content).ToList();
|
||||
|
||||
if (needCompressContents.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int uncompressedSize = 0;
|
||||
int compressedSize = 0;
|
||||
List<BlogContent> compressedContent = new(needCompressContents.Count);
|
||||
|
||||
foreach (BlogContent content in needCompressContents)
|
||||
{
|
||||
List<BlogImageInfo> uncompressedImages = (from image in content.Images
|
||||
where image is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" }
|
||||
select image).ToList();
|
||||
|
||||
uncompressedSize += uncompressedImages.Select(i => i.Size).Sum();
|
||||
|
||||
foreach (BlogImageInfo image in uncompressedImages)
|
||||
{
|
||||
logger.LogInformation("Uncompressed image: {} belonging to blog {}.", image.File.Name,
|
||||
content.BlogName);
|
||||
}
|
||||
|
||||
CompressResult[] compressedImages = (await Task.WhenAll(from image in uncompressedImages
|
||||
select Task.Run(async () => new CompressResult(image, await ConvertToWebp(image.Content))))).ToArray();
|
||||
|
||||
compressedSize += compressedImages.Select(i => i.CompressContent.Length).Sum();
|
||||
|
||||
// 直接在原有的图片列表上添加图片
|
||||
List<BlogImageInfo> images = content.Images.Concat(from r in compressedImages
|
||||
select r.ImageInfo with
|
||||
{
|
||||
File = new FileInfo(r.ImageInfo.File.FullName.Split('.')[0] + ".webp"),
|
||||
Content = r.CompressContent
|
||||
}).ToList();
|
||||
// 修改文本
|
||||
string blogContent = compressedImages.Aggregate(content.Content, (c, r) =>
|
||||
{
|
||||
string originalName = r.ImageInfo.File.Name;
|
||||
string outputName = originalName.Split('.')[0] + ".webp";
|
||||
|
||||
return c.Replace(originalName, outputName);
|
||||
});
|
||||
|
||||
compressedContent.Add(content with { Images = images, Content = blogContent });
|
||||
}
|
||||
|
||||
logger.LogInformation("Compression ratio: {}%.", (double)compressedSize / uncompressedSize * 100.0);
|
||||
|
||||
if (dryRun is false)
|
||||
{
|
||||
await Task.WhenAll(from content in compressedContent
|
||||
select essayScanService.SaveBlogContent(content, content.IsDraft));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ConvertToWebp(byte[] image)
|
||||
{
|
||||
using ImageJob job = new();
|
||||
BuildJobResult result = await job.Decode(MemorySource.Borrow(image))
|
||||
.EncodeToBytes(new WebPLossyEncoder(75))
|
||||
.Finish()
|
||||
.InProcessAsync();
|
||||
|
||||
ArraySegment<byte>? array = result.First?.TryGetBytes();
|
||||
|
||||
if (array.HasValue)
|
||||
{
|
||||
return array.Value.ToArray();
|
||||
}
|
||||
|
||||
throw new BlogFileException();
|
||||
}
|
||||
}
|
@@ -41,14 +41,14 @@ public partial class RendererService(
|
||||
uint wordCount = GetWordCount(content);
|
||||
BlogEssay essay = new()
|
||||
{
|
||||
Title = content.Metadata.Title ?? content.FileName,
|
||||
FileName = content.FileName,
|
||||
Title = content.Metadata.Title ?? content.BlogName,
|
||||
FileName = content.BlogName,
|
||||
IsDraft = content.IsDraft,
|
||||
Description = GetDescription(content),
|
||||
WordCount = wordCount,
|
||||
ReadTime = CalculateReadTime(wordCount),
|
||||
PublishTime = content.Metadata.Date ?? DateTime.Now,
|
||||
HtmlContent = content.FileContent
|
||||
HtmlContent = content.Content
|
||||
};
|
||||
|
||||
if (content.Metadata.Tags is not null)
|
||||
@@ -156,17 +156,17 @@ public partial class RendererService(
|
||||
private string GetDescription(BlogContent content)
|
||||
{
|
||||
const string delimiter = "<!--more-->";
|
||||
int pos = content.FileContent.IndexOf(delimiter, StringComparison.Ordinal);
|
||||
int pos = content.Content.IndexOf(delimiter, StringComparison.Ordinal);
|
||||
bool breakSentence = false;
|
||||
|
||||
if (pos == -1)
|
||||
{
|
||||
// 自动截取前50个字符
|
||||
pos = content.FileContent.Length < 50 ? content.FileContent.Length : 50;
|
||||
pos = content.Content.Length < 50 ? content.Content.Length : 50;
|
||||
breakSentence = true;
|
||||
}
|
||||
|
||||
string rawContent = content.FileContent[..pos];
|
||||
string rawContent = content.Content[..pos];
|
||||
MatchCollection matches = DescriptionPattern.Matches(rawContent);
|
||||
|
||||
StringBuilder builder = new();
|
||||
@@ -182,18 +182,18 @@ public partial class RendererService(
|
||||
|
||||
string description = builder.ToString();
|
||||
|
||||
logger.LogDebug("Description of {} is {}.", content.FileName,
|
||||
logger.LogDebug("Description of {} is {}.", content.BlogName,
|
||||
description);
|
||||
return description;
|
||||
}
|
||||
|
||||
private uint GetWordCount(BlogContent content)
|
||||
{
|
||||
int count = (from c in content.FileContent
|
||||
int count = (from c in content.Content
|
||||
where char.IsLetterOrDigit(c)
|
||||
select c).Count();
|
||||
|
||||
logger.LogDebug("Word count of {} is {}", content.FileName,
|
||||
logger.LogDebug("Word count of {} is {}", content.BlogName,
|
||||
count);
|
||||
return (uint)count;
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ImageFlow.NativeRuntime.ubuntu-x86_64" Version="2.1.0-rc11"/>
|
||||
<PackageReference Include="ImageFlow.Net" Version="0.13.2"/>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.0"/>
|
||||
<PackageReference Include="Markdig" Version="0.38.0"/>
|
||||
@@ -13,7 +15,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="Build">
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
@@ -25,9 +27,13 @@
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled">
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild" Condition="'$(_IsPublishing)' == 'yes'">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwind -i wwwroot/input.css -o wwwroot/output.css"/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o $(IntermediateOutputPath)tailwind.g.css"/>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="$(IntermediateOutputPath)tailwind.g.css" Visible="false" TargetPath="wwwroot/tailwind.g.css"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "YaeBlog",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.16"
|
||||
}
|
||||
"name": "yae-blog",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"dev": "tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css -w"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
1107
YaeBlog/pnpm-lock.yaml
generated
1107
YaeBlog/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,884 +0,0 @@
|
||||
---
|
||||
title: async/await究竟是如何工作的?
|
||||
tags:
|
||||
- dotnet
|
||||
- 技术笔记
|
||||
- 译文
|
||||
---
|
||||
|
||||
### 译者按
|
||||
|
||||
如何正确而快速的编写异步运行的代码一直是软件工程界的难题,而C#提出的`async/await`范式无疑是探索道路上的先行者。本篇文章便是翻译自.NET开发者博客上一篇名为“How async/await really works in C#”的文章,希望能够让读者在阅读之后明白`async/await`编程范式的前世今生和`.NET`实现方式。另外,.Net开发者中文博客也翻译了[这篇文章](https://devblogs.microsoft.com/dotnet-ch/async-await%e5%9c%a8-c%e8%af%ad%e8%a8%80%e4%b8%ad%e6%98%af%e5%a6%82%e4%bd%95%e5%b7%a5%e4%bd%9c%e7%9a%84/),一并供读者参考。
|
||||
|
||||
---
|
||||
|
||||
数周前,[.NET开发者博客](https://devblogs.microsoft.com/dotnet/)发布了一篇题为[什么是.NET,为什么你应该选择.NET](https://devblogs.microsoft.com/dotnet/why-dotnet/)的文章。文章中从宏观上概览了整个`dotnet`生态系统,总结了系统中的各个部分和其中的设计决定;文章还承诺在未来推出一系列的深度文章介绍涉及到的方方面面。这篇文章便是这系列文章中的第一篇,深入介绍C#和.NET中`async/await`的历史、设计决定和实现细节。
|
||||
|
||||
对于`async/await`的支持大约在十年前就提供了。在这段时间里,`async/await`语法大幅改变了编写可扩展.NET代码的方式,同时该语法使得在不了解`async/await`工作原理的情况下使用它提供的功能编写异步代码也是十分容易和常见的。以下面的**同步**方法为例:(因为这个方法的调用者在整个操作完成之前、将控制权返回给它之前都不能进行任何操作,所以这个方法被称为**同步**)
|
||||
|
||||
```csharp
|
||||
// 将数据同步地从源复制到目的地
|
||||
public void CopyStreamToStream(Stream source, Stream destination)
|
||||
{
|
||||
var buffer = new byte[0x1000];
|
||||
int numRead;
|
||||
while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
|
||||
{
|
||||
destination.Write(buffer, 0, numRead);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在这个方法的基础上,你只需要修改几个关键词、改变几个方法的名称,就可以得到一个**异步**的方法(因为这个方法将很快,往往实在所有的工作完成之前,就会将控制权返回给它的调用者,所以被称作异步方法)。
|
||||
|
||||
```csharp
|
||||
// 将数据异步地从源复制到目的地
|
||||
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
|
||||
{
|
||||
var buffer = new byte[0x1000];
|
||||
int numRead;
|
||||
while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
|
||||
{
|
||||
await destination.WriteAsync(buffer, 0, numRead);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
有着几乎相同的语法,类似的控制流结构,但是现在这个方法在执行过程中不会阻塞,有着完全不同的底层执行模型,而且C#编译器和核心库帮你完成所有这些复杂的工作。
|
||||
|
||||
尽管在不了解底层原理的基础上使用这类技术是十分普遍的,但是我们坚持认为了解这些事务的运行原理将会帮助我们更好的利用它们。之于`async/await`,了解这些原理将在你需要深入探究时十分有用,例如当你需要调试一段错误的代码或者优化某段正确运行代码的运行效率时。在这篇文章中,我们将深入了解`async/await`具体如何在语言、编译器和库层面运行,然后你将更好地利用这些优秀的设计。
|
||||
|
||||
为了更好的理解这一切,我们将回到没有`async/await`的时代,看看在没有它们的情况下最优秀的异步代码是如何编写的。平心而论,这些代码看上去并不好。
|
||||
|
||||
### 原初的历史
|
||||
|
||||
回到.NET框架1.0时代,当时流行的异步编程范式是**异步编程模型**,“Asynchronous Programming Model”,也被称作`APM`范式、`Being/End`范式或者`IAsyncResult`范式。从宏观上来看,这种范式是相当简单的。例如对于一个同步操作`DoStuff`:
|
||||
|
||||
```csharp
|
||||
class Handler
|
||||
{
|
||||
public int DoStuff(string arg);
|
||||
}
|
||||
```
|
||||
|
||||
在这种编程模型下会存在两个相关的方法:一个`BeginStuff`方法和一个`EndStuff`方法:
|
||||
|
||||
```csharp
|
||||
class Handler
|
||||
{
|
||||
public int DoStuff(string arg);
|
||||
|
||||
public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
|
||||
public int EndDoStuff(IAsyncResult asyncResult);
|
||||
}
|
||||
```
|
||||
|
||||
`BeginStuff`方法首先会接受所有`DoStuff`方法会接受的参数,同时其会接受一个`AsyncCallback`回调和一个**不透明**的状态对象`state`,而且这两个参数都可以为空。这个“开始”方法将负责异步操作的初始化,而且如果提供了回调函数,这个函数还会负责在异步操作完成之后调用这个回调函数,因此这个回调函数也常常被称为初始化操作的“下一步”。开始方法还会负责构建一个实现了`IAsyncResult`接口的对象,这个对象中的`AsyncState`属性由可选的`state`参数提供:
|
||||
|
||||
```csharp
|
||||
namespace System
|
||||
{
|
||||
public interface IAsyncResult
|
||||
{
|
||||
object? AsyncState { get; }
|
||||
WaitHandle AsyncWaitHandle { get; }
|
||||
bool IsCompleted { get; }
|
||||
bool CompletedSynchronously { get; }
|
||||
}
|
||||
|
||||
public delegate void AsyncCallback(IAsyncResult ar);
|
||||
}
|
||||
```
|
||||
|
||||
这个`IAsynResult`实例将会被开始方法返回,在调用`AsyncCallback`时这个实例也会被传递过去。当准备好使用该异步操作的结果时,调用者也会将这个`IAsyncResult`实例传递给结束方法,同时结束方法也会负责保证这个异步操作完成,如果没有完成该方法就会阻塞代码的运行直到完成。结束方法会返回异步操作的结果,异步操作过程中引发的各种错误和异常也会通过该方法传递出来。因此,对于下面这种同步的操作:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
int i = handler.DoStuff(arg);
|
||||
Use(i);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
... // 在这里处理DoStuff方法和Use方法中引发的各种异常
|
||||
}
|
||||
```
|
||||
|
||||
可以使用开始/结束方法改写为异步运行的形式:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
handler.BeginDoStuff(arg, iar =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Handler handler = (Handler)iar.AsyncState!;
|
||||
int i = handler.EndDoStuff(iar);
|
||||
Use(i);
|
||||
}
|
||||
catch (Exception e2)
|
||||
{
|
||||
... // 处理从EndDoStuff方法和Use方法中引发的各种异常
|
||||
}
|
||||
}, handler);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
... // 处理从同步调用BeginDoStuff方法引发的各种异常
|
||||
}
|
||||
```
|
||||
|
||||
对于熟悉使用含有回调`API`语言的开发者来说,这样的代码应该会显得相当眼熟。
|
||||
|
||||
但是事情在这里变得更加复杂了。例如,这段代码存在“栈堆积”`stack dive`的问题。栈堆积就是代码在重复的调用方法中使得栈越来越深,直到发生栈溢出的现象。如果“异步”操作同步完成,开始方法将会使同步的调用回调方法,这就意味着对于开始方法的调用就会直接调用回调方法。同时考虑到“异步”方法同步完成却是一种非常常见的现象,它们只是承诺会异步的完成操作而不是只被允许异步的完成。例如一个对于某个网络操作的异步操作,比如读取一个套接字,如果你只需要从一次操作中读取少量的数据,例如在一次回答中只需要读取少量响应头的数据,你可能会直接读取大量数据存储在缓冲区中。相比于每次使用都使用系统调用但是只读取少量的数据,你一次读取了大量数据在缓冲区中,并在缓冲区失效之前都是从缓冲区中读取,这样就减少了需要调用昂贵的系统调用来和套接字交互的次数。像这样的缓冲区可能在你进行任何异步调用之后存在,例如第一次操作异步的完成对于缓冲区的填充,之后的若干次“异步”操作都不需要同I/O进行任何交互而直接通过与缓冲区的同步交互完成,直到缓冲区失效之后再次异步的填充缓冲区。因此当开始方法进行上述的一次调用时,开始方法会发现操作同步地完成了,因此开始方法同步地调用回调方法。此时,你有一个调用了开始方法的栈帧和一个调用了回调方法的栈帧。想想看如果回调方法再次调用了开始方法会发生什么?如果开始方法和回调方法都是被同步调用的,现在你就会在站上得到多个重复的栈帧,如此重复下去直到将栈上的空间耗尽。
|
||||
|
||||
这并不是杞人忧天,使用下面这段代码就可以很容易的复现这个问题:
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
listener.Listen();
|
||||
|
||||
using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(listener.LocalEndPoint!);
|
||||
|
||||
using Socket server = listener.Accept();
|
||||
_ = server.SendAsync(new byte[100_000]);
|
||||
|
||||
var mres = new ManualResetEventSlim();
|
||||
byte[] buffer = new byte[1];
|
||||
|
||||
var stream = new NetworkStream(client);
|
||||
|
||||
void ReadAgain()
|
||||
{
|
||||
stream.BeginRead(buffer, 0, 1, iar =>
|
||||
{
|
||||
if (stream.EndRead(iar) != 0)
|
||||
{
|
||||
ReadAgain(); // uh oh!
|
||||
}
|
||||
else
|
||||
{
|
||||
mres.Set();
|
||||
}
|
||||
}, null);
|
||||
};
|
||||
ReadAgain();
|
||||
|
||||
mres.Wait();
|
||||
```
|
||||
|
||||
在代码中我们建立一个简单的客户端套接字和一个简单的服务端套接字并让它们连接。服务端会向客户端发送十万字节的信息,而客户端会使用开始/结束方法尝试去“异步的”接收这些信息(需要注意这样做是十分低效的,在教学实例之外的地方都不应该这样编写代码)。传递给`BeingRead`的回调函数通过调用`EndRead`方法停止读取,如果在读取过程中读取到数据(意味着还没有读取完成),就通过对于本地方法`ReadAgain`的递归调用来再次调用`BeingRead`方法继续读取。值得指出的是,在.NET Core中套接字操作比原来在.NET Framework中的版本快上许多,同时如果操作系统可以同步的完成这些操作,那么.NET Core中的操作也会同步完成(需要注意操作系统内核也有一个缓冲区来完成套接字接收操作)。因此,运行这段代码就会出现栈溢出。
|
||||
|
||||
鉴于这个问题非常容易出现,因此`APM`模型中内建了缓解这个问题的方法。容易想到有两种方法可以缓解这个问题:
|
||||
|
||||
1. 不允许`AsyncCallback`被同步调用。如果该回调方法始终都是被异步调用的,即使操作是异步完成的,栈堆叠的方法也就不存在了。但是这样做会降低性能,因为同步完成的操作(或者快到难以注意到的操作)是相当的常见的,强制这些操作的回调排队完成会增加相当可观的开销。
|
||||
2. 引入一个机制让调用者而不是回调函数在工作异步完成时完成剩余的工作。在这种情况下,我们就避免了引入额外的栈帧,在不增加栈深度的情况下完成了余下的工作。
|
||||
|
||||
`APM`模型使用了第二种方法。为了实现这个方法,`IAsyncResult`接口提供了另外两个成员:`IsCompleted`和`CompletedSynchronusly`。`IsCompeleted`成员告诉我们操作是否完成,在程序中可以反复检查这个成员直到它从`false`变成`true`。相对的,`CompletedSynchronously`在运行过程中不会变化,(或者它存在一个还未被发现的`bug`会导致这个值变化,笑),这个值的主要作用是判断后续的工作是应该由开始方法的调用者还是`AsyncCallback`来进行。如果`CompletedSynchronously`的值是`false`,说明这个操作是异步进行的,所有后续的工作应该由回调函数来进行处理;毕竟,如果工作是异步完成的,开始方法的调用者不能知道工作是何时完成的(如果开始方法的调用者调用了结束方法,那么结束方法就会阻塞直到工作完成)。反之,如果`CompletedSynchronously`的值是`true`,如果此时使用回调方法处理后续的工作就会引发栈堆叠问题,因为此时回调方法会在栈上比开始它更低的位置上进行后续的操作。因此任何在意栈堆叠问题的实现需要关注`CompletedSynchronously`的值,当为真的时候,让开始方法的调用者处理后续的工作,而回调方法在此时不应处理任何工作。这也是为什么`CompletedSynchronously`的值不能改变——开始方法的调用者和回调方法需要相同的值来保证后续工作在任何情况下都进行且只进行一次。
|
||||
|
||||
因此我们之前的`DoStuff`实例就需要被修改为:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
IAsyncResult ar = handler.BeginDoStuff(arg, iar =>
|
||||
{
|
||||
if (!iar.CompletedSynchronously)
|
||||
{
|
||||
try
|
||||
{
|
||||
Handler handler = (Handler)iar.AsyncState!;
|
||||
int i = handler.EndDoStuff(iar);
|
||||
Use(i);
|
||||
}
|
||||
catch (Exception e2)
|
||||
{
|
||||
... // handle exceptions from EndDoStuff and Use
|
||||
}
|
||||
}
|
||||
}, handler);
|
||||
if (ar.CompletedSynchronously)
|
||||
{
|
||||
int i = handler.EndDoStuff(ar);
|
||||
Use(i);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
... // handle exceptions that emerge synchronously from BeginDoStuff and possibly EndDoStuff/Use
|
||||
}
|
||||
```
|
||||
|
||||
这里的代码已经~~显得冗长~~,而且我们还只研究了如何使用这种范式,还没有涉及如何实现这种范式。尽管大部分的开发者并不需要在这些子调用(例如实现`Socket.BeginReceive/EndReceive`这些方法去和操作系统交互),但是很多开发者需要组合这些操作(从一个“较大的”的异步操作调用多个异步操作),而这不仅需要使用其他的开始/结束方法,还需要自行实现你自己的开始/结束方法,这样你才能在其他的地方使用这个操作。同时,你还会注意到在上述的`DoStuff`范例中没有任何的控制流代码。如果需要引入一些控制流代码——即使是一个简单的循环——这也会立刻变成~~抖M才会编写的代码~~,同时也给无数的博客作者提供水`CSDN`的好题材。
|
||||
|
||||
所以让我们现在就来写一篇`CSDN`,给出一个完成的实例。在文章的开头我展示了一个`CopyStreamToStream`方法,这个方式会将一个流中的数据复制到另外一个流中(就是`Stream.CopyTo`方法所完成的工作,但是为了说明,让我们假设这个方法并不存在):
|
||||
|
||||
```csharp
|
||||
public void CopyStreamToStream(Stream source, Stream destination)
|
||||
{
|
||||
var buffer = new byte[0x1000];
|
||||
int numRead;
|
||||
while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
|
||||
{
|
||||
destination.Write(buffer, 0, numRead);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
直白的说,我们只需要不停的从一个流中读取数据然后写入到另外一个流中,直到我们没法从第一个流中读取到任何数据。现在让我们使用`APM`模型使用这个操作的异步模式吧:
|
||||
|
||||
```csharp
|
||||
public IAsyncResult BeginCopyStreamToStream(
|
||||
Stream source, Stream destination,
|
||||
AsyncCallback callback, object state)
|
||||
{
|
||||
var ar = new MyAsyncResult(state);
|
||||
var buffer = new byte[0x1000];
|
||||
|
||||
Action<IAsyncResult?> readWriteLoop = null!;
|
||||
readWriteLoop = iar =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (bool isRead = iar == null; ; isRead = !isRead)
|
||||
{
|
||||
if (isRead)
|
||||
{
|
||||
iar = source.BeginRead(buffer, 0, buffer.Length, static readResult =>
|
||||
{
|
||||
if (!readResult.CompletedSynchronously)
|
||||
{
|
||||
((Action<IAsyncResult?>)readResult.AsyncState!)(readResult);
|
||||
}
|
||||
}, readWriteLoop);
|
||||
|
||||
if (!iar.CompletedSynchronously)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int numRead = source.EndRead(iar!);
|
||||
if (numRead == 0)
|
||||
{
|
||||
ar.Complete(null);
|
||||
callback?.Invoke(ar);
|
||||
return;
|
||||
}
|
||||
|
||||
iar = destination.BeginWrite(buffer, 0, numRead, writeResult =>
|
||||
{
|
||||
if (!writeResult.CompletedSynchronously)
|
||||
{
|
||||
try
|
||||
{
|
||||
destination.EndWrite(writeResult);
|
||||
readWriteLoop(null);
|
||||
}
|
||||
catch (Exception e2)
|
||||
{
|
||||
ar.Complete(e);
|
||||
callback?.Invoke(ar);
|
||||
}
|
||||
}
|
||||
}, null);
|
||||
|
||||
if (!iar.CompletedSynchronously)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
destination.EndWrite(iar);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ar.Complete(e);
|
||||
callback?.Invoke(ar);
|
||||
}
|
||||
};
|
||||
|
||||
readWriteLoop(null);
|
||||
|
||||
return ar;
|
||||
}
|
||||
|
||||
public void EndCopyStreamToStream(IAsyncResult asyncResult)
|
||||
{
|
||||
if (asyncResult is not MyAsyncResult ar)
|
||||
{
|
||||
throw new ArgumentException(null, nameof(asyncResult));
|
||||
}
|
||||
|
||||
ar.Wait();
|
||||
}
|
||||
|
||||
private sealed class MyAsyncResult : IAsyncResult
|
||||
{
|
||||
private bool _completed;
|
||||
private int _completedSynchronously;
|
||||
private ManualResetEvent? _event;
|
||||
private Exception? _error;
|
||||
|
||||
public MyAsyncResult(object? state) => AsyncState = state;
|
||||
|
||||
public object? AsyncState { get; }
|
||||
|
||||
public void Complete(Exception? error)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
_completed = true;
|
||||
_error = error;
|
||||
_event?.Set();
|
||||
}
|
||||
}
|
||||
|
||||
public void Wait()
|
||||
{
|
||||
WaitHandle? h = null;
|
||||
lock (this)
|
||||
{
|
||||
if (_completed)
|
||||
{
|
||||
if (_error is not null)
|
||||
{
|
||||
throw _error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
h = _event ??= new ManualResetEvent(false);
|
||||
}
|
||||
|
||||
h.WaitOne();
|
||||
if (_error is not null)
|
||||
{
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
|
||||
public WaitHandle AsyncWaitHandle
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
return _event ??= new ManualResetEvent(_completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool CompletedSynchronously
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
if (_completedSynchronously == 0)
|
||||
{
|
||||
_completedSynchronously = _completed ? 1 : -1;
|
||||
}
|
||||
|
||||
return _completedSynchronously == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCompleted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
return _completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
~~Yowsers~~。即使写完了这些繁文缛节,这实际上仍然不是一个完美的实现。例如,`IAsyncResult`的实现会在每次操作时上锁,而不是在任何可能的时候都使用无锁的实现;异常也是以原始的模型存储,如果使用`ExceptionDispatchInfo`可以让异常在传播的过程中含有调用栈的信息,在每次操作中都分配了大量的空间来存储变量(例如在每次`BeingWrite`调用时都会分配一片空间来存储委托),如此等等。现在想象这就是你每次编写方法时需要做的工作,每次当你需要编写一个可重用的异步方法来使用另外一个异步方法时,你需要自己完成上述所有的工作。而且如果你需要编写使用多个不同的`IAsyncResult`的可重用代码——就像在`async/await`范式中`Task.WhenAll`所完成的那样,难度又上升了一个等级;每个不同操作都会实现并暴露针对相关的`API`,这让编写一套逻辑代码并简单的复用它们也变得不可能(尽管一些库作者可能会通过提供一层针对回调方法的新抽象来方便开发者编写需要访问暴露`API`的回调方法)。
|
||||
|
||||
上述这些复杂性也说明只有很少的一部分人尝试过这样编写代码,而且对于这些人来说,`bug`也往往如影随形。而且这并不是一个`APM`范式的黑点,这是所有使用基于回调的异步方法都具有的缺点。我们已经十分习惯现代语言都有的控制流结构所带来的强大和便利,因此使用会破坏这种结构的基于回调的异步方式会带来大量的复杂性也是可以理解的。同时,也没有任何主流的语言提供了更好的替代。
|
||||
|
||||
我们需要一种更好的办法,一个既继承了我们在`APM`范式中所学习到所有经验也规避了其所有的各种缺点的方式。一个有趣的点是,`APM`范式只是一种编程范式,运行时、核心库和编译器在使用或者实现这种范式的过程中没有提供任何协助。
|
||||
|
||||
### 基于事件的异步范式
|
||||
|
||||
在.NET Framework 2.0中提供了一系列的`API`来实现一种不同的异步编程范式,当时设想这种范式的主要应用场景是客户端应用程序。这种基于事件的异步范式,也被称作`EAP`范式,也是以提供一系列成员的方式提供的,包含一个用于初始化异步操作的方式和一个监听异步操作是否完成的事件。因此上述的`DoStuff`示例可能会暴露如下的一系列成员:
|
||||
|
||||
```csharp
|
||||
class Handler
|
||||
{
|
||||
public int DoStuff(string arg);
|
||||
|
||||
public void DoStuffAsync(string arg, object? userToken);
|
||||
public event DoStuffEventHandler? DoStuffCompleted;
|
||||
}
|
||||
|
||||
public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);
|
||||
|
||||
public class DoStuffEventArgs : AsyncCompletedEventArgs
|
||||
{
|
||||
public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
|
||||
base(error, canceled, usertoken) => Result = result;
|
||||
|
||||
public int Result { get; }
|
||||
}
|
||||
```
|
||||
|
||||
首先通过`DoStuffCompleted`事件注册需要在完成异步操作时进行的工作然后调用`DoStuff`方法,这个方法将初始化异步操作,一旦异步操作完成,`DoStuffCompleted`事件将会被调用者引发。已经注册的回调方法可以运行剩余的工作,例如验证提供的`userToken`是否是期望的`userToken`,同时我们可以注册多个回调方法在异步操作完成的时候运行。
|
||||
|
||||
这个范式确实让一系列用例的编写更好编写,同时也让一系列用例变得更加复杂(例如上述的`CopyStreamToStream`例子)。这种范式的影响范围并不大,只在一次.NET Framework的更新中引入便匆匆地消失了,除了留下了一系列为了支持这种范式而实现的`API`,例如:
|
||||
|
||||
```csharp
|
||||
class Handler
|
||||
{
|
||||
public int DoStuff(string arg);
|
||||
|
||||
public void DoStuffAsync(string arg, object? userToken);
|
||||
public event DoStuffEventHandler? DoStuffCompleted;
|
||||
}
|
||||
|
||||
public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);
|
||||
|
||||
public class DoStuffEventArgs : AsyncCompletedEventArgs
|
||||
{
|
||||
public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
|
||||
base(error, canceled, usertoken) => Result = result;
|
||||
|
||||
public int Result { get; }
|
||||
}
|
||||
```
|
||||
|
||||
但是这种编程范式确实在`APM`范式所没有注意到的地方前进了一大步,并且这一点还保留到了我们今天所介绍的模型中:[同步上下文](https://github.com/dotnet/runtime/blob/967a59712996c2cdb8ce2f65fb3167afbd8b01f3/src/libraries/System.Private.CoreLib/src/System/Threading/SynchronizationContext.cs#L6) (`SynchronizationContext`)。
|
||||
|
||||
同步上下文作为一个对于通用调度器的实现,也是在.NET Framework中引入的。在实践中,同步上下文最常用的方法是`Post`,这个方法将一个工作实现传递给上下文所代表的一种调度器。举例来说,一个基础的同步上下文实现是一个线程池`ThreadPool`,因此`Post`方法的典型实现就是`ThreadPool.QueueUserWorkItem`方法,这个方法将让线程池在池中任意的线程上以指定的状态调用指定的委托。然而,同步上下文的巧妙之处不仅在于提供了对于不同调度器的支持,而是提供了一种针对不同的应用模型使用不同调度方法的抽象能力。
|
||||
|
||||
考虑像Windows Forms之类的`UI`框架。对于大多数工作在Windows上的`UI`框架来说,控件往往关联到一个特定的线程,这个线程负责运行一个消息管理中心,这个中心用来运行那些需要同控件交互的工作:只有这个控件有能力来修改控件,任何其他试图同控件进行交互的线程都需要发送消息到这个消息控制中心。Windows Forms通过一系列方法来实现这一点,例如`Control.BeingInvoke`,这类方法将会把提供的委托和参数传递给同这个控件相关联的线程来运行。你可以写出如下的代码:
|
||||
|
||||
```csharp
|
||||
private void button1_Click(object sender, EventArgs e)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
string message = ComputeMessage();
|
||||
button1.BeginInvoke(() =>
|
||||
{
|
||||
button1.Text = message;
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
这段代码首先将`ComputeMessage`方法交给线程池中的一个线程运行(这样可以保证该方法在运行时`UI`界面不会卡死),当上述工作完成之后,再将一个更新`button1`标签的委托传递给关联到`button1`的线程运行。简单而易于理解。在`WPF`框架中也是类似的逻辑,使用一个被称为`Dispatcher`的类型:
|
||||
|
||||
```csharp
|
||||
private void button1_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
string message = ComputeMessage();
|
||||
button1.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
button1.Content = message;
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`.NET MAUI`亦然。但是如果我想将这部分的逻辑封装到一个独立的辅助函数中,例如下面这种:
|
||||
|
||||
```csharp
|
||||
// 调用ComputeMessage然后触发更新逻辑
|
||||
internal static void ComputeMessageAndInvokeUpdate(Action<string> update) { ... }
|
||||
```
|
||||
|
||||
这样我就可以直接:
|
||||
|
||||
```csharp
|
||||
private void button1_Click(object sender, EventArgs e)
|
||||
{
|
||||
ComputeMessageAndInvokeUpdate(message => button1.Text = message);
|
||||
}
|
||||
```
|
||||
|
||||
但是`ComputerMessageAndInvokeUpdate`应该如何实现才能适配各种类型的应用程序呢?难道需要硬编码所有可能涉及的`UI`框架吗?这就是`SynchronizationContext`大显神威的地方,我们可以这样实现这个方法:
|
||||
|
||||
```csharp
|
||||
internal static void ComputeMessageAndInvokeUpdate(Action<string> update)
|
||||
{
|
||||
SynchronizationContext? sc = SynchronizationContext.Current;
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
string message = ComputeMessage();
|
||||
if (sc is not null)
|
||||
{
|
||||
sc.Post(_ => update(message), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
update(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
在这个实现中将`SynchronizationContext`作为同`UI`进行交互的调度器之抽象。任何应用程序模型都需要保证在`SynchronizationContext.Current`属性上注册一个继承了`SynchronizationContext`的类,这个就会完成调度相关的工作。例如在`Windows Forms`中:
|
||||
|
||||
```csharp
|
||||
public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable
|
||||
{
|
||||
public override void Post(SendOrPostCallback d, object? state) =>
|
||||
_controlToSendTo?.BeginInvoke(d, new object?[] { state });
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
在`WPF`中有:
|
||||
|
||||
```
|
||||
public sealed class DispatcherSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
public override void Post(SendOrPostCallback d, Object state) =>
|
||||
_dispatcher.BeginInvoke(_priority, d, state);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`ASP.NET`*曾经*也有过一个实现,尽管Web框架实际上并不关心是哪个线程在运行指定的工作,但是非常关心指定工作和那个请求相关,因此该实现主要负责保证多个线程不会在同时访问同一个`HttpContext`。
|
||||
|
||||
```csharp
|
||||
internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase
|
||||
{
|
||||
public override void Post(SendOrPostCallback callback, Object state) =>
|
||||
_state.Helper.QueueAsynchronous(() => callback(state));
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
这个概念也并不局限于像上面的主流应用程序模型。例如在[xunit](https://github.com/xunit/xunit),一个流行的单元测试框架(`.NET`核心代码仓库也使用了)中也实现了需要自定义的`SynchronizationContext`。例如限制同步运行单元测试时同时运行单元测试数量就可以用`SynchroniaztionContext`实现:
|
||||
|
||||
```
|
||||
public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable
|
||||
{
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
var context = ExecutionContext.Capture();
|
||||
workQueue.Enqueue((d, state, context));
|
||||
workReady.Set();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`MaxConcurrentSyncContext`中的`Post`方法只是将需要完成的工作压入其内部的工作队列中,这样就能够控制同时多少工作能够并行的运行。
|
||||
|
||||
那么同步上下文这个概念时如何同基于事件的异步范式关联起来的呢?`EAP`范式和同步上下文都是在同一时间引入的,而`EAP`范式要求当异步操作启动的时候,完成事件需要由当前`SynchronizationContext`进行调度。为了简化这个过程(可能反而引入多余的复杂性),在`System.ComponentModel`命名控件中引入了一些帮助程序,具体来说是`AsyncOperation`和`AsyncOperationManager`。其中前者是一个由用户提供的状态对象和捕获到的`SynchronizationContext`组成的元组,后者是一个捕获`SynchronizationContext`和创建`AsyncOperation`对象的工厂类。`EAP`范式会在实现中使用上述帮助类,例如`Ping.SendAsync`会首先调用`AsyncOperationManager.CreateOperationi`来捕获同步上下文,然后当异步操作完成的时候调用`AsyncOperation.PostOperationCompleted`方法来调用捕获到的`SynchronizationContext.Post`方法。
|
||||
|
||||
`SynchronizationContext`还提供了其他一些后面会用到的小工具。这个类暴露了`OperationStarted`和`OperationCompleted`两个方法。这个虚方法在基类中的实现都是空的,并不完成任何工作。但是继承其的实现可能会重载这些来了解运行中的操作。`EAP`的实现就会在每个操作开始和结束的时候调用`OperationStarted`和`OperationCompleted`,来方便可能存在的同步上下文跟踪工作的进度。鉴于在`EAP`范式中启动异步操作的方法往往不会返回任何东西,不能指望可以获得任何帮助你跟踪工作进度的东西,因而可能获得工作进度的同步上下文就显得很有价值了。
|
||||
|
||||
综上所说,我们需要一些比`APM`编程范式更好的东西,而`EAP`范式引入了一些新的东西,但是没有解决我们面对的核心问题,我们仍然需要一些更好的东西。
|
||||
|
||||
### 进入Task时代
|
||||
|
||||
在.NET Framework 4.0中引入了`System.Threading.Tasks.Task`类型。当时`Task`类型还只代表某些异步操作的最终完成(在其他编程框架中可能成称为`promise`或者`future`)。当一个操作开始时,创建一个`Task`来表示这个操作,当这个操作完成之后,操作的结果就会被保存在这个`Task`中。简单而明确。但是`Task`相较于`IAsyncResult`提供的重要特点是其蕴含了一个任务在持续运行的状态。这个特点让你能够随意找到一个`Task`,让它在异步操作完成的时候异步的通知你,而不用你关注任务当前是处在已经完成、没有完成、正在完成等各种状态。为什么这点非常重要?首先想想`APM`范式中存在的两个主要问题:
|
||||
|
||||
1. 你需要对每个操作实现一个自定义的`IAsycResult`实现:库中没有任何内置开箱即用的`IAsycResult`实现。
|
||||
2. 你需要在调用开始方法之前就知道在操作结束的时候需要做什么。这让编写使用任意异步操作的组合代码或者通用运行时非常困难。
|
||||
|
||||
相对的,`Task`提供了一个通用的接口让你在启动一个异步操作之后“接触”这个操作,还提供了针对“持续”的抽象,这样你就不需要为启动异步操作的方法提供一个持续性。任何需要进行异步操作的人都可以产生一个`Task`,任何人需要使用异步操作的人都可以使用一个`Task`,在这个过程中不用自定义任何东西,`Task`成为了沟通异步操作的生产者和消费者之间最重要的桥梁。这一点大大改变了.NET框架。
|
||||
|
||||
现在让我们深入理解`Task`所带来的重要意义。与其直接去研究错综复杂的`Task`源代码,我们将尝试去实现一个`Task`的简单版本。这不会是一个完善的实现,只会完成基础的功能来让我们更好的理解什么是`Task`,即一个负责协调设置和存储完成信号的数据结构。
|
||||
|
||||
开始时`Task`中只有很少的字段:
|
||||
|
||||
```csharp
|
||||
class MyTask
|
||||
{
|
||||
private bool _completed;
|
||||
private Exception? _error;
|
||||
private Action<MyTask>? _continuation;
|
||||
private ExecutionContext? _ec;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
我们首先需要一个字段告诉我们任务是否完成`_completed`,一个字段存储造成任务执行失败的错误`_error`;如果我们需要实现一个泛型的`MyTask<TResult>`,还需要一个`private TResult _result`字段来存储操作运行完成之后的结果。到目前为止的实现和`IAsyncResult`相关的实现非常类似(当然这不是一个巧合)。`_continuation`字段时实现中最重要的字段。在这个简单的实现中,我们只支持一个简单的后续过程,在真正的`Task`实现中是一个`object`类型的字段,这样既可以是一个独立的后续过程,也可以是一个后续过程的列表。这个委托会在任务完成的时候调用。
|
||||
|
||||
让我们继续深入。如上所述,`Task`相较于之前的异步执行模型一个基础的优势是在异步操作开始之后再提供后续需要完成的工作。因此我们需要一个方法来实现这个功能:
|
||||
|
||||
```csharp
|
||||
public void ContinueWith(Action<MyTask> action)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
if (_completed)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => action(this));
|
||||
}
|
||||
else if (_continuation is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_continuation = action;
|
||||
_ec = ExecutionContext.Capture();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果在调用`ContinueWith`的时候异步操作已经完成,那么就直接将该委托的执行加入执行队列。反之,这个方法就会将存储这个委托,当异步任务完成的时候进行执行(这个方法同时也存储一个被称为`ExecutionContext`的对象,会在后续调用委托的涉及到,我们后续会继续介绍)。
|
||||
|
||||
然后我们需要能够在异步过程完成的时候标记任务已经完成。我们将添加两个方法,一个负责标记任务成功完成,一个负责标记任务报错退出。
|
||||
|
||||
```csharp
|
||||
public void SetResult() => Complete(null);
|
||||
|
||||
public void SetException(Exception error) => Complete(error);
|
||||
|
||||
private void Complete(Exception? error)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
if (_completed)
|
||||
{
|
||||
throw new InvalidOperationException("Already completed");
|
||||
}
|
||||
|
||||
_error = error;
|
||||
_completed = true;
|
||||
|
||||
if (_continuation is not null)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
if (_ec is not null)
|
||||
{
|
||||
ExecutionContext.Run(_ec, _ => _continuation(this), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
_continuation(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
我们会存储任何的错误、标记任务已经完成,如果已经注册的任何的后续过程,我们也会引发其进行执行。
|
||||
|
||||
最后我们还需要一个方法将在工作中发生的任何传递出来,(如果是泛型类型,还需要将执行结果返回),为了方便某些特定的场景,我们将允许这个方法阻塞直到异步操作完成(通过调用`ContinueWith`注册一个`ManualResetEventSlim`实现)。
|
||||
|
||||
```csharp
|
||||
public void Wait()
|
||||
{
|
||||
ManualResetEventSlim? mres = null;
|
||||
lock (this)
|
||||
{
|
||||
if (!_completed)
|
||||
{
|
||||
mres = new ManualResetEventSlim();
|
||||
ContinueWith(_ => mres.Set());
|
||||
}
|
||||
}
|
||||
|
||||
mres?.Wait();
|
||||
if (_error is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Throw(_error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这就是一个基础的`Task`实现。当然需要指出的是实际的`Task`会复杂很多:
|
||||
|
||||
- 支持设置任意数量的后续工作;
|
||||
- 支持配置其的工作行为(例如配置后续工作是应该进入工作队列等待执行还是作为任务完成的一部分同步被调用);
|
||||
- 支持存储多个错误;
|
||||
- 支持取消异步操作;
|
||||
- 一系列的帮助函数(例如`Task.Run`创建一个代表在线程池上运行委托的`Task`)。
|
||||
|
||||
但是这些内容中没有什么奥秘,核心工作原理和我们自行实现的是一样的。
|
||||
|
||||
你可以会注意到我们自行实现的`MyTask`直接公开了`SetResult/SetException`方法,而`Task`没有;这是因为`Task`是以`internal`声明了上述两个方法,同时`System.Threading.Tasks.TaskCompletionSource`类型负责作为一个独立的`Task`生产者和管理任务的完成。这样做的目的并不是出于技术目的,只是将负责控制完成的方法从消费`Task`的方法中分离出来。这样你就可以通过保留`TaskCompletionSource`对象来控制`Task`的完成,不必担心你创建的`Task`在你不知道的地方被完成。(`CancellationToken`和`CanellationTokenSource`也是处于同样的设计考虑,`CancellationToken`是一个包装`CancellationTokenSource`的结构,只暴露了和接受消费信号相关的结构而缺少产生一个取消信号的能力,这样就限制只有`CancellationToeknSource`可以产生取消信号。)
|
||||
|
||||
当前我们也可以像`Task`一样为我们自己的`MyTask`添加各种工具函数。例如我们添加一个`MyTask.WhenAll`:
|
||||
|
||||
```csharp
|
||||
public static MyTask WhenAll(MyTask t1, MyTask t2)
|
||||
{
|
||||
var t = new MyTask();
|
||||
|
||||
int remaining = 2;
|
||||
Exception? e = null;
|
||||
|
||||
Action<MyTask> continuation = completed =>
|
||||
{
|
||||
e ??= completed._error; // just store a single exception for simplicity
|
||||
if (Interlocked.Decrement(ref remaining) == 0)
|
||||
{
|
||||
if (e is not null) t.SetException(e);
|
||||
else t.SetResult();
|
||||
}
|
||||
};
|
||||
|
||||
t1.ContinueWith(continuation);
|
||||
t2.ContinueWith(continuation);
|
||||
|
||||
return t;
|
||||
}
|
||||
```
|
||||
|
||||
然后是一个`MyTask.Run`的示例:
|
||||
|
||||
```csharp
|
||||
public static MyTask Run(Action action)
|
||||
{
|
||||
var t = new MyTask();
|
||||
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
t.SetResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
t.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
return t;
|
||||
}
|
||||
```
|
||||
|
||||
还有一个简单的`MyTask.Delay`:
|
||||
|
||||
```csharp
|
||||
public static MyTask Delay(TimeSpan delay)
|
||||
{
|
||||
var t = new MyTask();
|
||||
|
||||
var timer = new Timer(_ => t.SetResult());
|
||||
timer.Change(delay, Timeout.InfiniteTimeSpan);
|
||||
|
||||
return t;
|
||||
}
|
||||
```
|
||||
|
||||
在`Task`横空出世之后,之前的所有异步编程范式都成为了过去式。任何使用过去的编程范式暴露的异步`API`,现在都提供了返回`Task`的方法。
|
||||
|
||||
### 添加Value Task
|
||||
|
||||
直到现在,`Task`都是.NET异步编程中的主力军,在每次新版本发布或者社区发布的新`API`都会返回`Task`或者`Task<TResult>`。但是,`Task`是一个类,而每次创建一个类是都需要分配一次内存。在大多数情况下,为一个会长期存在的异步操作进行一次内存分配时无关紧要的,并不会操作明显的性能影响。但是正如之前所说的,同步完成的异步操作十分创建。例如,`Stream.ReadAsync`会返回一个`Task<int>`,但是如果是在一个类似与`BufferedStream`的实现上调用该方法,那么你的调用由很大概率就会是同步完成的,因为大多数读取只需要从内存中的缓冲区中读取数据而不需要通过系统调用访问`I/O`。在这种情况下还需要分配一个额外的对象显然是不划算的(而且在`APM`范式中也存在这个问题)。对于返回非泛型类型的方法来说,还可以通过返回一个预先分配的已完成单例来缓解这个问题,而且`Task`也提供了一个`Task.CompletedTask`。但是对于泛型的`Task<TResult>`则不行,因为不可能针对每个不同的`TResult`都创建一个对应的单例。那么我们可以如何让这个同步操作更快呢?
|
||||
|
||||
我们可以试图缓存一个常见的`Task<TResult>`。例如`Task<bool>`就非常的常见,而且也只存在两种需要缓存的情况:当结果为真时的一个对象和结果为假时的一个对象。同样的,尽管我们可能不想尝试(也不太可能)去缓存数亿个`Task<int>`对象以覆盖所有可能出现的值,但是鉴于很小的`Int32`值时非常常见的,我们可以尝试去缓存给一些较小的结果,例如从-1到8的结果。 而且对于其他任意的类型来说,`default`就是一个常常出现的值,因此缓存一个结果是`default(TResult)`的`Task`。而且 在最近的.NET版本中添加了一个称作`Task.FromResult`辅助函数,该函数就会完成与上述类似的工作,如果存在可以重复使用的`Task<Result>`单例就返回该单例,反之再创建一个新的`Task`对象。对于其他常常出现的值也也可以设计方法进行缓存。还是以`Stream.ReadAsync`为例子,这个方法常常会在同一个流上调用多次,而且每次读取的值都是允许读取的字节数量`count`。再考虑到使用者往往只需要读取到这个`count`值,因此`Stream.ReadAsync`操作常常会重复返回有着相同`int`值的`Task`对象。为了避免在这种情况下重复的内存分配,许多`Stream`的实现(例如`MemoryStream`)会缓存上一次成功缓存的`Task<int>`对象,如果下一次读取仍然是同步返回的且返回了相同的数值,该方法就会返回上一次读取创建的`Task<int>`对象。但是仍然会存在许多无法覆盖的其他情况,能不能找到一种更加优雅的解决方案来来避免在异步操作同步完成的时候避免创建新的对象,尤其是在性能非常重要的场景下。
|
||||
|
||||
这就是`ValueTask<TResult>`诞生的背景([这篇博客](https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/)详细测试了`ValueTask<TResult>`的性能)。`ValueTask<TResult>`在诞生之初是`TResult`和`Task<TResult>`的歧视性联合。在这些争论尘埃落定之后,`ValueTask<TResult>`便不是一个立刻可以返回的结果就是一个对未来结果的承诺:
|
||||
|
||||
```csharp
|
||||
public readonly struct ValueTask<TResult>
|
||||
{
|
||||
private readonly Task<TResult>? _task;
|
||||
private readonly TResult _result;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
一个方法可以通过返回`ValueTask<TResult>`来避免在`TResult`已知的情况下创建新的`Task<Result>`对象,当然返回的类型会更大、返回的结果更加不直接。
|
||||
|
||||
当然,实际应用中也存在对性能需求相当高的场合,甚至你会想在操作异步完成的时候也避免`Task<TResult>`对象的分配。例如`Socket`作为整个网络栈的最底层,对于网络中的大多数服务来说`SendAsync`和`ReceiveAsync`都是绝对的热点代码路径,不论是同步操作还是异步操作都是非常常见的(鉴于内核中的缓存,大多数发送请求都会同步完成,部分接受请求会同步完成)。因此对于像`Socket`这类的工具,如果我们可以在异步我弄成和同步完成的情况下都实现无内存分配的调用是十分有意义的。
|
||||
|
||||
这就是`System.Threading.Tasks.Sources.IValueTaskSource<TResult>`产生的背景:
|
||||
|
||||
```csharp
|
||||
public interface IValueTaskSource<out TResult>
|
||||
{
|
||||
ValueTaskSourceStatus GetStatus(short token);
|
||||
void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags);
|
||||
TResult GetResult(short token);
|
||||
}
|
||||
```
|
||||
|
||||
该接口允许自行为`ValueTask<TResult>`实现一个“背后“的对象,并且让这个对象提供了获得操作结构的`GetResult`方法和设置操作后续工作的`OnCompleted`。在这个接口出现之后,`ValueTask<TResult>`也小小修改了定义,`Task<TResult>? _task`字段被一个`object? _obj`字段替换了:
|
||||
|
||||
```csharp
|
||||
public readonly struct ValueTask<TResult>
|
||||
{
|
||||
private readonly object? _obj;
|
||||
private readonly TResult _result;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
现在`_obj`字段就可以存储一个`IValueTaskSource<TReuslt>`对象了。而且相较于`Task<TResult>`在完成之后就只能保持完成的状态,不能变回未完成的状态,`IValueTaskSource<TResult>`的实现有着完全的控制权,可以在已完成和未完成的状态之间双向变化。但是`ValueTask<TResult>`要求一个特定的实例只能被使用一次,不能观察到这个实例在使用之后的任何变化,这也是分析规则[CA2012](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2012)存在的意义。这就让让类似于`Socket`的工具为重复的调用建立一个`IValueTaskSource<TResult>`对象池。从实现上来说,`Socket`会至多缓存两个类似的实例,一个用于读取操作一个用于写入操作,因为在99.999%的情况下同时只会有一个发送请求和一个接受请求。
|
||||
|
||||
值得说明的是我只提到了`ValueTask<TResult>`却没有提到`ValueTask`。因为如果只是为了在操作同步完成的时候避免内存分配,非泛型类型的`ValueTask`指挥提供很少的性能提升,因为在同样的条件下可以使用`Task.CompletedTask`。但是如果要考虑在异步完成的时候通过缓存对象避免内存分配,非泛型类型也有作用。因而,在引入`IValueTaskSource<TResult>`的同时,`IValueTaskSource`和`ValueTask`也被引入了。
|
||||
|
||||
到目前我们,我们已经可以利用`Task`,`Task<TResult>`,`ValueTask`,`ValueTask<TResult>`表示各种各样的异步操作,并注册在操作完成之前和之后注册后续的操作。
|
||||
|
||||
但是这些后续操作仍然是回调方法,我们仍然陷入了基于回调的异步控制流程。该怎么办?
|
||||
|
||||
### 迭代器成为大救星
|
||||
|
||||
解决方案的先声实际上在`Task`诞生之前就出现了,在C# 2.0引入迭代器语法的时候。
|
||||
|
||||
你可能会问,迭代器就是`IEnumerable<T>`吗?这是其中的一个。迭代器是一个让编译器将你编写的方法自动实现`IEnumerable<T>`或者`IEnumertor<T>`的语法。例如我可以用迭代器语法编写一个产生斐波那契数列的可遍历对象:
|
||||
|
||||
```csharp
|
||||
public static IEnumerable<int> Fib()
|
||||
{
|
||||
int prev = 0, next = 1;
|
||||
yield return prev;
|
||||
yield return next;
|
||||
|
||||
while (true)
|
||||
{
|
||||
int sum = prev + next;
|
||||
yield return sum;
|
||||
prev = next;
|
||||
next = sum;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个方法可以直接用`foreach`遍历,也可以和`System.Linq.Enumerable`中提供的各种方法组合,也可以直接用一个`IEnumerator<T>`对象遍历。
|
||||
|
||||
```csharp
|
||||
foreach (int i in Fib())
|
||||
{
|
||||
if (i > 100) break;
|
||||
Console.Write($"{i} ");
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
foreach (int i in Fib().Take(12))
|
||||
{
|
||||
Console.Write($"{i} ");
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
using IEnumerator<int> e = Fib().GetEnumerator();
|
||||
while (e.MoveNext())
|
||||
{
|
||||
int i = e.Current;
|
||||
if (i > 100) break;
|
||||
Console.Write($"{i} ");
|
||||
}
|
||||
```
|
||||
|
@@ -2,7 +2,8 @@
|
||||
title: 2021年终总结
|
||||
date: 2022-01-12 16:27:19
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
---
|
||||
|
||||
2021年已经过去,2022年已经来临。每每一年开始的时候,我都会展开一张纸或者新建一个文档,思量着又是一年时光,也该同诸大杂志一般,写几句意味深长的话语,怀念过去的时光,也祝福未来的自己。可往往脑海中已是三万字的长篇,落在笔头却又是一个字都没有了。
|
||||
|
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 2022年终总结
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
date: 2022-12-30 14:58:12
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 2022年暑假碎碎念
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: 2022-summer-vacation
|
||||
date: 2022-08-22 15:39:13
|
||||
---
|
||||
|
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 2023年年终总结
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
date: 2024-2-29 20:18:19
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 人生代码大作业初体验
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: big-homework
|
||||
date: 2022-07-27 11:34:49
|
||||
---
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第0篇 初见Arch Linux
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2023-01-15 22:23:08
|
||||
typora-root-url: daily-linux-0
|
||||
---
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第1篇 问题与挑战
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2023-03-08 22:37:29
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 日用Linux挑战 第2篇 Wayland
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- Linux
|
||||
date: 2023-07-23 11:44:34
|
||||
typora-root-url: daily-linux-2
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 日用Linux挑战 第3篇 放弃Wayland
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- Linux
|
||||
typora-root-url: daily-linux-3
|
||||
date: 2023-09-04 14:47:46
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第4篇 新的开始
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2024/03/09 14:00:00
|
||||
---
|
||||
|
||||
|
100
YaeBlog/source/posts/hpc-2025-cpu-architecture.md
Normal file
100
YaeBlog/source/posts/hpc-2025-cpu-architecture.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: High Performance Computing 25 SP CPU Architecture
|
||||
date: 2025-03-13T23:59:08.8167680+08:00
|
||||
tags:
|
||||
- 学习资料
|
||||
- 高性能计算
|
||||
---
|
||||
|
||||
How to use the newly available transistors?
|
||||
|
||||
<!--more-->
|
||||
|
||||
Parallelsim:
|
||||
|
||||
Instruction Level Parallelism(ILP):
|
||||
|
||||
- **Implicit/transparent** to users/programmers.
|
||||
- Instruction pipelining.
|
||||
- Superscalar execution.
|
||||
- Out of order execution.
|
||||
- Register renaming.
|
||||
- Speculative execution.
|
||||
- Branch prediction.
|
||||
|
||||
Task Level Parallelism(TLP):
|
||||
|
||||
- **Explicit** to users/programmers.
|
||||
- Multiple threads or processes executed simultaneously.
|
||||
- Multi-core processors.
|
||||
|
||||
Data Parallelism:
|
||||
|
||||
- Vector processors and SIMD.
|
||||
|
||||
Von Neumann Architecture: the **stored-program** concept. Three components: processor, memory and data path.
|
||||
|
||||
Bandwidth: the gravity of modern computer system.
|
||||
|
||||
## Instruction Pipelining
|
||||
|
||||
Divide incoming instructions into a series of sequential steps performed by different processor unit to keep every part of the processor busy.
|
||||
|
||||
Superscalar execution can execute more than one instruction during a clock cycle.
|
||||
|
||||
Order of order execution.
|
||||
|
||||
Very long instruction word(VLIW): allows programs to explicitly specify instructions to execute at the same time.
|
||||
|
||||
EPIC: Explicit parallel instruction computing.
|
||||
|
||||
Move the complexity of instruction scheduling from the CPU hardware to the software compiler:
|
||||
|
||||
- Check dependencies between instructions.
|
||||
- Assign instructions to the functional units.
|
||||
- Determine when instructions are initiated placed together into a single word.
|
||||
|
||||

|
||||
|
||||
Comparisons between different architecture:
|
||||
|
||||

|
||||
|
||||
## Multi-Core Processor Gala
|
||||
|
||||
Symmetric multiprocessing(SMP): a multiprocessor computer hardware and software architecture.
|
||||
|
||||
Two or more identical processors are connected to a **single shared main memory** and have full access to all input and output devices.
|
||||
|
||||
> Current trend: computer clusters, SMP computers connected with network.
|
||||
|
||||
Multithreading: exploiting thread-level parallelism.
|
||||
|
||||
Multithreading allows multiple threads to share the functional units of a single processor in an overlapping fashion **duplicating only private state**. A thread switch should be much more efficient than a process switch.
|
||||
|
||||
Hardware approaches to multithreading:
|
||||
|
||||
**fine-grained multithreading**:
|
||||
|
||||
- Switches between threads on each clock.
|
||||
- Hide the throughput losses that arise from the both short and long stalls.
|
||||
- Disadvantages: slow down the execution of an individual thread.
|
||||
|
||||
**Coarse-grained multithreading**:
|
||||
|
||||
- Switch threads only on costly stalls.
|
||||
- Limited in its ability to overcome throughput losses
|
||||
|
||||
**Simultaneous multithreading(SMT)**:
|
||||
|
||||
- A variation on fine-grained multithreading
|
||||
|
||||

|
||||
|
||||
## Data Parallelism: Vector Processors
|
||||
|
||||
Provides high-level operations that work on vectors.
|
||||
|
||||
Length of the array also varies depending on hardware.
|
||||
|
||||
SIMD and its generalization in vector parallelism approach improved efficiency by the same operation be performed on multiple data elements.
|
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184421305.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184421305.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184732892.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184732892.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313190913475.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313190913475.png
(Stored with Git LFS)
Normal file
Binary file not shown.
33
YaeBlog/source/posts/hpc-2025-intro.md
Normal file
33
YaeBlog/source/posts/hpc-2025-intro.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: High Performance Computing 25 SP Introduction
|
||||
date: 2025-03-08T00:31:54.5775860+08:00
|
||||
tags:
|
||||
- 高性能计算
|
||||
- 学习资料
|
||||
---
|
||||
|
||||
High performance computing is the use of supercomputing and computing clusters to solve advanced problems.
|
||||
|
||||
<!--more-->
|
||||
|
||||
> High Performance Computing: Software execution speedup.
|
||||
>
|
||||
> High Throughput Computing: Resource utilization efficiency.
|
||||
|
||||
High performance computing:
|
||||
|
||||
- Provide computer power.
|
||||
- Full-Domain HPC = Full-Stack HPC + Full-Network HPC.
|
||||
|
||||
Two key weapons: **partitioning** and **duplicating** to increase granularity.
|
||||
|
||||
HPC History:
|
||||
|
||||
- Mainframe computer
|
||||
- Mini computer
|
||||
- Cluster
|
||||
- Grids
|
||||
- Clouds
|
||||
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 大学生用啥配置——计算机专业
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: laptop-for-computer
|
||||
date: 2022-06-13 16:17:27
|
||||
---
|
||||
|
163
YaeBlog/source/posts/mlir-standalone.md
Normal file
163
YaeBlog/source/posts/mlir-standalone.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: 构建运行基于MLIR的独立项目
|
||||
date: 2025-03-19T20:57:31.1928528+08:00
|
||||
tags:
|
||||
- 技术笔记
|
||||
- LLVM
|
||||
---
|
||||
|
||||
MLIR是多层次中间表示形式(Multi-Level Intermediate Representation),是LLVM项目中提供的一项新编译器开发基础设施,使得编译器开发者能够在源代码和可执行代码之间定义多层IR来保留程序信息指导编译优化。本博客指导如何创建一个独立(out-of-tree)的MLIR项目。
|
||||
|
||||
<!--more-->
|
||||
|
||||
## 编译LLVM和MLIR
|
||||
|
||||
考虑到大多数的Linux发行版在打包LLVM时不会编译MLIR,因此自行编译安装包括MLIR项目的LLVM就成为开发独立MLIR项目的前置条件。
|
||||
|
||||
首先在GitHub上下载LLVM的源代码包,我这里选择最新的稳定版本`20.1.0`。
|
||||
|
||||
```shell
|
||||
wget https://github.com/llvm/llvm-project/releases/download/llvmorg-20.1.0/llvm-project-20.1.0.src.tar.xz
|
||||
```
|
||||
|
||||
下载之后解压进入,准备进行构建。
|
||||
|
||||
```shell
|
||||
tar xvf llvm-project-20.1.0.src.tar.xz
|
||||
cd llvm-project-20.1.0.src
|
||||
```
|
||||
|
||||
创建`build`文件夹,使用下面的命令进行生成构建文件。在这里选择使用`Release`构建类型,安装的位置是`~/.local/share/llvm`文件夹,构建的项目包括`llvm`、`clang`和`mlir`三个项目,并指定使用系统上的`clang`和`clang++`编译器作为编译过程中使用的编译器。
|
||||
|
||||
```shell
|
||||
mkdir build
|
||||
cd build
|
||||
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="/home/ricardo/.local/share/llvm-20.1.0" -DLLVM_ENABLE_PROJECTS="llvm;clang;mlir" -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_INSTALL_UTILS=true ../llvm
|
||||
```
|
||||
|
||||

|
||||
|
||||
生成构建文件之后使用`ninja`进行构建。
|
||||
|
||||
```shell
|
||||
ninja
|
||||
```
|
||||
|
||||

|
||||
|
||||
构建在我的i5-13600K上大约需要20分钟。
|
||||
|
||||
构建完成之后进行安装。
|
||||
|
||||
```shell
|
||||
ninja install
|
||||
```
|
||||
|
||||
## 编译MLIR官方的独立项目
|
||||
|
||||
MLIR的官方提供了一个独立项目,项目文件夹在`mlir/examples/standalone`中,将这个文件夹中的内容复制到我们需要的地方,尝试使用上面构建的`mlir`进行构建。
|
||||
|
||||
```shell
|
||||
cp -r ~/Downloads/llvm-project-20.1.0.src/mlir/examples/standalone mlir-standalone
|
||||
cd mlir-standalone
|
||||
```
|
||||
|
||||
### 不启用测试
|
||||
|
||||
编译过程中可能遇到的最大问题是`llvm-lit`,这个使用`python`编写的LLVM集成测试工具,在`standalone`的`README.md`中要求编译过程中使用`LLVM_EXTERNAL_LIT`变量指定到LLVM编译过程中生成的`llvm-lit`可执行文件。
|
||||
|
||||
> 也许就是因为`llvm-lit`是用Python撰写的,所以`llvm-lit`不会安装到`PREFIX`指定的位置。
|
||||
|
||||
不过我们可以禁用测试(笑)。在`CMakeLists.txt`文件中注释对于测试文件夹的添加:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(include)
|
||||
add_subdirectory(lib)
|
||||
if(MLIR_ENABLE_BINDINGS_PYTHON)
|
||||
message(STATUS "Enabling Python API")
|
||||
add_subdirectory(python)
|
||||
endif()
|
||||
#add_subdirectory(test)
|
||||
add_subdirectory(standalone-opt)
|
||||
add_subdirectory(standalone-plugin)
|
||||
add_subdirectory(standalone-translate)
|
||||
|
||||
```
|
||||
|
||||
回到构建文件夹,使用如下的`cmake`指令生成构建文件。
|
||||
|
||||
```shell
|
||||
export LLVM_DIR=/home/ricardo/.local/share/llvm-20.1.0
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir ..
|
||||
```
|
||||
|
||||
可以顺利通过编译。
|
||||
|
||||

|
||||
|
||||
### 启用测试
|
||||
|
||||
但是测试还是非常重要的。我们尝试启动测试看看,取消对于测试文件夹的注释:
|
||||
|
||||
```shell
|
||||
rm -rf build && mkdir build && cd build
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir ..
|
||||
```
|
||||
|
||||
很好顺利报错,报错的提示是缺失`FileCheck`、`count`和`not`。
|
||||
|
||||

|
||||
|
||||
那么按照`README.md`中的提示添加上来自构建目录的`llvm-lit`会怎么样呢?
|
||||
|
||||
```shell
|
||||
rm -rf build && mkdir build && cd build
|
||||
export LLVM_BUILD_DIR=/home/ricardo/Downloads/llvm-project-20.1.0.src/build
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir -DLLVM_EXTERNAL_LIT=$LLVM_BUILD_DIR/bin/llvm-lit ..
|
||||
```
|
||||
|
||||
同样的报错,看来问题不是出在这里。
|
||||
|
||||
经过对于LLVM文档的仔细研究,发现原来是没有启动这个变量:
|
||||
|
||||

|
||||
|
||||
遂修改最初的LLVM编译指令。
|
||||
|
||||
重新运行来自`README.md`的构建文件生成指令之后,测试也完美运行通过:
|
||||
|
||||
```shell
|
||||
ninja test-standalone
|
||||
```
|
||||
|
||||

|
||||
|
||||
不过这个还是有一点令我不是特别满意,这依赖了一个来自于构建目录的工具`llvm-lit`,如果我编译安装的时候眼疾手快的删除了编译目录不就完蛋了。而且我都**standalone**了还依赖似乎有点说不过去?
|
||||
|
||||
于是发现了一篇从`pip`上下载使用`llvm-lit`的[博客](https://medium.com/@mshockwave/using-llvm-lit-out-of-tree-5cddada85a78)和一个LLVM Discourse上面的[帖子](https://discourse.llvm.org/t/running-llvm-lit-on-external-project-test-file-derived-from-standalone-fails/67787),遂进行尝试。
|
||||
|
||||
首先在当前目录下创建一个虚拟环境,并下载安装`llvm-lit`。
|
||||
|
||||
```shell
|
||||
python -m venv .llvm-lit
|
||||
source .llvm-lit/bin/activate
|
||||
pip install lit
|
||||
```
|
||||
|
||||
~~不过这个库似乎没有提供运行入口点,需要我们手动创建一个可执行的`python`文件:~~
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
from lit.main import main
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
经哥们纠正说`lit`包在某个版本之后会安装`lit`的可执行文件,在安装之后可以直接在命令行调用`lit`。因此在激活虚拟环境之后,`cmake`中直接在`LLVM_EXTERNAL_LIT`配置为`$(which lit)`即可。
|
||||
|
||||
```shell
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir -DLLVM_EXTERNAL_LIT=$(which lit) ..
|
||||
ninja test-standalone
|
||||
```
|
||||
|
||||

|
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319192618697.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319192618697.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319194742171.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319194742171.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202218503.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202218503.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202553644.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202553644.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204057832.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204057832.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204522857.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204522857.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319205520649.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319205520649.png
(Stored with Git LFS)
Normal file
Binary file not shown.
117
YaeBlog/source/posts/msbuild-generate-files.md
Normal file
117
YaeBlog/source/posts/msbuild-generate-files.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: 如何在MSBuild中复制生成的文件到发布目录中
|
||||
date: 2025-03-20T22:33:21.6955385+08:00
|
||||
tags:
|
||||
- 技术笔记
|
||||
- dotnet
|
||||
---
|
||||
|
||||
如何使用`MSBuild`将构建过程中生成文件复制到生成目录中?
|
||||
|
||||
<!--more-->
|
||||
|
||||
### 遇到的问题
|
||||
|
||||
最近在尝试在`blazor`项目中使用`tailwindcss`作为`css`工具类的提供工具,而不是使用老旧的`bootstrap`框架,不过使用`tailwindcss`需要在项目构建时使用`tailwindcss`工具扫描文件中使用到的`css`属性并生成最终的`css`文件,这就带来了在构建时运行`tailwindcss`生成并复制到输出目录的需求。
|
||||
|
||||
由于我是使用`pnpm`作为前端管理工具,我在项目的`csproj`文件中添加了下面的`Target`来生成文件:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css"/>
|
||||
</Target>
|
||||
```
|
||||
|
||||
这套生成逻辑在本地工作良好,但是却在CI上运行时出现了问题:`CI`上打包的`Docker`镜像中没有`tailwind.g.css`文件,导致最终部署的站点丢失了所有的格式。
|
||||
|
||||
### 产生问题的原因
|
||||
|
||||
经过反复实验,我发现只有在构建之前`wwwroot`目录中已经存在`tailwind.g.css`文件的情况下,`MSBuild`才会将生成的文件复制到最终的输出目录中。但是在`CI`环境下,因为使用`.gitignore`没有将`*.g.css`文件添加到代码管理,因此`CI`运行构建之前没有该文件,因此构建的结果中也没有该文件。
|
||||
|
||||
仔细研究`MSBuild`的文档和网络上的[分享](https://gist.github.com/BenVillalobos/c671baa1e32127f4ab582a5abd66b005),我意识到这是由于`MSBuild`的构建流程导致的,MSBuild`的构建流程分成两个大的阶段:
|
||||
|
||||
- 评估阶段(Evaluation Phase)
|
||||
|
||||
在这个阶段,`MSBuild`将会运行读取所有的配置文件,创建需要的属性,展开所有的`glob`,建立好整个构建流程。
|
||||
|
||||
- 执行阶段(Execution Phase)
|
||||
|
||||
在这个阶段,`MSBuild`将按照上一阶段执行的属性执行实际的构建指令。
|
||||
|
||||
这两个阶段的划分就导致在生成阶段才生成的文件不会被包含在复制文件的指令中,因此他们不会被拷贝到最终的输出目录中。
|
||||
|
||||
>这和`cmake`的构建过程很像,首先调用`cmake`生成一些构建指令,在调用实际的构建指令构建二进制文件。
|
||||
|
||||
因此这类问题的推荐解决办法是手动将这些文件添加到构建流程中,即在`BeforeBuild`目标调用之前使用`Content`和`None`等项。
|
||||
|
||||
### 解决问题
|
||||
|
||||
总结上述的解决问题方法,我在上面的构建流程中添加了如下的`None`项:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css"/>
|
||||
|
||||
<!-- Make sure generated file will be copied to output directory-->
|
||||
<ItemGroup>
|
||||
<Content Include="wwwroot/tailwind.g.css" Visible="false" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
```
|
||||
|
||||
在运行构建之后,在最终的`publish`文件夹的`wwroot`文件夹中就可以找到`tailwind.g.css`文件。
|
||||
|
||||
不过我还想进行一点优化,`MSBuild`文档中建议将自动生成的文件放在`IntermediateOutputPath`,也就是`obj`文件加中,因此这里尝试将`tailwind.g.css`文件生成到`IntermediateOuputPath`中,优化之后的`Target`项长这个样子:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o $(IntermediateOutputPath)tailwind.g.css"/>
|
||||
|
||||
<!-- Make sure generated file will be copied to output directory-->
|
||||
<ItemGroup>
|
||||
<Content Include="$(IntermediateOutputPath)tailwind.g.css" Visible="false" TargetPath="wwwroot/tailwind.g.css"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
```
|
||||
|
||||
经过测试,这套生成逻辑在`blazor`类库环境下也可以正常运行,类库的文件会被正确地生成到`wwwroot/_content/<ProjectName>/`文件夹下面。
|
||||
|
@@ -1,9 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.razor", "**/*.cshtml", "**/*.html", "Processors/EssayStylesPostRenderProcessor.cs"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
Binary file not shown.
BIN
YaeBlog/wwwroot/fonts/bootstrap-icons.woff2
(Stored with Git LFS)
BIN
YaeBlog/wwwroot/fonts/bootstrap-icons.woff2
(Stored with Git LFS)
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
1
YaeBlog/wwwroot/tailwind.css
Normal file
1
YaeBlog/wwwroot/tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
Reference in New Issue
Block a user