pengantar
Halo semuanya! Saya baru-baru ini menulis bot Discord untuk guild World of Warcraft. Dia secara teratur mengumpulkan data tentang pemain dari server game dan menulis pesan di Discord bahwa seorang pemain baru telah bergabung dengan guild atau bahwa seorang pemain lama telah meninggalkan guild. Di antara kami sendiri, kami menjuluki bot ini Batrak.
Pada artikel ini, saya memutuskan untuk membagikan pengalaman saya dan memberi tahu Anda cara membuat proyek semacam itu. Intinya, kami akan mengimplementasikan layanan mikro di .NET Core: kami akan menulis logika, mengintegrasikan dengan api layanan pihak ketiga, menutupinya dengan tes, mengemasnya di Docker dan menempatkannya di Heroku. Selain itu, saya akan menunjukkan kepada Anda bagaimana menerapkan integrasi berkelanjutan menggunakan Tindakan Github.
Tidak ada pengetahuan tentang permainan yang dibutuhkan dari Anda . Saya menulis materi sehingga memungkinkan untuk mengabstraksi dari permainan dan membuat rintisan untuk data tentang para pemain. Tetapi jika Anda memiliki akun Battle.net, maka Anda bisa mendapatkan data nyata.
Untuk memahami materi tersebut, Anda diharapkan memiliki minimal pengalaman dalam membuat web service menggunakan framework ASP.NET dan sedikit pengalaman dengan Docker.
Rencana
Pada setiap langkah, kami akan meningkatkan fungsionalitas secara bertahap.
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
Anda tidak perlu mengubah apa pun di file ini, semuanya sudah akan berfungsi di luar kotak.
Dorong sesuatu ke master dan lihat apakah pekerjaan dimulai. Omong-omong, itu seharusnya sudah dimulai setelah membuat alur kerja baru.
Luar biasa! Jadi kami membuat layanan mikro di .NET Core yang dikumpulkan dan dipublikasikan di Heroku. Proyek ini memiliki banyak poin untuk pengembangan: dapat menambahkan logging, tes pompa, metrik hang, dll. dll.
Semoga artikel ini memberi Anda beberapa ide dan topik baru untuk dijelajahi. Terima kasih atas perhatiannya. Semoga sukses dengan proyek Anda!