introduction
Bonjour à tous! J'ai récemment écrit un bot Discord pour la guilde World of Warcraft. Il collecte régulièrement des données sur les joueurs sur les serveurs du jeu et écrit des messages dans Discord indiquant qu'un nouveau joueur a rejoint la guilde ou qu'un ancien joueur a quitté la guilde. Entre nous, nous avons surnommé ce bot Batrak.
Dans cet article, j'ai décidé de partager mon expérience et de vous dire comment réaliser un tel projet. Essentiellement, nous allons implémenter un microservice sur .NET Core : nous allons écrire la logique, l'intégrer à l'api de services tiers, le couvrir de tests, le packager dans Docker et le placer sur Heroku. De plus, je vais vous montrer comment mettre en œuvre l'intégration continue à l'aide de Github Actions.
Aucune connaissance du jeu n'est requise de votre part . J'ai écrit le matériel pour qu'il soit possible de faire abstraction du jeu et j'ai fait un talon pour les données sur les joueurs. Mais si vous avez un compte Battle.net, vous pouvez obtenir de vraies données.
Pour comprendre le matériel, vous devez avoir au moins une expérience minimale dans la création de services Web à l'aide du framework ASP.NET et une petite expérience avec Docker.
Plan
A chaque étape, nous augmenterons progressivement la fonctionnalité.
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
Vous n'avez pas besoin de changer quoi que ce soit dans ce fichier, tout fonctionnera déjà hors de la boîte.
Poussez quelque chose dans le maître et voyez si le travail commence. Soit dit en passant, il devrait déjà avoir commencé après la création d'un nouveau flux de travail.
Excellent! Nous avons donc créé un microservice sur .NET Core qui est collecté et publié dans Heroku. Le projet a de nombreux points à développer : il pourrait ajouter une journalisation, des tests de pompe, des métriques de blocage, etc. etc.
Espérons que cet article vous a donné quelques nouvelles idées et sujets à explorer. Merci pour l'attention. Bonne chance dans vos projets !