Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Join-Verticals task, use it at the final Join Point #19369

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
64b685f
Add Join-Verticals task, use it at the final Join Point
dkurepa Apr 9, 2024
3b0b615
parametrize mainVertical
dkurepa Apr 9, 2024
c2ee890
small fix
dkurepa Apr 9, 2024
76855fe
Update src/SourceBuild/content/eng/tools/join-verticals.proj
dkurepa Apr 9, 2024
cc3ba36
Update src/SourceBuild/content/eng/tools/join-verticals.proj
dkurepa Apr 9, 2024
c0ec4d4
Update src/SourceBuild/content/eng/tools/join-verticals.proj
dkurepa Apr 9, 2024
44028e6
Fix bug, small improvements
dkurepa Apr 9, 2024
cb0c2f9
Address comments
dkurepa Apr 9, 2024
2d268a2
Start downloading zip as soon as it's available, use copy method
dkurepa Apr 9, 2024
69e6f1f
Merge remote-tracking branch 'source/main' into dev/dkurepa/VmrMerged…
dkurepa Apr 10, 2024
2810fb2
make some properties const
dkurepa Apr 10, 2024
645af11
Use OrdinalIgnoreCase when searching for files to copy
dkurepa Apr 10, 2024
f5b4bcc
Use build.proj to call join-verticals, based on DotNetBuildPass
dkurepa Apr 10, 2024
39bad4e
Also run final join in PRs
dkurepa Apr 10, 2024
9de8ec1
fix nullability error
dkurepa Apr 10, 2024
5d87fcd
Always run the final join stage
dkurepa Apr 10, 2024
3d35720
add the vmr-final-join in the correct PR yaml
dkurepa Apr 10, 2024
0531668
fix join-verticals.proj indentation, fix yaml parameter name
dkurepa Apr 10, 2024
6bceba3
don't set a default value for DotNetBuildPass yet
dkurepa Apr 10, 2024
0782cda
fix yaml parameters
dkurepa Apr 11, 2024
7dfafd6
improve logging
dkurepa Apr 11, 2024
749b4bf
Refactor yml, address comments
dkurepa Apr 11, 2024
4c3db41
Update src/SourceBuild/content/build.proj
dkurepa Apr 11, 2024
150cd78
Add linux support to join-verticals step, make Azdo API models records
dkurepa Apr 11, 2024
fb3b996
small fix
dkurepa Apr 11, 2024
f9936a1
Merge branch 'main' into dev/dkurepa/VmrMergedManifest
dkurepa Apr 11, 2024
586e019
fix yaml
dkurepa Apr 12, 2024
f703e79
Merge remote-tracking branch 'origin/dev/dkurepa/VmrMergedManifest' i…
dkurepa Apr 12, 2024
ebfb759
define empty variable when in public
dkurepa Apr 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 10 additions & 6 deletions eng/pipelines/templates/jobs/vmr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ jobs:
displayName: Publish Artifacts
condition: always()

- output: buildArtifacts
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
PathtoPublish: $(sourcesPath)/artifacts/$(Agent.JobName).xml
ArtifactName: VerticalManifests
displayName: Publish Vertical Manifest

- ${{ if not(parameters.isBuiltFromVmr) }}:
- output: pipelineArtifact
displayName: Upload failed patches
Expand Down Expand Up @@ -238,7 +243,7 @@ jobs:

- ${{ if eq(parameters.targetOS, 'windows') }}:
- script: |
call $(sourcesPath)\build.cmd -ci -cleanWhileBuilding -prepareMachine /p:TargetOS=${{ parameters.targetOS }} /p:TargetArchitecture=${{ parameters.targetArchitecture }} ${{ parameters.extraProperties }}
call $(sourcesPath)\build.cmd -ci -cleanWhileBuilding -prepareMachine /p:VerticalName=$(Agent.JobName) /p:TargetOS=${{ parameters.targetOS }} /p:TargetArchitecture=${{ parameters.targetArchitecture }} ${{ parameters.extraProperties }}
displayName: Build

- ${{ if eq(parameters.runTests, 'True') }}:
Expand Down Expand Up @@ -326,6 +331,8 @@ jobs:
extraBuildProperties="$extraBuildProperties ${{ parameters.extraProperties }}"
fi

