Задача «Получить значение у N url из списка» с собеседования на Go

На данный момент я нахожусь в активном поиске нового проекта, поэтому активно хожу на собеседования.

Решил поделиться своими мыслями о решении задачи, которую (как мне кажется) часто дают на собеседованиях.

Задача

Написать функцию, которая принимает несколько url адресов, а отдает сумму байт body ответов списка адресов и ошибку, если что-то пошло не так (если произошла ошибка, нужно вернуть ошибку как можно скорее, значение - не важно).

Интересно обсудить варианты решения?

Итак, у нас отдельная программа. Есть 2 набора данных - с успешным и не успешным кейсом. Причем в наборах данных неуспешного кейса специально включены разные зоны. Набор данных придумывал я сам, поэтому если считаете, что их охват не полный, напишите в комментариях.

Банальный вариант

В банальном варианте (чтобы работало) мы берем и просто обходим весь набор данных. Зато вариант - рабочий!

// Банальный синхронный вариант

package main

import (
	"fmt"
	"io"
	"net/http"
	"time"
)

const byteInMegabyte = 1024 * 1024

func main() {

	urlsList1 := []string{
		"https://youtube.com",
		"https://ya.ru",
		"https://reddit.com",
		"https://google.com",
		"https://mail.ru",
		"https://amazon.com",
		"https://instagram.com",
		"https://wikipedia.org",
		"https://linkedin.com",
		"https://netflix.com",
	}
	urlsList2 := append(urlsList1, "https://111.321", "https://999.000")

	{
		t1 := time.Now()
		byteSum, err := requesSumm(urlsList1)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabyte), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
	fmt.Println("++++++++")
	{
		t1 := time.Now()
		byteSum, err := requesSumm(urlsList2)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabyte), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
}

func requesSumm(urlsSlv []string) (int64, error) {

	var sum int64

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	for _, v := range urlsSlv {
		resp, err := client.Get(v)
		if err != nil {
			return 0, err
		}
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return 0, err
		}

		sum += int64(len(body))

	}
	return sum, nil
}

Время выполнение, как думаю понятно из определения, равно сумме всех запросов.

ilia@goDevLaptop sobesi % go run httpget/v1.go
Сумма страниц в Мб=2.12, ошибка - <nil> 
Время выполнение запросов 16.01 сек. 
++++++++
Сумма страниц в Мб=0.00, ошибка - Get "https://111.321": context deadline exceeded (Client.Timeout exceeded while awaiting headers) 
Время выполнение запросов 18.88 сек. 
ilia@goDevLaptop sobesi %

Затем очевидный вариант для языка Golang - это подключение асинхронного вызова, основанного на отдельных горутинах. Давайте посмотрим, как изменится время выполнения?

// Банальный ассинхронный вариант
package main

