Как писать тестируемый код: два ядра и три идеи
Как писать тестируемый код: два ядра и три идеи

👉Введение

Написание модульных тестов в проектах, требующих длительных итераций, постепенно стало лицемерным консенсусом среди различных команд. Хотя я говорю хорошие вещи, мое тело очень честное.

Написание модульных тестов в проектах, требующих длительных итераций, постепенно стало лицемерным консенсусом среди различных команд. Хотя я говорю хорошие вещи, мое тело очень честное. Ведь написание модуля требует дополнительных сил и времени помимо реализации бизнес-функций, поэтому многие считают это большой нагрузкой. Есть две основные фундаментальные проблемы, которые вызывают такое восприятие. -потребляющий. При тестировании часто уходит много времени на разработку вариантов использования теста, а для того, чтобы запустить тестируемую функцию, нужно много времени на создание для нее рабочей среды, инициализацию переменной, макетирование Объекты и т. д. Иногда я даже чешу голову и не умею писать тесты. Поэтому данная статья основана на Go На примере языка мы поговорим о том, как проектировать и писать бизнес-код, который легко тестировать.

Фактически, если мы сознательно проектируем структуры данных и интерфейсы функций, наш код можно легко протестировать без каких-либо особых навыков. Однако на практике большинство студентов не имеют представления о «Для тестирования» на этапе проектирования и, естественно, пишут код, который трудно протестировать. Важно понимать, что легкость тестирования кода и наличие четкой логической структуры — это две разные вещи. Четкая логика не означает, что код легко тестировать. Даже опытные программисты будут писать код, который сложно протестировать, если они этого не делают. осторожны, например:

Язык кода:javascript
копировать
func GetUserInfo(uid int64) (*UserInfo, error) {
    key := buildUserCacheKey(uid)
    val, err := redis.NewClient(USERDB).GetString(key)
    if err == nil {
      return unmarshalUserInfoFromStr(val)
    }
    res, err := mysqlPool.GetConn().Query("select * from user where uid=?", uid)
    // ... 
}

Логика приведенного выше кода по-прежнему очень понятна (не для хвастовства): сначала получите кеш от Redis, если не получите, перейдите за ним в MySQL. Хотя текст легко читается, если бы вас попросили написать модульный тест для этой функции, у вас были бы проблемы. Поскольку для получения данных функция должна обращаться к Redis, она вообще не может подключиться к Redis в среде разработки. Даже если он подключен, в Redis нет данных. То же самое касается MySQL. И заметили ли вы, что над этими зависимостями вообще нельзя издеваться? При написании одного теста для функции GetUserInfo у меня не было возможности контролировать поведение объектов MySQL и Redis. Если нет возможности их контролировать, то и писать тестовый код действительно невозможно.

Тогда давайте перейдем к делу: как написать бизнес-код, который легко протестировать.

01. Поместите слона в холодильник

Сколько шагов нужно, чтобы положить слона в холодильник?

  1. Откройте дверцу холодильника;
  2. Уложите слона;
  3. Закройте дверцу холодильника.

Конечно, это всего лишь шутка. Открыть и закрыть дверь просто, но засунуть слона не так просто. Однако если вы сознательно учитываете возможность тестирования при написании бизнес-кода, то писать модульные тесты действительно легко. Есть два основных шага:

  • Установите значения всех входных параметров;
  • Определите, соответствует ли выходное значение да ожидаемому.

Эти два шага очень интуитивно понятны и просты для понимания, но почему на практике так сложно написать один тест?

02. Чистая функция

Чтобы внести ясность в этот вопрос,Сначала я хочу поговорить очистая функцияконцепция。еслифункцияудовлетворить:

  • Ввод тех же входных параметров даст те же результаты;
  • Никаких побочных эффектов;
  • Никаких внешних зависимостей.

Тогда эта функция является чистой функцией. Существует множество примеров чистых функций, и почти все они в стандартной библиотеке Go являются чистыми функциями. Мы также можем сами реализовать некоторые чистые функции, например:

Язык кода:javascript
копировать
func Add(a, b int) int {
    return a+b
}

func getRedisUserInfoKey(uid int64) {
    return fmt.Sprintf("uinfo:%d", uid)
}

func sortByAgeAsc(userList []User) []User {
    n := len(userList)
    for i:=0; i<n; i++ {
        for j := i+1; j<n; j++ {
            if userList[i].Age > userList[j].Age {
                userList[i], userList[j] = userList[j], userList[i]
            }
        }
    }
    return userList
}

