Pull to refresh

Тонкости реализации Singleton на Golang

Reading time5 min
Views13K

Привет, друзья.

Меня зовут Alex Versus и сегодня с вами посмотрим шаблон Singleton, реализацию на языке Golang.

Какая суть?

Одиночка - относится к порождающим шаблонам. Гарантирует:

  • что у класса/типа есть только один экземпляр

  • предоставляет к нему глобальную точку доступа.

Какую задачу решает?

Поговорим про задачу, которую решает шаблон. Одиночка решает сразу две проблемы, нарушая принцип единой ответственности (SRP):

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

  2. Предоставить глобальную точку доступа. Обращаю внимание, что это не просто глобальная переменная, через которую можно достучаться до определенного объекта. Глобальная переменная не защищает вас от перезаписи созданного объекта.

Разработчики часто называют Одиночкой объекты, которые выполняют только одну задачу, озвученную выше. Это ошибочное понимание шаблона.

Какое решение в Golang?

Как решить озвученные задачи в GOlang? Кто знаком с реализацией шаблона в ООП, знают, что нужно скрыть конструктор и объявить публичный статический метод, контролирующий жизненный цикл объекта-одиночки. Статический метод обеспечит доступ к объекту из любого места вашей программы. Реализацию можно посмотреть тут. Сколько бы вы не вызывали данный метод - он всегда вернет один и тот же объект. Диаграмма классов шаблона следующая:

Диаграмма классов Singleton
Диаграмма классов Singleton

В GOlang нет классов и конструкторов. Но есть типы и структуры. Как нам реализовать метод getInstance()? Создадим определенный тип singleton:

// declaration defined type 
type singleton struct {
   
}

Инициализируем переменную с типом singleton, равную пустому значению nil:

// declare variable
var instance *singleton = nil

Для установки значения в instance нам нужно воспользоваться методом стандартной библиотеки sync.Once. Он принимает в аргумент функцию, которая отработает один раз за вызов. А так же мы должны с вами определить тип Sigleton и определить в нем интерфейс с методами работы со свойствами нашего типа:

// defined type with interface
type Singleton interface {
// here will be methods
}

И функцию возврата объекта:

// Get only one object
func GetInstance() Singleton {
   once.Do(func() {
      instance = new(singleton)
   })
   return instance
}

Для проверки реализации мы добавим поле в тип singleton, которое будем менять с помощью сеттеров и геттеров:

// declaration defined type
type singleton struct {
   title string
}

И определим в интерфейсе типа Singleton методы, позволяющий изменить значение этого свойства:

// defined type with interface
type Singleton interface {
   SetTitle(t string)
   GetTitle() string
}


// Setter for singleton variable
func (s *singleton) SetTitle(t string) {
   s.title = t
}

// Getter singleton variable
func (s *singleton) GetTitle() string {
   return s.title
}

Реализуем код для работы со свойством данных объектов.

Теперь нам надо проверить, как работает наш код. Напишем небольшой тест и увидим, что работает все корректно при такой реализации:

package Singleton

import "testing"

func TestGetInstance(t *testing.T) {
   var s Singleton
   s = GetInstance()
   if s == nil {
      t.Fatalf("First sigletone is nil")
   }

   s.SetTitle("First value")
   checkTitle := s.GetTitle()

   if checkTitle != "First value" {
      t.Errorf("First value is not setted")
   }

   var s2 Singleton
   s2 = GetInstance()
   if s2 != s {
      t.Error("New instance different")
   }

   s2.SetTitle("New title")
   newTitle := s.GetTitle()
   if newTitle != "New title" {
      t.Errorf("Title different after change")
   }
}

Запускаем код:

go test -v -run TestGetInstance
=== RUN   TestGetInstance
--- PASS: TestGetInstance (0.00s)
PASS
ok      main/Singleton  0.310s

Отлично! Кажется, что все ок, но на самом деле нет. Хочу показать еще один тест, который покажет, какая проблема существует:

package Singleton

import (
   "fmt"
   "strconv"
   "sync"
   "testing"
)