import (
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

const byteInMegabytev2 = 1024 * 1024

type respSt struct {
	lenBody int64
	err     error
}

func main() {
	urlsList1 := []string{
		"https://youtube.com",
		"https://ya.ru",
		"https://reddit.com",
		"https://google.com",
		"https://mail.ru",
		"https://amazon.com",
		"https://instagram.com",
		"https://wikipedia.org",
		"https://linkedin.com",
		"https://netflix.com",
	}
	urlsList2 := append(urlsList1, "https://111.321", "https://999.000")

	{
		t1 := time.Now()
		byteSum, err := requesSummAsync(urlsList1)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev2), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
	fmt.Println("++++++++")
	{
		t1 := time.Now()
		byteSum, err := requesSummAsync(urlsList2)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev2), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
}

func requesSummAsync(urls []string) (int64, error) {
	var wg sync.WaitGroup
	ansCh := make(chan respSt, len(urls))

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	for _, url := range urls {
		wg.Add(1)
		go func(u string) {
			defer wg.Done()
			resp, err := client.Get(u)
			if err != nil {
				ansCh <- respSt{
					lenBody: 0,
					err:     err,
				}
				return
			}
			defer resp.Body.Close()

			body, err := io.ReadAll(resp.Body)
			if err != nil {
				ansCh <- respSt{
					lenBody: 0,
					err:     err,
				}
				return
			}
			ansCh <- respSt{
				lenBody: int64(len(body)),
				err:     nil,
			}
		}(url)
	}

	go func() {
		wg.Wait()
		close(ansCh)
	}()

	var sum int64
	var err error
	for bodyLen := range ansCh {
		sum += bodyLen.lenBody
		if bodyLen.err != nil {
			if err == nil {
				err = fmt.Errorf("Ошибка %v у сайта %v", bodyLen.err)
				continue
			}
			err = fmt.Errorf("Ошибка %v у сайта %v;%v", bodyLen.err, err)
		}
	}
	if err != nil {
		return 0, err
	}

	return sum, err
}

Фактически время выполнение будет равно выполнению самого медленного запроса + время на сложение.

ilia@goDevLaptop sobesi % go run httpget/v2.go
Сумма страниц в Мб=2.50, ошибка - <nil> 
Время выполнение запросов 2.81 сек. 
++++++++
Сумма страниц в Мб=0.00, ошибка - Ошибка Get "https://111.321": context deadline exceeded (Client.Timeout exceeded while awaiting headers) у сайта Ошибка Get "https://999.000": dial tcp: lookup 999.000: no such host у сайта %!v(MISSING);%!v(MISSING) 
Время выполнение запросов 10.00 сек. 
ilia@goDevLaptop sobesi %

Тайм аут запроса 10 секунд, но можно ли улучшить скорость в нашей задаче?


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

// Ассинхронный вариант с контекстом
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

type respStC struct {
	lenBody int64
	err     error
}

const byteInMegabytev3 = 1024 * 1024

func main() {
	urlsList1 := []string{
		"https://youtube.com",
		"https://ya.ru",
		"https://reddit.com",
		"https://google.com",
		"https://mail.ru",
		"https://amazon.com",
		"https://instagram.com",
		"https://wikipedia.org",
		"https://linkedin.com",
		"https://netflix.com",
	}
	urlsList2 := append(urlsList1, "https://111.321", "https://999.000")

	{
		t1 := time.Now()
		byteSum, err := requestSumAsyncWithCtx(urlsList1)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev3), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
	fmt.Println("++++++++")
	{
		t1 := time.Now()
		byteSum, err := requestSumAsyncWithCtx(urlsList2)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev3), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
}

func requestSumAsyncWithCtx(urls []string) (int64, error) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	ansCh := make(chan respStC, len(urls))

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	for _, url := range urls {
		wg.Add(1)
		go func(u string) {
			defer wg.Done()
			req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
			if err != nil {
				ansCh <- respStC{lenBody: 0, err: err}
				return
			}

			resp, err := client.Do(req)
			if err != nil {
				ansCh <- respStC{lenBody: 0, err: err}
				return
			}
			defer resp.Body.Close()

			body, err := io.ReadAll(resp.Body)
			if err != nil {
				ansCh <- respStC{lenBody: 0, err: err}
				return
			}

			ansCh <- respStC{lenBody: int64(len(body)), err: nil}
		}(url)
	}

	go func() {
		wg.Wait()
		close(ansCh)
	}()

	var sum int64
	var err error
	for bodyLen := range ansCh {
		sum += bodyLen.lenBody
		if bodyLen.err != nil && !errors.Is(bodyLen.err, context.Canceled) {
			if err != nil {
				err = fmt.Errorf("Ошибка %v у сайта %v;%v", bodyLen.err, bodyLen.lenBody, err)
			} else {
				err = fmt.Errorf("Ошибка %v у сайта %v", bodyLen.err, bodyLen.lenBody)
			}
			cancel()
		}
	}
	return sum, err
}

Теперь посмотрим на время исполнения.

ilia@goDevLaptop sobesi % go run httpget/v3.go
Сумма страниц в Мб=2.50, ошибка - <nil> 
Время выполнение запросов 2.89 сек. 
++++++++
Сумма страниц в Мб=0.00, ошибка - Ошибка Get "https://999.000": dial tcp: lookup 999.000: no such host у сайта 0 
Время выполнение запросов 0.00 сек. 
ilia@goDevLaptop sobesi %

И вот уже получается, что мы можем не ждать каждый запрос, а вернем ошибку сразу.


Но реальная жизнь, это всегда борьба с ограничениями, и, если нам дадут слишком большой список (больше доступных сетевых соединений) в той среде, где выполняется программа, то мы уже получим не определенное поведение как нашей программы, так и среды вокруг. Поэтому мы ограничим возможность программы создавать больше заданного количества сетевых соединений одновременно.

Для этого конечно мы воспользуемся буфферизированным каналом ;-)

// Ассинхронный вариант с контекстом и пулом соединений в poolHTTPReq
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