func ParseInt(s string) (int64, error) {
// ...
}

чистая функция Самая большая особенностьдакак результаттолько принятьвходной контроль,Когда входные параметры подтверждены,Определяется выходной результат. Существует детерминированная связь между входными параметрами и выходными результатами (хотя она может быть сложной).,Точно так же, как математические функции. На основании этой характеристики,Для функции очистки очень легко написать сценарии использования теста.,Особенно табличный тест,например:

Язык кода:javascript
копировать
var testCases = []struct{
  input string
  expectOutput int64
  expectErr error
}{
  {"100",100,nil,},
  {"-99999",-99999,nil,},
  {"1.2",0,ErrNotInt,},
// ...
}
for _, tc := range testCases {
  actual, err := ParseInt(tc.input)
  assert_eq(tc.expectOutput, actual)
  assert_eq(tc.expectErr, err)
}

Написание тестовых примеров на основе таблиц — лучший способ написания отдельных тестов, без исключений. Для каждого набора тестов сразу понятно, какие входные данные, какие выходные данные должны быть и какая ошибка должна быть возвращена в случае возникновения ошибки. И мы можем легко добавить больше тестовых примеров, не изменяя другие части кода.

Но в реальном развитии бизнеса мы редко пишем чистые функции, большинство из них являются нечистыми, например:

Язык кода:javascript
копировать
func NHoursLater(n int64) time.Time {
    return time.Now().Add(time.Duration(n) * time.Hour)
}

Эта функция возвращает n часов спустя. Хотя получение параметра n,нода Фактически, результат каждого выполнениядаслучайный,из-за этогофункция Помимо зависимостей n Также зависит от текущего времени. Значение текущего времени не контролируется вызывающей стороной и постоянно меняется, поэтому вы не можете предсказать, когда будет введено значение. n Что будет выведено после Function. Это самом деле Сразуда Очень типичныйнеявная зависимость——Хотя мы ввели параметры A, но функция также неявно зависит от других параметров.

Давайте посмотрим на другой пример:

Язык кода:javascript
копировать
func GetUserInfoByID(uid int64) (*UserInfo, error) {
  val, err := mysqlPool.GetConn().Query("select * from t_user where id=? limit 1", uid)
  if err != nil {
    return nil, err
  }
  return UnmarshalUserInfo(val)
}

Проблема с этой функцией аналогична: даже если вы передадите uid, вы не сможете определить, какое значение вернет функция, поскольку она полностью зависит от возврата внутреннего модуля MySQL. Это очень распространенные примеры в повседневном бизнес-коде. Вы можете подумать об этом: если бы вас попросили написать один тест для двух вышеупомянутых нечистых функций, что бы вы сделали?

Фактически, если функция реализована так, как в двух приведенных выше примерах, протестировать ее практически невозможно, кроме как с помощью MonkeyPatch. Но поскольку это показательная операция, я не буду здесь вдаваться в подробности. К MonkeyPatch следует относиться в крайнем случае. Если нам приходится использовать MonkeyPatch при написании теста для определенной функции, это означает лишь то, что с кодом что-то не так. MonkeyPatch должен появляться только при составлении одиночных тестов для старых проектов. Подробнее я расскажу о том, как писать тестируемый код.

на самом деле поговорим о приведенном выше примере,величайшая цель Сразуда Я хочу тебе кое-что сказать:Если вы хотите легко протестировать функцию, вам нужно найти способ сделать все переменные, от которых зависит функция, управляемыми.чтобы сделать это,Я суммировал некоторые руководящие мысли:

03. Избавьтесь от зависимости

Самый простой способ — передать все зависимости функции в качестве входных параметров. В приведенном выше примере мы можем изменить его следующим образом:

Язык кода:javascript
копировать
func NHoursLater(n int64, now time.Time) time.Time {
    return now.Add(time.Duration(n) * time.Hour)
}

func GetUserInfoByID(uid int64, db *sql.DB) (*UserInfo, error) {
    val, err := db.Query("select * from t_user where id=? limit 1", uid)
    if err != nil {
        return nil, err
    }
    return UnmarshalUserInfo(val)
}

