Есть ли жизнь на Go после C#?
Всем привет! На связи Пётр, Go-разработчик в команде Ozon, которая занимается управлением товарами торговой площадки. Всё, что загружают продавцы, обрабатывается нашими сервисами. Девять месяцев назад я сменил основной язык программирования с C# на новый для меня Go. В статье будут впечатления от Go, расскажу о некоторых различиях между языками, а в конце поделюсь своим опытом поиска работы на новом языке. Ведь вопрос смены стека технологий рано или поздно встаёт перед каждым разработчиком.
Почему Go?
На шарпе я проработал два с половиной года. Сначала разрабатывал программное обеспечение для предприятий нефтегазовой отрасли. Мы делали монолитные десктопные приложения на .NET Framework, WPF и WinForms. Через год с небольшим захотелось перемен, и я перешёл в компанию, осуществляющую денежные переводы по Европе. На собеседовании мне обещали распиливание монолитного ядра (снова .NET Framework). Но за год позволили вынести лишь небольшую часть кода в отдельный сервис на .NET 5.0. В основном я писал бизнес-логику на хранимых процедурах.
Нельзя сказать, что меня устраивала эта работа. Я искал что-то новое, читал новости в Telegram-каналах и стал везде встречать информацию о Go. Узнал, что Docker, Kubernetes, Jaeger и другие известные продукты написаны на нём. Многие компании, такие как Netflix, Twitch, Dropbox, переносят на него требовательные к производительности части ПО. В России его используют Яндекс, Авито, Ozon, VK и другие IT-гиганты. Когда я искал первую работу на Go, столкнулся с двумя компаниями, которые переходили на этот язык с C#: первая занималась разработкой игровой платформы, вторая — развитием облачного хранилища.
Go — хорошо оплачиваемый язык
В отчёте Хабр Карьеры за второе полугодие 2021 года Go разделил со Swift третье место в России, а через шесть месяцев вышел на чистое третье место:
Ёмкость рынка
Осенью 2021 года вакансий на C# было в два раза больше, чем на Go (я сравнивал поисковую выдачу HeadHunter в Москве по ключевым словам «C# разработчик» и «Go разработчик»). Но меня это не смутило, так как рынок в то время был очень горячим. Чувствовалось, что, имея два с половиной года коммерческого опыта, проблем с переходом на новый язык не будет.
Доступность обучения
Мне часто попадалась реклама курсов по Go от Skillbox, GeekBrains, Слёрма и других проектов. Яндекс Практикум заканчивал подготовку курса «Продвинутый Go-разработчик». Осенью в Ozon стартовал первый набор на бесплатные курсы Route 256. Для получения недостающих знаний о микросервисах на C# я записался на соответствующий курс. Тогда же увидел рекламу бесплатной программы «Golang разработчик» от CloudMTS. Этапы отбора не были сложными, поэтому я попал и туда. Так началось знакомство с новым языком.
Синтаксис
Первое, что бросилось в глаза, — простота синтаксиса. Она действительно обеспечивает низкий порог входа. Go немногословен. Согласно документации, в языке всего 25 ключевых слов:
break | default | func | goto | select |
case | defer | if | map | struct |
chan | else | import | package | switch |
const | fallthrough | interface | range | type |
continue | for | go | return | var |
В C# вы найдёте аж 77 ключевых слов. Разница более чем в три раза! Шарп многословен, особенно это касается модификаторов доступа: public, private, internal и прочих. Часто требуется добавлять к ним ключевое слово static для указания того, что методы и классы будут использоваться без создания экземпляра класса. Если же в коде есть наследование, то описание метода усложняется ключевыми словами virtual, new, abstract, override.
В Go при объявлении структуры можно управлять видимостью поля с помощью регистра первой буквы. Также можно встраивать структуры друг в друга и перечислять поля одного типа через запятую (подобная запись в C# нарушит соглашение о написании кода):
type baseItem struct {
baseID int64
}
type Item struct {
baseItem
id, otherID int64
Description string
}
Определение типа происходит очень просто. Но для доступа к внутренним полям структуры придётся писать геттеры. В C# с этой задачей прекрасно справляются свойства.
Синтаксис Go полностью соответствует замыслу: любые операции должны быть описаны явно. Следование ему помогает добиться высокой производительности. Однако приходится расплачиваться поддерживаемостью и читаемостью кода. Практически полное отсутствие синтаксического сахара усложняет написание простых операций на Go.
Возьмём для примера следующую задачу. Есть списки людей и компаний. Необходимо распечатать информацию о сотруднике и компании, в которой он работает:
type Person struct {
Name string
CompanyID int
}
type Company struct {
ID int
Title, Language string
}
type Employee struct {
Name, Company, Language string
}
func main() {
var persons = []Person{
{"Petya", 1},
{"Tanya", 2},
{"Misha", 4},
{"Sasha", 2},
}
var companies = []Company{
{1, "Ozon", "Go"},
{2, "Yandex", "C#"},
{3, "Sber", "Java"},
{4, "VK", "Python"},
}
companySet := make(map[int]Company)
for _, company := range companies {
companySet[company.ID] = company
}
var employees []Employee
for _, p := range persons {
if company, ok := companySet[p.CompanyID];ok {
emp := Employee{p.Name, company.Title, company.Language}
employees = append(employees, emp)
}
}
for _, emp := range employees {
fmt.Printf("%s - %s (%s)\n", emp.Name, emp.Company, emp.Language)
}
}
Если для решения этой задачи воспользоваться C#, то сразу несколько возможностей языка сделают код более понятным:
записи для краткого объявления DTO (data transfer object) — объектов для передачи данных;
анонимные типы от избыточных объявлений DTO;
LINQ to Objects для удобной работы с коллекциями;
упрощённое форматирование строк с помощью интерполяции.
В итоге получится лаконичный код, который к тому же меньше по размеру:
record Person(string Name, int CompanyId);
record Company(int Id, string Title, string Language);
public static void Main()
{
var people = new Person[]
{
new ("Petya", 1),
new ("Tanya", 2),
new ("Misha", 4),
new ("Sasha", 1),
};
var companies = new Company[]
{
new (1, "Ozon", "Go"),
new (2, "Yandex", "C#"),
new (3, "Sber", "Java"),
new (4, "VK", "Python")
};
var employees = people.Join(companies,
p => p.CompanyId,
c => c.Id,
(p, c) => new { p.Name, c.Title, c.Language });
foreach (var emp in employees)
Console.WriteLine($"{emp.Name} - {emp.Title} ({emp.Language})");
}
Структура проектов
С# и Go сильно отличаются организацией кода в проекте. При использовании языков с полновесным ООП код разбивается на классы. Как правило, один класс — один файл. Далее логически объединённые файлы раскладываются по папкам и подпапкам. Например, так будет выглядеть пространство имён Drivers, которому соответствует одноимённая папка с драйверами для подключения к различным СУБД:
Drivers/
Sqlite.cs
Postgres.cs
Mysql.cs
Clickhouse.cs
Имея полное имя класса, компилятор знает, какой именно код нужен для сборки проекта. Если мы пишем var sqlite = new Drivers.Sqlite()
, то компилятор будет использовать только зависимость из Sqlite.cs.
Что же произойдёт, если в Go-проекте будет похожая папка?
driver/
sqlite.go
postgres.go
mysql.go
clickhouse.go
Единица компиляции в Go — пакет. Он представляет собой файлы с расширением .go в одноимённой директории. В нашем примере внутри пакета drivers имеется код для подключения ко всем СУБД. При компиляции будут подтягиваться ненужные зависимости. А при исправлении ошибки в коде для ClickHouse появится обновление для всех клиентов, использующих данный пакет. Поэтому файлы следует разнести по отдельным папкам. Чтобы не получались пакеты с большим количеством кода (тысячи строк), следует логически связанные блоки выносить в соседние файлы. Если обратить внимание на многие реальные Go-проекты, то в них неожиданно мало пакетов, однако в пакетах много файлов с сотнями и даже тысячами строк. А в проектах, написанных на языках с полноценным ООП, будет очень много вложенных файлов с парой десятков строк.
Подводные камни срезов
В C# параметры в методы передаются по значению или по ссылке. В Go всё передаётся по значению, то есть копируется. Но в нём есть указатели, поэтому копирование указателя равносильно передаче параметров по ссылке. Конечно, нужно вручную указать, хочешь ли ты передать в метод данные или только ссылку на них, однако в целом поведение обоих языков схожее.
Но есть нюансы! Возьмём для примера C#-код с добавлением элементов в динамический массив List:
public static void Main()
{
var list = new List<int>{1, 2, 3};
Print(list); // 1 2 3
Add(list, new List<int>{4, 5});
Print(list); // 1 2 3 4 5
}
public static void Add(List<int> list, List<int> values)
{
list.AddRange(values);
}
Код ведёт себя ожидаемо: в переменной list было три элемента, передали её по ссылке в метод, добавили два элемента. Так как классы передаются по ссылке, то после метода получаем в коллекции пять элементов.
Аналог динамического массива в Go — срез (или слайс). Он представляет собой структуру, состоящую из ссылки на массив, количества выделенных для заполнения ячеек памяти и количества заполненных ячеек памяти. Убедимся, что внутри среза находится указатель на массив с данными:
func main() {
slice := []int{1, 2, 3}
fmt.Println("[1]", slice) // 1 2 3
changeFirst(slice, -1)
fmt.Println("[2]", slice) // -1 2 3
}
func changeFirst(sl []int, first int) {
sl[0] = first
}
Изменился первый элемент среза. Следовательно, при работе с ним мы имеем ссылку на массив. Благодаря этому срезы очень широко применяются в Go. В отличие от массивов, которые копируются полностью при передаче в функции.
Однако всё усложняется, когда требуется изменить количество элементов в срезе. Вернёмся к примеру на C#, в котором мы добавляли элементы в конец коллекции, и перепишем его на Go:
func main() {
slice := []int{1, 2, 3}
fmt.Println("[1]", slice) // 1 2 3
add(slice, []int{4, 5})
fmt.Println("[2]", slice) // 1 2 3
}
func add(sl []int, values []int) {
sl = append(sl, values...)
fmt.Println("add", sl) // 1 2 3 4 5
}
Как видим, в функции количество цифр в срезе изменилось, однако в main()
оно осталось прежним. Почему же так произошло? В функции append()
выделяется память под новый увеличенный массив. Получается, что в main()
внутри среза указатель на один массив, а внутри add()
мы распечатываем срез со ссылкой на другой массив.
Это лишь один из многих примеров неочевидного поведения в Go. На Хабре ему посвящена пара отличных статей: «Как не наступать на грабли в Go» и «50 оттенков Go: ловушки, подводные камни и распространённые ошибки новичков». Необходимо время, чтобы разобраться с ним и не делать простых ошибок.
В свою очередь, C# более предсказуем. При изучении языка и в начале карьерного пути хлопоты мне доставляло поведение ссылочных типов, используемых в разных частях проекта. Но это было не проблемой языка, а следствием отсутствия опыта или плохой архитектуры.
Горутины и каналы
До непосредственного знакомства с Go я часто слышал о загадочных горутинах и каналах. Язык отлично подходит для многопоточной разработки. Чтобы создать «легковесный поток» — горутину — достаточно лишь перед вызовом функции написать “go”. Для примера запустим десять горутин в цикле и выведем в консоль номера итераций:
func main() {
for i := 0; i < 10; i++ {
go func(x int) {
fmt.Println(x)
}(i)
}
И в консоли не будет ничего напечатано! Потому что Go не использует концепцию ожидания в отличие от C#. Разработчик, владеющий шарпом, ожидает поведения, вызванного таким кодом:
public static void Main()
{
Parallel.For(1, 10, i => Console.WriteLine(i));
}
Или каким-то подобным:
public static async Task Main()
{
var tasks = new List<Task>(10);
for (int i = 0; i < 10; i++)
{
var x = i;
tasks.Add(Task.Run(() => Console.WriteLine(x)));
}
await Task.WhenAll(tasks);
}
Но на самом деле получается код, аналогичный этому:
public static void Main()
{
for (int i = 0; i < 10; i++)
{
var x = i;
Task.Run(() => Console.WriteLine(x));
}
}
Запускается десять задач на выполнение — и не ожидается окончание этого процесса. Поэтому в Go необходимо синхронизировать выполнение горутин.
В приведённом примере можно воспользоваться структурой WaitGroup
из пакета sync:
func main() {
wg := &sync.WaitGroup{}
wg.Add(10)
for i := 0; i < 10; i++ {
go func(x int) {
defer wg.Done()
fmt.Println(x)
}(i)
}
wg.Wait()
}
Перед циклом происходит объявление, что будут выполняться десять горутин. Внутри структуры WaitGroup
значение счётчика выставляется равным 10. В каждой горутине уменьшаем его на 1 с помощью метода Done()
. А в конце функции main()
ожидаем, когда значение счётчика будет равно 0. И только потом завершает своё выполнение главная горутина.
Пара слов о каналах
Передача данных между потоками может осуществляться либо с помощью разделяемой памяти (как в предыдущем примере), либо через обмен сообщениями. И реализация второго принципа делает Go действительно мощным инструментом для высоконагруженных многопоточных приложений. В документации сказано:
«Don’t communicate by sharing memory, share memory by communicating».
Общей памятью при «общении» горутин являются отдельные структуры — каналы. Канал представляет собой трубу (pipe), в которую один или несколько потоков кладут данные, а один или несколько других потоков их из неё забирают. Благодаря этому возможно просто и эффективно реализовывать такие шаблоны конкурентности, как генератор (реализация ниже), распылитель, очередь, конвейер, набор обработчиков и прочие.
Параллельное программирование
Go — молодой язык. Его релиз состоялся в 2009 году, а создание было реакцией компании Google на активное развитие многоядерности процессоров. Поэтому горутины изначально не завязаны на потоки операционной системы, а рантайм предназначен для того, чтобы добиваться максимальной производительности от всех ядер. И специально для этого были созданы каналы и горутины. Многопоточность — конёк Go.
Шарп появился в 2001 году. Тогда же IBM представила свой первый двухъядерный процессор для широкого использования — POWER4. Так что язык создавался для других задач и на другом фундаменте. Однако в 2010 году вышла версия 4.0 с библиотекой параллельных задач TPL (Task Parallel Library), концепцией задач и классами Task, TaskFactory и Parallel. В последующих релизах Microsoft усиливала в языке асинхронность и параллельность. На C# можно решать любые задачи, причём зачастую несколькими способами. Однако многопоточная обработка с обменом сообщениями между потоками на Go пишется быстрее и получается лаконичнее.
В качестве примера приведу реализацию генератора на обоих языках. Из одного потока пишем в канал пять цифр с задержкой в секунду. В главном потоке читаем эти цифры, печатаем и заканчиваем программу при закрытии канала.
Пример на Go:
func main() {
rand.Seed(time.Now().UnixNano())
ch := generate(5)
for item := range ch {
fmt.Println(item)
}
}
func generate(count int) chan string {
ch := make(chan string)
go func() {
for i := 0; i < count; i++ {
time.Sleep(time.Second)
ch <- fmt.Sprint(rand.Intn(10))
}
close(ch)
}()
return ch
}
А вот так это будет выглядеть на C#:
public static async Task Main()
{
var channelReader = Generate(5);
await foreach (var item in channelReader.ReadAllAsync())
Console.WriteLine(item);
}
public static ChannelReader<string> Generate(int count)
{
var ch = Channel.CreateUnbounded<string>();
var rnd = new Random();
Task.Run(async () =>
{
for (var i = 0; i < count; i++)
{
await Task.Delay(TimeSpan.FromSeconds(1));
var delay = rnd.Next(10).ToString();
await ch.Writer.WriteAsync(delay);
}
ch.Writer.Complete();
});
return ch.Reader;
}
В данном случае отсутствие синтаксического сахара не только не мешает Go решать задачу, но и упрощает поддержку за счёт лаконичности кода.
Дженерики
В C# активно используются обобщённые типы. В Go до релиза версии 1.18 (февраль 2021 года) их не было вовсе. Но по проектам, в которых я работал, незаметно, чтобы отсутствие дженериков сильно затрудняло написание кода. Чего не скажешь о пакетах-библиотеках. С обобщёнными типами можно не повторять код в разных функциях. Например, разбиение среза элементов любых типов на пачки:
func Batch[T any](items []T, size int) [][]T {
if len(items) == 0 {
return [][]T{}
}
batches := make([][]T, 0, (len(items)+size-1)/size)
for size < len(items) {
items, batches = items[size:], append(batches, items[0:size:size])
}
batches = append(batches, items)
return batches
}
Раньше можно было написать аналогичную функцию, которая принимает срез пустых интерфейсов в качестве первого параметра:
func Batch(items []interface{}, size int) [][]interface{} {
...
}
Однако при вызове функции со срезом строк в качестве первого параметра компилятор выдаст ошибку: «Невозможно использовать срез строк в качестве среза пустых интерфейсов». Чтобы всё-таки воспользоваться функцией, необходимо сначала выполнить указанное приведение вручную:
sl := []string{"1", "2", "3", "4", "5"}
ifaceSl := make([]interface{}, 0, len(sl))
for _, item := range sl {
ifaceSl = append(ifaceSl, item)
}
Поэтому раньше разработчикам приходилось писать функции для каждого типа: StringBatch
, Int64Batch
, SomeStructBatch
и прочих.
Интерфейсы
В C# активно используются интерфейсы. Для того чтобы класс им соответствовал, необходимо не только реализовать их методы, но и явно перечислить интерфейсы при объявлении класса:
interface IGlorifier
{
void Glorify();
}
class GoGlorifier : IGlorifier
{
public void Glorify()
{
Console.WriteLine("Go is the best!!!");
}
}
class SharpGlorifier : IGlorifier
{
public void Glorify()
{
Console.WriteLine("C# is the best!!!");
}
}
public static void Main()
{
new List<IGlorifier>{new GoGlorifier(), new SharpGlorifier()}
.ForEach(g => g.Glorify());
}
В примере объявляется интерфейс. Два класса ему соответствуют. Затем в Main()
вызывается реализованный каждым метод.
Go поддерживает утиную типизацию:
«Если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, утка».
Для соответствия интерфейсу достаточно, чтобы методы типа реализовывали все методы интерфейса. В противном случае возникнет ошибка на этапе компиляции.
type Glorifier interface {
Glorify()
}
type GoGlorifier struct{}
func (GoGlorifier) Glorify() {
fmt.Println("Go is the best!!!")
}
type SharpGlorifier struct{}
func (SharpGlorifier) Glorify() {
fmt.Println("C# is the best!!!")
}
func main() {
glorifiers := []Glorifier{GoGlorifier{}, SharpGlorifier{}}
for _, g := range glorifiers {
g.Glorify()
}
}
На собеседованиях любят спрашивать, где следует объявлять интерфейс: в пакете, в котором он реализуется, или в том, в котором используется? Правильный ответ — второй. Нашему коду требуются от типа определённые методы, и мы явно это указываем. Если объявить интерфейс в другом пакете, то получится ненужная зависимость, при обновлении которой может сломаться код.
Поиск работы
В начале декабря 2021 года стал подходить к концу курс по Go. Тогда же назрело желание сменить работу. Сначала решил попробовать сменить язык программирования, а в случае неудачи — продолжить развиваться в C#. Открыл резюме на HeadHunter и Хабр Карьере, а уже через пару дней закрыл: ближайшие две недели были расписаны собеседованиями. Работодателей не смущало отсутствие коммерческого опыта на Go. Приятно поразило большое количество предложений на высоконагруженные проекты. Это именно то, чем хотелось заниматься. На первых технических встречах я проваливался на вопросах о выделении памяти при добавлении элементов в срезы и о работе указателей. Но пробелы в знаниях быстро заполнялись — и через две недели на руках было два оффера.
Ранее я упоминал, что на шарпе можно решить любую задачу несколькими способами. Следовательно, нужно иметь более объёмные познания, чтобы принимать оптимальные решения. Так как Go имеет простой синтаксис, то и материала для подготовки к собеседованиям довольно немного. Бросилось в глаза, что на каждой встрече спрашивают почти одно и то же:
область видимости, встраиваемые типы;
указатели;
интерфейсы, утиная типизация;
массивы;
внутреннее устройство срезов, выделение памяти при append();
внутреннее устройство мапы, эвакуация данных при увеличении мапы;
каналы, типы каналов;
горутины, синхронизация горутин;
рантайм.
Вопросы же по С# более обширны:
ссылочные и значимые типы;
стек и куча;
парадигмы ООП;
интерфейсы и абстрактные классы;
весь SOLID;
разновидности коллекций;
большой пласт про асинхронность, многопоточность и конкурентность;
сборщик мусора;
контейнер зависимостей;
Entity Framework Core;
ASP.NET Core.
Неспроста о шарпе написано множество толстых книг. Достаточно вспомнить широко известный труд Рихтера, к которому я подступался несколько раз, однако так и не побывал на всех страницах. Что-то сопоставимое по объёму для изучения Go я не встречал.
Заключение
Я обратил внимание лишь на несколько аспектов, удививших меня при знакомстве с Go. C# и Go очень разные, несмотря на то что оба являются C-подобными, компилируемыми и статически типизированными языками. Однако шарписту среднего уровня довольно несложно перейти на Go. Помогут:
простой синтаксис;
понимание интерфейсов и внедрения зависимостей;
удобная работа с многопоточностью;
обилие вакансий с высоконагруженными приложениями;
меньше легаси на проектах;
ограниченность круга тем на собеседованиях;
более высокая оплата труда (правда, зависит от компании).
Освоение языка будут затруднять:
отсутствие некоторых привычных функций в стандартных библиотеках;
малое количество синтаксического сахара;
постоянная необходимость императивно обрабатывать коллекции;
немедленная обработка ошибок после каждой функции;
иная структура проектов;
большее количество подводных камней в языке;
непривычный обмен данными между потоками.
Тем не менее Go прекрасно справляется с задачей, для решения которой был создан. Доставляет удовольствие писать на нём надёжные сложные приложения!
Если вам интересно попробовать перейти на Go, то уже в сентябре стартует 4 поток бесплатного курса по Go-разработке для мидлов.