type respStCWP struct {
	lenBody int64
	err     error
}

const poolHTTPReq = 2
const byteInMegabytev4 = 1024 * 1024

func main() {
	urlsList1 := []string{
		"https://youtube.com",
		"https://ya.ru",
		"https://reddit.com",
		"https://google.com",
		"https://mail.ru",
		"https://amazon.com",
		"https://instagram.com",
		"https://wikipedia.org",
		"https://linkedin.com",
		"https://netflix.com",
	}
	urlsList2 := append(urlsList1, "https://111.321", "https://999.000")

	{
		t1 := time.Now()
		byteSum, err := requestSumAsyncWithCtxAndPool(urlsList1)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev4), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
	fmt.Println("++++++++")
	{
		t1 := time.Now()
		byteSum, err := requestSumAsyncWithCtxAndPool(urlsList2)
		fmt.Printf("Сумма страниц в Мб=%.2f, ошибка - %v \n", (float64(byteSum) / byteInMegabytev4), err)
		fmt.Printf("Время выполнение запросов %.2f сек. \n", time.Now().Sub(t1).Seconds())
	}
}

func requestSumAsyncWithCtxAndPool(urls []string) (int64, error) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	ansCh := make(chan respStCWP, len(urls))
	semaphore := make(chan struct{}, poolHTTPReq)

	for _, url := range urls {
		semaphore <- struct{}{}
		wg.Add(1)
		go func(u string) {
			defer func() {
				<-semaphore
				wg.Done()
			}()

			req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
			if err != nil {
				ansCh <- respStCWP{lenBody: 0, err: err}
				return
			}

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				ansCh <- respStCWP{lenBody: 0, err: err}
				return
			}
			defer resp.Body.Close()

			body, err := io.ReadAll(resp.Body)
			if err != nil {
				ansCh <- respStCWP{lenBody: 0, err: err}
				return
			}

			ansCh <- respStCWP{lenBody: int64(len(body)), err: nil}
		}(url)
	}

	go func() {
		wg.Wait()
		close(ansCh)
		close(semaphore)
	}()

	var sum int64
	var err error
	for bodyLen := range ansCh {
		sum += bodyLen.lenBody
		if bodyLen.err != nil && !errors.Is(bodyLen.err, context.Canceled) {
			if err != nil {
				err = fmt.Errorf("Ошибка %v у сайта %v;%v", bodyLen.err, bodyLen.lenBody, err)
			} else {
				err = fmt.Errorf("Ошибка %v у сайта %v", bodyLen.err, bodyLen.lenBody)
			}
			cancel()
		}
	}
	return sum, err
}

И получим значения, конечно, хуже предыдущего варианта, но уже более приближенные к жизни.

ilia@goDevLaptop sobesi % go run httpget/v4.go
Сумма страниц в Мб=2.50, ошибка - <nil> 
Время выполнение запросов 9.05 сек. 
++++++++
Сумма страниц в Мб=2.12, ошибка - Ошибка Get "https://999.000": dial tcp: lookup 999.000: no such host у сайта 0 
Время выполнение запросов 4.29 сек. 
ilia@goDevLaptop sobesi % 

Весь код естественно выложен на GitHub

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

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

@GoDevSeoTaxi
07.02.2024 01:16 UTC
Первоисточник

Комментарии

@vilgeforce
06.02.2024 20:24 UTC
+11

Я ни разу не программист на Go, но человеку, уверенно решающему задачу вычисления " сумму адресов" по списку URL я бы отказал. Потому что он не в состоянии объяснить что же именно он вычисляет. И второй пункт - опечатки в коде

@GoDevSeoTaxi
06.02.2024 20:29 UTC
-1

Спасибо за комментарий. Можете подсказать, что именно про опечатки в коде вы имеете ввиду?

06.02.2024 20:31 UTC
+2

Просто перечитайте свой код. Если вам в нем все нравится - у меня для вас плохие новости...

06.02.2024 20:34 UTC
0

Если вы не про summ и sum - то я не знаю даже(
Прошу подсказать.

06.02.2024 20:35 UTC
+4

"reques"

@RahimovMEP
06.02.2024 21:03 UTC
+5

А "сумма тел" (видимо, имелось ввиду html <body>), а также "время выполнение" Вас не смутило?

Как по мне, это либо статья написанная целиком ГПТ или перевод откуда-то. Прошу прощения, если я ошибаюсь и для автора статьи русский не родной язык.

06.02.2024 21:52 UTC
+2

Конечно смутило, я сразу написал что автор решает непойми что с уверенным видом ;-) И да, это не GPT точно, он бы такого не написал