func TestSecondGetInstance(t *testing.T) {
   s1 := GetInstance()
   s2 := GetInstance()

   var w sync.WaitGroup

   for i := 0; i < 3000; i++ {
      j := i
      w.Add(1)
      go func() {
         t := "title_" + strconv.Itoa(j)
         s1.SetTitle(t)
         w.Done()
      }()
      w.Add(1)
      go func() {


         t2 := "title_2_" + strconv.Itoa(j)
         s2.SetTitle(t2)
         w.Done()
      }()
   }

   fmt.Println(s1.GetTitle())
   fmt.Println(s2.GetTitle())
}

На выходе мы увидим следующее:

go test -v -run TestSecondGetInstance
=== RUN   TestSecondGetInstance
title_2998
title_2_2999

Запускается цикл на 3000 итераций в которых создается по две горутины, в которых вызывается метод установки свойства. Метод меняет значения свойств каждую итерацию, используя сеттер. После выполнения блока кода мы ожидаем, что значения свойств объектов-одиночек будут одинаковыми, но это не так. Почему такое происходит?

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

Есть несколько способов решить проблему. Мы с вами не будем останавливаться подробно на этой теме, сегодня говорим про реализацию шаблона Singleton. Я просто покажу одно из решений. Решение называется взаимным исключением, достигнуть можно с помощью мьютекса. Условно, классический мьютекс можно представить в виде переменной, которая может находиться в двух состояниях: в заблокированном и в незаблокированном. При входе в свою критическую секцию поток вызывает функцию перевода мьютекса в заблокированное состояние, при этом поток блокируется до освобождения мьютекса, если другой поток уже владеет им. В Go его можно реализовать через стандартную библиотеку в которой есть примитивы синхронизации: sync.Mutex и sync.RWMutex. Реализация выглядит так:

// declaration defined type
type singleton struct {
   title string
   sync.RWMutex
}

// Setter for singleton variable
func (s *singleton) SetTitle(t string) {
   s.Lock()
   defer s.Unlock()
   s.title = t
}

// Getter singleton variable
func (s *singleton) GetTitle() string {
   s.RLock()
   defer s.RUnlock()
   return s.title
}

Еще раз запустим второй тест и увидим, что сейчас обе переменные равны:

go test -v -run TestSecondGetInstance
=== RUN   TestSecondGetInstance
--- PASS: TestSecondGetInstance (0.00s)
PASS

Вот такая реализация шаблона проектирования Singleton на Golang.

В чем преимущества шаблона?

  1. Гарантируем наличие в программе единственного объекта.

  2. Предоставляет к нему глобальную точку доступа.

  3. Реализация отложенной инициализации объекта.

Какие недостатки?

  1. Нарушает принцип единой ответственности для ООП языков. Каждый объект/класс/тип должен иметь только одну ответственность и соблюдение этой ответственности должно быть инкапсулировано, методы должны решать задачи этой ответственности.

  2. Маскирует плохой дизайн кода.

  3. Проблемы мультипоточности для языков типа Golang. Рассмотрели на сегодняшнем уроке один из способов, как решается проблема.

  4. При автоматизированном тестировании требует создания mock-объектов. Такие объекты представляют собой конкретную фиктивную реализацию интерфейса, предназначенную исключительно для тестирования и взаимодействия между объектами. В процедурных языках такая конструкция называется dummy.

Singleton — очень обманчивый паттерн, его часто хочется применить, но делать это надо очень осторожно. Например, если мы хотим создать единый регулятор громкости при разработке программного обеспечения для эквалайзера, мы полагаем, что громкость будет являться глобальным свойством программы. Однако если мы захотим ввести отдельный регулятор для громкости каких-нибудь уведомлений — появится второй Singleton, а затем могут появиться зависимости между экземплярами. Нарушения принципа единой обязанности (SRP), согласно которому каждый объект должен иметь только одну обязанность, а все его методы должны быть направленны на ее выполнение. Если мы реализуем Singleton, то наш тип помимо своей основной работы может начать контролировать количество созданных экземпляров. Singleton — глобальный объект, со всеми вытекающими проблемами.

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

А на сегодня все. Надеюсь помог вам разобраться в тонкостях реализации практики на языке Golang. Рад был поделиться материалом.
Alex Versus. Всем удачи!

Tags:
Hubs:
Total votes 13: ↑5 and ↓8-3
Comments7

Articles