Introdução
Olá a todos! Recentemente, escrevi um bot Discord para a guilda de World of Warcraft. Ele regularmente coleta dados sobre os jogadores dos servidores do jogo e escreve mensagens no Discord informando que um novo jogador se juntou à guilda ou que um antigo jogador deixou a guilda. Entre nós, apelidamos este bot de Batrak.
Neste artigo, decidi compartilhar minha experiência e dizer como fazer esse projeto. Em essência, vamos implementar um microsserviço no .NET Core: vamos escrever a lógica, integrar com a API de serviços de terceiros, cobri-la com testes, empacotá-la no Docker e colocá-la no Heroku. Além disso, vou mostrar como implementar a integração contínua usando o Github Actions.
Nenhum conhecimento do jogo é exigido de você . Escrevi o material para que fosse possível abstrair do jogo e fiz um esboço dos dados sobre os jogadores. Mas se você tiver uma conta Battle.net, poderá obter dados reais.
Para entender o material, espera-se que você tenha pelo menos uma experiência mínima na criação de serviços da Web usando a estrutura ASP.NET e um pouco de experiência com o Docker.
Plano
A cada etapa, aumentaremos gradativamente a funcionalidade.
web api /check. “Hello!” Discord .
.
. Discord.
Dockerfile Heroku.
.
, master
1. Discord
ASP.NET Core Web API .
[ApiController]
public class GuildController : ControllerBase
{
[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
return Ok();
}
}
webhook Discord . Webhook - . , http .
integrations Discord .
webhook appsettings.json . Heroku. ASP Core .
{
"DiscordWebhook":"https://discord.com/api/webhooks/****/***"
}
DiscordBroker, Discord. Services , .
post webhook .
public class DiscordBroker : IDiscordBroker
{
private readonly string _webhook;
private readonly HttpClient _client;
public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)
{
_client = clientFactory.CreateClient();
_webhook = configuration["DiscordWebhook"];
}
public async Task SendMessage(string message, CancellationToken ct)
{
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(_webhook),
Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})
};
await _client.SendAsync(request, ct);
}
}
, . IConfiguration webhook , IHttpClientFactory HttpClient.
, , . .
Startup.
services.AddScoped<IDiscordBroker, DiscordBroker>();
HttpClient, IHttpClientFactory.
services.AddHttpClient();
.
private readonly IDiscordBroker _discordBroker;
public GuildController(IDiscordBroker discordBroker)
{
_discordBroker = discordBroker;
}
[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
await _discordBroker.SendMessage("Hello", ct);
return Ok();
}
, /check Discord .
2. Battle.net
: battle.net . battle.net, .
https://develop.battle.net/ BattleNetId BattleNetSecret. api . appsettings.
BattleNetApiClient Services.
public class BattleNetApiClient
{
private readonly string _guildName;
private readonly string _realmName;
private readonly IWarcraftClient _warcraftClient;
public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)
{
_warcraftClient = new WarcraftClient(
configuration["BattleNetId"],
configuration["BattleNetSecret"],
Region.Europe,
Locale.ru_RU,
clientFactory.CreateClient()
);
_realmName = configuration["RealmName"];
_guildName = configuration["GuildName"];
}
}
WarcraftClient.
, . .
, appsettings RealmName GuildName. RealmName , GuildName . .
GetGuildMembers WowCharacterToken .
public async Task<WowCharacterToken[]> GetGuildMembers()
{
var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");
if (!roster.Success) throw new ApplicationException("get roster failed");
return roster.Value.Members.Select(x => new WowCharacterToken
{
WowId = x.Character.Id,
Name = x.Character.Name
}).ToArray();
}
public class WowCharacterToken
{
public int WowId { get; set; }
public string Name { get; set; }
}
WowCharacterToken Models.
BattleNetApiClient Startup.
services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
WowCharacterToken Models. .
public class WowCharacterToken
{
public int WowId { get; set; }
public string Name { get; set; }
}
public class BattleNetApiClient
{
private bool _firstTime = true;
public Task<WowCharacterToken[]> GetGuildMembers()
{
if (_firstTime)
{
_firstTime = false;
return Task.FromResult(new[]
{
new WowCharacterToken
{
WowId = 1,
Name = ""
},
new WowCharacterToken
{
WowId = 2,
Name = ""
}
});
}
return Task.FromResult(new[]
{
new WowCharacterToken
{
WowId = 1,
Name = ""
},
new WowCharacterToken
{
WowId = 3,
Name = ""
}
});
}
}
. , . api. .
Startup.
services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
Discord
BattleNetApiClient, - Discord.
[ApiController]
public class GuildController : ControllerBase
{
private readonly IDiscordBroker _discordBroker;
private readonly IBattleNetApiClient _battleNetApiClient;
public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)
{
_discordBroker = discordBroker;
_battleNetApiClient = battleNetApiClient;
}
[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
var members = await _battleNetApiClient.GetGuildMembers();
await _discordBroker.SendMessage($"Members count: {members.Length}", ct);
return Ok();
}
}
3.
api. InMemory ( ) .
InMemory , . Redis Heroku .
InMemory Startup.
services.AddMemoryCache();
IDistributedCache, . , . GuildRepository Repositories.
public class GuildRepository : IGuildRepository
{
private readonly IDistributedCache _cache;
private const string Key = "wowcharacters";
public GuildRepository(IDistributedCache cache)
{
_cache = cache;
}
public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)
{
var value = await _cache.GetAsync(Key, ct);
if (value == null) return Array.Empty<WowCharacterToken>();
return await Deserialize(value);
}
public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)
{
var value = await Serialize(characters);
await _cache.SetAsync(Key, value, ct);
}
private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)
{
var binaryFormatter = new BinaryFormatter();
await using var memoryStream = new MemoryStream();
binaryFormatter.Serialize(memoryStream, tokens);
return memoryStream.ToArray();
}
private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)
{
await using var memoryStream = new MemoryStream();
var binaryFormatter = new BinaryFormatter();
memoryStream.Write(bytes, 0, bytes.Length);
memoryStream.Seek(0, SeekOrigin.Begin);
return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);
}
}
GuildRepository Singletone , .
services.AddSingleton<IGuildRepository, GuildRepository>();
.
public class GuildService
{
private readonly IBattleNetApiClient _battleNetApiClient;
private readonly IGuildRepository _repository;
public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)
{
_battleNetApiClient = battleNetApiClient;
_repository = repository;
}
public async Task<Report> Check(CancellationToken ct)
{
var newCharacters = await _battleNetApiClient.GetGuildMembers();
var savedCharacters = await _repository.GetCharacters(ct);
await _repository.SaveCharacters(newCharacters, ct);
if (!savedCharacters.Any())
return new Report
{
JoinedMembers = Array.Empty<WowCharacterToken>(),
DepartedMembers = Array.Empty<WowCharacterToken>(),
TotalCount = newCharacters.Length
};
var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();
var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();
return new Report
{
JoinedMembers = joined,
DepartedMembers = departed,
TotalCount = newCharacters.Length
};
}
}
Report. Models.
public class Report
{
public WowCharacterToken[] JoinedMembers { get; set; }
public WowCharacterToken[] DepartedMembers { get; set; }
public int TotalCount { get; set; }
}
GuildService .
[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
var report = await _guildService.Check(ct);
return new JsonResult(report, new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)
});
}
Discord .
if (joined.Any() || departed.Any())
{
foreach (var c in joined)
await _discordBroker.SendMessage(
$":smile: **{c.Name}** ",
ct);
foreach (var c in departed)
await _discordBroker.SendMessage(
$":smile: **{c.Name}** ",
ct);
}
GuildService Check. , . Discord GuildService.
await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);
BattleNetApiClient, .
Unit
GuildService , . . BattleNetApiClient, GuildRepository DiscordBroker. .
Unit . Fakes .
public class DiscordBrokerFake : IDiscordBroker
{
public List<string> SentMessages { get; } = new();
public Task SendMessage(string message, CancellationToken ct)
{
SentMessages.Add(message);
return Task.CompletedTask;
}
}
public class GuildRepositoryFake : IGuildRepository
{
public List<WowCharacterToken> Characters { get; } = new();
public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)
{
return Task.FromResult(Characters.ToArray());
}
public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)
{
Characters.Clear();
Characters.AddRange(characters);
return Task.CompletedTask;
}
}
public class BattleNetApiClientFake : IBattleNetApiClient
{
public List<WowCharacterToken> GuildMembers { get; } = new();
public List<WowCharacter> Characters { get; } = new();
public Task<WowCharacterToken[]> GetGuildMembers()
{
return Task.FromResult(GuildMembers.ToArray());
}
}
. Moq. .
GuildService :
[Test]
public async Task SaveNewMembers_WhenCacheIsEmpty()
{
var wowCharacterToken = new WowCharacterToken
{
WowId = 100,
Name = "Sam"
};
var battleNetApiClient = new BattleNetApiApiClientFake();
battleNetApiClient.GuildMembers.Add(wowCharacterToken);
var guildRepositoryFake = new GuildRepositoryFake();
var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);
var changes = await guildService.Check(CancellationToken.None);
changes.JoinedMembers.Length.Should().Be(0);
changes.DepartedMembers.Length.Should().Be(0);
changes.TotalCount.Should().Be(1);
guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);
}
, , . , Should, Be... FluentAssertions, Assertion .
. , .
. .
4. Docker Heroku!
Heroku. Heroku .NET , Docker .
Docker Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builder
WORKDIR /sources
COPY *.sln .
COPY ./src/peon.csproj ./src/
COPY ./tests/tests.csproj ./tests/
RUN dotnet restore
COPY . .
RUN dotnet publish --output /app/ --configuration Release
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=builder /app .
CMD ["dotnet", "peon.dll"]
peon.dll Solution. Peon .
Heroku, Heroku CLI.
heroku .
heroku git:remote -a project_name
heroku.yml . :
build:
docker:
web: Dockerfile
:
# heroku registry
heroku container:login
# registry
heroku container:push web
#
heroku container:release web
:
heroku open
Heroku, Redis . InMemory .
Heroku RedisCloud.
Redis REDISCLOUD_URL. , Heroku.
.
Microsoft.Extensions.Caching.StackExchangeRedis.
Redis IDistributedCache Startup.
services.AddStackExchangeRedisCache(o =>
{
o.InstanceName = "PeonCache";
var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");
if (string.IsNullOrEmpty(redisCloudUrl))
{
throw new ApplicationException("redis connection string was not found");
}
var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);
o.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = {endpoint},
Password = password
};
});
REDISCLOUD_URL . RedisUtils. :
public static class RedisUtils
{
public static (string endpoint, string password) ParseConnectionString(string connectionString)
{
var bodyPart = connectionString.Split("://")[1];
var authPart = bodyPart.Split("@")[0];
var password = authPart.Split(":")[1];
var endpoint = bodyPart.Split("@")[1];
return (endpoint, password);
}
}
Unit .
[Test]
public void ParseConnectionString()
{
const string example = "redis://user:password@url:port";
var (endpoint, password) = RedisUtils.ParseConnectionString(example);
endpoint.Should().Be("url:port");
password.Should().Be("password");
}
, GuildRepository , Redis. .
.
5.
, 15 .
:
- https://cron-job.org. get /check N .
- Hosted Services. ASP.NET Core . , Heroku . Hosted Service . . , .
- Cron . Heroku Scheduler. cron job Heroku.
6. ,
-, Heroku.
Deploy. Github Automatic deploys master.
Wait for CI to pass before deploy. Heroku . , .
Github Actions.
Actions. workflow .NET
dotnet.yml. .
, build master.
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
. , dotnet build dotnet test.
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 5.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
Você não precisa alterar nada neste arquivo, tudo já funcionará fora da caixa.
Empurre algo no mestre e veja se o trabalho começa. A propósito, ele já deveria ter começado após a criação de um novo fluxo de trabalho.
Excelente! Então, fizemos um microsserviço no .NET Core que é coletado e publicado no Heroku. O projeto tem muitos pontos para desenvolvimento: pode adicionar registro, testes de bomba, métricas de travamento, etc. etc.
Espero que este artigo tenha lhe dado algumas novas idéias e tópicos para explorar. Obrigado pela atenção. Boa sorte com seus projetos!