После этого преобразования, хотя при вызове необходимо создать экземпляры некоторых дополнительных объектов, это не является большой проблемой, и нашу функцию легче тестировать. Для функции NHoursLater я могу установить значение now по своему усмотрению и посмотреть, соответствуют ли результаты ожиданиям. Это очень легко проверить. Но во втором примере есть некоторые проблемы, поскольку передаваемый параметр — это *sql.DB, указатель на объект структуры, и мне сложнее контролировать его поведение. Поскольку sql.DB — это объект, реализованный стандартной библиотекой, все его методы реализованы в стандартной библиотеке и не могут быть изменены. Поэтому вам следует рассмотреть возможность использования здесь интерфейса Go, например:

Язык кода:javascript
копировать
type Queryer interface {
    Query(string, args ...interface{}) (*sql.Rows, error)
}

func GetUserInfoByID(uid int64, db Queryer) (*UserInfo, error) {
    val, err := db.Query("select * from t_user where id=? limit 1", uid)
    if err != nil {
        return nil, err
    }
    return UnmarshalUserInfo(val)
}

Вы можете сразу увидеть преимущества использования интерфейса здесь! Интерфейс ограничивает поведение объектов, но не ограничивает реализацию конкретных объектов, так называемая динамическая диспетчеризация. Поэтому, когда мы пишем тестовый код, мы можем просто реализовать Queryer, чтобы контролировать его поведение при завершении теста, например:

Язык кода:javascript
копировать
type mockQuery struct {}

func (m *mockQuery) Query(string, args ...interface{}) (*sql.Rows, error) {
    return sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(1, "jerry", 5).AddRow(2, "tom", 7)
}

func TestGetUserInfoByID(t *testing.T) {
    userInfo, err := GetUserInfoByID(1, new(mockQuery ))
    assert_eq(err, nil)
    assert_eq(*userInfo, UserInfo{ID: 1, Name:"jerry", Age: 5})
}

Затем вы можете написать дополнительные тестовые примеры для этой функции в табличном виде с помощью собственного фиктивного объекта.

Подводя краткий итог, можно подвести итогТрилогия отстранения:

  • Расчесывание функциональной зависимости;
  • Зависимости преобразуются во входные параметры;
  • Преобразуйте конкретный объект в интерфейс.

Извлечение зависимостей во входные параметры — распространенный метод, но он не полностью применим в некоторых сценариях, поскольку некоторые функции имеют слишком много зависимостей, например:

Язык кода:javascript
копировать
func NewOrder(user UserInfo, order OrderInfo) error {
// Идемпотентное обнаружение
    if err := idempotenceCheck(user, order); err != nil {
        return err
    }
    // Перейдите в систему заказов, чтобы создать заказ и вернуть информацию об успешно созданном заказе.
    newInfo, err := orderSystem.NewOrder(user, order)
    if err != nil {
        return err
    }
    // Отправить информацию о заказе в new_order очереди сообщений. в теме
    err = mq.SendToTopic("new_order", newInfo)
    if err != nil {
        return err
    }
    // Сохраняйте информацию о заказе в Redis, чтобы облегчить запрос пользователя.
    cacheKey := getUserOrderCacheKey(user.ID)
    redis.Hset(cacheKey, newInfo.ID, newInfo)
    return nil
}

Вышеуказанное представляет собой упрощенную функцию создания заказов. Помимо использования userInfo и orderInfo, она также использует нисходящую систему для обнаружения идемпотентности. Для создания заказов она использует систему заказов. Она должна помещать сообщения в очередь сообщений, кеш. данные в Redis и т. д. Если просто преобразовать зависимости в параметры функции, например:

Язык кода:javascript
копировать
func NewOrder(user UserInfo, order OrderInfo, idempotent IdemChecker, orderSystem OrderSystemSDK, mq KafKaPusher, redis Redis.Client) error {
// ...
}

Приведенная выше сигнатура функции будет очень сложной, и перед вызовом функции вызывающему объекту необходимо создать экземпляры многих объектов. Хоть тестирование и удобно, но использовать его в деле крайне неудобно. И что еще более серьезно, если позже в код будут добавлены некоторые функции защиты от мошенничества и фильтрации безопасности пользователей, и все эти функции зависят от нижестоящих микросервисов, нужно ли им все равно каждый раз менять сигнатуру функции? Очевидно, что это неприемлемо. Поэтому нам следует рассмотреть второй подход.

04. Объективизация

