CPPkg:从 Visual Studio 创建源代码的 Zip 包






4.50/5 (6投票s)
轻松地从源代码创建zip包,适合上传到CodeProject
引言
如果您在此处提交文章,您应该知道流程:要不就是退出Visual Studio以清除所有文件锁定,删除不必要的文件(例如bin、obj和.vs目录),然后将剩余内容压缩成一个文件;或者小心地逐个添加所需文件到zip文件中,并排除上述目录。另一种选择是,如果您使用GitHub,可以直接将其项目下载为一个预打包的zip文件,然后上传到这里。
嗯,这足够麻烦了,我终于做了一个工具来自动化它。主要是,我厌倦了为了提交我的项目而退出Visual Studio。也许您也是。如果是这样,那么这个工具就是为您准备的。
更新: 修复了一个错误,当您使用链接在解决方案的多个项目之间共享同一个文件时,它会将文件添加两次。
使用这个烂摊子
有两种方式可以使用此工具
第一种,也是可能最常见的一种,是通过Visual Studio的工具菜单。安装此工具后,在工具菜单下,您会看到创建代码项目/Zip包。点击它将创建一个以解决方案命名的zip文件,并将其放置在解决方案目录中。然后它会启动一个资源管理器窗口,并选中新创建的zip文件。每次更新代码后,在提交到Code Project之前,您都需要执行此操作。任何之前的zip文件都会被自动静默覆盖,所以请注意这一点。
第二种方式是使用命令行实用程序cppkg。我们来看看它的用法屏幕
cppkg.exe <inputFile> [/output <outputFile>]
Creates a zip file out of the projects in the solution.
<inputFile> The input solution file to package
<outputFile> The output zip file to create
正如您所见,它非常简单。它接受一个不带开关的参数和一个可选的带开关的参数。前者是解决方案文件(.sln)的路径,后者是zip文件的路径。如果未指定output选项,则zip文件将与解决方案同名并在同一目录下创建。同样,任何之前的zip文件都会被静默覆盖。
您可以稍微“作弊”一下,并将此命令行作为解决方案中每个项目的生成后事件。这样,每次重新生成解决方案时,它都会重新生成zip文件。这比使用VS工具更自动化一些,但也有点过度。主要来说,这个CLI工具是为了在您需要批量处理的情况下提供的。
该工具的工作原理是扫描解决方案文件中的所有项目,然后对于每个项目,它会对项目文件执行各种查询,以确定要包含或排除哪些文件。因此,您必须在项目中包含任何您想包含在zip文件中的文件。仅仅在解决方案的文件夹中是不够的。这有优点也有缺点,优点是更精细地控制zip文件中的内容,缺点是需要记住将内容文件包含到项目中。
理解代码
代码的核心部分涉及处理解决方案文件格式以及项目文件格式,项目文件有两种风格。第一种风格是.NET Framework风格的项目文件,第二种是.NET Core和Standard项目文件。它们共享相似的格式,但遵循略有不同的规则。
项目文件是XML格式的,而解决方案文件采用其专有的格式,需要一些手工解析。为此,我们使用了我的LexContext代码,它大大简化了过程。对于XML部分,我们严重依赖XPath
。
首先,我们来看看解决方案的格式。这里,我们将使用这个cppkg.sln作为例子
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29905.134
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cppkg", _
"cppkg\cppkg.csproj", "{76E9E90F-A812-468A-BE80-4B9577CCFB97}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", _
"{C91DBE2D-B696-47DB-A04D-483D0E109321}"
ProjectSection(SolutionItems) = preProject
TextFile1.txt = TextFile1.txt
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cppkgvs", _
"cppkgvs\cppkgvs.csproj", "{A4607C1A-CD0E-4878-9BF1-520146AFC55D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "testCore", _
"testCore\testCore.csproj", "{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = _
"testVBCore", "testVBCore\testVBCore.vbproj", "{261DF867-B1A4-41C2-AAD3-87BFB4B22151}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76E9E90F-A812-468A-BE80-4B9577CCFB97}.Release|Any CPU.Build.0 = Release|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4607C1A-CD0E-4878-9BF1-520146AFC55D}.Release|Any CPU.Build.0 = Release|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88A06D5A-9993-4F2E-9B3B-DDF92061AB26}.Release|Any CPU.Build.0 = Release|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Debug|Any CPU.Build.0 = Debug|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Release|Any CPU.ActiveCfg = Release|Any CPU
{261DF867-B1A4-41C2-AAD3-87BFB4B22151}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3578F29E-FEF5-4B2A-8259-3FE6EAB153D9}
EndGlobalSection
EndGlobal
我们只关心上面加粗的部分,所以不用担心其他部分。我们的代码首先在每行的第一个子字符串中查找“Project”。如果找到,它会区分包含“solution folders”的部分,其标识GUID为{2150E333-8FDC-42A3-9474-1A3956D46DE8}
,因为我们需要特殊处理它。这是执行此操作并处理其他项目的代码
var projects = new List<string>();
var files = new List<string>();
using (var sr = new StreamReader(solutionFile))
{
var dir = Path.GetDirectoryName(solutionFile);
string line;
while (null != (line = sr.ReadLine()))
{
// i have no idea if the .sln format
// is case sensitive or not so this
// assumes it isn't.
if (line.ToLowerInvariant().StartsWith("project"))
{
var lc = LexContext.Create(line);
lc.TrySkipLetters();
lc.TrySkipWhiteSpace();
if ('(' == lc.Current)
{
if(-1!=lc.Advance())
{
lc.ClearCapture();
if(lc.TryReadDosString())
{
if ("\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\""
!= lc.GetCapture().ToUpperInvariant())
{
lc.TrySkipWhiteSpace();
if (')' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
if ('=' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
string name;
lc.ClearCapture();
if (lc.TryReadDosString())
{
name = lc.GetCapture();
lc.TrySkipWhiteSpace();
if (',' == lc.Current && -1 != lc.Advance())
{
lc.TrySkipWhiteSpace();
lc.ClearCapture();
// I'm not actually sure what sort of escapes
// the string uses.
string path;
if (lc.TryReadDosString())
{
path = lc.GetCapture();
path = path.Substring(1, path.Length - 2);
path = path.Replace("\"\"", "\"");
path = Path.Combine(dir, path);
path = Path.GetFullPath(path);
if(File.Exists(path))
projects.Add(path);
}
}
}
}
}
}
else
{
lc.TrySkipWhiteSpace();
if (')' == lc.Current && -1 != lc.Advance())
{
while (null != (line = sr.ReadLine().Trim().ToLowerInvariant()) &&
line != "endproject" &&
!line.StartsWith("projectsection")) ;
if(line.StartsWith("projectsection") &&
null != (line = sr.ReadLine()) &&
line.Trim().ToLowerInvariant() != "endproject")
{
do
{
lc = LexContext.Create(line);
lc.TrySkipWhiteSpace();
if(lc.TryReadUntil('=',false))
{
var fpath = lc.GetCapture().TrimEnd();
fpath = Path.Combine(dir, fpath);
fpath = Path.GetFullPath(fpath);
if(File.Exists(fpath))
files.Add(fpath);
}
} while (null != (line = sr.ReadLine()) &&
line.Trim().ToLowerInvariant() != "endproject" &&
!line.Trim().ToLowerInvariant().StartsWith_
("endprojectsection"));
}
}
}
}
}
}
}
}
}
return (projects,files);
这有点复杂,但这就是解析过程的常态。幸运的是,lex上下文使得它比其他方式简单得多。基本上,它会忽略任何不以“project”开头的行,然后使用LexContext
解析该行,直到找到项目路径。如果在任何时候解析没有产生预期的输入,它就会跳过该行的其余部分的解析。每次找到项目路径时,它都会将其添加到projects
。对于Solution Folders部分,我们进行更深入的扫描,直到找到文件列表,然后将其添加到files
。
一旦代码获得了项目和文件的列表,它就会依次扫描每个项目,查找每个项目的独立内容。它主要使用.NET的XML功能,特别是XPath。由于存在两种不同的项目格式并且它们遵循不同的规则,所以会变得复杂,但至少XML本身在每种情况下都是相似的。无论哪种情况,我们只关心<ItemGroup>
标签之间的内容
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.Numerics" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="LexContext.BaseExtensions.cs" />
<Compile Include="LexContext.CommonExtensions.cs" />
<Compile Include="LexContext.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
如您所见,有几个标签具有不同的本地名称,但每个标签都有一个Include
属性。这些就是我们要寻找的。我们排除了<Reference>
和<ProjectReference>
标签,但除此之外,我们接受所有带有Include
属性的内容。
对于.NET Core或.NET Standard项目,情况略有不同。虽然上述方法使用的是白名单方式包含文件,但Core/Standard项目文件使用的是黑名单方式。也就是说,它们包含子目录下的所有文件,除了bin和obj,或者除非它们被明确排除。同时,bin和obj下的文件可以通过明确包含来包含。这是一个典型的Core或Standard项目文件
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Remove="testExclude.txt" />
</ItemGroup>
</Project>
项目文件非常小,我在这里只展示了整个文件。请注意,只有一个<ItemGroup>
标签,它有一个带有Remove
属性的元素。这表明该文件将从项目中排除。如上所述,目录和子目录下的其余文件(不包括bin和obj)将被包含。
我不会在此包含扫描项目的全部代码,因为它很长,而且由于规则不同,但基本上我们只是使用XPath在项目文件中查找上述所有感兴趣的项目,然后收集源路径,这也可以包括扫描项目目录以查找要包含的文件。这是代码主要功能的部分
iter = nav.Select("/e:Project/e:ItemGroup/*", res);
while (iter.MoveNext())
{
if ("Reference" != iter.Current.LocalName && "ProjectReference" != iter.Current.LocalName)
{
var iter2 = iter.Current.Select("@Include");
if (iter2.MoveNext())
{
var file = iter2.Current.Value;
result.Add(file);
}
}
}
这大多是以上内容的变种,或者当我们还需要扫描文件系统时
var dir = Path.GetDirectoryName(projectFile);
var files = Directory.GetFiles(dir, "*.*");
for (var i = 0; i < files.Length; ++i)
{
var f = Path.Combine(dir, files[i]);
f = Path.GetFullPath(f);
result.Add(f);
}
foreach (string d in Directory.GetDirectories(dir))
{
_DirSearch(dir, d, result);
}
上面的_DirSearch()
只是进行递归目录搜索。请注意,有一个参数和一些代码用于排除文件进行搜索。这是我CSBrick项目的遗留部分,该项目需要它。我将那段代码改编到这个项目中,因为它也扫描项目文件。我决定保留排除文件功能,以防将来需要它,因为它很复杂,而且我不想在将来需要时重新集成。
最后,一旦我们收集了所有项目和文件,我们就必须获取它们相对于解决方案的路径,并用它构建一个zip文件(代码已在第一次更新中修改,但未在此显示 - 大部分相同)
if (File.Exists(zipPath))
File.Delete(zipPath);
using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
zip.CreateEntryFromFile(sln, Path.Combine
(Path.GetFileNameWithoutExtension(sln),Path.GetFileName(sln)));
stdout.WriteLine(Path.GetFileName(sln));
var ss = _GetSolutionStuff(sln);
foreach(var fpath in ss.Files)
{
var relPath = Path.Combine(Path.GetFileNameWithoutExtension(sln),
_GetRelativePath(fpath, dir, true));
stderr.Write("\t");
stdout.WriteLine(relPath);
zip.CreateEntryFromFile(fpath, relPath);
}
stderr.WriteLine();
foreach (var projPath in ss.Projects)
{
stdout.WriteLine(_GetRelativePath(projPath, dir, true));
var projRelPath = Path.Combine(Path.GetFileNameWithoutExtension(sln),
_GetRelativePath(projPath, dir, true));
zip.CreateEntryFromFile(projPath, projRelPath);
foreach (var filePath in _GetProjectInputs(projPath, new HashSet<string>()))
{
var relPath = _GetRelativePath(filePath, dir, true);
stderr.Write("\t");
stdout.WriteLine(relPath);
zip.CreateEntryFromFile(filePath,
Path.Combine(Path.GetFileNameWithoutExtension(sln), relPath));
}
stderr.WriteLine();
}
}
stderr.WriteLine();
我们不会在此探讨的_GetRelativePathCode()
不是我的代码。它是(c)2014年Yves Goergen的版权,http://unclassified.software/source/getrelativepath。我决定节省一些时间。版权声明也出现在源代码中。不幸的是,.NET 4.72尚未包含该功能。它计划在.NET 5中加入,尽管目前已包含在Core和Standard中。
您可能会注意到的一个地方是我向stdout
和stderr
写入,而不是使用Console
。原因是我这个可执行文件也作为库被关联的Visual Studio扩展使用。在该环境中,没有控制台。当从该项目内部调用它时,我们传递TextReader.Null
。
限制
首先,如果您的某些文件不在解决方案文件夹下,该工具将失败,所以请不要这样做。它不知道如何将它们放入zip文件中,因为一切都相对于解决方案的路径。
这应该适用于C++项目,但我只测试过一个非常小的C++项目,而且我也没有测试过F#项目。您可能会遇到不同的结果。我很快会为C++进行更多测试。
我想,总的来说,它还需要更多的测试,因为您可以创建各种不同类型的项目。所以如果您遇到问题,请留下评论。只需记住包含您的readme、许可文档、图片和其他内容到您的项目中——任何您想包含在zip文件中的东西。否则,它们就不会被包含。
关注点
我最初打算编写完全独立的代码来从Visual Studio内部收集文件,但当我尝试时,我发现奇怪的是,它并没有给我准确的结果。一方面,在某些情况下它返回的是目录而不是文件,并且它丢失了诸如Resources文件夹下的文件之类的内容。我最初认为会更健壮的方法结果却是个失败。这确实很糟糕,但没关系。至少这样,我就不需要两套不同的代码来完成同一件事了。
历史
- 2020年7月7日 - 首次提交
- 2020年7月8日 - 修复了因时间问题引入的错误,该错误导致shell未能选中zip文件。
- 2020年7月9日 - 修复了使用链接可能导致同一文件被添加两次的错误