@GoodGod
07.02.2024 05:55 UTC
0

Как хорошо, что не программисты не нанимают программистов. По вашему комментарию я понял, что вы не понимаете сути программирования, а "смотрите на рюшечки".

@VladimirFarshatov
06.02.2024 20:56 UTC
+1

Поначалу вообще не въехал в постановку задачи: как это " вычислить сумму тел ответов"?!? И почему автор решил что это про сумму времени получения ответов? А если в ответ по этим урл должны прилетать чиселки, что тогда?

@vilgeforce
06.02.2024 21:02 UTC
+1

Или размер ответов, или конкатенация content-type, или...

@Evengard
07.02.2024 01:16 UTC
+1

Я сначала подумал что урлы возвращают числовое значение, надо его распарсить и сложить. Что речь про размеры страниц даже не додумался пока не начал читать статью...

@ilving
06.02.2024 21:08 UTC
+2

Канал результатов в последнем примере (другие не смотрел) можно делать небуферизованным - это сэкономит немного памяти.

Для объединения ошибок есть errors.Join

Для вычисления "время с момента Х" есть time.Since

При заранее известном размере пула проще сразу запустить Х рутин, в которых читать канал с урлами, ибо запуск рутин тоже не бесплатная операция, а их тут по одной ка каждый урл

@uvelichitel
06.02.2024 22:03 UTC
+4

Уж оптимизировать, так оптимизировать) Чтобы "сэкономить немного памяти" можно обойтись без канала результатов, а защитить счетчик результатов sync/atomic https://pkg.go.dev/sync/atomic из стандартной библлиотеки. В частности https://pkg.go.dev/sync/atomic#Int64.Add

@VladimirFarshatov
07.02.2024 14:07 UTC
+1

ещё есть MultiErrors для сбора ошибок

@uvelichitel
06.02.2024 21:29 UTC
+3

На собесе по этой задаче, скорее всего, хотели увидеть вот это https://pkg.go.dev/golang.org/x/sync/errgroup

golang.org/x -- придворное тюнинговое ателье, почти stdlib)
Там этот кейс первым примером. Вы расписали, что там под капотом, а можно просто использовать готовое решение.

@GoDevSeoTaxi
07.02.2024 01:27 UTC
0

Класс! Спасибо, что подсветили пробел в знаниях.

@BugM
07.02.2024 00:00 UTC
0

ЧатГПТ же. Абсолютно бестолковая постановка задачи и не менее бестолковая оптимизация.

На собеседовании с таким вопросом я бы долго допытывался: А что задающему этот вопрос на самом деле надо? Ну глупо же просить написать GET в цикле, а потом распаралелить эти геты в пуле. Это не даст никаких знаний о кандидате. Наверно от меня хотят понимания миллиона корнер кейсов этой задачи. Вроде переадресаций, протоколов, проксей, кук, защиты от роботов, флапов сети и подобного. Это уже интересно и даст определенные знания о кандидате.

@GoDevSeoTaxi
07.02.2024 01:26 UTC
0

Я думаю, вопросы были именно на понимание ассинхронщины. Потому что потом продолжили разговор именно в эту сторону.

@Wolfie
07.02.2024 00:08 UTC
+5

А если не пытаться вычитывать все тело ответа (а вдруг там много гигабайт?), то можно упростить код.

Вместо:

body, err := io.ReadAll(resp.Body)
if err != nil {
	ansCh <- respStCWP{lenBody: 0, err: err}
	return
}

ansCh <- respStCWP{lenBody: int64(len(body)), err: nil}

Написать:

lenBody, err := io.Copy(io.Discard, resp.Body)

ansCh <- respStCWP{lenBody: lenBbody, err: err}

if err != nil {
	return
}

@GoDevSeoTaxi
07.02.2024 01:23 UTC
0

Даже не подумал в эту сторону, спасибо.

@ayuantropov
07.02.2024 15:14 UTC
+1

Можно, наверное, даже ещё проще: брать значение header-а "Content-Length"

07.02.2024 15:14 UTC
0

Оно не всегда есть.

07.02.2024 15:23 UTC
0

Да, если оно есть, и если ему можно верить. Если body передается в сжатом виде, то там будет значение после сжатия, а не в натуральном, несжатом, виде.

