Introduction
Hello everyone! I recently wrote a Discord bot for the World of Warcraft guild. He regularly collects data about players from the game servers and writes messages in Discord that a new player has joined the guild or that an old player has left the guild. Among ourselves, we nicknamed this bot Batrak.
In this article, I decided to share my experience and tell you how to make such a project. In essence, we will implement a microservice on .NET Core: we will write the logic, integrate with the api of third-party services, cover it with tests, package it in Docker and place it on Heroku. In addition, I will show you how to implement continuous integration using Github Actions.
No knowledge of the game is required from you . I wrote the material so that it was possible to abstract from the game and made a stub for the data about the players. But if you have a Battle.net account, then you can get real data.
To understand the material, you are expected to have at least minimal experience in creating web services using the ASP.NET framework and a little experience with Docker.
Plan
At each step, we will gradually increase the functionality.
Let's create a new web api project with one controller / check. When accessing this address, we will send the string โHello!โ in Discord chat.
We will learn how to get data on the composition of the guild using a ready-made library or a stub.
Let's learn how to save the resulting list of players in the cache so that during the next checks we can find differences with the previous version of the list. We will write about all the changes in Discord.
Let's write a Dockerfile for our project and host the project on Heroku hosting.
Let's look at a few ways to do periodic code execution.
We implement automatic building, running tests and publishing the project after each commit to master
Step 1: Submitting a Discord Message
We need to create a new ASP.NET Core Web API project.
[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
You don't need to change anything in this file, everything will already work out of the box.
Push something into master and see if the job starts. By the way, it should have already started after creating a new workflow.
Excellent! So we made a microservice on .NET Core that is collected and published in Heroku. The project has many points for development: it could add logging, pump tests, hang metrics, etc. etc.
Hopefully this article has given you a couple of new ideas and topics to explore. Thanks for attention. Good luck with your projects!