Если мы реализуем функцию,Затем зависимости, которые может использовать функция, передаются либо через параметры, либо через параметры.,Или просто процитируйте глобальную переменную. Если слишком много зависимостей,Передавать да через параметры нереально,Это кажется Сразу Только глобальныйпеременная Это сделано??не забывайМетоды объекта:

Язык кода:javascript
копировать
type Foo struct {
    Name string
    Age int
}

func (f *Foo) Bar(a,b,c int) string {
// f.Name
// f.Age
}

В методе объекта, хотя имеется только три входных параметра a, b и c, на самом объекте (this или self в других языках) фактически можно ссылаться. Сам объект может иметь бесконечное количество переменных-членов, поэтому, реализуя методы объекта вместо функций, мы можем легче добавлять зависимости, например:

Язык кода:javascript
копировать
type orderCreator struct {
    checker IdemChecker
    orderSystem OrderSystemSDK
    kafka KafkaPusher
    redis Redis.Client
}

func (self *orderCreator) NewOrder(user UserInfo, order OrderInfo) error {
// ...
}

Помещая зависимости внутри объекта, мы можем легко управлять нашими зависимостями. При написании тестового кода мы можем просто написать конструктор по мере необходимости:

Язык кода:javascript
копировать
func constructOrderCreator() *orderCreator {
    return &orderCreator{
        checker: newMockChecker(),
        // ...
    }
}

func TestNewOrder(t *testing.T) {
    obj := constructOrderCreator()
    obj.NewOrder(user, order)
}

Этот метод на самом деле является своего рода абстракцией зависимостей. Он просто абстрагирует зависимости в объект, а не помещает их в параметры. Он может поддерживать сложные зависимости. Независимо от того, сколько существует зависимостей, просто добавьте элементы в определение структуры. Недостаток заключается в том, что создание экземпляра немного громоздко, поэтому каждый обработчик запроса редко создается один раз. Обычно он использует глобальный объект, поэтому он создается только один раз (во избежание его недостатков) или с помощью шаблона фабрики. А при написании тестов, поскольку Go не является RAII-языком, мы можем лениться и выполнять лишь частичную реализацию. Другими словами, если я знаю, что obj.FuncA использует только obj.X, то при создании экземпляра obj я создаю только экземпляр obj.X.

В дополнение к двум вышеперечисленным методам,Есть еще один очень распространенный способ,СразудаПеременная функции

05. Переменная функции

Давайте сначала посмотрим на пример:

Язык кода:javascript
копировать
import (
    "repo/group/proj/log"
)

func add(ctx context.Context, a,b int) int {
    c := a+b
    log.InfoContextf(ctx, "a+b=%d", c)
    return c
}

Журналирование можно увидеть повсюду в бизнес-коде. Если тестируемая функция содержит операторы журналирования, вы часто сталкиваетесь со следующими проблемами:

  • Дескриптор бревно не был создан и ссылался на нулевой указатель. panic;
  • бревно попадает в файловую систему по умолчанию,Создавать много ненужных файлов

Как и в приведенном выше примере, log.InfoContextf да log Статический метод, предоставляемый пакетом, log даа сумка, а не дааобъект,Поэтому я не могу поместить его как дочерний объект в объект. Для этого сценария,нас СразурассмотретьПеременная функцииПонятно。так называемый Переменная функциина самом деле Сразудаиспользоватьодинпеременнаясохранитьфункцияуказатель,например:

Язык кода:javascript
копировать
import (
    "domain/group/proj/log"
)

var (
    infoContextf = log.InfoContextf
)

func add(ctx context.Context, a,b int) int {
    c := a+b
    infoContextf(ctx, "a+b=%d", c)
    return c
}

Мы используем infoContextf сохранить log.InfoContextf Выполнение указателя функции кажется еще одной адресацией памяти, но на самом дело вообще не имеет значения. Но польза от этого огромна, потому что когда мы пишем вариант использования, мы можем сделать следующее:

Язык кода:javascript
копировать
type logHandler func(context.Context, string, ...interface{})

// Замените указатель функции своей собственной реализацией.
func replaceinfoContextf(f logHandler) func() {
    old := infoContextf
    infoContextf = f
    return func() {
        infoContextf = old
    }
}

// Реализуйте логфункцию самостоятельно и ничего не делайте
func logDiscard(_ context.Context, _ string, _...interface{}) {
    return
}

