[.NET, API, C#] Как я сделал Discord бота для игровой гильдии с помощью .NET Core
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Батрак предупреждает о том что к гильдии присоединился игрокВступлениеВсем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.От вас не потребуется никаких знаний об игре. Я написал материал так чтобы можно было абстрагироваться от игры и сделал заглушку для данных об игроках. Но если у вас есть учетная запись в Battle.net, то вы сможете получать реальные данные.Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.ПланНа каждом шаге будем постепенно наращивать функционал.
- Создадим новый web api проект с одним контроллером /check. При обращении к этому адресу будем отправлять строку “Hello!” в Discord чат.
- Научимся получать данные о составе гильдии с помощью готовой библиотеки или заглушки.
- Научимся сохранять в кэш полученный список игроков чтобы при следующих проверках находить различия с предыдущей версией списка. Обо всех изменениях будем писать в Discord.
- Напишем Dockerfile для нашего проекта и разместим проект на хостинге Heroku.
- Посмотрим на несколько способов сделать периодическое выполнение кода.
- Реализуем автоматическую сборку, запуск тестов и публикацию проекта после каждого коммита в master
Шаг 1. Отправляем сообщение в DiscordНам потребуется создать новый ASP.NET Core Web API проект. Создание нового проекта - это одна из фундаментальных вещей которые я не буду подробно расписывать. При работе над проектом используйте сервис Github для хранения кода. В дальнейшем мы воспользуемся несколькими возможностями Github.Добавим к проекту новый контроллер
[ApiController]
public class GuildController : ControllerBase
{
[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
return Ok();
}
}
Затем нам понадобится webhook от вашего Discord сервера. Webhook - это механизм отправки событий. В данном случае, то это адрес к которому можно слать простые http запросы с сообщениями внутри.Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.
Создание webhookДобавим 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.Подключим к проекту библиотеку ArgentPonyWarcraftClient.Создадим новый класс 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);
}
}
Теперь можно написать сервис который будет сравнивать новый список игроков с сохраненным.
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.На первом скриншоте в статье вы видели что я вывел больше информации об игроке. Ее можно получить если воспользоваться библиотекой ArgentPonyWarcraftClient
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 переводится как батрак.О том как работать с Docker и Heroku можно прочитать здесь. Но я все же опишу последовательность действий.Вам понадобится создать аккаунт в 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
Менять в этом файле ничего не нужно, все уже будет работать из коробки.Запуште что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.
Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!
===========
Источник:
habr.com
===========
Похожие новости:
- [Управление персоналом, Здоровье, IT-компании, Удалённая работа] Сотрудники Apple против возвращения в офисы
- [Управление проектами, Удалённая работа] Работа в распределенной команде: типичные проблемы и их решения
- [Разработка под Android, Dart, Flutter] Основы Flutter для начинающих (Часть VI)
- [PHP, IT-инфраструктура, IT-стандарты, Бизнес-модели, Софт] Есть будущее у Fullstack-разработчиков?
- [Конференции, Искусственный интеллект, Голосовые интерфейсы] Биометрия, персонализация голоса, NLU и речевая аналитика: о чем расскажут на конференции Conversations
- [C++, API] Альтернатива ML-Agents: интегрируем нейросети в Unity-проект с помощью PyTorch C++ API (перевод)
- [Удалённая работа] История удаленной работы. Моя причина почему я ненавижу работать дома
- [.NET, Алгоритмы, C#] Задача о рюкзаке (Knapsack problem) простыми словами
- [.NET, C#] Как уменьшить размер приложения на C#, которое независимо от среды?
- [Open source, Разработка игр] Исходный код RTS Периметр выложен в OpenSource
Теги для поиска: #_.net, #_api, #_c#, #_bot (бот), #_bot, #_c#, #_api, #_dotnet, #_mikroservis (микросервис), #_discord, #_wow, #_docker, #_heroku, #_.net, #_api, #_c#
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 24-Ноя 13:23
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Батрак предупреждает о том что к гильдии присоединился игрокВступлениеВсем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.От вас не потребуется никаких знаний об игре. Я написал материал так чтобы можно было абстрагироваться от игры и сделал заглушку для данных об игроках. Но если у вас есть учетная запись в Battle.net, то вы сможете получать реальные данные.Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.ПланНа каждом шаге будем постепенно наращивать функционал.
[ApiController]
public class GuildController : ControllerBase { [HttpGet("/check")] public async Task<IActionResult> Check(CancellationToken ct) { return Ok(); } } Создание webhookДобавим webhook в appsettings.json нашего проекта. Позже мы унесем его в переменные окружения Heroku. Если вы не знакомы с тем как работать с конфигурацией в ASP Core проектах предварительно изучите эту тему. {
"DiscordWebhook":"https://discord.com/api/webhooks/****/***" } 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); } } services.AddScoped<IDiscordBroker, DiscordBroker>();
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(); } 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"]; } } Этот класс относится к библиотеке, которую мы установили ранее. С его помощью можно получать данные об игроках.Кроме этого, нужно создать в 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; } } services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
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 = "Непобедимый" } }); } } services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
[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(); } } services.AddMemoryCache();
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); } } 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 }; } } public class Report
{ public WowCharacterToken[] JoinedMembers { get; set; } public WowCharacterToken[] DepartedMembers { get; set; } public int TotalCount { get; set; } } [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) }); } 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); } await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);
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()); } } [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); } 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"] heroku git:remote -a project_name
build:
docker: web: Dockerfile # Залогинимся в heroku registry
heroku container:login # Соберем и запушим образ в registry heroku container:push web # Зарелизим приложение из образа heroku container:release web heroku open
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 }; }); 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); } } [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"); } Второй способ - это использовать 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 ] 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 Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах! =========== Источник: habr.com =========== Похожие новости:
|
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 24-Ноя 13:23
Часовой пояс: UTC + 5