0 4K ru

Работа с MongoDB в ASP.NET Core приложении с использованием докера

Categories: 💻 Programming

Это вторая часть серии статей по докеру и .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 заработала.

Вы должны увидеть следующее:

mongo express

Если в режиме 'detach' вам необходимо подключиться на ваш контейнер, вам  нужно выполнить команду docker container ls

container

В моем случае, как вы видите из изображения, мой контейнер называется iplookup_mongo_1

Для того чтобы зайти на контейнер, нужно выполнить команду:

docker exec -i -t iplookup_mongo_1 bash

Далее нужно ввести пароль для root юзера, в нашем случае это example (мы его конфигурировали в docker-compose файле).

Вернемся на наш mongo express (http://localhost:8081/) и создадим новую базу данных.

ip db mongo

Работа с 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;
        }
    }

Запускаем нашу консольку и ждем пока все завершится:

console

Проверяем результат:

result import csv data to mongo

Как видим у нас появились записи с ренджами и локациями (страны, города, регионы и тд). Теперь вернемся к основному проекту.

Реконфигурация 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:

test api

test api

 

Все отлично, кроме одного, времени работы метода, давайте добавим индексы для этих полей:

end index mongo

Теперь как видим у нас есть 2 индекса, один по возрастанию, второй по убыванию:

index

Проверим как это помогло ускорить поиск и выполним еще раз наши запросы:

ip test

ip test 2

Как видите, теперь результаты отдаются намного быстрее.

Код этого решения можно найти по ссылке на гитхабе.

Comments:

Please log in to be able add comments.