func TestAdd(t *testing.T) {
    // Замените infoContextf на logDiscard перед тестом.
    resume := replaceinfoContextf(logDiscard)
    // Автоматически возобновлять работу после завершения теста
    defer resume()
    // do your testing
}

Нам больше не нужно беспокоиться о том, что журнал не будет инициализирован, мы можем сделать это сами. mock Бревно функция ручки! Кроме бревно, на самом деле существует много таких вызовов статических методов.,Мы все могли бы использовать переменнуюсохранить эту функцию.,например:

Язык кода:javascript
копировать
// in bussiness file
var (
    hostName = os.HostName
    getNow = time.Now
    openFile = os.Open
    // ...
)

func NHoursLater(n int64) time.Time {
    return getNow().Add(time.Duration(n)*time.Hour)
}

// in test file
func TestNHoursLater(t *testing.T) {
    now := time.Now()
    fiveHoursLater := now.Add(time.Duration(5)*time.Hour)
    getNow = func() time.Time {
        return now
    }
    assert_eq(NHoursLater(5), fiveHoursLater)
}

Избегайте вызова статических методов непосредственно внутри функции. С помощью этих «переменных указателей функций» мы можем легко заменить их нашей собственной реализацией во время тестирования, экранируя системные различия, разницу во времени и другие факторы вне программы, чтобы можно было протестировать тестовый код. каждый раз Все они могут работать в одной и той же среде.

Переменная функциина самом деле Сразуда Что мы часто говоримштабелирование

06. Подведите итоги

на самом деле Некоторые из упомянутых выше методов кодирования не включают в себя какие-либо сложные шаблоны проектирования.,Никакой технической глубины здесь нет. Это всего лишь некоторые процедуры программирования,Но прежде чем писать существующий бизнес-код, вы должны уметь писать отдельные тесты.,Только тогда вы сможете написать бизнес-код, который будет прост в использовании.

Подводя итог, можно выделить две простые руководящие идеи:

  • Явная зависимость функций (независимо от явных и неявных зависимостей).,Все зависит от объективного существования (существования);
  • Извлечение зависимостей (найдите способ сделать внутренние зависимости функции управляемыми извне).,и Внедрение зависимостей очень похоже).

Конкретные методы добычи:

  • Для менее зависимой функции,Зависимости можно передавать непосредственно в качестве входных параметров;
  • Для более сложных зависимостей Функция,Способ написать это как объект,Зависимости хранятся как члены этого переменного объекта;
  • Функция не вызывает внутренние статические методы напрямую.,Используйте переменную, чтобы сохранить указатель функции статического метода (не вызывайте его напрямую).,Используйте переменную в качестве агента).

Учитывая эти моменты, очень легко писать бизнес-код, который легко тестировать.

В то же время мы можем заняться созданием набора тестов, поскольку большая часть этого требует mock все объекты являются общими внешними зависимостями, особенно MySQL Redis и т. д., поэтому мы можем реализовать некоторые общие testsuite, удобный для нас в настройке mock поведение объекта без необходимости каждый раз писать много кода для его реализации mock объект. например:

  • mock mysql: https://github.com/DATA-DOG/go-sqlmock
  • testify/mock: https://github.com/stretchr/testify/tree/master/mock Фреймворк для написания мокобъекта (возможно)

Чем богаче будут эти наборы тестов, тем легче нам будет писать тесты (ну же, команда Wheel).

07. Наконец, старайтесь избегать использования init

на самом деле Go Есть некоторые дополнительные факторы, которые повлияют на наше написание одиночных тестов, это одна из его особенностей — init. инициализировать существовать Go средний класс самом деледа — очень спорная функция. Многие люди против ее использования. Некоторые даже спрашивали. Go2 нести proposal Хочу удалить init (конечно это нереально). Основная причина - да, если пакет содержит init функция,это будетсуществовать main Выполнять перед началом выполнения (также запускать до запуска нашей единственной тестовой функции).

Это вызывает проблему,Потому что внедрение этих пакетов имеет побочные эффекты,Например, они прочитают файл конфигурации в оговоренном месте.,Зарегистрируйте какой-нибудь глобальный объект,или Человек, пытающийся подключиться к услуге, обнаружил agent зарегистрировать услугу. Если есть проблема в какой-либо ссылке, то уровень платформы посчитает, что инициализация не удалась, и, скорее всего, будет напрямую паника. Но Датисна самом дело повлияет на работу нашего единственного теста. Время выполнения одного теста не зависит от реальной среды, а потому, что init характеристики, если это действительно init функция приводит к Паника, мы, вероятно, не сможем провести ни одного теста.

