Работа с MongoDB в ASP.NET Core приложении с использованием докера
Это вторая часть серии статей по докеру и .NET Core приложениям. В Первой части мы рассказывали как создать свое .NET Core приложение и развернуть его в докер контейнере.
В этой статье мы рассмотрим как развернуть MongoDB и добавить базу IP адресов для поиска локации пользователя.
MongoDB в докере
Для использования монго, нам нужно выполнить несколько действий:
docker-compose
Создадим наш docker-compose.yml
файл в корне проекта.
version: "3.7"
services:
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
mongo-express:
image: mongo-express
restart: always
ports:
- 8081:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
depends_on:
- mongo
iplookup:
build: ./WebApi
restart: always
ports:
- 8080:80
depends_on:
- mongo
Для запуска у нас есть несколько вариантов:
Запуск проекта без "аттача" (в консоли не будут показываться логи и вы сможете выполнять другие команды)
docker-compose up -d
Для ребилда проекта с сбросом всех настроек и данных вы пожете выполнить:
docker-compose up -d --force-recreate --build
Еще один вариант это запуск контейнером с "аттачем", но предворительно сбилдив наши проекты
docker-compose up --build
Конфигурация
После успешной отработки docker-compose, мы переходим http://localhost:8081/ для того чтобы проверить, что mongo заработала.
Вы должны увидеть следующее:
Если в режиме 'detach' вам необходимо подключиться на ваш контейнер, вам нужно выполнить команду docker container ls
В моем случае, как вы видите из изображения, мой контейнер называется iplookup_mongo_1
Для того чтобы зайти на контейнер, нужно выполнить команду:
docker exec -i -t iplookup_mongo_1 bash
Далее нужно ввести пароль для root юзера, в нашем случае это example (мы его конфигурировали в docker-compose файле).
Вернемся на наш mongo express (http://localhost:8081/) и создадим новую базу данных.
Работа с MongoDB в .NET Core
Для того чтобы использовать базу в нашем проекте. нам нужно создать новый class library проект, давайте сделаем это через dotnet CLI^
dotnet new classlib -n Data
И добавим новый проект в solution:
dotnet sln IpLookup.sln add Data/Data.csproj
Подключим MongoDB.Driver nuget пакет для нашего data проекта
и добавим основные классы для работы с mongodb.
MongoDbConfig:
public interface IMongoDbConfig
{
string Database { get; set; }
string Host { get; set; }
int Port { get; set; }
string User { get; set; }
string Password { get; set; }
string ConnectionString { get; }
}
public class MongoDbConfig : IMongoDbConfig
{
public MongoDbConfig(IConfiguration config)
{
if (config != null)
{
Database = config["MongoDB:Database"];
Port = int.Parse(config["MongoDB:Port"]);
Host = config["MongoDB:Host"];
User = config["MongoDB:User"];
Password = config["MongoDB:Password"];
}
}
public string Database { get; set; }
public string Host { get; set; }
public int Port { get; set; }
public string User { get; set; }
public string Password { get; set; }
public string ConnectionString
{
get
{
if (string.IsNullOrEmpty(User) || string.IsNullOrEmpty(Password))
{
return $@"mongodb://{Host}:{Port}";
}
return $@"mongodb://{User}:{Password}@{Host}:{Port}";
}
}
}
DbContext
public interface IDbContext
{
IMongoCollection<IpEntity> IpList { get; }
}
public class DbContext : IDbContext
{
private readonly IMongoDatabase _db;
public DbContext(IMongoDbConfig config)
{
var client = new MongoClient(config.ConnectionString);
_db = client.GetDatabase(config.Database);
}
public IMongoCollection<IpEntity> IpList => _db.GetCollection<IpEntity>("IpList");
}
IpEntity
public class IpEntity
{
[BsonId]
public ObjectId Id { get; set; }
public double StartIpNumber { get; set; }
public double EndIpNumber { get; set; }
public string CountryPrefix { get; set; }
public string Country { get; set; }
public string Region { get; set; }
public string City { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string ZipCode { get; set; }
public string TimeZone {get;set;}
}
Импорт данных
Теперь скачаем базу ipv4 и ipv6 адресов с локацией и индексом с этого сайта https://lite.ip2location.com/.
Создадим консольный проект ImportData, который будет выступать в роли парсера данных для нашей бд.
Поставим нугет для работы с CSV
Install-Package CsvHelper -Version 12.2.2
И набросаем код по быстрому для импорта данных
class Program
{
static void Main(string[] args)
{
var context = new DbContext(new MongoDbConfig(null)
{
Database = "iplookup",
Host = "localhost",
Password = "example",
User = "root",
Port = 27017
});
Console.WriteLine("START IpV6");
var result = ReadInCsv("v6.csv");
context.IpList.BulkWrite(result);
Console.WriteLine("END IpV6");
Console.WriteLine("START IpV4");
var result2 = ReadInCsv("v4.csv");
context.IpList.BulkWrite(result2);
Console.WriteLine("END IpV4");
}
public static List<WriteModel<IpEntity>> ReadInCsv(string path)
{
List<WriteModel<IpEntity>> batch = new List<WriteModel<IpEntity>>();
using (TextReader fileReader = File.OpenText(path))
{
var csv = new CsvReader(fileReader);
csv.Configuration.HasHeaderRecord = false;
var totalCount = 0;
while (csv.Read())
{
try
{
var record = csv.GetRecord<dynamic>();
var upsertOne = new InsertOneModel<IpEntity>(new IpEntity
{
StartIpNumber = Convert.ToDouble(record.Field1),
EndIpNumber = Convert.ToDouble(record.Field2),
CountryPrefix = record.Field3,
Country = record.Field4,
Region = record.Field5,
City = record.Field6,
Latitude = Convert.ToDecimal(record.Field7),
Longitude = Convert.ToDecimal(record.Field8),
ZipCode = record.Field9,
TimeZone = record.Field10
});
batch.Add(upsertOne);
}
finally
{
Console.WriteLine(++totalCount);
}
}
}
return batch;
}
}
Запускаем нашу консольку и ждем пока все завершится:
Проверяем результат:
Как видим у нас появились записи с ренджами и локациями (страны, города, регионы и тд). Теперь вернемся к основному проекту.
Реконфигурация docker'а
Для того чтобы наш проект работал и правильно билдился вместе с data проектом, нам нужно переместить dockerfile в корень к docker-compose файлу и изменить dockerfile следующим образом:
FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
WORKDIR /src
COPY ["WebApi/WebApi.csproj", "WebApi/"]
COPY ["Data/Data.csproj", "Data/"]
RUN dotnet restore "WebApi/WebApi.csproj"
COPY . .
WORKDIR "/src/WebApi"
RUN dotnet build "WebApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "WebApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApi.dll"]
и в docker-compose файле мы должны изменить путь для iplookup build с ./WebApi
на build: ./
compose файл теперь будет выглядеть так:
version: "3.7"
services:
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
mongo-express:
image: mongo-express
restart: always
ports:
- 8081:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
depends_on:
- mongo
iplookup:
build: ./
restart: always
ports:
- 8080:80
depends_on:
- mongo
Реализуем поиск локации по ip
Далее, в data проекте создадим сервис, который будет работать с нашим Db контекстом:
public interface IIpService
{
Task<List<IpEntity>> GetLocationsByIp(string ip);
}
public class IpService : IIpService
{
private static readonly ulong MaxIpV4 = 4294967295;
private readonly IDbContext _context;
public IpService(IDbContext context)
{
_context = context;
}
public async Task<List<IpEntity>> GetLocationsByIp(string ip)
{
var ipAddress = IPAddress.Parse(ip);
FilterDefinition<IpEntity> finalFilter;
double number;
switch (ipAddress.AddressFamily)
{
case System.Net.Sockets.AddressFamily.InterNetwork:
number = (double)IpV4ToNumber(ip);
finalFilter = Builders<IpEntity>.Filter.And(new List<FilterDefinition<IpEntity>>
{
Builders<IpEntity>.Filter.Lte(m => m.StartIpNumber, number),
Builders<IpEntity>.Filter.Gte(m => m.EndIpNumber, number),
Builders<IpEntity>.Filter.Lte(m => m.EndIpNumber, MaxIpV4)
});
break;
case System.Net.Sockets.AddressFamily.InterNetworkV6:
number = (double)IpV6ToNumber(ipAddress);
finalFilter = Builders<IpEntity>.Filter.And(new List<FilterDefinition<IpEntity>>
{
Builders<IpEntity>.Filter.Lte(m => m.StartIpNumber, number),
Builders<IpEntity>.Filter.Gte(m => m.EndIpNumber, number)
});
break;
default:
throw new Exception("Bad Ip Address");
}
return await GetDataFromDb(finalFilter);
}
private async Task<List<IpEntity>> GetDataFromDb(FilterDefinition<IpEntity> finalFilter)
{
var result = await _context.IpList.FindAsync(finalFilter);
return result.ToList();
}
private static BigInteger IpV4ToNumber(string originalIpV4)
{
string[] ipList = originalIpV4.Split(".".ToCharArray());
string ipNumber = "";
foreach (string ip in ipList)
{
ipNumber += Convert.ToInt16(ip) < 16
? "0" + Convert.ToInt16(ip).ToString("x")
: Convert.ToInt16(ip).ToString("x");
}
return new BigInteger(ulong.Parse(ipNumber, NumberStyles.HexNumber));
}
private static BigInteger IpV6ToNumber(IPAddress originalIpV6)
{
var addrBytes = originalIpV6.GetAddressBytes();
System.Numerics.BigInteger result;
if (System.BitConverter.IsLittleEndian)
{
System.Collections.Generic.List<byte> byteList = new System.Collections.Generic.List<byte>(addrBytes);
byteList.Reverse();
addrBytes = byteList.ToArray();
}
if (addrBytes.Length > 8)
{
result = System.BitConverter.ToUInt64(addrBytes, 8);
result <<= 64;
result += System.BitConverter.ToUInt64(addrBytes, 0);
}
return result;
}
}
Не могу сказать что этот код выглядит как "production-ready", я бы еще его порефакторил и заюзал бы паттерн Stategy для выбора стратегии конвертации ip адреса в число (ipv4 или ipv6 в зависимости от строки). Но для примера и тестирования данный пример вполне релевантный. Так же я использую private static readonly ulong MaxIpV4 = 4294967295;
как "хак" для того чтобы избежать поиска не релевантного значения с базы, тк в файле ipv6 существует запись с ренжжом 0 - 281470681743359 и все записи ipv4 содержали и эту запись.
Далее отредактируем Startup.cs для того чтобы добавить поддержку mongodb и новосозданного сервиса:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
RegisterDIServices(services);
services.AddResponseCompression();
services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});
services.AddMvcCore(options =>
{
options.Filters.Add(new ApiControllerAttribute());
options.Filters.Add(new ModelStateValidationActionFilterAttribute());
options.EnableEndpointRouting = false;
})
.AddFormatterMappings()
.AddCors(options =>
{
options.AddPolicy("AllowAll",
builder =>
{
builder
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
}).AddApiExplorer()
.AddNewtonsoftJson()
.AddDataAnnotations()
.AddAuthorization().SetCompatibilityVersion(CompatibilityVersion.Latest);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", true, true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", true);
builder.Build();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseResponseCompression();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseMvc();
}
protected void RegisterDIServices(IServiceCollection services)
{
services.AddScoped<IDbContext, DbContext>();
services.AddSingleton<IMongoDbConfig, MongoDbConfig>();
services.AddSingleton<IIpService, IpService>();
}
}
Модифицируем наш IP контроллер, теперь он будет выглядеть так:
[ApiController]
[Route("api/[controller]")]
public class IpController : ControllerBase
{
private readonly IIpService _service;
public IpController(IIpService service)
{
_service = service;
}
[HttpGet("{ip}")]
public async Task<ActionResult> Get(string ip)
{
var result = await _service.GetLocationsByIp(ip);
return Ok(result.FirstOrDefault());
}
}
Конфигурация web проекта будет выглядеть таким образом:
{
"MongoDB": {
"Database": "iplookup",
"Host": "mongo",
"Port": 27017,
"User": "root",
"Password": "example"
},
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*"
}
Обратите внимание, что в качестве хоста мы используем не Localhost, а mongo. Мongo - это название контейнера который мы используем в docker-compose файле, а следовательно внутри контейнера мы можем доступиться только через mongo хост к нашей базе.
Сбилдим наш проект и запустим контейнеры:
docker-compose up --build
И потестируем работу API:
Все отлично, кроме одного, времени работы метода, давайте добавим индексы для этих полей:
Теперь как видим у нас есть 2 индекса, один по возрастанию, второй по убыванию:
Проверим как это помогло ускорить поиск и выполним еще раз наши запросы:
Как видите, теперь результаты отдаются намного быстрее.
Код этого решения можно найти по ссылке на гитхабе.