extraBuildProperties="$extraBuildProperties /p:VerticalName=$(Agent.JobName)"

buildArgs="$(additionalBuildArgs) $customBuildArgs $extraBuildProperties"

# Only use Docker when a container is specified
Expand Down Expand Up @@ -463,11 +470,8 @@ jobs:

- task: CopyFiles@2
inputs:
SourceFolder: $(sourcesPath)/artifacts
Contents: |
VerticalManifest.xml
assets/**
TargetFolder: $(Build.ArtifactStagingDirectory)/publishing
SourceFolder: $(sourcesPath)/artifacts/assets
TargetFolder: $(Build.ArtifactStagingDirectory)/publishing/assets
displayName: Copy artifacts to Artifact Staging Directory
condition: succeededOrFailed()

Expand Down
61 changes: 61 additions & 0 deletions eng/pipelines/templates/stages/vmr-final-join.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
parameters:
# Branch of the VMR to use (to push to for internal builds)
- name: vmrBranch
type: string
default: $(Build.SourceBranch)

- name: pool_Windows
type: object
default:
name: $(defaultPoolName)
image: $(poolImage_Windows)
demands: ImageOverride -equals $(poolImage_Windows)
os: windows

- name: mainVertical
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the yml parameter, I didn't change msbuild property name tho

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also update the spec if you think mainVertical makes more sense.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind keeping this as is, I'd just prefer to keep MainVertical in the task, since job is an AzDo thing, while Vertical is a VMR concept

type: string
default: Windows_x64

stages:
- stage: VMR_Final_Join
displayName: VMR Final Join
dependsOn: VMR_Vertical_Build
variables:
- group: Publish-Build-Assets
- template: ../variables/vmr-build.yml
parameters:
vmrBranch: ${{ parameters.vmrBranch }}

jobs:
- job: VMR_Final_Join
pool: ${{ parameters.pool_Windows }}

templateContext:
outputs:
- output: pipelineArtifact
path: $(Build.ArtifactStagingDirectory)/artifacts
artifact: JoinedArtifacts
condition: succeededOrFailed()

steps:
- checkout: self

- task: DownloadPipelineArtifact@2
inputs:
artifactName: 'VerticalManifests'
targetPath: $(Build.ArtifactStagingDirectory)/VerticalManifests

- powershell: |
. $(Build.SourcesDirectory)/eng/common/tools.ps1
InitializeToolset
./.dotnet/dotnet restore $(Build.SourcesDirectory)/eng/tools/join-verticals.proj
MSbuild $(Build.SourcesDirectory)/eng/tools/join-verticals.proj `
ViktorHofer marked this conversation as resolved.
Show resolved Hide resolved
/p:VerticalManifestsPath=$(Build.ArtifactStagingDirectory)/VerticalManifests `
/p:MainVertical=$(parameters.mainVertical) `
/p:BuildId=$(Build.BuildId) `
/p:AzureDevOpsToken=$(dn-bot-dnceng-build-rw-code-rw) `
/p:AzureDevOpsOrg=dnceng `
/p:AzureDevOpsProject=internal `
/p:TmpFolder=$(Agent.TempDirectory) `
/p:OutputFolder=$(Build.ArtifactStagingDirectory)/artifacts `
/p:FlatCopy=true
6 changes: 5 additions & 1 deletion src/SourceBuild/content/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,8 @@
<PoisonUsageReportFile>$(PackageReportDir)poison-usage.xml</PoisonUsageReportFile>
</PropertyGroup>

</Project>
<PropertyGroup>
<VerticalName Condition="'$(VerticalName)' == ''">DefaultVertical</VerticalName>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if vertical is an appropriate term to use here. What this captures is a unique build identifier, right? cc @mmitche

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would something like ManifestName make more sense? We have the same in Arcade's Publish.proj: https://github.com/dotnet/arcade/blob/87b015b938e5400d6e57afd7650348c17a764b73/src/Microsoft.DotNet.Arcade.Sdk/tools/Publish.proj#L70

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most repos, like installer, that produce multiple manifests do not call each build a 'vertical', though in essence that is what they are. They also do often have a concept of a primary vertical, but there isn't really any formal term used. It's usually along the lines of: "/p:PublishNugetPackages=true". It's not consistent across repos.

You could just say "ManifestName" here. What differentiates this usage, I think, is that we're really formalizing the 'primary' vertical for join points, and we have to give that some kind of name. Primary/main vertical makes the most sense in that context, vs. "Primary Manifest" or such. Then I think it makes sense to use Vertical here too.

<MergedAssetManifestOutputPath>$(ArtifactsDir)$(VerticalName).xml</MergedAssetManifestOutputPath>
</PropertyGroup>
</Project>
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 3 additions & 6 deletions src/SourceBuild/content/build.proj
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@
<!-- Create a merge manifest from the individual repository manifest files. -->
<UsingTask TaskName="Microsoft.DotNet.UnifiedBuild.Tasks.MergeAssetManifests" AssemblyFile="$(MicrosoftDotNetUnifiedBuildTasksAssembly)" TaskFactory="TaskHostFactory" />
<Target Name="MergeAssetManifests" AfterTargets="Build">
<PropertyGroup>
<MergedAssetManifestOutputPath>$(ArtifactsDir)VerticalManifest.xml</MergedAssetManifestOutputPath>
</PropertyGroup>

<ItemGroup>
<RepoAssetManifest Include="$(AssetManifestsIntermediateDir)\**\*.xml" />
</ItemGroup>

<!-- It's OK for the VmrBuildNumber to be empty -->
<!-- It's OK for the VmrBuildNumber and VerticalName to be empty -->
<Microsoft.DotNet.UnifiedBuild.Tasks.MergeAssetManifests
AssetManifest="@(RepoAssetManifest)"
MergedAssetManifestOutputPath="$(MergedAssetManifestOutputPath)"
VmrBuildNumber="$(BUILD_BUILDNUMBER)" />
VmrBuildNumber="$(BUILD_BUILDNUMBER)"
VerticalName="$(VerticalName)" />
</Target>

<Import Project="$(RepositoryEngineeringDir)build.sourcebuild.targets" Condition="'$(DotNetBuildSourceOnly)' == 'true'" />
Expand Down
3 changes: 3 additions & 0 deletions src/SourceBuild/content/eng/pipelines/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@ extends:
scope: lite
${{ else }}:
scope: full

- ${{ if eq(variables['isSourceOnlyBuild'], false) }}:
- template: /src/installer/eng/pipelines/templates/stages/vmr-final-join.yml@self
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as in vmr-build-pr.yml. Why don't we define this in the stages/vmr-build.yml?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some refactoring, renamed the stage to VMR Post Build for better clarity. I think with that name it becomes clear that it should be a separate yml. Let me know if you think differently please

39 changes: 39 additions & 0 deletions src/SourceBuild/content/eng/tools/join-verticals.proj
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.Build.NoTargets">
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
dkurepa marked this conversation as resolved.
Show resolved Hide resolved

<PropertyGroup>
<TargetFramework>$(NetCurrent)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="$(TasksDir)Microsoft.DotNet.UnifiedBuild.Tasks\Microsoft.DotNet.UnifiedBuild.Tasks.csproj" />
</ItemGroup>

<UsingTask TaskName="Microsoft.DotNet.UnifiedBuild.Tasks.JoinVerticals" AssemblyFile="$(MicrosoftDotNetUnifiedBuildTasksAssembly)" TaskFactory="TaskHostFactory" />
<Target Name="JoinVerticals " AfterTargets="Build">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would call this project "publish-final.proj" and the target "PublishFinal":

Suggested change
<Target Name="JoinVerticals " AfterTargets="Build">
<Target Name="PublishFinal" AfterTargets="Build">

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should call it publish-final, as this will also be used to download artifacts for join point builds like we discussed. I named it join-verticals since it can join any number of verticals, including all of them

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression that we can't directly use this target for the join but if we can, great.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to, there's a property called VerticalSubSet that has a list of verticals we want to join, so it doesn't have to be the full set. On second thought, this isn't a very good name, perhaps VerticalsToJoin is better.
There's also a FlatCopy property, that tells the task how we want it to copy artifacts, if it's set to true, all packages will be in a flat layout in the packages folder, and if it's not, we'll keep the same layout as it is in the artifacts, so for the non final join points we should keep it false. Same goes for assets

<Error Condition="'$(MainVertical)' == ''" Text="MainVertical is not set." />
<Error Condition="'$(VerticalManifestsPath)' == ''" Text="VerticalManifestsPath is not set." />
<Error Condition="'$(FlatCopy)' == ''" Text="FlatCopy property is not set, set to true for final vertical join, otherwise flase"/>

<ItemGroup>
<VerticalManifest Include="$(VerticalManifestsPath)\*.xml"/>
</ItemGroup>

<PropertyGroup>
<FlatCopy Condition="'$(FlatCopy)' == ''">true</FlatCopy>
</PropertyGroup>

<Message Importance="High" Text="Joining verticals $(VerticalSubSet)" Condition="'$(VerticalSubSet)' != ''"/>
<Message Importance="High" Text="VerticalSubSet not set, joining all verticals @(VerticalManifest)" Condition="'$(VerticalSubSet)' == ''"/>
<Microsoft.DotNet.UnifiedBuild.Tasks.JoinVerticals
VerticalManifest="@(VerticalManifest)"
MainVertical="$(MainVertical)"
BuildId="$(BuildId)"
AzureDevOpsToken="$(AzureDevOpsToken)"
AzureDevOpsOrg="$(AzureDevOpsOrg)"
AzureDevOpsProject="$(AzureDevOpsProject)"
TmpFolder="$(TmpFolder)"
OutputFolder="$(OutputFolder)"
VerticalSubSet="$(VerticalSubSet)"
FlatCopy="$(FlatCopy)" />
</Target>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

#nullable enable

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Task = System.Threading.Tasks.Task;

namespace Microsoft.DotNet.UnifiedBuild.Tasks;

public class AzureDevOpsClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly TaskLoggingHelper _logger;

private readonly string _azureDevOpsBaseUri = "https://dev.azure.com";
private readonly string _azureDevOpsApiVersion = "7.1-preview.5";
// download in 100 MB chunks
private readonly int _downloadBufferSize = 1024 * 1024 * 100;
private readonly int _httpTimeoutSeconds = 300;
private readonly string _assetsFolderName = "assets";
private readonly string _packagesFolderName = "packages";

public AzureDevOpsClient(
string azureDevOpsToken,
string azureDevOpsOrg,
string azureDevOpsProject,
TaskLoggingHelper logger)
{

_logger = logger;

_httpClient = new(new HttpClientHandler { CheckCertificateRevocationList = true });

_httpClient.BaseAddress = new Uri($"{_azureDevOpsBaseUri}/{azureDevOpsOrg}/{azureDevOpsProject}/_apis/");

_httpClient.Timeout = TimeSpan.FromSeconds(_httpTimeoutSeconds);

_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($":{azureDevOpsToken}"))
);
}

/// <summary>
/// Downloads specified packages and symbols from a specific build artifact and stores them in an output folder, either flat or with the same relative path as in the artifact.
/// </summary>
public async Task DownloadArtifactFiles(
string buildId,
string artifactName,
List<string> packageNames,
List<string> symbolNames,
string downloadFolder,
string outputFolder,
bool flatCopy)
{
string downloadPath = Path.Combine(downloadFolder, "artifact.zip");
string extractPath = Path.Combine(downloadFolder, "extracted");
string extractedAssetsPath = Path.Combine(extractPath, artifactName, _assetsFolderName);
string extractedPackagesPath = Path.Combine(extractPath, artifactName, _packagesFolderName);

try{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try{
try
{

await DownloadArtifactZip(buildId, artifactName, downloadPath);

ZipFile.ExtractToDirectory(downloadPath, extractPath);

// assets include all kinds of files, so just look at all files
List<string> sourceSymbolPaths = Directory.GetFiles(extractedAssetsPath, "*", SearchOption.AllDirectories).ToList();
List<string> sourcePackagePaths = Directory.GetFiles(extractedPackagesPath, "*.nupkg", SearchOption.AllDirectories).ToList();

string packageOutputPath = Path.Combine(outputFolder, _packagesFolderName);
string symbolOutputPath = Path.Combine(outputFolder, _assetsFolderName);

if (!Directory.Exists(packageOutputPath))
{
Directory.CreateDirectory(packageOutputPath);
}
if (!Directory.Exists(symbolOutputPath))
{
Directory.CreateDirectory(symbolOutputPath);
}

CopyFiles(packageNames, sourcePackagePaths, extractedPackagesPath, packageOutputPath, flatCopy);
CopyFiles(symbolNames, sourceSymbolPaths, extractedAssetsPath, symbolOutputPath, flatCopy);
}
finally
{
if (Directory.Exists(extractPath))
{
Directory.Delete(extractPath, true);
}
if (File.Exists(downloadPath))
{
File.Delete(downloadPath);
}
}
}

private async Task DownloadArtifactZip(string buildId, string artifactName, string downloadPath)
{
string relativeUrl = $"build/builds/{buildId}/artifacts?artifactName={artifactName}&api-version={_azureDevOpsApiVersion}";
_logger.LogMessage(MessageImportance.High, $"Downloading artifact information from {relativeUrl}");
HttpResponseMessage response = await _httpClient.GetAsync(relativeUrl);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This functionality probably needs a try/catch w/retry on certain responses.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A common pattern is EnsureSuccessStatusCode() here with the corresponding try/catch and retry

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition a number of these objects (e.g. HttpResponseMessage) are IDisposable and should have using blocks.


if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Failed to download artifact information. Status code: {response.StatusCode} Reason: {response.ReasonPhrase}");
}

AzureDevOpsArtifactInformation azdoArtifactInformation = await response.Content.ReadFromJsonAsync<AzureDevOpsArtifactInformation>()
?? throw new ArgumentException($"Couldn't parse AzDo response {response.Content} to {nameof(AzureDevOpsArtifactInformation)}");

_logger.LogMessage(MessageImportance.High, $"Downloading artifact zip from {azdoArtifactInformation.Resource.DownloadUrl}");
response = await _httpClient.GetAsync(azdoArtifactInformation.Resource.DownloadUrl, HttpCompletionOption.ResponseHeadersRead);

if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Failed to download artifact zip. Status code: {response.StatusCode} Reason: {response.ReasonPhrase}");
}

using Stream readStream = await response.Content.ReadAsStreamAsync();
using FileStream writeStream = File.Create(downloadPath);

await readStream.CopyToAsync(writeStream, _downloadBufferSize);
}

private void CopyFiles(List<string> fileNamesToCopy, List<string> sourceFiles, string sourceDirectory, string destinationFolder, bool flatCopy)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need/purpose of this method isn't super obvious. Can you add a comment as to why this is written this way?

{
foreach (string file in fileNamesToCopy)
{
string sourceFilePath = sourceFiles.FirstOrDefault(f => f.Contains(file))
?? throw new ArgumentException($"File {file} not found in source files.");

string destinationFilePath = flatCopy
? GetFlatDestinationPath(sourceFilePath, destinationFolder)
: GetRelativeDestinationPath(sourceFilePath, sourceDirectory, destinationFolder);

if (!Directory.Exists(Path.GetDirectoryName(destinationFilePath)))
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath)!);
}

if (File.Exists(destinationFilePath))
{
_logger.LogWarning($"File {destinationFilePath} already exists. Overwriting.");
}

File.Copy(sourceFilePath, destinationFilePath, true);
}
}

private string GetRelativeDestinationPath(string sourceFilePath, string sourceDirectory, string destinationFolder)
{
string relativeFilePath = string.Empty;

// We need to keep the relative file path the same as in the artifacts
// For example d:/extracted/artifacts/packages/Release/Shipping/emsdk/package.nupkg
// should be copied to d:/output/packages/Release/Shipping/emsdk/package.nupkg
string helpFilePath = sourceFilePath;
while (helpFilePath != sourceDirectory)
{
relativeFilePath = Path.Combine(Path.GetFileName(helpFilePath)!, relativeFilePath);
helpFilePath = Path.GetDirectoryName(helpFilePath)!;
}

return Path.Combine(destinationFolder, relativeFilePath);;
}

private string GetFlatDestinationPath(string sourceFilePath, string destinationFolder)
{
return Path.Combine(destinationFolder, Path.GetFileName(sourceFilePath));
}

public void Dispose()
{
_httpClient.Dispose();
}
}