@tuxi
07.02.2024 10:15 UTC
0

А проверяли кейс, когда сервер отдаст 200 и пустое тело ответа?

@GoDevSeoTaxi
07.02.2024 15:15 UTC
0

Там будет 0.
Сам http.get - выполнится без ошибки.

07.02.2024 16:22 UTC
0

То есть res никогда не сможет быть nil?

@noRoman
07.02.2024 12:48 UTC
+2

Если бы мне дали именно такую задачу, то я читал бы только заголовки и брал длину оттуда.
Зачем читать тело целиком!

@tuxi
07.02.2024 13:31 UTC
+3

тогда уж не GET, а HEAD надо делать

но сильно подозреваю, что если дадут файл с 1млн+ адресами ссылками, больше 25% либо не будут поддерживать HEAD, либо не будут отдавать длину ответа.

Я делал аналогичный проект (кстати тоже на go), там был список под 5млн адресов/серверов и запросы надо было сделать за ограниченное время. Увы, не все так хорошо с соблюдением HTTP стандартов как хотелось бы.

07.02.2024 13:34 UTC
0

Это задача для собеседования и тут можно "хитрить". Зададут вопрос про url не соблюдающие HTTP, то предлагаете другой способ. Тут важно показать ход ваших мыслей. Что вы готовы решить как по-простому варианту (иногда бывает этого более чем достаточно) и готовы решить со всеми известными исключениями.
Получается вы можете из одной задачи "выжать" 2 варианта с полной выкладкой почему и как). С умом надо к собеседованию подходить!

07.02.2024 14:10 UTC
+2

Что делать кандидату, получающему ответ на уточняющий вопрос "достаточно ли делать HEAD и считать по заголовкам" в виде встречного "А что так можно?" ;)

07.02.2024 15:10 UTC
+3

Сделать для себя выводы о техническом уровне возможных коллег! =)

07.02.2024 15:18 UTC
0

Это вам видимо хорошие сайты подали на вход.

@GoDevSeoTaxi
07.02.2024 15:16 UTC
0

Во многих местах - его не заполняют.

08.02.2024 05:43 UTC
0

Обратите внимание - это собеседование. Здесь выясняется что вы умеете и знаете. Вам не дают реальную задачу. Если дают, то повод задуматься, а не решают ли они свою маленькую проблемку?
Я встречал компании, где битриксоид не знал как получить файл по url. На том конце сидел сервис и формировал файл.

@stitrace
08.02.2024 03:51 UTC
0

Классический пример «скорость/качество/стоимость - выберите любые два». По хорошему, для максимально быстрого кода тут нужно делать запросы HEAD и анализировать ответы (сжаты ли данные, версия протокола и т.д.) и на основании этих данных по разному обрабатывать каждый урл (делать повторно GET и подсчитывать фактическое количество вернувшихся байт или довериться данным из заголовков).

@devian3000
09.02.2024 05:52 UTC
+1

Вот тут ошибка будет, получается запрос N раз одного и того же адреса (go <= 1.23)

for _, url := range urls {
semaphore <- struct{}{}
wg.Add(1)
go func(u string) {}(url)
}

Если же делать параллельную обработку в несколько потоков в пуле, то правильнее все-таки делать вот так

const poolSize = 3
type ResultData struct {
	BodyLen int64
	Err error
}

addressCh := make(chan string, 3)
resultCh := make(chan ResultData, 3)

wg := &amp;sync.WaitGroup{}
wg.Add(poolSize)
for c := 0; c &lt;= poolSize; c++ {
	go func() {
		for address := range addressCh {
			body, err := doRequest(address)
			resultCh &lt;- ResultData{BodyLen: len(body), Err: err}
		}
		wg.Done()
	}()
}

go func() {
	urls := []string{"1","2","3"}
	for _, url := range urls {
		addressCh &lt;- url
	}
	close(addressCh)
}()

type Result struct {
	AllLen int64
	Errors []error
}

result := Result{
	AllLen: 0,
	Errors: make([]error, 0),
}

go func() {
	for reqRes := range resultCh {
		if reqRes.Err != nil {
			result.Errors = append(result.Errors, reqRes.Err)
		} else {
			result.AllLen += reqRes.BodyLen
		}
	}
}()

wg.Wait()
close(resultCh)

fmt.Println(result)</code></pre><p><br> Все остальное от лукавого</p>