Еще вопрос да, init последовательность выполнения на самом деледаи import Порядок связан, и в нем есть вложенная логика. и gofmt Может быть скорректировано import порядке, иногда это может быть связано с init Порядок исполнения непоследователен и некоторые ошибки, и их трудно устранить. Если фреймворк был тщательно протестирован, используйте init В общем, не используйте его при написании бизнес-кода самостоятельно. init,я бы предпочел написать это сам InitXXX Затемсуществовать main Вызывается вручную в функции.

boy illustration
Неразрушающее увеличение изображений одним щелчком мыши, чтобы сделать их более четкими артефактами искусственного интеллекта, включая руководства по установке и использованию.
boy illustration
Копикодер: этот инструмент отлично работает с Cursor, Bolt и V0! Предоставьте более качественные подсказки для разработки интерфейса (создание навигационного веб-сайта с использованием искусственного интеллекта).
boy illustration
Новый бесплатный RooCline превосходит Cline v3.1? ! Быстрее, умнее и лучше вилка Cline! (Независимое программирование AI, порог 0)
boy illustration
Разработав более 10 проектов с помощью Cursor, я собрал 10 примеров и 60 подсказок.
boy illustration
Я потратил 72 часа на изучение курсорных агентов, и вот неоспоримые факты, которыми я должен поделиться!
boy illustration
Идеальная интеграция Cursor и DeepSeek API
boy illustration
DeepSeek V3 снижает затраты на обучение больших моделей
boy illustration
Артефакт, увеличивающий количество очков: на основе улучшения характеристик препятствия малым целям Yolov8 (SEAM, MultiSEAM).
boy illustration
DeepSeek V3 раскручивался уже три дня. Сегодня я попробовал самопровозглашенную модель «ChatGPT».
boy illustration
Open Devin — инженер-программист искусственного интеллекта с открытым исходным кодом, который меньше программирует и больше создает.
boy illustration
Эксклюзивное оригинальное улучшение YOLOv8: собственная разработка SPPF | SPPF сочетается с воспринимаемой большой сверткой ядра UniRepLK, а свертка с большим ядром + без расширения улучшает восприимчивое поле
boy illustration
Популярное и подробное объяснение DeepSeek-V3: от его появления до преимуществ и сравнения с GPT-4o.
boy illustration
9 основных словесных инструкций по доработке академических работ с помощью ChatGPT, эффективных и практичных, которые стоит собрать
boy illustration
Вызовите deepseek в vscode для реализации программирования с помощью искусственного интеллекта.
boy illustration
Познакомьтесь с принципами сверточных нейронных сетей (CNN) в одной статье (суперподробно)
boy illustration
50,3 тыс. звезд! Immich: автономное решение для резервного копирования фотографий и видео, которое экономит деньги и избавляет от беспокойства.
boy illustration
Cloud Native|Практика: установка Dashbaord для K8s, графика неплохая
boy illustration
Краткий обзор статьи — использование синтетических данных при обучении больших моделей и оптимизации производительности
boy illustration
MiniPerplx: новая поисковая система искусственного интеллекта с открытым исходным кодом, спонсируемая xAI и Vercel.
boy illustration
Конструкция сервиса Synology Drive сочетает проникновение в интрасеть и синхронизацию папок заметок Obsidian в облаке.
boy illustration
Центр конфигурации————Накос
boy illustration
Начинаем с нуля при разработке в облаке Copilot: начать разработку с минимальным использованием кода стало проще
boy illustration
[Серия Docker] Docker создает мультиплатформенные образы: практика архитектуры Arm64
boy illustration
Обновление новых возможностей coze | Я использовал coze для создания апплета помощника по исправлению домашних заданий по математике
boy illustration
Советы по развертыванию Nginx: практическое создание статических веб-сайтов на облачных серверах
boy illustration
Feiniu fnos использует Docker для развертывания личного блокнота Notepad
boy illustration
Сверточная нейронная сеть VGG реализует классификацию изображений Cifar10 — практический опыт Pytorch
boy illustration
Начало работы с EdgeonePages — новым недорогим решением для хостинга веб-сайтов
boy illustration
[Зона легкого облачного игрового сервера] Управление игровыми архивами
boy illustration
Развертывание SpringCloud-проекта на базе Docker и Docker-Compose