Русский
Русский
English
Статистика
Реклама

Ооп

Поддержка токенов PKCS11 с ГОСТ-криптографией в Python. Часть II Объекты класса Token

18.03.2021 18:13:59 | Автор: admin
imageВ предыдущей статье был представлен модуль pyp11, написанный на языке Си и обеспечивающий поддержку токенов PKCS#11 с российской криптографией. В этой статье будет рассмотрен класс Token, который позволит упростить использование функционала модуля pyp11 в скриптах, написанных на Python-е. Отметим, что в качестве прототипа этого класса был взят класс token, написанный на TclOO и который используется в утилите cryptoarmpkcs:


Прототип класса Token
oo::class create token {  variable libp11  variable handle  variable infotok  variable pintok  variable nodet#Конструктор  constructor {handlelp11 labtoken slottoken} {    global pass    global yespas    set handle $handlelp11    set slots [pki::pkcs11::listslots $handle]    array set infotok []    foreach slotinfo $slots {      set slotflags [lindex $slotinfo 2]      if {[lsearch -exact $slotflags TOKEN_PRESENT] != -1} {        if {[string first $labtoken [lindex $slotinfo 1]] != -1} {          set infotok(slotlabel) [lindex $slotinfo 1]          set infotok(slotid) [lindex $slotinfo 0]          set infotok(slotflags) [lindex $slotinfo 2]          set infotok(token) [lindex $slotinfo 3]          #Берется наш токен          break        }      }    }    #Список найденных токенов в слотах    if {[llength [array names infotok]] == 0 } {      error "Constructor: Token not present for library   : $handle"    }    #Объект какого токена    set nodet [dict create pkcs11_handle $handle]    dict set nodet pkcs11_slotid $infotok(slotid)    set tit "Введите PIN-код для токене $infotok(slotlabel)"    set xa [my read_password $tit]    if {$xa == "no"} {      error "Вы передумали вводить PIN для токена $infotok(slotlabel)"    }    set pintok $pass    set pass ""    set rr [my login ]    if { $rr == 0 } {      unset pintok      error "Проверьте PIN-код токена $infotok(slotlabel)."    } elseif {$rr == -1} {      unset pintok      error "Отсутствует токен."    }    my logout  }#Методы класса  method infoslot {} {    return [array get infotok]  }  method listcerts {} {    array set lcerts []    set certsder [pki::pkcs11::listcertsder $handle $infotok(slotid)]    #Перебираем сертификаты    foreach lc $certsder {      array set derc $lc      set lcerts($derc(pkcs11_label)) [list $derc(cert_der) $derc(pkcs11_id)]      #parray derc    }    return [array get lcerts]  }  method read_password {tit} {    global yespas    global pass    set tit_orig "$::labpas"    if {$tit != ""} {      set ::labpas "$tit"    }    tk busy hold ".st.fr1"    tk busy hold ".st.fr3"    #place .topPinPw -in .st.fr1.fr2_certs.labCert  -relx 1.0 -rely 3.0 -relwidth 3.5    place .topPinPw -in .st.labMain  -relx 0.35 -rely 5.0 -relwidth 0.30    set yespas ""    focus .topPinPw.labFrPw.entryPw    vwait yespas    catch {tk busy forget ".st.fr1"}    catch {tk busy forget ".st.fr3"}    if {$tit != ""} {      set ::labpas "$tit_orig"    }    place forget .topPinPw    return $yespas  }  unexport read_password  method rename {type ckaid newlab} {    if {$type != "cert" && $type != "key" && $type != "all"} {      error "Bad type for rename "    }    set uu $nodet    lappend uu "pkcs11_id"    lappend uu $ckaid    lappend uu "pkcs11_label"    lappend uu $newlab    if { [my login ] == 0 } {      unset uu      return 0    }    pki::pkcs11::rename $type $uu    my logout    return 1  }  method changeid {type ckaid newid} {    if {$type != "cert" && $type != "key" && $type != "all"} {      error "Bad type for changeid "    }    set uu $nodet    lappend uu "pkcs11_id"    lappend uu $ckaid    lappend uu "pkcs11_id_new"    lappend uu $newid    if { [my login ] == 0 } {      unset uu      return 0    }    pki::pkcs11::rename $type $uu    my logout    return 1  }  method delete {type ckaid} {    if {$type != "cert" && $type != "key" && $type != "all" && $type != "obj"} {      error "Bad type for delete"    }    set uu $nodet    lappend uu "pkcs11_id"    lappend uu $ckaid    my login    ::pki::pkcs11::delete $type $uu    my logout    return 1  }  method deleteobj {hobj} {    set uu $nodet    lappend uu "hobj"    lappend uu $hobj#tk_messageBox -title "class deleteobj" -icon info -message "hobj: $hobj\n" -detail "$uu"    return [::pki::pkcs11::delete obj $uu ]  }  method listmechs {} {    set llmech [pki::pkcs11::listmechs $handle $infotok(slotid)]    return $llmech  }  method pubkeyinfo {cert_der_hex} {    array set linfopk [pki::pkcs11::pubkeyinfo $cert_der_hex $nodet]    return [array get linfopk]  }  method listobjects {type} {    if {$type != "cert" && $type != "pubkey" && $type != "privkey" && $type != "all" && $type != "data"} {      error "Bad type for listobjects "    }    set allobjs [::pki::pkcs11::listobjects $handle $infotok(slotid) $type]    return $allobjs  }  method importcert {cert_der_hex cka_label} {    set uu $nodet    dict set uu pkcs11_label $cka_label    if {[catch {set pkcs11id [pki::pkcs11::importcert $cert_der_hex $uu]} res] } {      error "Cannot import this certificate:$res"      #          return 0    }    return $pkcs11id  }  method login {} {    set wh 1    set rl -1    while {$wh == 1} {      if {[catch {set rl [pki::pkcs11::login $handle $infotok(slotid) $pintok]} res]} {        if {[string first "SESSION_HANDLE_INVALID" $res] != -1} {          pki::pkcs11::closesession $handle          continue        } elseif {[string first "TOKEN_NOT_PRESENT" $res] != -1} {          set wh 0          continue        }      }      break    }    if {$wh == 0} {      return -1    }    return $rl  }  method logout {} {    return [pki::pkcs11::logout $handle $infotok(slotid)]  }  method keypair {typegost parkey} {    my login    set skey [pki::pkcs11::keypair $typegost $parkey $nodet]    my logout    return $skey  }  method digest {typehash source} {    return [pki::pkcs11::digest $typehash $source $nodet]  }  method signkey {ckm digest hobj_priv} {    set uu $nodet    dict set uu hobj_privkey $hobj_priv    my login    set ss [pki::pkcs11::sign $ckm $digest $uu]    my logout    return $ss  }  method signcert {ckm digest pkcs11_id} {    set uu $nodet    dict set uu pkcs11_id $pkcs11_id    my login    set ss  [pki::pkcs11::sign $ckm $digest $uu]    my logout    return $ss  }  method verify {digest signature asn1pubkey} {    set uu $nodet    dict set uu pubkeyinfo $asn1pubkey    return [pki::pkcs11::verify $digest $signature $uu]  }  method tokenpresent {} {    set slots [pki::pkcs11::listslots $handle]    foreach slotinfo $slots {      set slotid [lindex $slotinfo 0]      set slotlabel [lindex $slotinfo 1]      set slotflags [lindex $slotinfo 2]      if {[lsearch -exact $slotflags TOKEN_PRESENT] != -1} {        if {infotok(slotlabel) == $slotlabel && $slotid == $infotok(slotid)} {          return 1        }      }    }    return 0  }  method setpin {type tpin newpin} {    if {$type != "user" && $type != "so"} {      return 0    }    if {$type == "user"} {      if {$tpin != $pintok} {        return 0      }    }    set ret [::pki::pkcs11::setpin  $handle $infotok(slotid) $type $tpin $newpin]    if {$type == "user"} {      if {$ret} {        set pitok $newpin      }    }    return $ret  }  method inituserpin {sopin upin} {    set ret [::pki::pkcs11::inituserpin $handle $infotok(slotid) $sopin $upin]    return $ret  }  method importkey {uukey} {    set uu $nodet    append uu " $uukey"    my login    if {[catch {set impkey [pki::pkcs11::importkey $uu ]} res] } {        set impkey 0    }    my logout    return $impkey  }#Деструктор  destructor {    variable handle    if {[info exists pintok]} {      my login    }    #    ::pki::pkcs11::unloadmodule  $handle  }}

Класс Token, как и любой класс в Python, включает конструктор, методы и деструктор. Конструктор и деструктор это те же методы, только с предопределёнными именами. Конструктор имеет имя __init__, а деструктор имя __del__. Объявление конструктора и деструктора можно опускать. И в классе Token мы опустим объявление деструктора, а вот конструктор будет необходим. Конструктор будет создавать экземпляр класса Token для конкретного токена с конкретными атрибутами.

I. Конструктор класса Token


Итак, конструктор класса Token выглядит следующим образом:
import sysimport pyp11class Token:  def __init__ (self, handlelp11, slottoken, serialnum):    flags = ''    self.pyver = sys.version[0]    if (self.pyver == '2'):        print ('Только для python3')        quit()#Сохраняем handle библиотеки PKCS#11    self.handle = handlelp11#Сохраняем номер слота с токеном    self.slotid = slottoken#Сохраняем серийный номер токена    self.sn = serialnum#Проверяем наличие в указанном слоте токена с заданным серийным номером    ret, stat = self.tokinfo()#Проверяем код возврата    if (stat != ''):#Возвращаем информацию об ошибке        self.returncode = stat        return#Экземпляр класса (объект) успешно создан


Параметрами конструктора (метода __init__) являются (помимо обязательного self) handle библиотеки токена (handlelp11), номер слота (slottoken), в котором должен находиться токен, и серийный номер токена (serialnum).
Для получения handle библиотеки pkcs#11, номеров слотов и информации о находящихся в них токенах можно использовать следующий скрипт:
#!/usr/bin/python3import sysimport pyp11from Token import Tokendef listslots (handle):    slots = pyp11.listslots(aa)    i = 0    lslots = []    for v in slots:        for f in v[2]:        if (f == 'TOKEN_PRESENT'):                i = 1                lslots.append(v)                break    i += 1    return (lslots)#Библиотеки для Linux#Программный токенlib = '/usr/local/lib64/libls11sw2016.so'#Облачный токен#lib = '/usr/local/lib64/libls11cloud.so'#Аппаратный токен#lib = '/usr/local/lib64/librtpkcs11ecp_2.0.so'#Библиотеки для Windows#lib='C:\Temp\ls11sw2016.dll'try:#Вызываем команду загрузки библиотеки и получаем её handle (дескриптор библиотеки)    aa = pyp11.loadmodule(lib)    print('Handle библиотеки ' + lib + ': ' + aa)except:    print('Except load lib: ')    e = sys.exc_info()[1]    e1 = e.args[0]#Печать ошибки    print (e1)    quit()#Список слотовslots = listslots(aa)i = 0for v in slots:    for f in v[2]:        if (f == 'TOKEN_PRESENT'):        if (i == 0):            print ('\nИнформация о токенах в слотах\n')        it = v[3]        print ('slotid=' + str(v[0]))        print ('\tFlags=' + str(v[2]))        print ('\tLabel="' + it[0].strip() + '"')        print ('\tManufacturer="' + it[1].strip() + '"')        print ('\tModel="' + it[2].strip() + '"')        print ('\tSerialNumber="' + it[3].strip() + '"')        i = 1        break    i += 1pyp11.unloadmodule(aa)if (i == 0):    print ('Нет ни одного подключенного токена. Вставьте токен и повторите операцию')quit()

Если с библиотекой и слотом всё ясно, то с серийным номером токена может возникнуть вопрос а зачем этот параметр нужен и почему именно он, а, например, не метка токена. Сразу оговоримся, что это принципиально для извлекаемых токенов, когда злоумышленником (или случайно) один токен в слоте будет заменён другим токеном. Более того, различные экземпляры токена могут иметь одинаковые метки. И наконец, токен может быть еще не проинициализирован или владелец будет его переинициализировать, в частности, сменит метку токена. Теоретически даже серийный номер не гарантирует его идентичность, оптимально учитывать всю информацию о токене (серийный номер, модель, производитель). В задачи конструктора и входит сохранение в переменных создаваемого экземпляра класса аргументов объекта токен:
...#Сохраняем handle библиотеки PKCS#11    self.handle = handlelp11#Сохраняем номер слота с токеном    self.slotid = slottoken#Сохраняем серийный номер токена    self.sn = serialnum...

Проверкой наличия указанного токена в указанном слоте занимается метод tokinfo(), определенный в данном классе.
Метод tokinfo возвращает два значения (см. выше в конструкторе):
#Проверяем наличие в указанном слоте токена с заданным серийным номером    ret, stat = self.tokinfo()

В первой переменной (ret) содержится результат выполнения метода, а во второй (stat) информация о том, как завершилось выполнение метода. Если вторая переменная пуста, то метод tokinfo успешно выполнился. Если вторая переменная не пуста, то выполнение метода завершилось с ошибкой. Информация об ошибке будет находиться в этой переменной. При обнаружении ошибки выполнения метода self.tokinfo конструктор записывает её в переменную returncode:
#Проверяем наличие в указанном слоте токена с заданным серийным номером    ret, stat = self.tokinfo()#Проверяем код возврата    if (stat != ''):#Возвращаем информацию об ошибке в переменной returncode        self.returncode = stat        return

После создания объекта (экземпляра класса) необходимо проверить значение переменной returncode, чтобы быть уверенным в том, что объект для указанного токена создан:
#!/usr/bin/python3import sysimport pyp11from Token import Token#Выбираем библиотеку#Аппаратный токенlib = '/usr/local/lib64/librtpkcs11ecp_2.0.so'try:    aa = pyp11.loadmodule(lib)except:    e = sys.exc_info()[1]    e1 = e.args[0]    print (e1)    quit()#Серийный номер токенаsn = '9999999999999999'slot = 110#Создаем объект токенаt1 = Token(aa, slot, sn)#Проверка переменной returncodeif (t1.returncode != ''):#Объект создан с ошибкой    print (t1.returncode)#Уничтожение объекта    del t1#Завершение скрипта    quit()#объект успешно создан. . .

Если обнаружена ошибка при создании объекта, то целесообразно этот объект уничтожить:
del <идентификатор объекта>

II. Архитектура методов в классе Token


Главным принципом при написании методов было то, чтобы обработка исключений была внутри методов, а информация об исключениях (ошибках) возвращалась в текстовом виде. Исходя из этого все методы возвращают два значения: собственно результат выполнения и информацию об ошибке. Если ошибок нет, то второе значение пустое. Мы это уже видели на примере использования метода tokinfo в конструкторе. А вот и сам код метода tokinfo:
  def tokinfo(self):    status = ''#Получаем список слотов    try:        slots = pyp11.listslots(self.handle)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        dd = ''        status = e1        return (dd, status)    status = ''#Ищем заданный слот с указанным  токеном#Перебираем слоты    for v in slots:#Ищем заданный слот            if (v[0] != self.slotid):                status = "Ошибочный слот"                continue            self.returncode = ''#Список флагов текущего слота            self.flags = v[2]#Проверяем наличие в стоке токена            if (self.flags.count('TOKEN_PRESENT') !=0):#Проверяем серийный номер токена                tokinf = v[3]                sn = tokinf[3].strip()                if (self.sn != sn):                    status = 'Серийный номер токена=\"' + sn + '\" не совпадает с заданным \"' + self.sn + '\"'                    dd = ''                    return (dd, status)                status = ''                break            else:                dd = ''                status = "В слоте нет токена"                return (dd, status)    tt = tokinf    dd = dict(Label=tt[0].strip())    dd.update(Manufacturer=tt[1].strip())    dd.update(Model=tt[2].strip())    dd.update(SerialNumber=tt[3].strip())    self.infotok = dd#Возвращаемые значения    return (dd, status)

Полное описание класса Token находится здесь.
import sysimport pyp11class Token:  def __init__ (self, handlelp11, slottoken, serialnum):    flags = ''    self.pyver = sys.version[0]    if (self.pyver == '2'):        print ('Только для python3')        quit()#Сохраняем handle библиотеки PKCS#11    self.handle = handlelp11#Сохраняем номер слота с токеном    self.slotid = slottoken#Сохраняем серийный номер токена    self.sn = serialnum#Проверяем наличие в указанном слоте с токена с заданным серийным номером    ret, stat = self.tokinfo()#Проверяем код возврата    if (stat != ''):#Возвращаем информацию об ошибке        self.returncode = stat        return#Экземпляр класса (объект) успешно создан  def tokinfo(self):    status = ''#Получаем список слотов    try:        slots = pyp11.listslots(self.handle)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        dd = ''        status = e1        return (dd, status)    status = ''#Ищем заданный слот с указанным  токеном#Перебираем слоты    for v in slots:#Ищем заданный слот            if (v[0] != self.slotid):                status = "Ошибочный слот"                continue            self.returncode = ''#Список флагов текущего слота            self.flags = v[2]#Проверяем наличие в стоке токена            if (self.flags.count('TOKEN_PRESENT') !=0):#Проверяем серийный номер токена                tokinf = v[3]                sn = tokinf[3].strip()                if (self.sn != sn):                    status = 'Серийный номер токена=\"' + sn + '\" не совпадает с заданным \"' + self.sn + '\"'                    dd = ''                    return (dd, status)                status = ''                break            else:                dd = ''                status = "В слоте нет токена"                return (dd, status)    tt = tokinf    dd = dict(Label=tt[0].strip())    dd.update(Manufacturer=tt[1].strip())    dd.update(Model=tt[2].strip())    dd.update(SerialNumber=tt[3].strip())    self.infotok = dd    return (dd, status)  def listcerts(self):    try:        status = ''        lcerts = pyp11.listcerts(self.handle, self.slotid)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        lcerts = ''        status = e1    return (lcerts, status)  def listobjects(self, type1, value = '' ):    try:        status = ''        if (value == ''):        lobjs = pyp11.listobjects(self.handle, self.slotid, type1)        else:        lobjs = pyp11.listobjects(self.handle, self.slotid, type1, value)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        lobjs = ''        status = e1    return (lobjs, status)  def rename(self, type, pkcs11id, label):    try:        status = ''        dd = dict(pkcs11_id=pkcs11id, pkcs11_label=label)        ret = pyp11.rename(self.handle, self.slotid, type, dd)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        ret = ''        status = e1    return (ret, status)  def changeckaid(self, type, pkcs11id, pkcs11idnew):    try:        status = ''        dd = dict(pkcs11_id=pkcs11id, pkcs11_id_new=pkcs11idnew)        ret = pyp11.rename(self.handle, self.slotid, type, dd)    except:#Проблемы с библиотекой токена        e = sys.exc_info()[1]        e1 = e.args[0]        ret = ''        status = e1    return (ret, status)  def login(self, userpin):    try:        status = ''        bb = pyp11.login (self.handle, self.slotid, userpin)    except:        e = sys.exc_info()[1]        e1 = e.args[0]        bb = 0        status = e1    return (bb, status)  def logout(self):    try:        status = ''        bb = pyp11.logout (self.handle, self.slotid)    except:        e = sys.exc_info()[1]        e1 = e.args[0]        bb = 0        status = e1    return (bb, status)  def keypair(self, typek, paramk, labkey):#Параметры для ключей    gost2012_512 = ['1.2.643.7.1.2.1.2.1', '1.2.643.7.1.2.1.2.2', '1.2.643.7.1.2.1.2.3']    gost2012_256 = ['1.2.643.2.2.35.1', '1.2.643.2.2.35.2',  '1.2.643.2.2.35.3',  '1.2.643.2.2.36.0', '1.2.643.2.2.36.1', '1.2.643.7.1.2.1.1.1', '1.2.643.7.1.2.1.1.2', '1.2.643.7.1.2.1.1.3', '1.2.643.7.1.2.1.1.4']    gost2001 = ['1.2.643.2.2.35.1', '1.2.643.2.2.35.2',  '1.2.643.2.2.35.3',  '1.2.643.2.2.36.0', '1.2.643.2.2.36.1']#Тип ключа    typekey = ['g12_256', 'g12_512', 'gost2001']    genkey = ''    if (typek == typekey[0]):    gost = gost2012_256    elif (typek == typekey[1]):    gost = gost2012_512    elif (typek == typekey[2]):    gost = gost2001    else:    status = 'Неподдерживаемый тип ключа'    return (genkey, status)    if (gost.count(paramk) == 0) :    status = 'Неподдерживаемые параметры ключа'    return (genkey, status)    try:#Ошибок нет, есть ключевая пара    status = ''    genkey = pyp11.keypair(self.handle, self.slotid, typek, paramk, labkey)    except:#Не удалось создать ключевую пару    e = sys.exc_info()[1]    e1 = e.args[0]    print (e1)#Возвращаеи текст ошибки в словаре    status = e1    return (genkey, status)     def digest(self, typehash, source):#Считаем хэш    try:        status = ''        digest_hex = pyp11.digest (self.handle, self.slotid, typehash, source)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в словаре    status = e1    digest_hex = ''    return (digest_hex, status)#Формирование подписи  def sign(self, ckmpair, digest_hex, idorhandle):#Для подписи можно использовать CKA_ID или handle закрытого ключа    try:        status = ''        sign_hex = pyp11.sign(self.handle, self.slotid, ckmpair, digest_hex, idorhandle)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в словаре    status = e1    sign_hex = ''    return (sign_hex, status)#Проверка подписи  def verify(self, digest_hex, sign_hex, pubkeyinfo):#Для подписи можно использовать CKA_ID или handle закрытого ключа    try:        status = ''        verify = pyp11.verify(self.handle, self.slotid, digest_hex, sign_hex, pubkeyinfo)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    verify = 0    status = e1    return (verify, status)#Инициализировать токен  def inittoken(self, sopin, labtoken):    try:        status = ''        dd = pyp11.inittoken (self.handle, self.slotid, sopin, labtoken)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = 0    status = e1    return (dd, status)#Инициализировать пользовательский PIN-код  def inituserpin(self, sopin, userpin):    try:        status = ''        dd = pyp11.inituserpin (self.handle, self.slotid, sopin, userpin)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = 0    status = e1    return (dd, status)#Сменить пользовательский PIN-код  def changeuserpin(self, oldpin, newpin):    try:        status = ''        dd = pyp11.setpin (self.handle, self.slotid, 'user', oldpin, newpin)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = 0    status = e1    self.closesession ()    return (dd, status)  def closesession(self):    try:        status = ''        dd = pyp11.closesession (self.handle)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = 0    status = e1    return (dd, status)  def parsecert(self, cert_der_hex):    try:        status = ''        dd = pyp11.parsecert (self.handle, self.slotid, cert_der_hex)    except:#Не удалось разобрать сертификат    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = ''    status = e1    return (dd, status)  def importcert(self, cert_der_hex, labcert):    try:        status = ''        dd = pyp11.importcert (self.handle, self.slotid, cert_der_hex, labcert)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = ''    status = e1    return (dd, status)  def delobject(self, hobject):    try:        status = ''        hobjc = dict(hobj=hobject)        dd = pyp11.delete(self.handle, self.slotid, 'obj', hobjc)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = ''    status = e1    return (dd, status)  def delete(self, type, pkcs11id):    if (type == 'obj'):        dd = ''        status = 'delete for type obj use nethod delobject'        return (dd, status)    try:        status = ''        idobj = dict(pkcs11_id=pkcs11id)        dd = pyp11.delete(self.handle, self.slotid, type, idobj)    except:    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = ''    status = e1    return (dd, status)  def listmechs(self):    try:        status = ''        dd = pyp11.listmechs (self.handle, self.slotid)    except:#Не удалось получить список механизмов токена    e = sys.exc_info()[1]    e1 = e.args[0]#Возвращаеи текст ошибки в status    dd = ''    status = e1    return (dd, status)


Рассмотрим импользование функционала модуля pyp11 и аналогичных операторов с использованием класса Token.
В последнем случае необходимо будет создать и объект токена:
<дескриптор объекта> = Token(<дескриптор библиоткети>, <номер слота>, <серийный номер>)if (<дескриптор объекта>.returncode != ''):   print('Ошибка при создании объекта:')#Печать ошибки   print(<дескриптор объекта>.returncode)#Уничтожение объекта   del <дескриптор объекта>   quit()

Начнем с инициализации токена:
try:    ret = pyp11.inittoken (<дескриптор библиоткети>, <номер слота>, <SO-PIN>, <метка токена>)except:#Не удалось проинициализировать токен    e = sys.exc_info()[1]    e1 = e.args[0]    print (e1)    quit()

Аналогичный код при использовании класса Token выглядит так (идентификатор объекта t1):
ret, stat = t1.inittoken(<SO-PIN>, <метка токена>)#Проверка корретности инициализацииif (stat != ''):   print('Ошибка при инициализации токена:')#Печать ошибки   print(stat)   quit()  

Далее мы просто дадим соответствие основных операторов модуля pyp11 и методов класса Token без обработки исключений и ошибок:
<handle> := <дескриптор библиотеки pkcs11><slot> := <дескриптор слота с токеном><error> := <переменная с текстом ошибки><ret> := <результат выполнения оператора><cert_der_hex> := <сертификат в DER-формате в HEX-кодировке>=================================================#Инициализация пользовательского PIN-кода<ret> = pyp11.inituserpin (<handle>, <slot>, <SO-PIN>, <USER-PIN>)<ret>, <error> = <идентификатор объекта>.inituserpin (<SO-PIN>, <USER-PIN>)#Смена USER-PIN кода<ret> = pyp11.setpin (<handle>, <slot>, 'user', <USER-PIN старый>, <USER-PIN новый>)<ret>, <error> = t1.changeuserpin (<USER-PIN старый>, <USER-PIN новый>)#Смена SO-PIN кода<ret> = pyp11.setpin (<handle>, <slot>, 'so', <SO-PIN старый>, <SO-PIN новый>)<ret>, <error> = t1.changesopin (<SO-PIN старый>, <SO-PIN новый>)#Login<ret> = pyp11.login (<handle>, <slot>, <USER-PIN>)<ret>, <error> = t1.login (<USER-PIN>)#Logout<ret> = pyp11.logout (<handle>, <slot>)<ret>, <error> = t1.logout ()#Закрытие сессии<ret> = pyp11.closesession (<handle>)<ret>, <error> = t1.closesession ()#Список сертификатов на токене<ret> = pyp11.listcerts (<handle>, <slot>)<ret>, <error> = t1.listcerts ()#Список объектов на токене<ret> = pyp11.listobjects (<handle>, <slot>, <'cert' | 'pubkey' | 'privkey' | 'data' | 'all'> [, 'value'])<ret>, <error> = t1.listobjects (<'cert' | 'pubkey' | 'privkey' | 'data' | 'all'> [, 'value'])#Разбор сертификата<ret> = pyp11.parsecert (<handle>, <slot>, <cert_der_hex>)<ret>, <error> = t1.parsecert(<cert_der_hex>)#Импорт сертификата<ret> = pyp11.importcert (<handle>, <slot>, <cert_der_hex>, <Метка сертификата>)<ret>, <error> = t1.importcert(<cert_der_hex>, <Метка сертификата>)#Вычисление хэша<ret> = pyp11.digest (<handle>, <slot>, <тип алгоритма>, <контент>)<ret>, <error> = t1.digest(<тип алгоритма>, <контент>)#Вычисление электронной подписи<ret> = pyp11.digest (<handle>, <slot>, <механизм подписи>, <хэш от контента>, <CKA_ID | handle закрытого ключа>)<ret>, <error> = t1.digest(<механизм подписи>, <хэш от контента>, <CKA_ID | handle закрытого ключа>)#Проверка электронной подписи<ret> = pyp11.verify (<handle>, <slot>, <хэш от контента>, <подпись>, <asn1-структура subjectpublickeyinfo в hex>)<ret>, <error> = t1.verify(<хэш от контента>, <подпись>, <asn1-структура subjectpublickeyinfo в hex>)#Генерация ключевой пары<ret> = pyp11.keypair (<handle>, <slot>, <тип ключа>, <OID криптопараметра>, <CKA_LABEL>)<ret>, <error> = t1.keypair(<тип ключа>, <OID криптопараметра>, <CKA_LABEL>)

III. Сборка и установка модуля pyp11 с классом Token


Сборка и установка модуля pyp11 с классом Token ничем не отличается от описанной в первой части.
Итак, скачиваем архив и распаковываем его. Заходим в папку PythonPKCS11 и выполняем команду установки:
python3 setup.py install

После установки модуля переходим в папку tests и запускаем тесты для модуля pyp11.
Для тестирования класса Token переходим в папку test/classtoken.
Для подключения модуля pyp11 и класса Token в скрипты достаточно добавить следующие операторы:
import pyp11from Token import Token


IV. Заключение


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

P.S. Хочу сказать спасибо svyatikov за то, что помог протестировать проект на платформе Windows.
Подробнее..

Поддержка токенов PKCS11 с ГОСТ-криптографией в Python. Часть II Обёртка PyKCS11

26.03.2021 18:19:00 | Автор: admin
image Подошло время рассказать как была добавлена поддержка поддержка российской криптографии в проект PyKCS11. Всё началось с того, что мне на глаза попалась переписка разработчика проекта PyKCS11 с потенциальными потребителями по поводу возможной поддержки алгоритмов ГОСТ Р 34.10-2012 в нём. В этой переписке автор PkCS11 сказал, что не собирается включать поддержку российских криптоалгоритмов до тех пор, пока они не будут стандартизованы.
Ту же самую мысль он выразил и мне, когда я предложил ему это сделать. И не просто сделать, а выслал соответствующий программный код:

image

После этого я посчитал возможным форкнуть код в свой репозиторий и внести в него соответствующие правки. Проект PyKCS11 с поддержкой российской криптографии находится здесь.

I. Добавляем поддержку российских криптоалгоритмов


Итак, что же было сделано. Я фактически последовал одному из советов автора проекта PyKCS11:
What I can propose you is to create a PyKCS11_GOST.py file with the constant names and functions you want in order to extend PyKCS11 with GOST support.
(Я могу предложить вам создать файл PyKCS11_GOST.py с именами констант и функциями, которыми вы хотите расширить PyKCS11 для поддержки ГОСТ.)

Все константы, утвержденные ТК-26 для PKCS#11, были сведены в один файл pkcs11t_gost.h, помещенный в папку src:
//ТК-26#define NSSCK_VENDOR_PKCS11_RU_TEAM 0xd4321000 #define NSSCK_VENDOR_PKSC11_RU_TEAM NSSCK_VENDOR_PKCS11_RU_TEAM#define CK_VENDOR_PKCS11_RU_TEAM_TC26 NSSCK_VENDOR_PKCS11_RU_TEAM#define CKK_GOSTR3410_512 0xd4321003UL#define CKK_KUZNYECHIK 0xd4321004UL#define CKK_MAGMA 0xd4321005UL#define CKK_GOSTR3410_256 0xd4321006UL#define CKP_PKCS5_PBKD2_HMAC_GOSTR3411_TC26_V1 0xd4321801UL#define CKP_PKCS5_PBKD2_HMAC_GOSTR3411_2012_256 0xd4321002UL#define CKP_PKCS5_PBKD2_HMAC_GOSTR3411_2012_512 0xd4321003UL#define CKM_GOSTR3410_512_KEY_PAIR_GEN0xd4321005UL#define CKM_GOSTR3410_5120xd4321006UL#define CKM_GOSTR3410_WITH_GOSTR34110x00001202#define CKM_GOSTR3410_WITH_GOSTR3411_12_2560xd4321008UL#define CKM_GOSTR3410_WITH_GOSTR3411_12_5120xd4321009UL#define CKM_GOSTR3410_12_DERIVE0xd4321007UL#define CKM_GOSR3410_2012_VKO_2560xd4321045UL#define CKM_GOSR3410_2012_VKO_5120xd4321046UL#define CKM_KDF_43570xd4321025UL#define CKM_KDF_GOSTR3411_2012_2560xd4321026UL#define CKM_KDF_TREE_GOSTR3411_2012_2560xd4321044UL#define CKM_GOSTR3410_PUBLIC_KEY_DERIVE0xd432100AUL#define CKM_LISSI_GOSTR3410_PUBLIC_KEY_DERIVE0xd4321037UL#define CKM_GOST_GENERIC_SECRET_KEY_GEN0xd4321049UL#define CKM_GOST_CIPHER_KEY_GEN0xd4321048UL#define CKM_GOST_CIPHER_ECB0xd4321050UL#define CKM_GOST_CIPHER_CBC0xd4321051UL#define CKM_GOST_CIPHER_CTR0xd4321052UL#define CKM_GOST_CIPHER_OFB0xd4321053UL#define CKM_GOST_CIPHER_CFB0xd4321054UL#define CKM_GOST_CIPHER_OMAC0xd4321055UL#define CKM_GOST_CIPHER_KEY_WRAP0xd4321059UL#define CKM_GOST_CIPHER_ACPKM_CTR0xd4321057UL#define CKM_GOST_CIPHER_ACPKM_OMAC0xd4321058UL#define CKM_GOST28147_PKCS8_KEY_WRAP0xd4321036UL#define CKM_GOST_CIPHER_PKCS8_KEY_WRAP0xd432105AUL#define CKM_GOST28147_CNT0xd4321825UL#define CKM_KUZNYECHIK_KEY_GEN0xd4321019UL#define CKM_KUZNYECHIK_ECB0xd432101AUL#define CKM_KUZNYECHIK_CBC0xd432101EUL#define CKM_KUZNYECHIK_CTR0xd432101BUL#define CKM_KUZNYECHIK_OFB0xd432101DUL#define CKM_KUZNYECHIK_CFB0xd432101CUL#define CKM_KUZNYECHIK_OMAC0xd432101FUL#define CKM_KUZNYECHIK_KEY_WRAP0xd4321028UL#define CKM_KUZNYECHIK_ACPKM_CTR0xd4321042UL#define CKM_KUZNYECHIK_ACPKM_OMAC0xd4321043UL#define CKM_MAGMA_KEY_GEN0xd432102AUL#define CKM_MAGMA_ECB0xd4321018UL#define CKM_MAGMA_CBC0xd4321023UL#define CKM_MAGMA_CTR0xd4321020UL#define CKM_MAGMA_OFB0xd4321022UL#define CKM_MAGMA_CFB0xd4321021UL#define CKM_MAGMA_OMAC0xd4321024UL#define CKM_MAGMA_KEY_WRAP0xd4321029UL#define CKM_MAGMA_ACPKM_CTR0xd4321040UL#define CKM_MAGMA_ACPKM_OMAC0xd4321041UL#define CKM_GOSTR3411_12_2560xd4321012UL#define CKM_GOSTR3411_12_5120xd4321013UL#define CKM_GOSTR3411_12_256_HMAC0xd4321014UL#define CKM_GOSTR3411_12_512_HMAC0xd4321015UL#define CKM_PBA_GOSTR3411_WITH_GOSTR3411_HMAC0xd4321035UL#define CKM_TLS_GOST_KEY_AND_MAC_DERIVE0xd4321033UL#define CKM_TLS_GOST_PRE_MASTER_KEY_GEN0xd4321031UL#define CKM_TLS_GOST_MASTER_KEY_DERIVE0xd4321032UL#define CKM_TLS_GOST_PRF0xd4321030UL#define CKM_TLS_GOST_PRF_2012_2560xd4321016UL#define CKM_TLS_GOST_PRF_2012_5120xd4321017UL#define CKM_TLS_TREE_GOSTR3411_2012_2560xd4321047UL

В этот перечень вошли механизмы как необходимые для формирования и проверки подписи по (ГОСТ Р 34.10-2012) ГОСТ Р 34.10-2012, так и шифрования (ГОСТ Р 34.12-2015 и ГОСТ Р 34.13-2015 алгоритмы шифрования Кузнечик и Магма). Естественно, здесь же присутствуют и алгоритмы хэширования ГОСТ Р 34.11-2012.
Для того, чтобы ГОСТ-овые константы попали в процесс сборки модуля, необходимо добавить в файл pkcs11.i (файл для SWIG) оператор включения файла pkcs11t_gost.h
%include "pkcs11t_gost.h"

перед оператором
%include "pkcs11lib.h"

Но это еще не всё. В методе getMechanismList (script PKCS11/__init__.py) заблокирован вывод механизмов чей код больше CKM_VENDOR_DEFINED (именно об этом и пишет автор проекта PyKCS11) (0x80000000L). Заметим, что ГОСТ-овые константы для новых алгоритмов попадают под это ограничение. Необходимо его снять хотя бы для ГОСТ-ов, заменим код метода getMechanismList на новый:
    def getMechanismList(self, slot):        """        C_GetMechanismList        :param slot: slot number returned by :func:`getSlotList`        :type slot: integer        :return: the list of available mechanisms for a slot        :rtype: list        """        mechanismList = PyKCS11.LowLevel.ckintlist()        rv = self.lib.C_GetMechanismList(slot, mechanismList)        if rv != CKR_OK:            raise PyKCS11Error(rv)        m = []#Правки для ГОСТ#define NSSCK_VENDOR_PKCS11_RU_TEAM 0xd4321000         for x in range(len(mechanismList)):            mechanism = mechanismList[x]            if mechanism >= CKM_VENDOR_DEFINED:                if mechanism >= CKM_VENDOR_DEFINED and mechanism < 0xd4321000:                    k = 'CKM_VENDOR_DEFINED_0x%X' % (mechanism - CKM_VENDOR_DEFINED)                    CKM[k] = mechanism                    CKM[mechanism] = k            m.append(CKM[mechanism])        return m#ORIGINAL#        for x in range(len(mechanismList)):#            mechanism = mechanismList[x]#            if mechanism >= CKM_VENDOR_DEFINED:#                k = 'CKM_VENDOR_DEFINED_0x%X' % (mechanism - CKM_VENDOR_DEFINED)#                CKM[k] = mechanism#                CKM[mechanism] = k#            m.append(CKM[mechanism])#        return m


Отметим также, что несмотря на то, что в модуль включены все механизмы, которые определены во включаемых файлах pkcs11t.h и pkcs11t_gost.h для pkcs11 v.2.40, все эти механизмы могут быть выполнены. Проблема состоит в том, что для некоторых из них требуется определенная структура параметров. Это, в частности, относится к механизму CKM_RSA_PKCS_OAEP, которому требуются параметры в виде структуры CK_RSA_PKCS_OAEP_PARAMS, и механизму CKM_PKCS5_PBKD2, который ждет параметров в виде структуры CK_PKCS5_PBKD2_PARAMS. Есть и другие механизмы. Но поскольку автор реализовал отдельные структуры для отдельных механизмов (для того же CKM_RSA_PKCS_OAEP), то не составит труда реализовать поддержку структур параметров и для других механизмов. Так, если кому потребуется работа с контейнером PKCS#12, то придется реализовать поддержку структуры CK_PKCS5_PBKD2_PARAMS.
Всё это относится к довольно сложным криптографическим механизмам.
А вот всё то, что касается хэширования, формирования проверки электронной подписи, наконец, шифрования, то всё работает замечательно. Но для начала надо собрать проект

II. Сборка обертки PyKCS11 с поддержкой ГОСТ-ов


Она ничем не отличается от сборки родной обёртки PkCS11 за исключением того, что исходный код необходимо получить здесь.
Далее следуем инструкции по сборке и установке пакета PyKCS11.
Для тестирования потребуется токен с поддержкой российской криптографии. Здесь мы имеем в виду ГОСТ Р 34.10-2012 и ГОСТ Р 34.11-2012. Это может быть как аппаратный токен, например RuTokenECP-2.0, так и программные или облачные токены.
Установить программный токен или получить доступ к облачному токену можно, воспользовавшись утилитой cryptoarmpkcs.
Скачать утилиту cryptoarmpkcs можно здесь.
Скачать утилиту cryptoarmpkcs можно здесь.

После запуска утилиты необходимо зайти на вкладку Создать токены:

image

На вкладке можно найти инструкции для получения и установки токенов.

II. Тестирование российских алгоритмов

Для тестирования можно использовать скрипты, которые лежат в папке testGost:
  • ckm_kuznyechik_cbc.py
  • ckm_gostr3411_12_256.py
  • ckm_gostr3410_with_gostr3411_12_256.py
  • ckm_gostr3410_512.py

Для тестирования исходные данные брались как из соответствующих ГОСТ-ов, так и из рекомендаций ТК-26.
В данных скриптах тестируются следующие механизмы:
1. Генерация ключевых пар:
  • CKM_GOSTR3410_512_KEY_PAIR_GEN (ГОСТ Р 34.10-2012 с длиной ключа 1024 бита)
  • CKM_GOSTR3410_KEY_PAIR_GEN (ГОСТ Р 34.10-2012 с длиной ключа 512 бит)

2. Формирование и проверка электронной подписи:
  • CKM_GOSTR3410
  • CKM_GOSTR3410_512
  • CKM_GOSTR3410_WITH_GOSTR3411_12_256

3. Хэширования:
  • CKM_GOSTR3411_12_256

4. Шифрование/расшифровка
  • CKM_KUZNYECHIK_CBC


Генерация ключевых пар позволяет владельцу токена получить закрытый ключ, которым он может подписать, например, запрос на сертификат. Запрос на сертификат может быть отправлен в удостоверяющий центр и там по нему могут выдать сертификат. Владелец сертификата может импортировать его на токен, где хранится закрытый ключ. Теперь у владельца токена есть личный сертификат с закрытым ключом, который он может использовать для подписи документы.
Ну а если ему требуется особый режим секретности, то он может зашифровать документ по одному из алгоритмов, а именно Магме или Кузнечику. Всё это конечно в том случае, если сам токен поддерживает эти механизмы, пакет PyKCS11 является всего лишь посредником.
На этом наше повествование, связанное с поддержкой в Python токенов с российской криптографией заканчивается.
Подробнее..

Типобезопасность в JavaScript Flow и TypeScript

16.04.2021 16:20:16 | Автор: admin
Все, кто имеют дело с разработкой UI в кровавом enterprise наверняка слышали о типизированном JavaScript, подразумевая под этим TypeScript от Microsoft. Но кроме этого решения существует как минимум ещё одна распространённая система типизации JS, и тоже от крупного игрока IT-мира. Это flow от Facebook. Из-за личной нелюбви к Microsoft раньше всегда использовал именно flow. Объективно это объяснял хорошей интеграцией с существующими утилитами и простотой перехода.

К сожалению, надо признать, что в 2021 году flow уже значительно проигрывает TypeScript как в популярности, так и в поддержке со стороны самых разных утилит (и библиотек), и пора бы его закопать поставить на полку, и перестать жевать кактус перейти на де-факто стандарт TypeScript. Но под этим хочется на последок сравнить эти технлогии, сказать пару (или не пару) прощальных слов flow от Facebook.

Зачем нужна безопасность типов в JavaScript?


JavaScript это замечательный язык. Нет, не так. Экосистема, построенная вокруг языка JavaScript замечательная. На 2021 год она реально восхищает тем, что вы можете использовать самые современные возможности языка, а потом изменением одной настройки системы сборки транспилировать исполняемый файл для того, чтобы поддержать его выполнение в старых версиях браузеров, в том числе в IE8, не к ночи он будет помянут. Вы можете писать на HTML (имеется ввиду JSX), а потом с помощью утилиты babel (или tsc) заменить все теги на корректные JavaScript-конструкции вроде вызова библиотеки React (или любой другой, но об этом в другом посте).

Чем хорош JavaScript как скриптовый язык, исполняемый в вашем браузере?

  • JavaScript не нужно компилировать. Вы просто добавляете конструкции JavaScript и браузер обязан их понимать. Это сразу даёт кучу удобных и почти бесплатных вещей. Например, отладку прямо в браузере, за работоспособность которой отвечает не программист (который должен не забыть, например, включить кучу отладочных опций компилятора и соответствующие библиотеки), а разработчик браузера. Вам не нужно ждать по 10-30 минут (реальный срок для C/C++), пока ваш проект на 10к строк скомпилируется, чтобы опробовать написать что-то по другому. Вы просто меняете строку, перегружаете страницу браузера и наблюдаете за новым поведением кода. А в случает использования, например, webpack, страницу еще и за вас перезагрузят. Многие браузеры позволяют менять код прямо внутри страницы с помощью своих devtools.
  • Это кросс-платформенный код. В 2021 году уже почти можно забыть о разном поведении разных браузеров. Вы пишете код под Chrome/Firefox, заранее запланировав, например, от 5% (enterprise-код) до 30% (UI/мультимедиа) своего времени, чтобы потом подрихтовать результат под разные браузеры.
  • В языке JavaScript почти не нужно думать о многопоточности, синхронизации и прочих страшных словах. Вам не нужно думать о блокировках потоков потому что у вас один поток (не считая worker'ов). До тех пор, пока ваш код не будет требовать 100% CPU (если вы пишете UI для корпоритавного приложения), то вполне достаточно знать, что код исполняется в одном единственном потоке, а асинхронные вызовы успешно оркестрируются с помощью Promise/async/await/etc.
  • При этом даже не рассматриваю вопрос, почему JavaScript важен. Ведь с помощью JS можно: валидировать формы, обновлять содержимое страницы без перезагрузки её целиком, добавлять нестандартные эффекты поведения, работать с аудио и видео, да можно вообще целиком клиент своего enterprise-приложения написать на JavaScript.

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

Но и, разумеется, это плохо. Потому что сам факт наличия чего-нибудь где-нибудь это плохо. И было бы здорово до того, как код попадёт на сайт, видимый пользователям, проверить все-все скрипты на сайте и убедиться, что они хотя бы компилируются. А в идеале и работают. Для этого используются самые разные наборы утилит (мой любимый набор npm + webpack + babel/tsc + karma + jsdom + mocha + chai).

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

  • Что вы корректно используете синтаксис языка JavaScript. Проверяется, что набранный вами текст может быть понят интерпретатором языка JavaScript, что вы не забыли закрыть открытую фигурную скобку, что строковые лексемы корректно ограничены кавычками и прочее, и прочее. Эту проверку выполняют почти все утилиты сборки/траспилирования/сжатия/обфускации кода.
  • Что семантика языка используется корректно. Можно попытаться проверить, что те инструкции, которые записаны в написанном вами скрипте могут быть корректно поняты интерпретатором. Например, пусть есть следующий код:
    var x = null;x.foo();
    

    Данный код является корректным с точки зрения синтаксиса языка. Но с точки зрения семантики он некорректен попытка вызова метода у null вызовет сообщение об ошибке во время выполнения программы.

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

console.log( input.value ) // 1console.log( input.value + 1 ) // 11

Существующие средства статического анализа кода (eslint, например), может попытаться отследить значительное количество потенциальных ошибок, которые программист допускает в своём коде. Например:


Обратите внимание, что все эти правила по сути представляют собой ограничения, которые линтер накладывает на программиста. То есть линтер фактически уменьшает возможности языка JavaScript, чтобы программист допускал меньше потенциальных ошибок. Если включить все-все правила, то будет нельзя делать присваивания в условиях (хотя JavaScript это изначально разрешает), использовать дубликаты ключей в литералах объектов и даже нельзя будет вызывать console.log().

Добавление типов переменных и проверка вызовов с учётом типов это дополнительные ограничения языка JavaScript, чтобы уменьшить количество потенциальных ошибок.

image
Попытка умножить число на строку

Попытка обратиться к несуществующему (неописанному в типе) свойству объекта
Попытка обратиться к несуществующему (неописанному в типе) свойству объекта

Попытка обратиться к несуществующему (неописанному в типе) свойству объекта
Попытка вызвать функцию с несовпадающим типом аргумента

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

То есть добавление типизации в JavaScript добавляет дополнительные ограничения на код, который пишет программист, но зато позволяет найти такие ошибки, которые в противном случае случились бы во время выполнения скрипта (то есть скорее всего у пользователя в браузере).

Возможности типизации JavaScript


Flow TypeScript
Возможность задать тип переменной, аргумента или тип возвращаемого значения функции
a : number = 5;function foo( bar : string) : void {    /*...*/} 
Возможность описать свой тип объекта (интерфейс)
type MyType {    foo: string,    bar: number}
Ограничение допустимых значений для типа
type Suit = "Diamonds" | "Clubs" | "Hearts" | "Spades";
Отдельный type-level extension для перечислений
enum Direction { Up, Down, Left, Right }
Сложение типов
type MyType = TypeA & TypeB;
Дополнительные типы для сложных случаев
$Keys<T>, $Values<T>, $ReadOnly<T>, $Exact<T>, $Diff<A, B>, $Rest<A, B>, $PropertyType<T, k>, $ElementType<T, K>, $NonMaybeType<T>, $ObjMap<T, F>, $ObjMapi<T, F>, $TupleMap<T, F>, $Call<F, T...>, Class<T>, $Shape<T>, $Exports<T>, $Supertype<T>, $Subtype<T>, Existential Type (*)
Partial<T>, Required<T>, Readonly<T>, Record<K,T>, Pick<T, K>, Omit<T, K>, Exclude<T, U>, Extract<T, U>, NonNullable<T>, Parameters<T>, ConstructorParameters<T>, ReturnType<T>, InstanceType<T>, ThisParameterType<T>, OmitThisParameter<T>, ThisType<T>

Оба движка для поддержки типов в JavaScript обладают примерно одинаковыми возможностями. Однако если вы пришли из языков со сторогой типизацией, даже в типизированном JavaScript есть очень важное отличие от той же Java: все типы по сути описывают интерфейсы, то есть список свойств (и их типы и/или аргументы). И если два интерфейса описывают одинаковые (или совместимые) свойства, то их можно использовать вместо друг-друга. То есть следующий код корректен в типизированным JavaScript, но явно некорректен в Java, или, скажем, C++:

type MyTypeA = { foo: string; bar: number; }type MyTypeB = { foo: string; }function myFunction( arg : MyTypeB ) : string {    return `Hello, ${arg.foo}!`;}const myVar : MyTypeA = { foo: "World", bar: 42 } as MyTypeA;console.log( myFunction( myVar ) ); // "Hello, World!"

Данный код является корректным с точки зрения типизированного JavaScript, так как интерфейс MyTypeB требует наличие свойства foo с типом string, а у переменной с интерфейсом MyTypeA такое свойство есть.

Данный код можно переписать чуть короче, используя литеральный интерфейс для переменной myVar.

type MyTypeB = { foo: string; }function myFunction( arg : MyTypeB ) : string {    return `Hello, ${arg.foo}!`;}const myVar = { foo: "World", bar: 42 };console.log( myFunction( myVar ) ); // "Hello, World!"

Тип переменной myVar в данном примере это литеральный интерфейс { foo: string, bar: number }. Он по прежнему совместим с ожидаемым интерфейсом аргумента arg функции myFunction, поэтому данный код не содержит ошибок с точки зрения, например, TypeScript.

Подобное поведение значительно уменьшает количество проблем при работе с разными библиотеками, пользовательским кодом и даже просто с вызовом функций. Типичный пример, когда некоторая библиотека определяет допустимые опции, а мы передаём их в виде объекта options:

// Где-то внутри библиотекиinterface OptionsType {    optionA?: string;    optionB?: number;}export function libFunction( arg: number, options = {} as OptionsType) { /*...*/ }

// В пользовательском кодеimport {libFunction} from "lib";libFunction( 42, { optionA: "someValue" } );

Обратите внимание, что тип OptionsType не экспортирован из библиотеки (и не импортирован в пользовательский код). Но это не мешает вызывать функцию с использованием литерального интерфейса для второго аргумента options функции, а для системы типизации проверить этот аргумент на совместимость типов. Попытка сделать что-то подобное в Java вызовет у компилятора явное непонимание.

Как это работает с точки зрения браузера?


Ни TypeScript от Microsoft, ни flow от Facebook не поддерживаются браузерами. Как впрочем и самые новые расширения языка JavaScript пока не нашли поддержки в некоторых браузерах. Так как же этот код, во-первых, проверяется на корректность, а во-вторых, как он исполняется браузером?

Ответ траспилирование. Весь нестандартный JavaScript код проходит через набор утилит, которые превращают нестандартный (неизвестный браузерам) код в набор инструкций, который браузеры понимают. Причём для типизации всё превращение заключается в том, что все уточнения типов, все описания интерфейсов, все ограничения из кода просто удаляются. Например, код из примера выше превращается в

/* удалено: type MyTypeA = { foo: string; bar: number; } *//* удалено: type MyTypeB = { foo: string; } */function myFunction( arg /* удалено: : MyTypeB */ ) /* удалено: : string */ {    return `Hello, ${arg.foo}!`;}const myVar /* удалено: : MyTypeA */ = { foo: "World", bar: 42 } /* удалено: as MyTypeA */;console.log( myFunction( myVar ) ); // "Hello, World!"

т.е.
function myFunction( arg ) {    return `Hello, ${arg.foo}!`;}const myVar = { foo: "World", bar: 42 };console.log( myFunction( myVar ) ); // "Hello, World!"


Такое преобразование обычно делается одним из следующих способов.
  • Для удаления информации о типах от flow используется плагин для babel: @babel/plugin-transform-flow-strip-types
  • Для работы с TypeScript можно использовать одно из двух решений. Во-первых можно использовать babel и плагин @babel/plugin-transform-typescript
  • Во-вторых вместо babel можно использовать собственный транспилер от Microsoft под названием tsc. Эта утилита встраивается в процесс сборки приложения вместо babel.


Примеры настроек проекта под flow и под TypeScript (с использованием tsc).
Flow TypeScript
webpack.config.js
{  test: /\.js$/,  include: /src/,  exclude: /node_modules/,  loader: 'babel-loader',},
{  test: /\.(js|ts|tsx)$/,  exclude: /node_modules/,  include: /src/,  loader: 'ts-loader',},
Настройки транспилера
babel.config.js tsconfig.json
module.exports = function( api ) {  return {    presets: [      '@babel/preset-flow',      '@babel/preset-env',      '@babel/preset-react',    ],  };};
{  "compilerOptions": {    "allowSyntheticDefaultImports": true,    "esModuleInterop": false,    "jsx": "react",    "lib": ["dom", "es5", "es6"],    "module": "es2020",    "moduleResolution": "node",    "noImplicitAny": false,    "outDir": "./dist/static",    "target": "es6"  },  "include": ["src/**/*.ts*"],  "exclude": ["node_modules", "**/*.spec.ts"]}
.flowconfig
[ignore]<PROJECT_ROOT>/dist/.*<PROJECT_ROOT>/test/.*[lints]untyped-import=offunclear-type=off[options]

Разница между подходами babel+strip и tsc с точки зрения сборки небольшая. В первом случае используется babel, во-втором будет tsc.


Но есть разница, если используется такая утилита как eslint. У TypeScript для линтинга с помощью eslint есть свой набор плагинов, которые позволяют найти ещё больше ошибок. Но они требуют, чтобы в момент анализа линтером у него была информация о типах переменных. Для этого в качестве парсера кода необходимо использовать только tsc, а не babel. Но если для линтера используется tsc, то использовать для сборки babel будет уже неправильно (зоопарк используемых утилит должен быть минимальным!).


Flow TypeScript
.eslint.js
module.exports = {  parser: 'babel-eslint',  parserOptions: {    /* ... */
module.exports = {  parser: '@typescript-eslint/parser',  parserOptions: {    /* ... */

Сравнение flow и TypeScript


Попытка сравнить flow и TypeScript. Отдельные факты собраны из статьи Nathan Sebhastian TypeScript VS Flow, часть собрана самостоятельна.

Нативная поддержка в различных фреймофорках. Нативная без дополнительным подходом с паяльником и сторонними библиотеками и плагинами.

Различные линейки
Flow TypeScript
Основной contributor Facebook Microsoft
Сайт flow.org www.typescriptlang.org
GitHub github.com/facebook/flow github.com/microsoft/TypeScript
GitHub Starts 21.3k 70.1k
GitHub Forks 1.8k 9.2k
StackOverflow frequent 2289 49 353
StackOverflow unanswered 123 11 451

Смотря на эти цифры у меня просто не остаётся морального права рекомендовать flow к использованию. Но почему же я использовал его сам? Потому что раньше была такая штука, как flow-runtime.

flow-runtime


flow-runtime это набор плагинов для babel, который позволяет встроить типы от flow в runtime, использовать их для определения типов переменных в runtime, и, самое главное для меня, позволяло проврять типы переменных в runtime. Что позволяло в runtime во время, например, автотестов или ручного тестирования отловить дополнительные баги в приложении.

То есть прямо во время выполнения (в debug-сборке, разумеется), приложение явно проверяло все-все типы переменных, аргументов, результаты вызова сторонних функций, и всё-всё-всё, на соответствие тех типов.

К сожалению, под новый 2021 год автор репозитория добавил информацию о том, что он перестаёт заниматься развитием данного проекта и в целом переходит на TypeScript. Фактически последняя причина оставаться на flow для меня стала deprecated. Ну что же, добро пожаловать в TypeScript.
Подробнее..

Vue.js и слоистая архитектура вынесение бизнес-логики в сервисы

10.05.2021 12:10:00 | Автор: admin

Когда нужно сделать код в проекте гибким и удобным, на помощь приходит разделение архитектуры на несколько слоев. Рассмотрим подробнее этот подход и альтернативы, а также поделимся рекомендациями, которые могут быть полезны как начинающим, так и опытным разработчикам Vue.js, React.js, Angular.

В старые времена, когда JQuery только появился, а о фреймворках для серверных языков лишь читали в редких новостях, веб-приложения реализовывали целиком на серверных языках. Зачастую для этого использовали модель MVC (Model-View-Controller): контроллер (controller) принимал запросы, отвечал за бизнес-логику и модели (model) и передавал данные в представление (view), которое рисовало HTML.

Объектно-ориентированное программирование (ООП) на тот момент только начинало формироваться, поэтому разработчики зачастую интуитивно решали, где и какой код надо писать. Таким образом, в мире разработки зародилось такое понятие, как Божественные объекты, которые первоначально отвечали практически за всю работу отдельных частей системы. Например, если в системе была сущность Пользователь, то создавался класс User и в нем писалась вся логика, так или иначе связанная с пользователями. Без разбиения на какие-то ещё файлы. И если приложение было большим, то такой класс мог содержать тысячи строк кода.

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

1. Выход есть

Как известно, Vue.js, React.js и прочие подобные фреймворки основаны на компонентах. То есть, по большому счету, приложение состоит из множества компонентов, которые могут заключать в себе и бизнес-логику и представление и много чего еще. Таким образом, разработчики во многих проектах пишут всю логику в компонентах и эти компоненты, как правило, начинают напоминать те самые божественные классы из прошлого. То есть, если компонент описывает какую-то крупную часть функционала с большим количеством (возможно сложной) логики, то вся эта логика и остается в компоненте. Появляются десятки методов и тысячи строк кода. А если учесть то, что, например, во Vue.js еще есть такие понятия как computed, watch, mounted, created, то логику пишут еще и во все эти части компонента. В итоге, чтобы найти какую-то часть кода, отвечающую за клик по кнопке, надо перелистать десяток экранов js-кода, бегая между methods, computed и прочими частями компонента.

Примерно в 2008 году, применительно к backend, была предложена слоистая архитектура. Основная идея этой архитектуры заключается в том, что весь код приложения следует разбивать на определенные слои, которые выполняют определенную работу и не очень знают о других слоях.

С подобным разбиением приложение становится намного проще поддерживать, писать тесты, искать ответственные зоны и вообще читать код.

Вот о таком разбиении кода на слои и пойдет речь, но уже применительно к frontend-фреймворкам, таким как Vue.js, React.js и прочим.

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

2. Создание удобной архитектуры приложения

Рассмотрим пример, в котором вся логика находится в одном компоненте.

2.1. Логика в компоненте

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

methods: {    duplicateCollage (collage) {      this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: true })      dataService.duplicateCollage(collage, false)        .then(duplicate => {          this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })        })        .catch(() => {          this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })          this.$store.dispatch('errorsSet', { api: `We couldn't duplicate collage. Please, try again later.` })        })    },    deleteCollage (collage, index) {      this.$store.dispatch('updateCollage', { id: collage.id, isDeleting: true })      photosApi.deleteUserCollage(collage)        .then(() => {          this.$store.dispatch('updateCollage', {            id: collage.id,            isDeleting: false,            isDeleted: true          })          this.$store.dispatch('setUserCollages', { total: this.userCollages.total - 1 })          this.$store.dispatch('updateCollage', {            id: collage.id,            deletingTimer: setTimeout(() => {              this.$store.dispatch('updateCollage', { id: collage.id, deletingTimer: null })              this.$store.dispatch('setUserCollages', { items: this.userCollages.items.filter(userCollage => userCollage.id !== collage.id) })               // If there is no one collages left - show templates              if (!this.$store.state.editor.userCollages.total) {                this.currentTabName = this.TAB_TEMPLATES              }            }, 3000)          })        })    },    restoreCollage (collage) {      clearTimeout(collage.deletingTimer)      photosApi.saveUserCollage({ collage: { deleted: false } }, collage.id)        .then(() => {          this.$store.dispatch('updateCollage', {            id: collage.id,            deletingTimer: null,            isDeleted: false          })          this.$store.dispatch('setUserCollages', { total: this.userCollages.total + 1 })        })    }}

2.2. Создание слоя сервисов для бизнес-логики

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

Один из классических способов хоть какого-то разбиения логики это деление на сущности. Например, почти всегда в проекте есть сущность Пользователь или, как в описываемом примере, Коллаж. Таким образом, можно создать папку services и в ней файлы user.js и collage.js. Такие файлы могут быть статическими классами или просто возвращать функции. Главное чтобы вся бизнес-логика, связанная с сущностью, была в этом файле.

services  |_collage.js  |_user.js

В сервис collage.js следует поместить логику дублирования, восстановления и удаления коллажей.

export default class Collage {  static delete (collage) {    // ЛОГИКА УДАЛЕНИЯ КОЛЛАЖА  }   static restore (collage) {    // ЛОГИКА ВОССТАНОВЛЕНИЯ  КОЛЛАЖА  }   static duplicate (collage, changeUrl = true) {    // ЛОГИКА ДУБЛИРОВАНИЯ КОЛЛАЖА  }}

2.3. Использование сервисов в компоненте

Тогда в компоненте надо будет лишь вызвать соответствующие функции сервиса.

methods: {  duplicateCollage (collage) {    CollageService.duplicate(collage, false)  },  deleteCollage (collage) {    CollageService.delete(collage)  },  restoreCollage (collage) {    CollageService.restore(collage)  }}

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

Также многие разработчики на пути к удобной архитектуре выносят вызовы методов API в отдельный файл (файлы). Это как раз создание слоя вызовов API, которое также приводит к удобству и структурированности кода.

import axios from '@/plugins/axios' export default class Api {   static login (email, password) {    return axios.post('auth/login', { email, password })      .then(response => response.data)  }   static logout () {    return axios.post('auth/logout')  }   static getCollages () {    return axios.get('/collages')      .then(response => response.data)  }    static deleteCollage (collage) {    return axios.delete(`/collage/${collage.id}`)      .then(response => response.data)  }    static createCollage (collage) {    return axios.post(`/collage/${collage.id}`)      .then(response => response.data)  }}

3. Что и куда выносить?

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

Бизнес-логика это все то, что описано в требованиях к приложению. Например, ТЗ, документации, дизайны. То есть все то, что напрямую относится к предметной области приложения. Примером может быть метод UserService.login() или ListService.sort(). Для бизнес-логики можно создать сервисный слой с сервисами.

Логика это тот код, который не имеет прямого отношения к предметной области приложения и его бизнес-логике. Например, создание уникальной строки или поиск некоего объекта в массиве. Для логики можно создать слой хэлперов: например, папку helpers и в ней файлы string.js, converter.js и прочие.

Представление все то, что непосредственно связано с компонентом и его шаблоном. Например, изменение реактивных свойств, изменение состояний и прочее. Этот код пишется непосредственно в компонентах (methods, computed, watch и так далее).

login (email, password) {  this.isLoading = true  userService.login(email, password)    .then(user => {      this.user = user      this.isLoading = false    })}

Далее в компонентах надо будет вызывать сервисы, а сервисы будут использовать хэлперы. Такой подход предоставит нам легкие, маленькие и простые компоненты, а вся логика будет находиться в логически понятных файлах.

Если же сервисы или хэлперы начнут разрастаться, то сущности всегда можно разделить на другие сущности. К примеру, если у пользователя в приложении маленький функционал в 3-5 методов и пара методов про заказы пользователя, то разработчик может вынести всю эту бизнес-логику в сервис user.jsрешить всю эту бизнес-логику написать в сервисе user.js. Если же у сервиса пользователя сотни строк кода, то можно все, что относится к заказам, вынести в сервис order.js.

4. От простого к сложному

В идеале можно сделать архитектуру на ООП, в которой будут, помимо сервисов, еще и модели. Это классы, описывающие сущности приложения. Те же User или Collage. Но использоваться они будут вместо обычных объектов данных.

Рассмотрим список пользователей.

Классический способ вывода ФИО пользователей выглядит так.

<template><div class="users">  <div    v-for="user in users"    class="user"  >    {{ getUserFio(user) }}  </div></div></template> <script>import axios from '@/plugins/axios' export default {  data () {    return {      users: []    }  },  mounted () {    this.getList()  },  methods: {    getList() {      axios.get('/users')        .then(response => this.users = response.data)    },    getUserFio (user) {      return `${user.last_name} ${user.first_name} ${user.third_name}`    }  }}</script>

Функцию получения ФИО можно вынести для того, чтобы легко и просто переиспользовать при необходимости.

Для начала следует создать модель Пользователь.

export default class User {  constructor (data = {}) {    this.firstName = data.first_name    this.secondName = data.second_name    this.thirdName = data.third_name  }   getFio () {    return `${this.firstName} ${this.secondName} ${this.thirdName}`  }}

Далее следует импортировать эту модель в компонент.

import UserModel from '@/models/user'

С помощью сервиса получить список пользователей и преобразовать каждый объект в массиве в объект класса (модели) User.

methods: {   getList() {     const users = userService.getList()     users.forEach(user => {       this.users.push(new UserModel(user))     })   },

Таким, образом, в шаблоне или в методах не надо будет создавать какие-то отдельные функции для работы с объектом пользователя, они будут уже внутри этого объекта.

<template><div class="users">  <div    v-for="user in users"    class="user"  >    {{ user.getFio() }}  </div></div></template>

К вопросу о том, какую логику выносить в модели, а какую в сервисы. Можно всю логику поместить в сервисы, а в моделях вызывать сервисы. А можно в моделях хранить логику, относящуюся непосредственно к сущности модели (тот же getFio()), а логику работы с массивами сущностей хранить в сервисах (тот же getList()). Как будет удобнее.

5. Заключение

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

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

Спасибо за внимание! Будем рады ответить на ваши вопросы.

Подробнее..

Антипаттерн Ёлочка

02.05.2021 10:10:12 | Автор: admin

Антипаттерны, или просто примеры неэффективного кода, могут быть очень коварными засланцами, даже в исходном тексте бывалых разработчиков. Иногда это следствие лени или очень сжатых сроков, иногда подводит невнимательность.

В этой статье я расскажу вам об антипаттерне проектирования Ёлочка или о том, как не превратить свой код в дрова.

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

Рождение проблемы

Представим, что существует несложный компонент, отображающий некое число Counter

const Counter = ({ value }) => {return <div>{ value }</div>;};

Counter не следует воспринимать буквально. Это просто упрощённая модель для контрастного выражения сути проблемы.

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

const Counter = ({ value, digits }) => {return <div>{ `${value} ${digits}` }</div>};

В процессе разработки оказалось, что иногда есть необходимость брать единицы измерения в скобки, например вот так: 10 (см). Мы решаем это сделать внутри компонента, для удобства:

const Counter = ({ value, digits, type }) => {const temp = type ? `(${digits})` : digits;return <div>{ `${value} ${temp}` }</div>};

Пожалуй, достаточно. Будем считать, что Counter достаточно функциональный компонент и пригоден к использованию в том виде, в котором сейчас существует. Теперь рассмотрим его с точки зрения алгоритмизации. Представим, что входные параметры этого компонента a, b и c, а y - это некая функция вида f(a, b, c), решение которой будет символизировать обработку всех возможных состояний и отрисовку компонента.

Из реализации Counter видно, что как минимум два аргумента (digits и type) взаимозависимы, т. е. всё множество вариантов их обработки имеет решение a * b, соответствеено. Итоговая формула, в этом случае, будет выглядеть так:

y = a * b + c

Выглядит не так и плохо, ведь digits это однотипная константа, как и value, а это значит, что аргумент a, можно принять за единицу. В итоге мы смогли показать, что общее количество обрабатываемых состояний растёт линейно, т. е. только вместе с type.

y = b + 1

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

При попытке усложнить условие, например, на основании digits выбирать тип скобок, наша функция проявит себя резким скачком своего значения, а это означает, что и содержание компонента Counter будет резко усложняться. Код растёт, как ёлочка...

Где ошибка?

Очевидно, что ошибка возникла когда внутри Counter появились взаимосвязанные аргументы. Даже, если бы мы хотели сохранить новый функционал, локализировав его в отдельный метод, мы бы смогли уменьшить негативное влияние на всё приложение в целом. Скажем так:

const getCoveredDigits = (digits) => `(${digits})`;<Counter  value={value}  digits={getCoveredDigits(digits)}/>

В итоге, записать функцию обработки состояний можно следующим образом:

y = a + b, b = f(x)

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

Выводы

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

Подробнее..

Простой вариант разношерстного recycler view на шаблоне Посетитель

07.04.2021 16:22:59 | Автор: admin

Прошло пол года, как я с паскаля перекатился на kotlin и влюбился в android-разработку, и вот уже разрешаю себе публично лезть со своими идеями в чужой монастырь. Но причина на то есть. Понаблюдав в профильных чатах за тем, какие чаще всего возникают вопросы у android-разработчиков, и не только у новичков, я понял, что в большинстве случаев, когда человек сталкивается с ошибкой, которую не может понять, как не может понять объяснение коллег из чата или их наводящие вопросы, причиной является бездумное использование готовых кусков кода или библиотек. Однако, полагаясь на готовые примеры кода, которые у них не работают (а в этой сфере код, написанный больше года назад, по умолчанию требует обновления или вообще переработки, и это касается кода со stack overflow, библиотечных гайдов и даже гайдов от самого Google), они не понимают причин возникающих ошибок или же отличающегося поведения, поскольку полагаются на библиотеку как китайскую комнату, не пытаясь разобраться в её архитектуре и принципах работы.

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


Изучая архитектурные шаблоны android-разработки, я приучил себя в первую очередь искать ответы на сервере Google developer guides. Но иногда там, особенно в обучающих codelabs, приводятся примеры кода больше упрощенного, чем рассчитанного на универсальность, чистоту и расширяемость.

В данном случае у меня возникла потребность использовать модный recycler view для отображения списка элементов с разной внутренней разметкой и логикой. На такой идее строятся все современные приложения - от мессенджеров и лент социальных сетей до банковских приложений. К тому же комбинирование на лету с использованием реактивного подхода разных визуальных элементов списка recycler view вместо ручной верстки разметки является мостиком в мир декларативно-функционального ui, который нам предлагают в Jetpack Compose, и на который рано или поздно Google мягко предложит переходить.

Codelab, посвященный включению в список recycler view элемента с другой разметкой, строится на оборачивании элемента списка внутрь sealed класса. Но это не главный недостаток. Главный, на мой взгляд,- помещение всего кода, обрабатывающего разные элементы списка, внутрь класса самого адаптера. Это заставит в будущем расти код адаптера как снежный ком, нарушая как принцип открытости/закрытости, так и принцип единственной ответственности (при желании, можно найти нарушения каждой буквы из акронима SOLID, но поэтому их и объединили).

Другим существенным минусом является то, что Google предложил вынести свойство id из data-классов в качестве дискриминатора двух типов элементов: для заголовка id будет равен Long.MIN_VALUE, а для данных id будет транзитом переходить из data-класса. И здесь полностью закрыта щелочка для дальнейшего расширения: у вас или data-класс, который для адаптера будет всегда одинаков, или заголовок. Вся архитектура могучего recycler view мгновенно сжалась до всего лишь двух вариантов.

Решением проблемы можно считать использование готовых библиотек. Я из самых актуальных и наиболее распространенных нашел adapter delegates, groupie и epoxy. По ним по всем написаны многие статьи как здесь, так и там. Самый базовый подход, который я собираюсь сейчас изложить, наиболее близко воплощен в первой библиотеке. Группи и эпокси гораздо мощнее, универсальнее, но при этом сложнее внутри и станут сложнее снаружи, если вдруг разработчику захочется использовать всю их мощь.

Любая библиотека всегда таит в себе две беды:

  • вам бывает лень разбираться в ее устройстве, в итоге вы не осознаете, что используете библиотеку всего лишь на 10%, прямо как мозг у некоторых млекопитающих;

  • вы зависите от библиотечных классов: вам или надо наследовать ваши данные от них, или еще как-то ломать свои представления об идеальных data-классах и их движении.

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

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

Так вот, в самом широком смысле в этих библиотеках применяется паттерн Посетитель с менеджером.

Адаптер recycler view, то есть обычный ListAdapter, уже имеет все необходимые методы, которые подталкивают к использованию Посетителя:

  • getItemType - функция, которая должна возвращать тип элемента (поскольку тип в данном случае всего лишь целое число, Google рекомендует использовать более понятные константы);

  • onCreateViewHolder - функция, которая возвращает ViewHolder такого класса, который реализован для требуемого типа элемента (тип передается в функцию параметром с помощью предыдущей функции);

  • onBindViewHolder - функция, которая осуществляет привязку конкретного элемента в списке (передается по номеру) и конкретного ViewHolder, который создан и возвращен предыдущей функцией.

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

Если вы реализуете стандартный шаблон DiffCallback,
class BaseDiffCallback : DiffUtil.ItemCallback<HasStringId>() {    override fun areItemsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem.id == newItem.id    override fun areContentsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem == newItem}

то всегда помните, что areContentsTheSame вызывается только тогда, когда areItemsTheSame возвращает true. В моем примере классы реализуют интерфейс HasStringId, в котором есть id типа String и метод equals, что позволяет использовать data-классы как для моделей данных, так и для моделей слоя view. Data-классы с данными из сети всегда имеют уникальный id, поэтому их отличие DiffUtil определяет максимально быстро, а для вспомогательных ui-классов с одинаковыми id вызываются оба метода.

Чтобы управлять набором посетителей, введем такое понятие, как менеджер. Это будет класс, реализующий следующий интерфейс:

interface ViewHoldersManager {    fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor)    fun getItemType(item: Any): Int    fun getViewHolder(itemType: Int): ViewHolderVisitor}

И определим для целей тестового примера набор типов для recycler view:

object ItemTypes {    const val UNKNOWN = -1    const val HEADER = 0    const val TWO_STRINGS = 1    const val ONE_LINE_STRINGS = 2    const val CARD = 3}

Собственно менеджер и будет тем "делегатом" в терминологии adapter delegates, которому адаптер делегирует функции по определению типа для отрисовки текущего элемента. Для этого в менеджере должны быть зарегистрированы все необходимые классы вью холдеров.

Я буду использовать hilt и data binding, поскольку с их помощью очень легко решить второстепенные задачи: внедрить менеджера вью холдеров и упростить отображение данных на ui. Бонусом будет точное знание того места, где именно и в какой момент инициализируется менеджер вью холдеров, а также какие вью холдеры регистрируются в нем:

@Module@InstallIn(FragmentComponent::class)object DiModule {    @Provides    @FragmentScoped    fun provideAdaptersManager(): ViewHoldersManager = ViewHoldersManagerImpl().apply {        registerViewHolder(ItemTypes.HEADER, HeaderViewHolder())        registerViewHolder(ItemTypes.ONE_LINE_STRINGS, OneLine2ViewHolder())        registerViewHolder(ItemTypes.TWO_STRINGS, TwoStringsViewHolder())        registerViewHolder(ItemTypes.CARD, CardViewHolder())    }}

Добавим верстку для всех элементов:

Сard item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable name="card" type="ru.alexmaryin.recycleronvisitor.data.ui_models.CardItem" />    </data>    <androidx.cardview.widget.CardView xmlns:card_view="http://personeltest.ru/away/schemas.android.com/apk/res-auto"        android:id="@+id/card_view"        android:layout_width="match_parent"        android:layout_height="200dp"        android:layout_margin="8dp"        card_view:cardBackgroundColor="@color/cardview_shadow_end_color"        card_view:cardCornerRadius="15dp">        <ImageView            android:id="@+id/card_background_image"            android:layout_width="match_parent"            android:layout_height="match_parent"            android:layout_gravity="center"            android:scaleType="centerCrop"            tools:ignore="ContentDescription"            tools:src="@android:mipmap/sym_def_app_icon" />        <LinearLayout            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:layout_gravity="bottom"            android:background="@android:drawable/screen_background_dark_transparent"            android:orientation="vertical"            android:padding="16dp">            <TextView                android:id="@+id/card_title"                android:layout_width="match_parent"                android:layout_height="wrap_content"                android:ellipsize="end"                android:maxLines="1"                android:paddingTop="8dp"                android:paddingBottom="8dp"                android:textAllCaps="true"                android:textColor="#FFFFFF"                android:textStyle="bold"                tools:text="Cart title"                android:text="@{card.title}"/>            <TextView                android:id="@+id/txt_discription"                android:layout_width="match_parent"                android:layout_height="wrap_content"                android:ellipsize="end"                android:maxLines="2"                android:textColor="#FFFFFF"                tools:text="this is a simple discription with losts of text lorem ipsum dolor sit amet,            consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."                android:text="@{card.description}"/>        </LinearLayout>    </androidx.cardview.widget.CardView></layout>
One line item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.OneLineItem2" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content">        <TextView            android:id="@+id/text1"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:paddingStart="8dp"            android:text="@{model.left}"            android:textAlignment="textEnd"            android:textAppearance="?attr/textAppearanceListItem"            android:textColor="@color/cardview_dark_background"            app:layout_constraintEnd_toStartOf="@+id/divider"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:ignore="RtlSymmetry,TextContrastCheck"            tools:text="Left text" />        <ImageView            android:id="@+id/divider"            android:layout_width="wrap_content"            android:layout_height="wrap_content"            android:alpha="0.6"            android:padding="5dp"            android:scaleType="center"            android:scaleX="0.5"            android:scaleY="0.9"            android:src="@drawable/ic_outline_waves_24"            android:visibility="visible"            app:layout_constraintBottom_toBottomOf="@+id/text1"            app:layout_constraintEnd_toStartOf="@+id/text2"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toEndOf="@+id/text1"            app:layout_constraintTop_toTopOf="@+id/text1"            app:srcCompat="@drawable/ic_outline_waves_24"            tools:ignore="ContentDescription"            tools:visibility="visible" />        <TextView            android:id="@id/text2"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:paddingEnd="8dp"            android:text="@{model.right}"            android:textAppearance="?attr/textAppearanceListItem"            app:layout_constraintBottom_toBottomOf="@+id/divider"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toEndOf="@+id/divider"            app:layout_constraintTop_toTopOf="@+id/divider"            tools:ignore="RtlSymmetry"            tools:text="Right text" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>
Two line item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto">    <data>        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.TwoStringsItem" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:minHeight="?attr/listPreferredItemHeight"        android:mode="twoLine"        android:paddingStart="?attr/listPreferredItemPaddingStart"        android:paddingEnd="?attr/listPreferredItemPaddingEnd">        <TextView            android:id="@+id/text1"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:layout_marginTop="8dp"            android:text="@{model.caption}"            app:layout_constraintTop_toTopOf="parent"            app:layout_constraintStart_toStartOf="parent"            android:textAppearance="?attr/textAppearanceListItem" />        <TextView            android:id="@id/text2"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:text="@{model.details}"            app:layout_constraintTop_toBottomOf="@id/text1"            app:layout_constraintStart_toStartOf="parent"            android:textAppearance="?attr/textAppearanceListItemSecondary" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>
Header item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">    <data>        <variable            name="headerItem"            type="ru.alexmaryin.recycleronvisitor.data.ui_models.RecyclerHeader" />    </data>    <TextView        style="@style/regularText"        android:id="@+id/header"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="#591976D2"        android:textAlignment="center"        android:textStyle="italic"        android:text="@{headerItem.text}"/></layout>

Вью холдер будет классом, реализующим простой интерфейс Посетителя:

interface ViewHolderVisitor {    val layout: Int    fun acceptBinding(item: Any): Boolean    fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById)}

Здесь два стандартных для Посетителя метода (классически они называются acceptVisitor и execute, однако мы же пишем не абстрактного посетителя для реализации паттерна в вакууме, а весьма конкретное его приложение) - acceptBinding и bind, а также свойство layout, в которое конкретные вью холдеры будут записывать ссылку на ресурс своей разметки.

Роль функции accept заключается в следующем: когда адаптер просит (или требует) от менеджера выдать ему соответствие типа элемента тому объекту, который должен быть отрисован, менеджер пробегается по всем зарегистрировавшимся вью холдерам, вызывая их метод accept, и выдает тип первого, который вернет true. Таким образом, ни адаптер, ни менеджер не знают ничего о внутреннем устройстве и даже классе самих элементов, что позволяет бесконечно увеличивать их количество и изменять как угодно. Единственное требование - зарегистрировавшийся вью холдер должен честно признаваться (accept = true), что готов забиндить какой-то элемент, тип которого он указал при регистрации.

Реализация как менеджера, так и конкретных вью холдеров будет настолько короткой, насколько это возможно. Практически везде однострочные функции:

class ViewHoldersManagerImpl : ViewHoldersManager {    private val holdersMap = emptyMap<Int, ViewHolderVisitor>().toMutableMap()    override fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor) {        holdersMap += itemType to viewHolder    }    override fun getItemType(item: Any): Int {        holdersMap.forEach { (itemType, holder) ->             if(holder.acceptBinding(item)) return itemType        }        return ItemTypes.UNKNOWN    }    override fun getViewHolder(itemType: Int) = holdersMap[itemType] ?: throw TypeCastException("Unknown recycler item type!")}

И для примера вью холдер карточки (другие реализации очевидны и практически аналогичны):

class CardViewHolder : ViewHolderVisitor {      override val layout: Int = R.layout.card_item    override fun acceptBinding(item: Any): Boolean = item is CardItem    override fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById) {        with(binding as CardItemBinding) {            card = item as CardItem            Picasso.get().load(item.image).into(cardBackgroundImage)        }    }}

Не стоит бояться операторов явного приведения типов as в коде. Во-первых, реализуя интерфейс, вы заключаете определенный контракт: если функция accept согласна с тем, что Посетитель работает с элементами класса CardItem, в метод bind совершенно точно будет передан объект только этого класса и никакого другого. Это же касается и разметки: если вы однозначно определяете имя ресурса разметки в свойстве layout, именно для этой разметки в свойство binding будет передаваться сгенерированный data binding класс. Ну и во-вторых, если бы это не было безопасно, разве линтер idea или android studio не разразились ли громкими воплями на всю сборку?

Что касается самого главного, адаптера для recycler view,- он единственный и универсальный, поскольку все функции по различению разных элементов делегирует менеджеру, а тот в свою очередь выбирает одного единственного холдера, согласного посетить и прибиндить намертво элемент к экрану смартфона:

class BaseListAdapter(    private val clickListener: AdapterClickListenerById,    private val viewHoldersManager: ViewHoldersManager) : ListAdapter<HasStringId, BaseListAdapter.DataViewHolder>(BaseDiffCallback()) {    inner class DataViewHolder(        private val binding: ViewDataBinding,        private val holder: ViewHolderVisitor    ) : RecyclerView.ViewHolder(binding.root) {        fun bind(item: HasStringId, clickListener: AdapterClickListenerById) =            holder.bind(binding, item, clickListener)    }    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder =        LayoutInflater.from(parent.context).run {            val holder = viewHoldersManager.getViewHolder(viewType)            DataViewHolder(DataBindingUtil.inflate(this, holder.layout, parent, false), holder)        }    override fun onBindViewHolder(holder: DataViewHolder, position: Int) = holder.bind(getItem(position), clickListener)    override fun getItemViewType(position: Int): Int = viewHoldersManager.getItemType(getItem(position))}

Сам адаптер создается и наполняется элементами уже в слое view, в данном случае во фрагменте таким нехитрым способом:

// где-то выше во фрагменте:// private val viewModel: MainViewModel by viewModels()// private lateinit var recycler: RecyclerView// @Inject lateinit var viewHoldersManager: ViewHoldersManager// private val items = mutableListOf<HasStringId>()override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        recycler = requireActivity().findViewById(R.id.recycller)        val itemsAdapter = BaseListAdapter(AdapterClickListenerById {}, viewHoldersManager)        itemsAdapter.submitList(items)        recycler.apply {            layoutManager = LinearLayoutManager(requireContext())            addItemDecoration(DividerItemDecoration(requireContext(), (layoutManager as LinearLayoutManager).orientation))            adapter = itemsAdapter        }        populateRecycler()    }private fun populateRecycler() {     lifecycleScope.launch {        viewModel.getItems().flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)           .collect { items.add(it) }     }   }

Благодаря одному "лишнему" классу менеджера вью холдеров и паре интерфейсов мы получили простой, но очень функциональный recycler view с возможностью безболезненного расширения. К тому же в противовес библиотекам у нас остались следующие преимущества:

  • нет необходимости наследовать классы данных от библиотечных супер-классов;

  • нет необходимости оборачивать разные элементы в sealed класс;

  • один и тот же data-класс можно использовать для сериализации/десериализации данных из интернета, сохранения в локальной базе и в качестве модели для view или data биндинга;

  • изменения классов данных и вью-холдеров не влекут изменений кода адаптера;

  • все внутренности элементов инкапсулированы от адаптера, законы SOLID выполняются;

  • отсутствует избыточная функциональность, неизбежно приносимая библиотеками (YAGNI).

Разумеется, моя реализация еще имеет пути для улучшения и расширения. Можно, как в groupie добавить группировку элементов и их визуальное сворачивание. Можно отказаться от data binding или дополнить адаптер вариантами для view binding или обычного инфлейта разметки со всеми любимыми findViewById во вью холдерах. И тогда код превратится в ту же самую библиотеку, которых уже вон сколько и так. Для моих же конкретных целей на тот момент, когда возникла необходимость, варианта с простым Посетителем более, чем достаточно:

Прошу не судить строго, поскольку в мире android это мое первое появление на свет. Полноценный код примера из текста статьи будет доступен в репозитории github.

Подробнее..

Перевод Руководство по работе с фреймворком Kotlin Exposed

29.04.2021 20:16:38 | Автор: admin

Перевод подготовлен в рамках набора учащихся на курс "Kotlin Backend Developer".

Также приглашаем всех желающих на демо-урок Объектно-ориентированное программирование в Kotlin. Цели занятия:
- узнать про элементы объектной модели Kotlin;
- создавать различные классы и объекты;
- выполнять наследование и делегирование;
- пользоваться геттерами и сеттерами.


1. Введение

В этой статье мы рассмотрим, как направлять запросы к реляционной базе данных с помощью Exposed.

Exposed это открытая библиотека, разработанная компанией JetBrains. Она распространяется по лицензии Apache и позволяет использовать идиоматический API Kotlin для реализации некоторых реляционных баз данных от различных поставщиков.

Exposed можно использовать как в качестве высокоуровневого языка DSL в SQL, так и в качестве облегченной технологии ORM (объектно-реляционного отображения). В этом руководстве мы рассмотрим оба варианта использования.

2. Установка

Фреймворк Exposed еще не опубликован в Maven Central, потому нам придется использовать отдельный репозиторий:

<repositories>    <repository>        <id>exposed</id>        <name>exposed</name>        <url>https://dl.bintray.com/kotlin/exposed</url>    </repository></repositories>

Теперь можно подключить библиотеку:

<dependency>    <groupId>org.jetbrains.exposed</groupId>    <artifactId>exposed</artifactId>    <version>0.10.4</version></dependency>

Ниже мы приведем несколько примеров использования базы данных H2 в памяти:

<dependency>    <groupId>com.h2database</groupId>    <artifactId>h2</artifactId>    <version>1.4.197</version></dependency>

Последняя версия Exposed доступна на Bintray, а последняя версия H2 на Maven Central.

3. Соединение с базой данных

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

Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")

Мы можем также указать пользователя (user) и пароль (password) в качестве именованных параметров:

Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver",user = "myself", password = "secret")

Обратите внимание: вызов метода connectне устанавливает соединения с БД сразу. Соединение будет установлено позже с использованием сохраненных параметров.

3.1. Дополнительные параметры

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

Database.connect({ DriverManager.getConnection("jdbc:h2:mem:test;MODE=MySQL") })

В этом случае нам придется использовать замыкание. Exposed вызывает замыкание при необходимости установления нового соединения с базой данных.

3.2. Подключение через DataSource

Если мы будем подключаться к базе данных с использованием DataSource (именно этот подход обычно используется в корпоративных приложениях, например, чтобы иметь преимущества пула подключений), нам потребуется соответствующий перегруженный метод connect:

Database.connect(datasource)

4. Открытие транзакции

Все операции с базами данных в Exposed выполняются только при наличии активных транзакций.

Метод transactionпринимает замыкание и вызывает его в активной транзакции.

transaction {//Do cool stuff}

Метод transactionвозвращает значение, которое вернуло замыкание.После выполнения блока Exposed автоматически закрывает транзакцию.

4.1. Подтверждение и откат транзакций

После успешного выполнения блока с помощью метода transaction Exposed подтверждает транзакцию. Если же замыкание генерирует исключение, фреймворк откатывает транзакцию.

Подтверждение и откат транзакции можно выполнить в ручном режиме. В Kotlin замыкание, которое мы передали в методе transaction, фактически является экземпляром класса Transaction.

Таким образом, мы можем использовать метод commitилиrollback:

transaction {//Do some stuffcommit()//Do other stuff}

4.2. Запись инструкций в журнал

При изучении фреймворка или отладке кода не лишним будет отследить инструкции и запросы SQL, которые Exposed отправляет в базу данных.

Для этого добавим регистратор к активной транзакции:

transaction {    addLogger(StdOutSqlLogger)    //Do stuff}

5. Определение таблиц

Как правило, Exposed не используется для работы с неформатированными строками и именами SQL. Мы определяем таблицы, столбцы, ключи, связи и т.д. с помощью высокоуровневого DSL.

AD

Для представления каждой таблицы будем использовать экземпляр класса Table:

object StarWarsFilms : Table()

Exposed автоматически присваивает таблице имя на основании имени класса, но мы можем задать имя самостоятельно:

object StarWarsFilms : Table("STAR_WARS_FILMS")

5.1. Столбцы

Без столбцов таблица работать не будет. Определим столбцы как свойства класса Table:

object StarWarsFilms : Table() {val id = integer("id").autoIncrement().primaryKey()val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}

Для краткости мы не указали типы Kotlin определит их автоматически. В любом случае каждая колонка относится к классу Column<T>, у нее есть имя, тип и, возможно, параметры типа.

5.2. Первичные ключи

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

Однако, если в таблице в качестве первичного ключа используется целое число, мы можем использовать встроенные в Exposed классы IntIdTableи LongIdTable для определения ключей:

object StarWarsFilms : IntIdTable() {val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}

Есть также класс UUIDTable, а еще мы можем определить собственные варианты, выделив подклассы в классе IdTable.

5.3. Внешние ключи

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

Предположим, что нам нужно узнать имена актеров, которые снимались в каждом фильме:

object Players : Table() {val sequelId = integer("sequel_id").uniqueIndex().references(StarWarsFilms.sequelId)val name = varchar("name", 50)}

Для того чтобы не прописывать тип столбца (в этом примере integer), который можно получить из связанного столбца, воспользуемся методом reference:

val sequelId = reference("sequel_id", StarWarsFilms.sequelId).uniqueIndex()

Если мы ссылаемся на первичный ключ, имя столбца можно не указывать:

val filmId = reference("film_id", StarWarsFilms)

5.4. Создание таблиц

Таблицы можно создавать программно, как указано выше:

transaction {SchemaUtils.create(StarWarsFilms, Players)//Do stuff}

Таблицу можно создать, только если она не существует. Однако миграция баз данных не поддерживается.

6. Запросы

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

6.1. Выбор всех объектов

Для того чтобы извлечь данные из базы, будем использовать объекты Query, созданные на основе классов таблиц. Самый простой запрос будет возвращать все строки заданной таблицы:

val query = StarWarsFilms.selectAll()

Запрос является итерируемым и поддерживает циклы forEach:

query.forEach {assertTrue { it[StarWarsFilms.sequelId] >= 7 }}

Параметр замыкания, которому в нашем примере присвоено имя it, это экземпляр класса ResultRow. В результате столбцам присваиваются ключи.

6.2. Выбор подмножества столбцов

Мы можем также выбрать подмножество столбцов таблицы, тоесть выполнить проекцию, с помощью метода slice:

StarWarsFilms.slice(StarWarsFilms.name, StarWarsFilms.director).selectAll().forEach {assertTrue { it[StarWarsFilms.name].startsWith("The") }}

Этот метод позволяет применить функцию к столбцу:

StarWarsFilms.slice(StarWarsFilms.name.countDistinct())

Часто при использовании агрегатных функций, например countи avg, направляя запрос, мы используем группировку по оператору. О группах мы поговорим в разделе 6.5.

6.3. Фильтрация с помощью выражения where

В Exposed для выражений where, которые используются для фильтрации запросов и других типов инструкций, используется специальный DSL. В основе этого мини-языка лежат свойства столбцов, с которыми мы познакомились ранее, и серия логических операторов.

Вот пример выражения where:

{ (StarWarsFilms.director like "J.J.%") and (StarWarsFilms.sequelId eq 7) }

Оно относится к комплексному типу и является подклассом SqlExpressionBuilder, который определяет такие операторы, как like, eq, and. Как видим, это последовательность операций сравнения, соединенных операторами andи or.

Мы можем передать такое выражение в метод select, который вернет очередной запрос:

val select = StarWarsFilms.select { ... }assertEquals(1, select.count())

Поскольку тип может быть выведен из контекста, нам не обязательно указывать тип complex для выражения where, когда оно передается непосредственно в метод select, как в рассмотренном примере.

В Kotlin выражения с where являются объектами, поэтому специальных параметров для запросов нет. Мы используем переменные:

val sequelNo = 7StarWarsFilms.select { StarWarsFilms.sequelId >= sequelNo }

6.4. Дополнительная фильтрация

Существует несколько методов для уточнения запросов, которые возвращает метод selectи его эквиваленты.

Например, можно удалить повторяющиеся строки:

query.withDistinct(true).forEach { ... }

Или вернуть только подмножество строк, например в случае нумерации страниц с результатами при работе над пользовательским интерфейсом:

query.limit(20, offset = 40).forEach { ... }

Эти методы будут возвращать новые объекты Query, поэтому мы можем выстроить их вызовы в цепочку.

6.5.Методы orderByи groupBy

Метод Query.orderByпринимает список столбцов, связанных со значением SortOrder, которое задает тип сортировки элементов по возрастанию или по убыванию:

query.orderBy(StarWarsFilms.name to SortOrder.ASC)

Группировка по одному или нескольким столбцам будет особенно полезна при использовании агрегатной функции (см. раздел 6.2). Для этого воспользуемся методом groupBy:

StarWarsFilms.slice(StarWarsFilms.sequelId.count(), StarWarsFilms.director).selectAll().groupBy(StarWarsFilms.director)

6.6. Соединения

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

(StarWarsFilms innerJoin Players).selectAll()

В этом примере мы использовали оператор innerJoin, но по этому же принципу можно использовать операторы LEFT JOIN, RIGHT JOIN и CROSS JOIN.

Затем можно добавить условия соединения, используя выражение where; например, если у нас нет внешнего ключа, придется указать явную операцию соединения:

(StarWarsFilms innerJoin Players).select { StarWarsFilms.sequelId eq Players.sequelId }

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

val complexJoin = Join(StarWarsFilms, Players,onColumn = StarWarsFilms.sequelId, otherColumn = Players.sequelId,joinType = JoinType.INNER,additionalConstraint = { StarWarsFilms.sequelId eq 8 })complexJoin.selectAll()

6.7. Псевдонимы

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

(StarWarsFilms innerJoin Players).selectAll().forEach {assertEquals(it[StarWarsFilms.sequelId], it[Players.sequelId])}

На самом деле в этом примере StarWarsFilms.sequelIdи Players.sequelId это разные столбцы.

Однако, если в запросе одна и та же таблица появляется несколько раз, можно присвоить ей псевдоним. Для этого воспользуемся функцией alias:

val sequel = StarWarsFilms.alias("sequel")

Псевдоним можно указывать в качестве названия таблицы:

Join(StarWarsFilms, sequel,additionalConstraint = {sequel[StarWarsFilms.sequelId] eq StarWarsFilms.sequelId + 1}).selectAll().forEach {assertEquals(it[sequel[StarWarsFilms.sequelId]], it[StarWarsFilms.sequelId] + 1)}

В этом примере sequel это таблица, которая участвует в операции соединения. Чтобы обратиться к столбцу, в качестве ключа будем использовать столбец таблицы, представленной псевдонимом:

sequel[StarWarsFilms.sequelId]

7. Инструкции

Мы рассмотрели, как выполнять запросы к базе данных. Теперь разберемся с DML-инструкциями.

7.1. Вставка данных

Для того чтобы вставить данные, вызовем функцию, эквивалентную функции insert. Все они принимают замыкание:

StarWarsFilms.insert {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}

В этом замыкании используются два объекта:

  • this(само замыкание) это экземпляр класса StarWarsFilms; именно этот объект позволяет нам обращаться к столбцам, которые являются свойствами, по неуточненному имени;

  • it(параметр замыкания) это InsertStatement; это структура, аналогичная коллекции ключ/значение, в которой есть слоты для вставки столбцов.

7.2. Извлечение автоинкрементного значения столбцов

Если у нас есть инструкция insert с автоматически генерируемыми столбцами (обычно это автоматическое увеличение индекса или последовательности), мы можем извлечь сгенерированные значения.

В типичном сценарии есть только одно сгенерированное значение. Воспользуемся методом insertAndGetId:

val id = StarWarsFilms.insertAndGetId {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}assertEquals(1, id.value)

Если у нас несколько сгенерированных значений, их можно считывать по имени:

val insert = StarWarsFilms.insert {it[name] = "The Force Awakens"it[sequelId] = 7it[director] = "J.J. Abrams"}assertEquals(2, insert[StarWarsFilms.id]?.value)

7.3. Обновление данных

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

StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {it[name] = "Episode VIII  The Last Jedi"}

В этом примере выражение where используется вместе с замыканием UpdateStatement. UpdateStatementи InsertStatement это потомки класса UpdateBuilder, поэтому в них используется один и тот же API и одна и та же логика. Родительский класс позволяет задать значение столбца с помощью квадратных скобок.

Если для обновления столбца нам нужно вычислять новое значение из старого, воспользуемся SqlExpressionBuilder:

StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {with(SqlExpressionBuilder) {it.update(StarWarsFilms.sequelId, StarWarsFilms.sequelId + 1)}}

Этот объект позволяет использовать инфиксный оператор (например, plus,minusи т. д.) для создания инструкции обновления.

7.4. Удаление данных

И наконец, мы можем удалить данные с помощью метода deleteWhere:

StarWarsFilms.deleteWhere ({ StarWarsFilms.sequelId eq 8 })

8. API DAO, облегченная технология ORM

Мы использовали Exposed для того, чтобы связать операции над объектами Kotlin с запросами и инструкциями SQL напрямую. Такие методы, как insert, update, select и т. д., немедленно отправляют строку SQL в базу данных.

Однако в Exposed есть высокоуровневый API DAO, который представляет собой простую технологию ORM. Давайте рассмотрим его подробнее.

8.1. Сущности

В рассмотренных выше примерах мы использовали классы для представления таблиц базы данных и описания операций над ними с использованием статических методов.

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

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)var sequelId by StarWarsFilms.sequelIdvar name   by StarWarsFilms.namevar director by StarWarsFilms.director}

Давайте подробно проанализируем это определение.

Из первой строки видно, что сущность это класс, расширяющий Entity. У нее есть ID специфического типа, в нашем случае Int.

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {

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

Объявляя объект-компаньон, мы соединяем сущность с именем StarWarsFilm(в единственном числе), которая представляет собой одну строку, с таблицей с именем StarWarsFilms (во множественном числе), которая представляет собой коллекцию всех строк.

companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)

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

var sequelId by StarWarsFilms.sequelIdvar name   by StarWarsFilms.namevar director by StarWarsFilms.director

Обратите внимание, что раньше мы объявляли столбцы с помощью val, поскольку они являются неизменяемыми метаданными. Теперь же мы объявляем свойства сущности с помощью var, поскольку они являются изменяемыми слотами в строке базы данных.

8.2. Вставка данных

Чтобы вставить строку в таблицу, нам нужно просто создать экземпляр класса сущности с использованием статического фабричного метода newв транзакции:

val theLastJedi = StarWarsFilm.new {name = "The Last Jedi"sequelId = 8director = "Rian Johnson"}

Обратите внимание: с базой данных выполняются отложенные операции, которые запускаются только после выполнения warm cache. В Hibernate, например, теплый кэш привязан к сессии (session).

Операция выполняется автоматически. Например, когда мы в первый раз считываем сгенерированный идентификатор, Exposed выполняет инструкцию insert:

assertEquals(1, theLastJedi.id.value) //Reading the ID causes a flush

Сравните это поведение с методом insert, который мы рассматривали в разделе 7.1, в этом примере метод сразу же выполняет инструкцию в базе данных. Здесь же мы работаем на более высоком уровне абстракции.

8.3. Обновление и удаление объектов

Для обновления строк нужно просто задать их свойства:

theLastJedi.name = "Episode VIII  The Last Jedi"

Для удаления объекта вызовем метод delete этого объекта:

theLastJedi.delete()

Так же, как при использовании метода new, обновление и операции выполняются в отложенном режиме.

Обновить и удалить можно только ранее загруженный объект. В этом фреймворке нет API для обновления и удаления нескольких объектов, и нам придется использовать API более низкого уровня (см. раздел 7). Тем не менее в одной транзакции можно одновременно использовать и тот и другой API.

8.4. Запросы

API DAO позволяет выполнять три типа запросов.

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

val movies = StarWarsFilm.all()

Для загрузки одного объекта по ID воспользуемся методом findById:

val theLastJedi = StarWarsFilm.findById(1)

Если объекта с таким ID нет, findByIdвернет значение null.

В самом общем случае мы можем использовать метод findс выражением where:

val movies = StarWarsFilm.find { StarWarsFilms.sequelId eq 8 }

8.5. Связь многие к одному

В ORM соотнесение соединений со ссылками так же важно, как соединения в реляционных базах данных. Посмотрим, какие возможности предлагает Exposed.

Предположим, что нам нужно узнать пользовательский рейтинг каждого фильма. Сначала определим две дополнительные таблицы:

object Users: IntIdTable() {val name = varchar("name", 50)}object UserRatings: IntIdTable() {val value = long("value")val film = reference("film", StarWarsFilms)val user = reference("user", Users)}

Затем создадим соответствующие сущности. Опустим сущность User (это очевидно) и перейдем сразу к классу UserRating:

class UserRating(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<UserRating>(UserRatings)var value by UserRatings.valuevar film by StarWarsFilm referencedOn UserRatings.filmvar user by User     referencedOn UserRatings.user}

Обратите внимание: инфиксный метод referencedOn вызывает свойства, которые представляют собой связи.Модель следующая: объявляем переменную varчерез сущность (by) со ссылкой на соответствующий столбец (referencedOn).

Свойства, объявленные таким образом, ведут себя как обычные свойства, но их значением является связанный объект:

val someUser = User.new {name = "Some User"}val rating = UserRating.new {value = 9user = someUserfilm = theLastJedi}assertEquals(theLastJedi, rating.film)

8.6. Дополнительные связи

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

Чтобы установить дополнительные связи, нам нужно сначала разрешить столбцу таблицы принимать значение null:

val user = reference("user", Users).nullable()

Вместо метода referencedOnбудем использовать optionalReferencedOn:

var user by User optionalReferencedOn UserRatings.user

Таким образом, свойство user сможет принимать значение null.

8.7. Связь один ко многим

Мы можем создать обратную связь с помощью внешнего ключа. Будем использовать его для моделирования рейтинга фильма в базе данных; у фильма будет несколько рейтингов.

Для отображения рейтингов нужно добавить свойство к той стороне связи, где используется один объект. В нашем случае это сущность film:

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {//Other properties elidedval ratings by UserRating referrersOn UserRatings.film}

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

theLastJedi.ratings.forEach { ... }

В отличие от обычных свойств, здесь мы определили ratingsчерезval.Свойство неизменяемо, поэтому мы можем только считывать его.

У значения свойства тоже нет API для изменения. Поэтому для добавления нового рейтинга нам нужно создать его, указав ссылку на фильм:

UserRating.new {value = 8user = someUserfilm = theLastJedi}

Теперь новый рейтинг появится в перечне рейтингов для этого фильма.

8.8. Связь многие ко многим

Иногда требуется установить связь многие ко многим. Предположим, что нам нужно связать класс StarWarsFilm с таблицей Actors:

object Actors: IntIdTable() {val firstname = varchar("firstname", 50)val lastname = varchar("lastname", 50)}class Actor(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<Actor>(Actors)var firstname by Actors.firstnamevar lastname by Actors.lastname}

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

object StarWarsFilmActors : Table() {val starWarsFilm = reference("starWarsFilm", StarWarsFilms).primaryKey(0)val actor = reference("actor", Actors).primaryKey(1)}

В этой таблице два столбца, каждый из которых является внешним ключом, а вместе они представляют собой сложный первичный ключ.

Теперь можно подключить таблицу связей к сущности StarWarsFilm:

class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) {companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms)//Other properties elidedvar actors by Actor via StarWarsFilmActors}

На момент написания статьи создать сущность с генерируемым идентификатором и использовать ее в связи многие ко многим в одной транзакции нельзя.

Нам приходится выполнять несколько транзакций:

//First, create the filmval film = transaction {   StarWarsFilm.new {    name = "The Last Jedi"    sequelId = 8    director = "Rian Johnson"r  }}//Then, create the actorval actor = transaction {  Actor.new {    firstname = "Daisy"    lastname = "Ridley"  }}//Finally, link the two togethertransaction {  film.actors = SizedCollection(listOf(actor))}

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

9. Заключение

В этой статье мы подробно рассмотрели фреймворк Kotlin Exposed. Дополнительную информацию и примеры можно найти в вики-учебнике по Exposed.

Варианты реализации рассмотренных примеров и фрагменты кода можно найти на GitHub.


Узнать подробнее о курсе "Kotlin Backend Developer".

Смотреть вебинар Объектно-ориентированное программирование в Kotlin.

Подробнее..

Перевод Глубокое обучение на Kotlin альфа-версия KotlinDL

04.05.2021 18:23:50 | Автор: admin

Перевод материала подготовлен в рамках онлайн-курса "Kotlin Backend Developer".

Приглашаем также всех желающих на бесплатный демо-урок Объектно-ориентированное программирование в Kotlin. Цели занятия:
- знать элементы объектной модели Kotlin;
- создавать различные классы и объекты;
- выполнять наследование и делегирование;
- пользоваться геттерами и сеттерами.


Привет, друзья! Сегодня мы расскажем о первой предварительной версии KotlinDL(v.0.1.0) высокоуровневого фреймворка для глубокого обучения, похожего на Keras, но написанного на Kotlin. В нем есть простые API для создания, тренировки и развертывания моделей глубокого обучения в среде JVM. Высокоуровневые API и точно настроенные параметры позволяют быстро приступить к работе с KotlinDL. Для создания и обучения своей первой нейронной сети вам достаточно написать всего несколько строк на Kotlin:

private val model = Sequential.of(    Input(28, 28, 1),    Flatten(),    Dense(300),    Dense(100),    Dense(10))fun main() {    val (train, test) = Dataset.createTrainAndTestDatasets(        trainFeaturesPath = "datasets/mnist/train-images-idx3-ubyte.gz",        trainLabelsPath = "datasets/mnist/train-labels-idx1-ubyte.gz",        testFeaturesPath = "datasets/mnist/t10k-images-idx3-ubyte.gz",        testLabelsPath = "datasets/mnist/t10k-labels-idx1-ubyte.gz",        numClasses = 10,        ::extractImages,        ::extractLabels    )    val (newTrain, validation) = train.split(splitRatio = 0.95)    model.use {        it.compile(            optimizer = Adam(),            loss = Losses.SOFT_MAX_CROSS_ENTROPY_WITH_LOGITS,            metric = Metrics.ACCURACY        )        it.summary()        it.fit(            dataset = newTrain,            epochs = 10,            batchSize = 100,            verbose = false        )        val accuracy = it.evaluate(            dataset = validation,            batchSize = 100        ).metrics[Metrics.ACCURACY]        println("Accuracy: $accuracy")        it.save(File("src/model/my_model"))    }}

Поддержка GPU

Тренировка моделей ресурсоемкая задача. Использование GPU может значительно ускорить этот процесс. Здесь на помощь придет KotlinDL! Для запуска кода на устройстве NVIDIA нужно добавить всего одну зависимость.

Широкие возможности API

В KotlinDL есть все необходимые API для создания и тренировки нейронных сетей прямого распространения, включая сверточные сети. В нем по умолчанию установлены разумные значения для большинства гиперпараметров. Фреймворк предлагает широкий выбор оптимизаторов, инициализаторов веса, функций активации и прочих возможностей для тонкой настройки модели. Вы можете сохранить полученную модель и импортировать ее в бэкенд-приложение JVM.

Импорт моделей, обученных на Keras

Встроенные в KotlinDL API позволяют создавать, тренировать и сохранять модели глубокого обучения и использовать их для работы с новыми данными. Можно импортировать и использовать модель, обученную с помощью KotlinDL или Keras версии 2.* на языке Python.

И для тех и для других моделей доступен метод трансферного обучения: просто загрузите уже имеющуюся обученную модель и подгоните ее под свою задачу.

Временные ограничения

В текущей альфа-версии доступно только несколько слоев: Input(),Flatten(),Dense(),Dropout(),Conv2D(),MaxPool2D() иAvgPool2D(). Это означает, что пока поддерживаются не все модели, обученные с помощью Keras. Можно импортировать и настроить обученные модели VGG-16 или VGG-19, а вот модель ResNet50, например, загрузить не получится. В следующих релизах появятся новые слои.

Еще одно временное ограничение связано с развертыванием. Вы можете развернуть модель в серверной части JVM-среды, однако поддержка устройств Android появится только в следующих версиях.

Что под капотом?

KotlinDL использует TensorFlow Java API, который активно развивается сообществом разработчиков открытого ПО.

Попробуйте сами!

Мы подготовили несколько статей (на английском языке), которые помогут вам начать работу с KotlinDL:

Будем рады получить ваши отзывы и пул-реквесты в GitHub Issues. Присоединяйтесь к каналу #deeplearning в Slack-сообществе Kotlin.


Узнать подробнее о курсе "Kotlin Backend Developer"

Смотреть вебинар Объектно-ориентированное программирование в Kotlin

Подробнее..

Распознавание команд

03.06.2021 20:19:56 | Автор: admin

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

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

/** Правило проверяет лексему на соответствие */typealias Rule = (String) -> Boolean/** Нормализованное семантическое представление */open class Semnorm(vararg val rules: Rule)/** Правило задает стемы для семантических представлений */fun stem(vararg stems: String): Rule = { stems.any(it::startsWith) }/** Правило задает точные соответствия для семантических представлений */fun word(vararg words: String): Rule = { words.any(it::equals) }/** Проверяем слово на соответствие семантике */fun String.matches(norm: Semnorm) = norm.rules.any { it(this) }

Теперь у нас появилась возможность задавать предопределенные нормализованные семантические представления в виде объектов:

object Day : Semnorm(stem("day", "суток", "сутк", "дня", "ден", "дне"))

Фреймворк ставит их в соответствие лексемам входящих фраз, и предложение начинает выглядеть, например так:

assertThat(  "забань васю на 5 минут".tokenize(),   equalTo(   listOf(     Token("забань", Ban),      Token("васю", null),     Token("на", null),      Token("5", Number),     Token("минут", Minute)   )  ))

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

object Help : ExecutableSemnorm(stem(  "помощ", "справк", "правил", "help",   "rule", "faq", "start", "старт",)) {  override fun execute(bot: Botm: Message) {    val faq = message.from.relatedFaq()    bot.sendMessage(m.chat.id, faq)  }}

Что насчет более сложного поведения, зависящего от различных слов в предложении? Оно тоже поддерживается, вот как, например, исполняется, уже известное из тестов предложение забанить Васю:

object Ban : DurableSemonrm(stem(  "ban", "block", "mute", "бан", "блок",  "забан", "завали", "замьют",)) {  override fun execute(    bot: Bot, attackerMessage: Message, duration: Duration) {    val victimMessage = attackerMessage.replyToMessage    val victimId = victimMessage.from.id    val untilSecond = now().epochSecond + duration.inWholeSeconds    bot.restrictChatMember(      attackerMessage.chat.id, victimId, untilSecond)  }}

Откуда это семантическое представление знает о своей продолжительности? Дело в том, что ему вовсе не обязательно парсить всю цепочку токенов целиком. Достаточно задать только минимально необходимое поведение для каждого представления, например для времени:

object Week : Semnorm(stem("week", "недел")) {  override fun toDuration(number: Long) =     days(number) * 7}

Или для любых команд, зависящих от времени:

class DurableSemnorm(vararg rules: Rule) : ExecutableSemnorm(*rules) {  final override fun execute(    token: Iterator<Token>, bot: Bot, m: Message) =       execute(bot, message, token.parseDuration())  abstract fun execute(bot: Bot, m: Message, duration: Duration)}

Благодаря такой архитектуре, нам больше не приходится думать о запутанной логике работы интерпретатора. Достаточно просто определить желаемые атрибуты для семантических представлений и наслаждаться результатом. Пример бота, использующего эту концепцию, можно посмотреть на Github: https://github.com/demidko/timecobot

Подробнее..

Мультивселенная и задачи о переправе

16.06.2021 04:11:19 | Автор: admin

Как-то прочел на Хабре статью Перевозим волка, козу и капусту через реку с эффектами на Haskell, которая так понравилась, что решил написать фреймворк для всего класса задач о переправах, используя мультипарадигменное проектирование. Наконец удалось найти время, и вот, спустя почти год, фреймворк готов. Теперь персонажи, их взаимодействия и описание искомого результата задаются через domain-specific language, который позволяет решать любые головоломки подобного рода с пошаговым выводом. Ниже приводится поэтапный разбор реализации DSL. Статья подойдет тем кто изучает язык Kotlin или просто интересуется примерами его использования. Некоторые малозначимые детали (вроде импортов и вывода) для кратости опущены.

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

open class Person(private val name: String)

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

typealias Riverside = Set<Person>

Дальше построим лодку. Лодка будет знать о населенности обоих берегов, но находится в состоянии квантовой неопределенности между ними, с возможностью инвертировать свое положение:

abstract class QuantumBoat(  val left: Riverside, val right: Riverside) {    abstract fun invert(): List<QuantumBoat>    fun where(    condition: Riverside.() -> Boolean,     selector: QuantumBoat.() -> Boolean  ) = Multiverse(this, condition).search(selector)}

Лодка также снабжена высокоуровневым методом where, для поиска необходимого состояния через N шагов по реке. Условие (condition) определяет валидность берегов в процессе, а селектор (selector) задает искомое конечное состояние. Обратите внимание, что при использовании этого метода лодка на самом деле не двигается с места, а перебирает альтернативные вселенные, пока не обнаружит подоходящую :)
Но об этом мы поговорим позже, а пока что перейдем к простой имплементации лодки для перемещения слева направо:

class LeftBoat(left: Riverside, right: Riverside) : QuantumBoat(left, right) {  override fun invert() =    left.map {      RightBoat(left - it - Farmer, right + it + Farmer)    } + RightBoat(left - Farmer, right + Farmer)}

Инверсия состояния возвращает сразу все возможные варианты перемещения на другую сторону. Это пригодится для реализации нашего мультиверсума. Поскольку по условиям таких задач, фермер выступает необходимым условием передвижения лодки, то перемещаем его во всех случаях вместе с ней. Аналогичным образом имплементируем и перемещение справа налево. Заметьте, насколько лаконичен наш код за счет предопределенных высокоуровневых функций Kotlin и перегрузки операторов для работы с множествами.

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

typealias History = LinkedList<QuantumBoat>  fun Sequence<History>.fork() = sequence {  for (history in this@fork) {    for (forked in history.last.invert()) {      yield((history.clone() as History).apply {        add(forked)      })    }  }}

Заодно описали функцию форка мультиверсума (историй перемещений) в следующий набор состояний (шаг). Чтобы все это добро не забивало лишний раз память, используем ленивые последовательности и yield.

Теперь нам осталось всего лишь описать мультиверсум (а код для поиска состояний у нас уже есть):

/** * Мультиверсум для лодки * @param boat исходное состояние лодки * @param condition валидатор промежуточных состояний */class Multiverse(boat: QuantumBoat, val condition: Riverside.() -> Boolean) {  /**   * Все смоделированные истории передвижений лодки   */  private var multiverse = sequenceOf(historyOf(boat))  /**   * Найти историю подходящей нам лодки   * @param selector нужное состояние берегов и лодки   * @return все найденные варианты достижения состояния   */  tailrec fun search(selector: QuantumBoat.() -> Boolean): List<History> {    multiverse = multiverse.fork().distinct().filter {      it.last.left.condition()        && it.last.right.condition()    }    val results = multiverse.filter { it.last.selector() }.toList()    return when {      results.isNotEmpty() -> results      else -> search(selector)    }  }}

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

Наконец, пример использования DSL на всем известной задачке про волка, козу и капусту:

object Wolf : Person("")object Goat : Person("")object Cabbage : Person("")fun Riverside.rule() =  contains(Farmer) ||    (!contains(Wolf) || !contains(Goat)) &&    (!contains(Goat) || !contains(Cabbage))fun main() {  val property = setOf(Wolf, Goat, Cabbage)  // стартовали с левого берега  LeftBoat(property)     // отбросили все невалидные состояния    .where(Riverside::rule)    // выбрали из оставшихся те варианты,    // где все имущество оказалось на правом берегу    { right.containsAll(property) }     // выводим на экран пошаговое решение    .forEach(History::prettyPrint)}

Вот что получилось, вставляю скриншотом, потому что смайлики хабр не переваривает:

Всем удачного дня и побольше времени на написание собственных DSL :)

Исходный код здесь: demidko/Wolf-Goat-Cabbage
Приветствуется критика и предложения как сделать лучше.

Подробнее..

Композиция вместо наследования в языке программирования Delight

22.04.2021 18:21:21 | Автор: admin

В данной статье рассматривается один из подходов к следующей ступени развития ООП (объектно-ориентированного программирования). Классический подход к ООП строиться на концепции наследования, что в свою очередь накладывает серьезные ограничения по использованию и модификации уже готового кода. Создавая новые классы, не всегда получается наследоваться от уже существующих классов (проблема ромбовидного наследования) или модифицировать существующие классы от которых уже унаследовалось множество других классов (хрупкий (или чрезмерно раздутый) базовый класс). При разработке языка программирования Delight был выбран альтернативный подход для работы с классами и их композицией - КОП (компонентно-ориентированное программирование).

Сразу к делу

Начать следовало бы с основ языка, его синтаксиса и правил работы КОП. Но это довольно скучно, так что сразу перейдем к конкретному игровому примеру. Для понимания всего нижеизложенного, требуется полноценное знание ООП, так как сама композиция строиться на тех же принципах что и ООП. Более детально о том, как это все-таки работает, можно прочитать в следующем после этого разделе.

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

class BaseBehavior  unitPos: UnitPos [shared]  fn DoTurn [virtual]class PathBuilder  unitPos: UnitPos [shared]  fn Moving:boolean [virtual]    ...  fn BuildPath(x:int, y:int) [virtual]    ...  // ... and some more helper functions ...

BaseBehavior - отвечает за базовое поведение существа, сам класс не имеет логики, только необходимые декларации.

PathBuilder - класс который отвечает за поиск пути по земле (включая обход препятствий).

Модификатор [shared] означает что это поле будет общим для всех подклассов финального класса.

Дальше пропишем классы, в которых уже есть непосредственно логика поведения:

class SimpleBehavior  base: BaseBehavior [shared]  path: PathBuilder [shared]    fne DoTurn // override of BaseBehavior.DoTurn    if path.Moving = false      path.BuildRandomPathclass AgressiveBehavior  open SimpleBehavior [shared]  fne DoTurn // override of SimpleBehavior.DoTurn    d: float = path.GetDistance(player.x, player.y) // get distance from this unit to player    if d < 30      path.BuildPath(player.x, player.y) // run to player    else      nextFn // inherited call to next DoTurnclass ScaredBehavior  open SimpleBehavior [shared]  fne DoTurn // override of SimpleBehavior.DoTurn    d: float = path.GetDistance(player.x, player.y) // get distance from this unit to player    if d < 50      path.BuildPathAwayFrom(player.x, player.y) // run away from player    else      nextFn // inherited call to next DoTurn

Здесь все просто:

SimpleBehavior - существо будет перемещаться по карте по случайным координатам.

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

ScaredBehavior - если игрок находиться недалеко, то существо отбегает от него или двигаеться согласно SimpleBehavior.

open - означает открытое поле класса заданного типа но без имени.

fne - перегрузка (override) виртуальной функции.

nextFn - виртуальный вызов следующей в цепочке функции.

Пока что весь код можно отобразить и с помощью обычного ООП, но для следующего поведения используем КОП:

class UncertainBehavior  open AgressiveBehavior [shared]  open ScaredBehavior [shared]

Здесь уже включается "магия" композиции. В этом простом коде, при вызове DoTurn, управление сначала передаётся в AgressiveBehavior.DoTurn. Если игрок близко, то существо побежит к нему. Если нет, то управление переходит к ScaredBehavior.DoTurn - если игрок недалеко, то существо убегает от него. Если нет, то дальше вызывается SimpleBehavior.DoTurn и существо просто бродит по карте.

На этом коде уже можно создать существ Волка (AgressiveBehavior), Зайца (ScaredBehavior) и Кошку (UncertainBehavior). Но что делать для других видов существ? Летающих или плавающих? Или комбинированных? В ООП подобная иерархия уже не сработает. Зато очень помогает композиция. Сначала создадим новые классы для поиска пути в разных средах:

class PathBuilder_air // поиск пути по воздуху  path: PathBuilder [shared]  fne BuildPath(x:int, y:int)    ...class PathBuilder_water // поиск пути в воде  path: PathBuilder [shared]  fne BuildPath(x:int, y:int)    ...

А дальше просто подменим этими классами уже существующий код поведения:

class Shark  open PathBuilder_water [shared]  open AgressiveBehavior [shared]

В этом классе "Акулы", сначала создаётся класс поиска пути по воде, дальше используется код с AgressiveBehavior, только учитывая, что класс PathBuilder общий (shared), то в AgressiveBehavior (как и в SimpleBehavior) будет использоваться PathBuilder_water (так как он был объявлен ранее чем обычный PathBuilder). Соответственно вся логика AgressiveBehavior сохранилась, но поиск пути будет работать уже по воде. Таким же способом, просто перебирая классы-компоненты и используя минимум кода, можно создать существ с разным поведением в разных средах обитания:

class Fish  open PathBuilder_water [shared]  open ScaredBehavior [shared]class Eagle  open PathBuilder_air [shared]  open UncertainBehavior [shared]class Pigeon  open PathBuilder_air [shared]  open ScaredBehavior [shared]class Wolf  open AgressiveBehavior [shared]

Как видим, суть компонентно-ориентированного программирования состоит в создании небольших классов-компонентов и правильной комбинации этих классов в финальном объекте-сущности.

Основы композиции в Delight

Объявление простого класа в Delight выглядит следующим образом:

class NonVirtualClass  val: OtherClass  fn SomeFn    Trace('Hello world')

Здесь val является обычным членом класса, с типом OtherClass.

Функции, как и в ООП языках могут быть виртуальными, для этого используется модификатор [virtual]

fn SomeFn [virtual]  Trace('Hello virtual world')

и перегружаться/дополняться с помощью ключевого слова fne (вместо fn)

fne SomeFn  Trace('Hello overrided world')

А вот синтаксис наследования (вернее композиции) сильно отличается от классических языков. В случае, если класс хочет перегрузить функцию своего базового класса, он должен объявить базовый класс с модификатором [shared] (общий), и использовать fne для перегрузки функции:

class BaseClass  fn SomeFn [virtual]    Trace('Hello virtual world')class NewClass  base: BaseClass [shared]  fne SomeFn    Trace('Hello overrided world')    nextFn

Ключевое слово nextFn вызовет следующую функцию в цепочке виртуальных вызовов.

Для примера похожий (но не эквивалентный) код на С++

class BaseClass{public:  virtual void SomeFn()  {    Trace('Hello virtual world');  }};class NewClass : public virtual BaseClass{  virtual void SomeFn() override  {    Trace('Hello overrided world');    BaseClass::SomeFn();  }};

В классе может быть множество полей с модификатором [shared], что соответствует концепции множественного наследования. Более того, shared поля одного типа могут повторяться в любом месте иерархии класса, но при этом в финальном объекте, независимо от количества [shared] деклараций одного типа, создастся только один общий объект этого типа, а все [shared] поля соответствующего типа во всех общих классах будут содержать только ссылки на этот объект (вернее запись в vtable).

Таким образом в коде:

class Base  val: intclass ClsA  base: Base [shared]class ClsB  base: Base [shared]class ClsC  a: ClsA [shared]  b: ClsB [shared]

при создании класса ClsC, этот объект будет содержать три базовых объекта в единичном экземпляре (Base, ClsA, ClsB) и значение val будет всегда одинаковым (общим) для всех этих объектов. Подобный подход соответствует виртуальному наследованию классов, которое используется в С++.

Как видно из синтаксиса, у общих полей при композиции задаются также имена (в отличие от классического ООП, где при наследовании, достаточно указать тип), и последующее обращение к такому полю должно начинаться с имени поля. Однако в Delight есть синтаксический сахар в виде декларации полей через ключевое слово open (делает поле открытым). Для компилятора это ничего не меняет, но программисту не нужно будет каждый раз обращаться к такому полю по имени (все поля и функции открытого класса могут быть доступны просто через this).

class ClsA  open Base [shared]  fne Constructor    val = 10

Обход классов и функций

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

  • Класс проходиться сверху вниз, если находиться новый общий (shared) класс, то происходит строительство этого класса;

  • Если в поле общий (shared) класс такого типа уже был построен, то используется указатель на уже построенный класс.

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

  • В цепочку вызовов сначала попадают функции из тела текущего класса, и только после них функции из общих полей класса;

  • Декларация функции всегда будет стоять в конце цепочки вызовов.

Для вызова следующей в цепочке виртуальной функции, используется оператор nextFn. Важно понимать, что этот оператор по сути является виртуальным вызовом (virtual call), в отличие от статического вызова перегруженной функции в классическом ООП (inherited call).

Например, такой код:

class Base  fn SomeVirtFn [virtual]    Trace('Base')class ClsA  open Base [shared]  fne SomeVirtFn    Trace('ClsA')class ClsB  open Base [shared]  fne SomeVirtFn    Trace('ClsB')class ClsC  open ClsA [shared]  open ClsB [shared]  fne SomeVirtFn    Trace('ClsC')....  fn Main    c: ClsC    c.SomeVirtFn

выдаст:

  ClsC  ClsA  ClsB  Base

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

Подробнее..

Перевод Почему принципы SOLID не являются надежным решением для разработки программного обеспечения

05.05.2021 14:10:44 | Автор: admin
Фото от https://unsplash.com/@lazycreekimagesФото от https://unsplash.com/@lazycreekimages

Роберт Мартин представилпринципы SOLIDв 2000 году, когда объектно-ориентированное программированиестало настоящимискусством для программистов.Каждый хочет создать что-то долговечное, которое можно использовать повторно, насколько это возможно, с минимальными изменениями, которые потребуются в будущем.SOLID - идеальное название для этого.

Фактически, объектно-ориентированное программирование работает лучше всего, когда мы можем отделить то, что останется, от того, что изменится.Принципы SOLID помогают разделять это.

Мне лично нравится идея, лежащая в основе принципов SOLID и я многому из нее научился.

Тем не менее

Здесь есть главный вызов. Всё программное обеспечение предназначено для изменения.А изменения действительно трудно предсказать, если вообще возможно.Поэтому нам действительно сложно определить четкую границу того, что останется и что изменится.

Чтобы проиллюстрировать, как эта проблема влияет на принципы SOLID, давайте рассмотрим каждый из принципов.

Принцип единственной ответственности

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

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

class Calculate {    fun add (a, b) = a + b    fun sub (a, b) = a - b    fun mul (a, b) = a * b    fun div (a, b) = a / b }

Для некоторых это идеально, так как у него одна обязанность - вычислить.

Но кто-то может возразить: Эй!Он делает 4 вещи!Сложить, вычесть, умножить и разделить!

Кто прав?Я скажу, это зависит от обстоятельств.

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

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

Определение единственной ответственности зависит от контекста программы и может меняться со временем.Мы можем следовать принципу сейчас, но не навсегда.

Принцип открытости/закрытости

Программные объекты ... должны быть открыты для расширения, но закрыты для модификации.

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

Давайте посмотрим на код ниже:

interface Operation {   fun compute(v1: Int, v2: Int): Int}class Add:Operation {   override fun compute(v1: Int, v2: Int) = v1 + v2}class Sub:Operation {   override fun compute(v1: Int, v2: Int) = v1 - v2}class Calculator {   fun calculate(op: Operation, v1: Int, v2: Int): Int {      return op.compute(v1, v2)   } }

В приведённом выше коде есть классCalculator, который принимает объект Operation для вычисления.Мы можем легко расширить этот класс с помощью операций Mul и Div без изменения кода самого классаCalculator.

class Mul:Operation {   override fun compute(v1: Int, v2: Int) = v1 * v2}class Div:Operation {   override fun compute(v1: Int, v2: Int) = v1 / v2}

Отлично, мы соблюдаем принцип открытости/закрытости!

Но однажды появилось новое требование. Cкажем, нам нужна новая операция Inverse.Она просто возьмет один операнд, например X, и вернет результат 1/X.

Кто на земле мог подумать, что это произойдет?Мы исправили функцию сompute нашего рабочего интерфейса, чтобы она имела 2 параметра.Теперь нам нужна новая операция, у которой всего 1 параметр.

Как теперь избежать модификации класса Calculator?Если бы мы знали это заранее, возможно, мы не писали бы наш класс калькулятора и интерфейс операций как таковые.

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

Принцип подстановки Лискоу

Функции, использующие указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Когда мы были молоды, мы узнавали основные атрибуты животных.Они подвижны.

interface Animal {   fun move()}class Mammal: Animal {   override move() = "walk"}class Bird: Animal {   override move() = "fly"}class Fish: Animal {   override move() = "swim"}fun howItMove(animal: Animal) {   animal.move()}

Это соответствует принципу замены Лискоу.

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

class WalkingAnimal: Animal {   override move() = "walk"}class FlyingAnimal: Animal {   override move() = "fly"}class SwimmingAnimal: Animal {   override move() = "swim"}

Круто, все по-прежнему хорошо, так как наша функция все еще может использовать Animal:

fun howItMove(animal: Animal) {   animal.move()}

Но сегодня я кое-что обнаружил.Есть животные, которые вообще не двигаются.Они называются Sessile.Может нам стоит изменить код так:

interface Animalinterface MovingAnimal: Animal {   move()}class Sessile: Animal {}

Теперь это нарушит приведенный ниже код.

fun howItMove(animal: Animal) {   animal.move()}

У нас нет возможности гарантировать, что мы никогда не изменим функциюhowItMove.Мы можем достичь этого, основываясь на том, что мы знаем на данный момент.Но по мере того, как мы осознаем новые требования, нам нужно меняться.

Даже в реальном мире существует множество исключений.Мир программного обеспечения - это не реальный мир.Все возможно.

Принцип разделения интерфейса

Многие клиентские интерфейсы лучше, чем один интерфейс общего назначения.

Давайте посмотрим на животное царство.У нас есть интерфейс Animal, как показано ниже.

interface Animal {   fun move()   fun eat()   fun grow()   fun reproduction()}

Однако, как мы поняли выше, есть некоторые животные, которые не двигаются, и это Sessile.Поэтому мы должны выделить функциюmove как отдельный интерфейс.

interface Animal {   fun eat()   fun grow()   fun reproduction()}interface MovingObject {   fun move()}class Sessile : Animal {   //...}class NonSessile : Animal, MovingObject {   //...}

Затем мы хотели бы иметь еще и PlantВозможно, нам следует отделитьgrowиreproduction:

interface LivingObject {   fun grow()   fun reproduction()}interface Plant: LivingObject {   fun makeFood()}interface Animal: LivingObject {   fun eat()}interface MovingObject {   fun move()}class Sessile : Animal {   //...}class NonSessile : Animal, MovingObject {   //...}

Мы довольны тем, что выделяем как можно больше клиентских интерфейсов.Это похоже на идеальное решение.

Однако, кто-то начинает кричать: Дискриминация!Некоторые животные бесплодны, это не значит, что они больше не LivingObject!.

Похоже, теперь нам нужно отделитreproductionот интерфейсаLivingObject.

Если мы это сделаем, у нас будет буквально одна функция на один интерфейс!Это очень гибко, но может быть слишком гибко, если нам не нужно такое тонкое разделение.

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

Принцип инверсии зависимостей

Положитесь на абстракции, а не на что-то конкретное.

Мне нравится этот принцип, поскольку он относительно универсален.Он применяет идеюконцепции независимого решения, в которой некоторая сущность в программе хотя и зависит от класса, но по-прежнему независима от него.

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

Давайте посмотрим на пример ниже.Он действительно применяет принцип инверсии зависимостей.

interface Operation {    fun compute (v1: Int, v2: Int): Int    fun name (): String }class Add: Operation {    override fun compute (v1: Int, v2: Int) = v1 + v2    override fun name () = "Add" }class Sub: Operation {    override fun compute (v1: Int, v2: Int) = v1 - v2    override fun name () = "Subtract" }class Calculator {    fun calculate (op: Operation, v1: Int, v2: Int): Int {       println ("Running $ {op.name ()}")       return op.compute (v1, v2)    } }

Calculator Не зависит отAdd илиSub.Новместо этогоон выполняетAdd иSub , которые зависят отOperation.Это выглядит хорошо.

Однако, если кто-то из группы разработчиков Android использует его, это будет проблемой.println не работает в Android.Нам понадобитсяLod.d взамен.

Чтобы решить эту проблему, мы должны сделатьCalculator независящим напрямую от println.Вместо этого мы должны внедрить интерфейс Printer:

interface Printer {   fun print(msg: String)}class AndroidPrinter: Printer {   override fun print(msg: String) = Log.d("TAG", msg)}class NormalPrinter: Printer {   override fun print(msg: String) = println(msg)}class Calculator(val printer: Printer) {   fun calculate(op: Operation, v1: Int, v2: Int): Int {      printer.print("Running ${op.name()}")      return op.compute(v1, v2)   } }

Это решает проблему соблюдения принципа инверсии зависимостей.

Но если Android никогда не будет использовать этотCalculator, и мы создадим такой интерфейс заранее, возможно, мы нарушилиYAGNI.


TL; DR;

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

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

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

Программное обеспечение по своей природе МЯГКОЕ и сделать его навсегда следующим SOLID сложно.Для программного обеспечения применение принципов SOLID - это цель, а не судьба.

Подробнее..

Prototype Design Pattern в Golang

24.05.2021 18:21:26 | Автор: admin

Привет друзья! С вами Алекс и я продолжаю серию статей, посвящённых применению шаблонов проектирования в языке Golang.

Интересно получать обратную связь от вас, понимать на сколько применима данная область знаний в мире языка Golang. Ранее уже рассмотрели шаблоны: Simple Factory, Singleton и Strategy. Сегодня хочу рассмотреть еще один шаблон проектирования - Prototype.

Для чего нужен?

Это порождающий шаблон проектирования, который позволяет копировать объекты, не вдаваясь в подробности их реализации.

Какую проблему решает?

Представьте, у вас есть объект, который необходимо скопировать. Как это сделать? Создать пустой объект такого же класса, затем поочерёдно скопировать значения всех полей из старого объекта в новый. Прекрасно, но есть нюанс! Не каждый объект удается скопировать таким образом, ведь часть его состояния может быть приватной, а значит - недоступной для остального кода программы.

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

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

Шаблон Prototype поручает создание копий самим копируемым объектам. Он вводит общий интерфейс для всех объектов, поддерживающих клонирование. Это позволяет копировать объекты, не привязываясь к их конкретным классам. Обычно такой интерфейс имеет всего один метод clone.

Реализация этого метода в разных классах очень схожа. Метод создаёт новый объект текущего класса и копирует в него значения всех полей собственного объекта. Так получится скопировать даже приватные поля, так как большинство языков программирования разрешает доступ к приватным полям любого объекта текущего класса. Объект, который копируют, называется прототипом, отсюда и название шаблона. Когда объекты программы содержат сотни полей и тысячи возможных конфигураций, прототипы могут служить своеобразной альтернативой созданию подклассов. Шаблон прототип должен копировать объекты любой сложности без привязки к их конкретным классам.

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

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

Prototype Class DiagramPrototype Class Diagram

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

Как реализовать?

Подробно пошаговую реализацию данного шаблона, а также других шаблонов проектирования на языке PHP можно посмотреть тут. Наша задача реализовать шаблон Prototype на языке Golang.

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

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

Каждую рубрика, как конечный элемент рубрикатора, может быть представлен интерфейсом prototype, который объявляет функцию clone. За основу конкретных прототипов рубрики и раздела мы берем тип struct, которые реализуют функции show и clone интерфейса prototype.

Итак, реализуем интерфейс прототипа. Далее мы реализуем конкретный прототип directory, который реализует интерфейс prototype представляет раздел рубрикатора. И конкретный прототип для рубрики. Обе структуру реализуют две функции show, которая отвечает за отображение конкретного контента ноды и clone для копирования текущего объекта. Функция clone в качестве единственного параметра принимает аргумент, ссылающийся на тип указателя на структуру конкретного прототипа - это либо рубрика, либо директория. И возвращает указатель на поле структуры, добавляя к наименованию поля _clone.

В клиенте создаем древовидную структуру рубрикатора. Копируем одну из директорий со всеми дочерними элементами. Видим, что все дерево успешно скопировано благодаря реализации функция клонирования для каждого конкретного прототипа. Вывод:

Open directory 2  Directory 2    Directory 1        category 1    category 2    category 3Clone and open directory 2  Directory 2_clone    Directory 1_clone        category 1_clone    category 2_clone    category 3_clone

Когда применять?

  1. У вас много объектов с множеством свойств и вам нужно создавать их клоны быстро и эффективно. Шаблон предлагает использовать набор прототипов, вместо создания подклассов для описания популярных конфигураций объектов. Вместо порождения объектов из подклассов, вы копируете существующие объекты-прототипы, в которых уже настроено состояние. Тем самым избегая роста количества классов в программе и уменьшая её сложность.

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

Итог

Друзья, шаблон Prototype предлагает:

  • Удобную концепцию для создания копий объектов.

  • Помогает избежать дополнительных усилий по созданию объекта стандартным путём, когда это непозволительно дорого для приложения.

  • В объектных языках позволяет избежать наследования создателя объекта в клиентском приложении, как это делает паттерн abstract factory, например.

Кстати, друзья, вот тут можно посмотреть результаты опроса читателей хабра. 63% опрошенных считают, что применение шаблонов проектирования в Golang - это зло. Связано, скорее всего, с тем, что язык Golang процедурный и ему чужды подходы объектно-ориентированных языков. Но рассматривать реализации и применение шаблонов стоит, так как это позволяет больше их понимать и периодически применять для решения тех или иных задач. Каждый подход требует, конечно, дискуссии и разумного применения.

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

Подробнее..

Принцип подстановки Барбары Лисков (предусловия и постусловия)

28.05.2021 00:20:41 | Автор: admin

Почему у многих возникают проблемы с этим принципом? Если взять не заумное, а более простое определение, то оно звучит так:

Наследующий класс должен дополнять, а не замещать поведение базового класса.

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

В данной статье мы НЕ будем рассматривать общие примеры данного принципа, о которых уже есть много материалов (пример с квадратом и прямоугольником или управления термостатами). Здесь мы немного подробнее остановимся на таких понятиях как Предусловия, Постусловия, рассмотрим что такое ковариантность, контравариантность и инвариантность, а также что такое исторические ограничения или правило истории.

Предусловия не могут быть усилены в подклассе

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

<?phpclass Customer{    protected float $account = 0;    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        $this->account += $sum;    }}class  MicroCustomer extends Customer{    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        // Усиление предусловий        if ($sum > 100) {             throw new Exception('Вы не можете положить на больше 100$');        }        $this->account += $sum;    }}

Добавление второго условия как раз является усилением. Так делать не надо!

К предусловиям также следует отнести Контравариантность, она касается параметров функции, которые может ожидать подкласс.

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

Этот пример показывает, как расширение допускается, потому что метод Bar->process() принимает все типы параметров, которые принимает метод в родительском классе.

<?phpclass Foo{    public function process(int|float $value)    {       // some code    }}class Bar extends Foo{    public function process(int|float|string $value)    {        // some code    }}

Пример ниже показывает, как дочерний класс VIPCustomer может принимать в аргумент переопределяемого метода putMoneyIntoAccount более широкий (более абстрактный) объект Money, чем в его родительском методе (принимает Dollars).

<?phpclass Money {}class Dollars extends Money {}class Customer{    protected Money $account;    public function putMoneyIntoAccount(Dollars $sum): void    {        $this->account = $sum;    }}class VIPCustomer extends Customer{    public function putMoneyIntoAccount(Money $sum): void    {        $this->account = $sum;    }}

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

Постусловия не могут быть ослаблены в подклассе

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

<?phpclass Customer{    protected Dollars $account;    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($result < 0) { // Постусловие            throw new Exception();        }        return $result;    }}class  VIPCustomer extends Customer{    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($sum < 1000) { // Добавлено новое поведение            $result -= 5;          }               // Пропущено постусловие базового класса              return $result;    }}

Условное выражение проверяющее результат является постусловием в базовом классе, а в наследнике его уже нет. Не делай так!

Сюда-же можно отнести и Ковариантность, которая позволяет объявлять в методе дочернего класса типом возвращаемого значения подтип того типа (ШО?!), который возвращает родительский метод.

На примере будет проще. Здесь в методе render() дочернего класса, JpgImage объявлен типом возвращаемого значения, который в свою очередь является подтипом Image, который возвращает метод родительского класса Renderer.

<?phpclass Image {}class JpgImage extends Image {}class Renderer{    public function render(): Image    {    }}class PhotoRenderer extends Renderer{    public function render(): JpgImage    {    }}

Таким образом в дочернем классе мы сузили возвращаемое значение. Не ослабили. Усилили :)

Инвариантность

Здесь должно быть чуть проще.

Все условия базового класса - также должны быть сохранены и в подклассе.

Инварианты это некоторые условия, которые остаются истинными на протяжении всей жизни объекта. Как правило, инварианты передают внутреннее состояние объекта.

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

<?php class Wallet{    protected float $amount;    // тип данного свойства не должен изменяться в подклассе}

Здесь также стоит упомянуть исторические ограничения (правило истории):

Подкласс не должен создавать новых мутаторов свойств базового класса.

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

<?phpclass Deposit{    protected float $account = 0;    public function __construct(float $sum)    {        if ($sum < 0) {            throw new Exception('Сумма вклада не может быть меньше нуля');        }        $this->account += $sum;    }}class VipDeposit extends Deposit{    public function getMoney(float $sum)    {        $this->account -= $sum;    }}

С точки зрения класса Deposit поле не может быть меньше нуля. А вот производный класс VipDeposit, добавляет метод для изменения свойства account, поэтому инвариант класса Deposit нарушается. Такого поведения следует избегать.

В таком случае стоит рассмотреть добавление мутатора в базовый класс.

Выводы

Даже не вникая в общие сложные абстрктные примеры самого принципа подстановки Барбары Лисков, а пользуясь этими, на мой взгляд, более простыми и более конкретными правилами, вы уже добьётесь более предсказуемого поведения дочерних классов.

Стоит упомянуть, что нужно страться избавляться от пред/пост условий. В идеале они должны быть определенны как входные/выходные параметры метода (например передачей в сигнатуру готовых value objects и возвращением конкретного валидного объекта на выход).

Надеюсь, было полезно.

Источники

  1. Вики - Принцип подстановки Барбары Лисков

  2. Metanit

  3. PHP.watch

  4. Telegram канал, с короткими заметками

Подробнее..

CDD Cli Driven Development

03.04.2021 10:09:09 | Автор: admin

Все-таки самоизоляция не проходит бесследно. Сидишь себе дома, а в голову разные мысли приходят. Как, чем осчастливить человечество? И вот оно: CDD! (И еще PDD / SOLID / KISS / YAGNI / TDD / Bootstraping...)

1. CDD - Cli Driven Development - Новый подход

Немного истории

Как-то поручили мне сделать Cli в одном нашем embedded устройстве. Разумеется, C/C++ (пусть будет C++, раз ресурсов хватает). Конечно есть много Cli-фреймворков.

Но я сделал свой вариант.

Для Linux можно использовать <termios.h> и получать коды символов после установки свойств терминала:

signal(SIGINT, SIGINT_Handler); // Ctrl+Csignal(SIGTSTP, SIGTSTP_Handler); // Ctrl+Zint res_tcgetattr = tcgetattr(STDIN_FILENO, &terminal_state_prev);terminal_state_new = terminal_state_prev;terminal_state_new.c_lflag &= ~(ICANON | ECHO);int res_tcsetattr = tcsetattr(STDIN_FILENO, TCSANOW, &terminal_state_new);

Для Windows можно использовать <conio.h>.

Добавляем немного классов, делаем список команд, и добавляем команды по типу:

{ Cli_Command_Abstract_t *cmd = new Cli_Command_Abstract_t(Cli_Command_ID_help); cmd->Add(help_keyword); cmd->Help_Set("show this help, \"help full\" - show all available commands"); command_tree->Add(cmd);}

И все-бы ничего, пока команд 10-20. Ну пусть еще help / quit / debug cli (типа очень нужная команда - об этом позже). Интересно, что основной функционал уложился в 20 команд, а вот разные обвязки Управление SNMP / Syslog / NTP / Users / FTP / SSH / VLAN и у нас - 250 команд. Ух ты! Начинаются проблемы с монолитным приложением, и очень хочется разбить все на модули, желательно попроще и поменьше. И вот отсюда и начинается CDD - Cli Driven Development.

1.1 Использование Cli в различных типах приложений

Вообще, Cli, не смотря на GUI, используется во многих типах приложений: САПР, игры, базы данных, среды выполнения (Erlang, Lua и др.), IDE. Можно утверждать, что включение консоли могло бы сделать многие приложения более удобными (например, можно представить Paint с командной строкой: количество команд невелико, VBA будет лишним, но одна лишь возможность выполнения скриптов могла бы значительно изменить работу с программой).

1.2 Введение в CDD

Cli-интерфейс жив и развивается. Cisco-like - это вполне вполне рабочий термин.

Что же может современный Cli? - Довольно много:

  • развитую систему команд с выводом подробной информации, в том числе об аргументах команды;

  • группировку команд ("уровни");

  • задание группы объектов для управления ("параметры");

  • логгирование;

  • исполнение скриптов;

  • типизированный ввод данных с валидацией;

Я придумал еще одну функцию: debug cli - проверка команд (CMD_ID / CMD_Item / CMD_Handler)

  • может показать число ID ("задуманные команды"), Realized- и NotRealized-команды для каждого модуля; (В идеале счетчики ID, Realized должны быть равны, но если NotRealized не равен 0, то это еще один стимул для разработчика: ну осталось всего-то 30...20...5...2 нереализованных команд - неужели оставим так? может лучше доделать? - и это работает!)

1.3 Основные идеи CDD

Можно сформулировать основные идеи CDD:

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

  2. Модульное построение: любой модуль можно убрать или заменить с предсказуемыми изменениями в функционале.

  3. Модули связываются только на самом верхнем уровне: все связи должны легко прослеживаться (фактически мы пользуемся тем, что приложений с полной связностью не существует / не может быть / мы должны избегать).

1.4 mCli - Реализация CDD

CDD использовано при построении mCli - Cli-фреймворка модульного типа (github.com/MikeGM2017/mCli). В текущем состоянии имеются события, типы и модули.

1.4.1 События mCli

В простейшем виде для ввода с клавиатуры нужно определение кода нажатой клавиши и (отдельно) определение нажатия Enter (ввод команды) и Ctrl+C (прерывание команды). В полном наборе необходимо определение нажатия Enter (ввод команды), Ctrl+C (прерывание команды), Up/Down (просмотр истории команд), Left/Right/Home/End (перемещение по строке ввода), Back/Delete (изменение строки ввода).

1.4.2 Типы mCli

mCli предполагает использование типов при вводе данных. В текущей реализации имеются следующие типы:

  • Word / Word_List / Word_Range (ключевые слова, List - можно ввести несколько ключевых слов через запятую, Range - выбор одного ключевого слова из нескольких вариантов)

  • Int / Int_List / Int_Range

  • Str

  • IP4 / IP6

  • MAC

  • Date / Time / DateTime

  • EQU_Range ( == != > < >= <= - для использования в скриптах, условное выполнение)

  • Rem (комментарий - для использования в скриптах)

1.4.3 Модули mCli

Модули mCli можно разделить на базовые, платформо-зависимые и кастомные.

Базовые модули:

  • Base_Quit (выход из приложения)

  • Base_Help (вывод информации по командам и их аргументам)

  • Base_Modules (вывод информации по задействованным модулям)

  • Base_History (история команд)

  • Base_Script (выполнение скриптов)

  • Base_Rem (комментарий, для использования в скриптах)

  • Base_Wait (пауза, для использования в скриптах)

  • Base_Log (управление логом)

  • Base_Debug (проверка списка команд, определение нереализованных команд)

  • Check (условное выполнение, для использования в скриптах)

Платформо-зависимые модули

Вывод:

  • Output_printf (Linux/Window)

  • Output_cout (Linux/Window)

  • Output_ncurses (Linux)

  • Output_pdcurses (Linux/Window)

Ввод:

  • Input_termios (Linux)

  • Input_conio (Window)

  • Input_ncurses (Linux)

  • Input_pdcurses (Linux/Window)

Кастомные модули:

  • ConfigureTerminal (демо: тестирование переменных)

  • SecureTerminal (демо: вход в модуль по паролю)

  • TestTerminal (демо: тестирование типов)

1.5 Объединение модулей в mCli

Связывание модулей происходит на самом верхнем уровне, например в функции main():

Cli_Modules Modules;// Modules Add - BeginModules.Add(new Cli_Module_Base_Rem(Str_Rem_DEF, Cli_Output));bool Cmd_Quit = false;Modules.Add(new Cli_Module_Base_Quit(Cmd_Quit));Str_Filter str_filter('?', '*');Modules.Add(new Cli_Module_Base_Help(User_Privilege, Modules, str_filter, Cli_Output));Modules.Add(new Cli_Module_Base_Modules(User_Privilege, Modules, str_filter, Cli_Output));Cli_History History;Modules.Add(new Cli_Module_Base_History(History, Cli_Output));Modules.Add(new Cli_Module_Base_Log(Cli_Input));bool Cmd_Script_Stop = false;int Script_Buf_Size = 1024;Modules.Add(new Cli_Module_Base_Script(History, Cli_Output,            Str_Rem_DEF, Cmd_Script_Stop, Cmd_Quit, Script_Buf_Size,            CMD_Processor));bool Log_Wait_Enable = true;bool Cmd_Wait_Stop = false;Modules.Add(new Cli_Module_Base_Wait(Log_Wait_Enable, Cmd_Wait_Stop, Cli_Input, Cli_Output));Modules.Add(new Cli_Module_Test_Tab_Min_Max());Modules.Add(new Cli_Module_Test_Terminal(Cli_Input, Cli_Output));Modules.Add(new Cli_Module_Base_Debug(User_Privilege, Modules, Levels, CMD_Processor, Cli_Output));Modules.Add(new Cli_Module_Check(Modules, Values_Map, str_filter, Cli_Output, Cmd_Script_Stop));// Modules Add - End

1.6 CDD и SOLID

SOLID в CDD достаточно легко обнаружить на уровне подключения и объединения модулей. Какие-то модули практически всегда используются, например Cli_Output нужен в большинстве модулей. Другие - гораздо реже (например, Cli_Input нужен только в модулях, в которых команда требует подтверждения).

Таким образом, SOLID в CDD - это:

  • S - каждый модуль отвечает за свой круг задач

  • O - здесь есть проблема: в каждом модуле есть enum Local_CmdID, и получается, что при наследовании список Local_CmdID не так просто расширить? Но в новом модуле мы можем завести новый enum Local_CmdID или (лучше) можно ввести новый enum Local_CmdID только для новых команд, стартующий с последнего элемента предыдущего enum (для этого можно использовать CMD_ID_LAST)

  • L - модуль может быть заменен на другой, с доработанной реализацией

  • I - при замене модуля может возникнуть ситуация, что потребуется больше (или меньше) связанных модулей; при создании экземпляра модуля это легко учесть (через конструктор или статический инициализатор)

  • D - модули связываются на верхнем уровне

1.7 CDD и KISS

В целом, набор команд любого модуля стремится к расширению: более удобный формат команд, новые флаги, новые возможности. Но сам список команд всегда доступен, и, если он сильно разрастается то просто делим модуль на 2 или более модуля!

На уровне модуля команды могут различаться флагами или дополнительными параметрами. Но реализация у них может быть одна. Например, "help" и "help full" реализуются одним методом, в качестве параметра принимающий строку фильтра - "*". Так что KISS сохраняется в таком смысле:

  • команда выполняется методом, имеющим несколько флагов (да, из-за этого метод делается чуть сложнее, зато несколько команд Cli могут выполняться однотипно).

1.8 CDD и DRY

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

1.9 CDD и YAGNI

Нужно убрать какой-то ненужный функционал? - Убираем ненужный модуль (или команды в модуле). За счет слабой связности модулей это несложно.

1.10 CDD и Bootstraping

В некоторых случаях (например, Embedded Baremetal) у нас есть только консоль. CDD может быть применено для разработки приложения "с нуля".

1.11 CDD и TDD

За счет наличия скриптов и модуля условного исполнения автоматизация тестирования сводится к следующему сценарию:

  • вручную вводится последовательность тестируемых команд;

  • история команд сохраняется в файле скрипта;

  • при необходимости скрипт редактируется / дополняется проверкой правильности выполнения;

  • вызов скрипта добавляется в общий скрипт тестирования или используется сам по себе;

1.12 CDD и GUI

А что GUI? GUI (да и Web - тоже) пусть посылает текстовые команды в Cli - эстетично, наглядно, надежно.

2. CDD и PDD

А вот еще и PDD!!!

2.1 PDD - Provocation Driven Development - еще один новый термин :)

Вообще, PDD - это то, что нас настигает постоянно. Допустим, есть путь, по которому мы идем к цели. Но на что нас провоцирует этот путь? Считаю, что мы должны осознавать это. Например, на что провоцируют языки программирования:

  • C провоцирует на нарушения доступа к памяти и на плохо контролируемые приведения типов;

  • C++ - на создание монолита (если за этим не следить, то имеем типовой пример: classMyCoolGame;myCoolGame.Run());

  • SQL, Lua - "все есть таблица";

  • Assembler - "стандартов нет";

  • Java - "щас понаделаем объектов";

  • JavaScript - "щас наподключаем библиотек, не самим же все делать"; и так далее - дополнительные примеры каждый, думаю, сможет придумать.

2.2 Что есть PDD для CDD?

В первую очередь - это тенденция на разбиение проекта на модули. Действительно:

  • Есть объект управления? - Выносим в модуль.

  • Есть повторяющийся код? - Выносим в модуль.

  • Новый функционал? - Добавляем новый модуль.

  • Новая архитектура? - Заменяем модули.

Описание команд - это текстовое описание функционала, фактически мы получаем DSL. Чтобы получить информацию о доступном функционале, достаточно ввести команду "help".

Предсказательный характер архитектуры:

  • пусть в расчетах на каждую Cli-команду отводим 1 (один) человеко-день. Да, можно за 1 день ввести 10-20 простых Cli-команд (да, простые или однотипные команды реализуются быстро), но не нужно обманываться: будет (обязательно будет!) функция, которая потребует 10 дней на реализацию и тестирование. Поэтому проект средней сложности на 200-300 Cli-команд займет 200-300 человеко-дней (хотя, это скорее оценка "сверху", реально проект может быть закончен раньше).

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

Расширяемость: новый модуль может, конечно, добавлять новые команды в глобальную видимость, но предпочтительный путь - группировка команд на уровне модуля, тогда в глобальной видимости появится всего лишь одна команда - переход на "уровень" модуля.

В Cli достаточно легко задавать обработку группы объектов. Можно, например:

  • ввести список объектов в команду;

  • ввести фильтр по именам объектов в команду;

  • ввести список объектов как параметр;

  • или даже ввести модуль Selection для управления группировкой объектов. Таким образом, можно использовать различные варианты групповой обработки.

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

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

3. Встроенный язык скриптов

3.1 Модуль Check

Условное выполнение реализовано в модуле "Check".

Для условного выполнения команд, в принципе, достаточно всего двух команд: "check label " - установка метки "check if == goto " - условный переход (здесь сравнение может быть не только на равенство: == != > < >= <= - вот полный список, но при этом команду можно оставить одну и ту же, а операторы сравнения ввести в виде списка возможных значений)

Переменные в простейшем случае - глобальные, заносятся в map<string,string>, для чего в модуле предусмотрен виртуальный метод .To_Map().

Для работы с переменными введены команды условного и безусловного присвоения, объединения, вывода на экран. Для полноценного языка этого, возможно, мало, но для задач тестирования функционала - вполне приемлемо.

3.2 Модуль Check vs Lua

Да, вместо встроенных модулей скриптов и условного выполнения можно подключить Lua. Однако, вместо нескольких команд (в действительности модуль условного выполнения Check получается не такой уж маленький - более 30 команд, хотя и однотипных) подключение Lua означает большое увеличение размера исполняемого файла, а в некоторых случаях это может быть критичным. Но как вариант, Lua выглядит очень привлекательно.

3.3 Модуль Check vs Erlang

Было бы неплохо, но запросы Уж очень большие у Erlang требования к ресурсам. Возможно, на "жирных" устройствах, как вариант, можно и Erlang подключить.

4. CDD vs Erlang

Неплохая попытка, подход Erlang - довольно похож на CDD. Но задумаемся, в чем PDD для Erlang? - "Ошибаемся и еще раз ошибаемся, а система все равно работает". Это, конечно, сильно. Поэтому вопрос: "CDD или Erlang" безусловно стоит. Но CDD можно реализовать на многих языках программирования (C/C++, C#, Java, JavaScript). А у Erlang - очень специфичный подход. Может быть, не Erlang vs CDD, а Erlang + CDD ??? Кажется, надо попробовать...

5. CDD и дробление монолита

Примерный путь преобразования монолита в CDD-приложение:

  • создаем CDD-приложение из Base-модулей;

  • legacy-монолит добавляем в виде нового Cli-модуля на новом "уровне" с минимальными командами вида "version get" / "info get" - на первом этапе достаточно "установить контакт" с монолитом;

  • в новом модуле вводим команды, специфичные для него: "start" / "stop" / "configure" ;

  • скорее всего новые команды будут группироваться вокруг каких-то понятий / объектов / процедур и т.п. - это повод выделить такие группы в отдельные модули + объекты управления; при этом в основном монолите вводятся ссылки на выделенные объекты;

  • в результате должен получиться набор модулей, причем каждый модуль должен содержать не более 10-20 команд;

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

6. Итоги

CDD выполняет SOLID, KISS, DRY, YAGNI, Bootstraping, TDD.

CDD провоцирует на модульное построение.

CDD дает возможность выполнения скриптов и внутреннее тестирование.

CDD может быть основой большого количества типов приложений.

CDD позволяет вводить новый функционал прогнозируемым способом.

CDD может быть основой построения OS.

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

CDD дает возможность разделения работ:

  • постановщик задачи описывает новый модуль в виде набора команд;

  • исполнитель реализует команды;

  • тестировщик пишет скрипты для проверки нового функционала.

CDD поддерживает введение нового функционала, в том числе на разных уровнях:

  • новые модули;

  • новые команды в существующих модулях.

CDD обеспечивает безопасность при вводе команд:

  • команды парсятся, данные валидируются, сделать что-то вне Cli-команд невозможно (если, конечно, не вводить команды типа exec / system / eval).

CDD фактически дает документацию по функционалу приложения:

  • достаточно подать команду "help * verbose" - и описание команд и их аргументов уже есть.

Этого мало?

Тогда вот вам напоследок: CDD позволяет захватить мир. КМК

Да, и Linux стоит переписать по CDD. КМК

Подробнее..

Конструирование эпидемиологических моделей

09.04.2021 20:23:06 | Автор: admin

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

За несколько месяцев до появления первых новостей о COVID-19 я для себя, так скажем, в образовательных целях программирования, начал писать программу визуализации эпидемий. В этой простой программе кружочки разных цветов двигались по полю и заражали друг друга. Через полгода, каким-то образом это уже стало темой моей дипломной работы (специальность биоинженерия и биоинформатика) и пришлось по-настоящему вникать в математическое моделирование эпидемий для написания статей. Благо интернет вдруг стал наполняться эпидемиологическими материалами. Я это виду к тому, что работать я начал за некоторое время до того, как эпидемиология стала мейнстримом.

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

Просто о SIR модели

Модель SIR, без упоминания которой не обходится почти ни одна статья по эпидемиологии, создана уже почти век назад. Она проста и элегантна, её много критикуют и много хвалят, как, собственно, любую сущность, которая применима лишь в определённых условиях и имеет свои допущения, о которых необходимо знать во время использования.

Я понимаю, что медиапространство заполнено всевозможными описаниями данной модели, поэтому я не побоюсь и добавлю ещё одно.

SIR модель. Всю популяцию, которая подвержена эпидемии, разделили на три группы: S (susceptible восприимчивые, то есть здоровые, не заразные, но могут заразиться, так как не имеют иммунитета), I (infected инфицированные, то есть болеют и заразны) и R (recovered выздоровевшие, то есть здоровые, не заразные и не могут заразиться, так как имеют иммунитет). Очевидно, что до начала эпидемии 100 % индивидов находятся в группе S (восприимчивые) и по нулям в остальных группах. Для удобства будем считать, популяция в 100 человек, соответственно S = 100, I = 0, R = 0. В таких условиях эпидемия, конечно, не пойдёт, так как чтобы она началась, должен быть хотя бы один больной. Поэтому рассмотри другую ситуацию: S = 99, I = 1, R = 0. Вот теперь начнётся эпидемия и её моделирование заключается в последовательном высчитывания состояния популяции на следующем шаге.

Дальше чуть сложнее, чтобы понимать сколько людей заразиться на каждом шаге, надо понимать наличие двух вероятностей: вероятность контакта между двумя индивидами и вероятность заразить при контакте инфицированного с восприимчивым (). Часто в модели для воплощения первой вероятности используют просто 1/N (N объём популяции), подразумевая, что в каждый момент времени каждый индивид контактирует с одним случайным индивидом в популяции. А вторая вероятность (), обеспечивает собственно биологический показатель заразности конкретного патогена (со всеми влияющим факторами: температура, наличие маски и т.п.).

Один инфицированный встретит и заразит в конкретный момент времени конкретного восприимчивого с вероятностью:

Тогда всего он заразит восприимчивых индивидов:

А все инфицированные вместе заразят восприимчивых

Таким образом, количество восприимчивых на втором шаге нашей модели уменьшиться на 0.99*.

Ещё инфицированные выздоравливают, тоже с какой-то вероятностью (), которую часто рассматривают как число обратное времени болезни. Имеется в виду, что если болезнь длится 10 дней, то больной индивид в конкретный день выздоровеет с вероятностью = 1/10. Получается количество выздоравливающих на каждом шаге будет равно:

Количество инфицированных на втором шаге, помимо того, что увеличится на 0.99* ещё уменьшится на 1*. Количество выздоровевших увеличится на 1*. Относительного полученного состояния будет высчитываться следующий шаг модели.

Таким образом модель формулируется следующими уравнениями:

Модификация SEIR

О SIR модели слышали теперь довольно многие. О существовании других моделей слышало уже меньше людей. Часто всё-таки вспоминают SEIR модель, которую рассматривают как модификацию SIR.

SEIR модель учитывает инкубационный период (E exposed, индивиды болеют, но не заразны и со временем полностью заболеют). В такой модели заражение восприимчивых происходит таким же способом как в модели SIR, но попадают такие особи не в группу I, а в группу E. А из E с определённой вероятностью (, число обратное длительности инкубационного периода) происходит переход уже в I.

Компартментальные модели в целом

Существует ещё много модификаций SIR моделей. Все они, включая саму SIR, являются представителями целого класса моделей, которые называют компартментальными эпидемиологическими моделями. Упоминаемое выше разделение популяции на группы или компартменты (отсеки) как раз и обуславливает такое название моделей.

Сложность компартментальных моделей не ограничена тремя или четырьмя группами. Такие модели могут учитывать самые различные сценарии: введение карантинных мер (SIQR, добавляется группа Q quarantine), потеря иммунитета (SIRS, переход с некоторой вероятностью из R обратно в S), группы риска у восприимчивых (несколько групп S: S1, S2, , каждая из которых со своей вероятностью заражается), различные варианты течения болезни (несколько групп I: I1, I2, , в каждую из которых своя вероятность попадания восприимчивых особей, а также у каждой допустим своя заразность) и т.д. Ограничением здесь является только фантазия исследователя.

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

Схема распространения туберкулёзаСхема распространения туберкулёза

Реализация компартментальных моделей заключается в описании уравнений переходов и последующем расчёте изменений каждого компартмента в течение какого-то периода.

DEMMo (конструктор моделей)

В этом и заключается назначение разрабатываемой мной программы DEMMo. DEMMo Designer of Epidemic Math Models (конструктор эпидемиологических математических моделей). Конструированные модели организуется в объектно-ориентированном виде. Есть класс стадии (аналог компартмента), класс потока (обеспечивает переход индивидов между стадиями) и внешнего потока (прибавление/вычитание индивидов к/из стадии). Подробную инструкцию по использованию программы попытался написать в документации.

Результаты различных моделей, реализованных с использованием программыРезультаты различных моделей, реализованных с использованием программы

Программу писал на python. Интерфейс на PyQt5. Выложил исходный код программы на github (с git работал впервые) и архив с готовой версией для windows на гугл диск. Будем считать, что начинаю бета тестирование программы. Надеюсь, я хоть немного понятно объяснил и для тех, кому интересно, буду очень рад если программу жестоко поэксплуатируют. Вроде бы настроил систему создания отчётов об ошибках. Это мой первый крупный проект после задачек на ряды Фибоначчи и т.п.

Код очень плохо задокументирован, причём часть комментариев на русском, а часть на английском. Знаю, что плохо, каюсь. Надеюсь займусь этим. Если будут какие-то конкретные советы от матёрых программистов, буду очень рад.
Контактная почта: demmo.development@gmail.com

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

Подробнее..

Как синхронизировать сценарий без транзакций? Штатными средствами Java

15.06.2021 02:14:19 | Автор: admin

Давайте представим, что вы параноик, и параноик вдвойне, когда дело касается многопоточности. Предположим, что вы делаете backend некого функционала приложения, а приложение переодически дергает на вашем серверы какие-то методы. Все вроде хорошо, но есть одно но. Что если ваш функционал напрямую зависит от каких-либо других данных, того же банального профиля например? Встает вопрос, как гарантировать то, что сценарий отработает именно так, как вы планировали и не будет каких-либо сюрпризов? Транзакции? Да это можно использовать, но что если Вы фантастический параноик и уже представляете как к вам на сервер летит 10 запросов к одному методу от разных клиентов и все строго в одно время. А в этот момент бизнес-логика данного метода завязана на 100500 разных данных. Как всем этим управлять? Можно просто синхронизировать метод и все. Но что если летят еще и те запросы, держать которые нет смысла? Тут уже начинаются костыли. Я пару раз уже задавался подобным вопросом, и были интересно, ведь задача до абсурда простая и повседневная (если вы заботитесь о том, чтобы не было логических багов конечно же :). Сегодня решил подумать, как это можно очень просто и без костылей реализовать. И решение вышло буквально на 100 строк кода.

Немного наглядного примера

Давайте предположим, что есть водитель и есть пассажир. Водитель не может менять машину до тех пор, пока клиент, например подтверждает поездку. Это что получается, клиент соглашался на поездку с одними характеристиками машины, а по факту у водителя другая машина? Не дела! Можно организовать что-то подобное:

String result = l.lock(new ArrayList<Locker.Item>() {{    add(new Locker.Item(SimpleType.TRIP, 1));    add(new Locker.Item(SimpleType.USER, 2));}}, () -> {    // Тут выполняем отмену поездки и держим водителя на привязи    // Кстати если кто-то где-то вызовет USER=2 (водитель), то он также будет ждать    // ну или кто-то обратится к поездке TRIP=1    // А если обратится к USER=3, то уже все будет нормально :)    // так как никто не блокировал третьего пользователя :)    return "Тут любой результат :)";    });

Элегантно и просто! :)

Исходники тут - https://github.com/GRIDMI/GRIDMI.Sync

Камнями не бросаться! :)

Подробнее..

В поисках упорядоченного множества в Python разбираемся с теорией и выбираем лучшую реализацию

24.05.2021 10:16:39 | Автор: admin


Множество (Set) структура данных, которая позволяет достаточно быстро (в зависимости от реализации) применить операции add, erase и is_in_set. Но иногда этого не достаточно: например, невозможно перебрать все элементы в порядке возрастания, получить следующий / предыдущий по величине или быстро узнать, сколько элементов меньше данного есть в множестве. В таких случаях приходится использовать Упорядоченное множество (ordered_set). О том, как оно работает, и какие реализации есть для питона далее.


Стандартный Set


В языке Python есть стандартная стукрура set, реализованная с помощью хэш-таблиц. Такую структуру обычно называют unordered_set. Данный метод работает так: каждый элемент присваивается какому-то классу элементов (например, класс элементов, имеющих одинаковый остаток от деления на модуль). Все элементы каждого класса хранятся в одтельном списке. В таком случае мы заранее знаем, в каком списке должен находиться элемент, и можем за короткое время выполнить необходимые операции. Равновероятность каждого остатка от деления случайного числа на модуль позволяет сказать, что к каждому классу элементов будет относиться в среднем size / modulo элементов.


Но хэш-таблица не позволяет выполнить операцию count_lower или подобные, поэтому придётся использовать другие структуры данных.


Что есть в других языках


В языке c++ есть структура std::set, которая поддерживает операции изменения, проверку на наличие, следующий / предыдущий по величине элемент, а также for по всем элементам. Но тут нет операций получения элемента по индексу и индекса по значению, так что надо искать дальше (индекс элемента количество элементов, строго меньших данного)


И решение находится достаточно быстро: tree из pb_ds. Эта структура в дополнение к возможностям std::set имеет быстрые операции find_by_order и order_of_key, так что эта структура именно то, что мы ищем.


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


Таким образом, целью этой статьи станет поиск аналога этой структуры в Python.


Как будем тестировать скорость работы структур данных


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


  1. Добавление в множество миллиона случайных чисел (при данном сиде среди них будет 999'936 различных)
  2. Проверка миллиона случайных чисел на присутствие в множестве
  3. Прохождение циклом по всем элементам в порядке возрастания
  4. В случайном порядке для каждого элемента массива узнать его индекс (а, соответственно, и количество элементов, меньше данного)
  5. Получение значения i-того по возрастанию элемента для миллиона случайных индексов
  6. Удаление всех элементов множества в случайном порядке

from SomePackage import ordered_setimport randomimport timerandom.seed(12345678)numbers = ordered_set()# adding 10 ** 6 random elements - 999936 uniquelast_time = time.time()for _ in range(10 ** 6):    numbers.add(random.randint(1, 10 ** 10))print("Addition time:", round(time.time() - last_time, 3))# checking is element in set for 10 ** 6 random numberslast_time = time.time()for _ in range(10 ** 6):    is_element_in_set = random.randint(1, 10 ** 10) in numbersprint("Checking time:", round(time.time() - last_time, 3))# for all elementslast_time = time.time()for elem in numbers:    now_elem = elemprint("Cycle time:", round(time.time() - last_time, 3))# getting index for all elementslast_time = time.time()requests = list(numbers)random.shuffle(requests)for elem in requests:    answer = numbers.index(elem)print("Getting indexes time:", round(time.time() - last_time, 3))# getting elements by indexes 10 ** 6 timesrequests = list(numbers)random.shuffle(requests)last_time = time.time()for _ in range(10 ** 6):    answer = numbers[random.randint(0, len(numbers) - 1)]print("Getting elements time:", round(time.time() - last_time, 3))# deleting all elements one by onerandom.shuffle(requests)last_time = time.time()for elem in requests:    numbers.discard(elem)print("Deleting time:", round(time.time() - last_time, 3))

SortedSet.sorted_set.SortedSet


Пакет с многообещающим названием. Используем pip install sortedset


К сожалению, автор не приготовил нам функцию add и erase в каком-либо варианте, поэтому будем использовать объединение и вычитание множеств


Использование:


from SortedSet.sorted_set import SortedSet as ordered_setnumbers = ordered_set()numbers |= ordered_set([random.randint(1, 10 ** 10)])  # добавлениеnumbers -= ordered_set([elem])  # удаление

Протестируем пока на множествах размера 10'000:


Задача Время работы
Добавление 16.413
Проверка на наличие 0.018
Цикл по всем элементам 0.001
Получение индексов 0.008
Получение значений по индексам 0.015
Удаление 30.548

Как так получилось? Давайте загляем в исходный код:


def __init__(self, items=None):    self._items = sorted(set(items)) if items is not None else []def __contains__(self, item):    index = bisect_left(self._items, item)

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


Вывод: почти бесполезно, несколько строчек кода завернули в класс


sortedcontainers.SortedSet


Внеший пакет, для установки можно использовать pip install sortedcontainers. Посмотрим же, что он нам покажет


Задача Время работы
Добавление 3.924
Проверка на наличие 1.198
Цикл по всем элементам 0.162
Получение индексов 3.959
Получение значений по индексам 4.909
Удаление 2.933

Но, не смотря на это, кажется мы нашли то, что искали! Все операции выполняются за приличное время. По сравнению с ordered_set некоторые операции выполняются дольше, но за то операция discard выполняется не за o(n), что очень важно для возможности использования этой структуры.


Также пакет нам предлагает SortedList и SortedDict, что тоже может быть полезно.


И как же оно работает?


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


Из-за особенностей реализации языка Python, в нём быстро работают list, а также bisect.insort (найти бинарным поиском за o(log n) место, куда нужно вставить элемент, а потом вставить его туда за o(n)). Insert работает достаточно быстро на современных процессорах. Но всё-таки в какой-то момент такой оптимизации не хватает, поэтому структуры реализованы как список списков. Создание или удаление списков происходит достаточно редко, а внутри одного списка можно выполнять операции даже за быструю линию.


Если говорить кратко, то принцип действия похож на корневую оптимизацию.


Проблема с ordered_set


Что вообще такое упорядоченное множество? Это множество, в котором мы можем сравнить любые 2 элемента и найти среди них больший / меньший. В течение всей статьи под операцией сравнения воспринималась операция сравнения двух элеметнов по своему значению. Но все пакеты называющиеся ordered_set считают что один элемент больше другого, если он был добавлен раньше в множество. Так что с формулировкой ordered_set нужно быть аккуратнее и уточнять, имеется ввиду ordered set или sorted set.


Bintrees



Так есть же модуль bintrees! Это же то, что нам нужно? И да, и нет. Его разработка была приостановлена в 2020 году со словами Use sortedcontainers instead.


Пакет предлагает нам несколько структур. К сожалению, ни одна из них не поддерживает операции find_by_order и подобные, так что эти струкруты являются аналогами std::set. Посмотрим же, на что они способны:


pip install bintrees


Название AVLTree говорит само за себя, RBTree красно-чёрное дерево, BinaryTree несбалансированное двоичное дерево, префикс Fast означает реализацию на Cython (соответственно, необходимо наличие Visual C++, если используется на Windows).


Задача AVLTree FastAVLTree RBTree FastRBTree BinaryTree FastBinaryTree
Добавление 21.946 2.285 20.486 2.373 11.054 2.266
Проверка на наличие 5.86 2.821 6.172 2.802 6.775 3.018
Цикл по всем элементам 0.935 0.297 0.972 0.302 0.985 0.295
Удаление 12.835 1.509 25.803 1.895 7.903 1.588

Результаты тестирования отчётливо показывают нам, почему использовать деревья поиска на Python плохая идея в плане производительности. А вот в интеграции с Cython всё становится намного лучше.


Оказывается, эта структура и SortedSet очень похожи по производительности. Все 3 Fast версии структур bintrees достаточно близки, поэтому будем считать, что оттуда мы используем FastAVLTree.


Задача SortedSet FastAVLTree
Добавление 3.924 2.285
Проверка на наличие 1.198 2.821
Цикл по всем элементам 0.162 0.297
Получение индексов 3.959 n/a
Получение значений по индексам 4.909 n/a
Удаление 2.933 1.509

Как мы видим, AVL в полтора раза быстрее в скорости добавления элементов и почти в 2 раза быстрее в операциях удаления. Но он в те же 2 раза медленнее в проверке на наличие и цикле по всем элементам. К тому же не стоит забывать, что 2 операции он выполнять не умеет, то есть не является тем ordered_set, что мы ищем.


Использование:


import bintreesnumbers = bintrees.FastAVLTree()numbers.insert(value, None)  # второй параметр - значение, как в словаре

Что же выбрать


Мои рекомендации звучат так: если вам нужны операции find_by_order и order_of_key, то ваш единственный вариант sortedcontainers.SortedSet. Если вам нужен только аналог std::map, то выбирайте на своё усмотрение между SortedSet и любым из fast контейнеров из bintrees, опираясь на то, каких операций ожидается больше.


Можно ли сделать что-то быстрее


Скорее нет, чем да. Использование Cython один из самых мощных способов оптимизации, а AVL считается очень быстрым решением исходной задачи. Про остальные операции ordered_set можно сказать, что модификация красно-чёрного дерева так, чтобы оно поддерживало эти операции, вряд ли будет быстрее SortedContainers, так что смысла изобретать велосипед я не вижу.




Облачные VPS серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

SQLAlchemy а ведь раньше я презирал ORM

05.06.2021 22:17:23 | Автор: admin

Так вышло, что на заре моей карьеры в IT меня покусал Oracle -- тогда я ещё не знал ни одной ORM, но уже шпарил SQL и знал, насколько огромны возможности БД.

Знакомство с DjangoORM ввело меня в глубокую фрустрацию. Вместо возможностей -- хрена с два, а не составной первичный ключ или оконные функции. Специфические фичи БД проще забыть. Добивало то, что по цене нулевой гибкости мне продавали падение же производительности -- сборка ORM-запроса не бесплатная. Ну и вишенка на торте -- в дополнение к синтаксису SQL надо знать ещё и синтаксис ORM, который этот SQL сгенерирует. Недостатки, которые я купил за дополнительную когнитивную нагрузку -- вот уж где достижение индустрии. Поэтому я всерьёз считал, что без ORM проще, гибче и в разы производительнее -- ведь у вас в руках все возможности БД.

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

Опыт и как результат субъективная система взглядов

Я занимался оптимизацией SQL-запросов. Мне удавалось добиться стократного и более уменьшения cost запросов, в основном для Oracle и Firebird. Я проводил исследования, экспериментировал с индексами. Я видел в жизни много схем БД: среди них были как некоторое дерьмо, так и продуманные гибкие и расширяемые инженерные решения.

Этот опыт сформировал у меня систему взглядов касательно БД:

  • ORM не позволяет забыть о проектировании БД, если вы не хотите завтра похоронить проект

  • Переносимость -- миф, а не аргумент:

    • Если ваш проект работает с postgres через ORM, то вы на локальной машине разворачиваете в докере postgres, а не работаете с sqlite

    • Вы часто сталкивались с переходом на другую БД? Не пишите только "Однажды мы решили переехать..." -- это было однажды. Если же это происходит часто или заявленная фича, оправданная разными условиями эксплуатации у разных ваших клиентов -- милости прошу в обсуждения

    • У разных БД свои преимущества и болячки, всё это обусловлено разными структурами данных и разными инженерными решениями. И если при написании приложения мы используем верхний мозг, мы пытаемся избежать этих болячек. Тема глубокая, и рассмотрена в циклах лекций Базы данных для программиста и Транзакции от Владимира Кузнецова

  • Структура таблиц определяется вашими данными, а не ограничениями вашей ORM

Естественно, я ещё и код вне БД писал, и касательно этого кода у меня тоже сформировалась система взглядов:

  • Контроллер должен быть тонким, а лучший код -- это тот код, которого нет. Код ORM -- это часть контроллера. И если код контроллера спрятан в библиотеку, это не значит, что он стал тонким -- он всё равно исполняется

  • Контроллер, выполняющий за один сеанс много обращений к БД -- это очень тонкий лёд

  • Я избегаю повсеместного использования ActiveRecord -- это верный способ как работать с неконсистентными данными, так и незаметно для себя сгенерировать бесконтрольное множество обращений к БД

  • Оптимизация работы с БД сводится к тому, что мы не читаем лишние данные. Есть смысл запросить только интересующий нас список колонок

  • Часть данных фронт всё равно запрашивает при инициализации. Чаще всего это категории. В таких случаях нам достаточно отдать только id

  • Отладка всех новых запросов ORM обязательна. Всегда надо проверять, что там ORM высрала (тут пара сочных примеров), дабы не было круглых глаз. Даже при написании этой статьи у меня был косяк как раз по этому пункту

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

Все вопросы данной статьи происходят из моего опыта и системы взглядов

Они могут и не найти в вас отголоска, и это нормально

Мы разные, и у нас всех разный фокус внимания. Я общался с разными разработчиками. Я видел разные позиции, от "да не всё ли равно, что там происходит? Работает же" до "я художник, у меня справка есть". При этом у некоторых из них были другие сильные стороны. Различие позиций -- это нормально. Невозможно фокусироваться на всех аспектах одновременно.

Мне, например, с большего без разницы, как по итогу фронт визуализирует данные, хотя я как бы фулстэк. Чем я отличаюсь от "да не всё ли равно, что там происходит"? Протокол? Да! Стратегия и оптимизация рендеринга? Да! Упороться в WebGL? Да! А что по итогу на экране -- пофиг.

Знакомство в SQLAlchemy

Первое, что бросилось в глаза -- возможность писать DML-запросы в стиле SQL, но в синтаксисе python:

order_id = bindparam('order_id', required=True)return \    select(        func.count(Product.id).label("product_count"),        func.sum(Product.price).label("order_price"),        Customer.name,    )\    .select_from(Order)\    .join(        Product,        onclause=(Product.id == Order.product_id),    )\    .join(        Customer,        onclause=(Customer.id == Order.customer_id),    )\    .where(        Order.id == order_id,    )\    .group_by(        Order.id,    )\    .order_by(        Product.id.desc(),    )

Этим примером кода я хочу сказать, что ORM не пытается изобрести свои критерии, вместо этого она пытается дать нечто, максимально похожее на SQL. К сожалению, я заменил реальный фрагмент ORM-запроса текущего проекта, ибо NDA. Пример крайне примитивен -- он даже без подзапросов. Кажется, в моём текущем проекте таких запросов единицы.

Естественно, я сразу стал искать, как тут дела с составными первичными ключами -- и они есть! И оконные функции, и CTE, и явный JOIN, и много чего ещё! Для особо тяжёлых случаев можно даже впердолить SQL хинты! Дальнейшее погружение продолжает радовать: я не сталкивался ни с одним вопросом, который решить было невозможно из-за архитектурных ограничений. Правда, некоторые свои вопросы я решал через monkey-patching.

Производительность

Насколько крутым и гибким бы ни было API, краеугольным камнем является вопрос производительности. Сегодня вам может и хватит 10 rps, а завтра вы пытаетесь масштабироваться, и если затык в БД -- поздравляю, вы мертвы.

Производительность query builder в SQLAlchemy оставляет желать лучшего. Благо, это уровень приложения, и тут масштабирование вас спасёт. Но можно ли это как-то обойти? Можно ли как-то нивелировать низкую производительность query builder? Нет, серьёзно, какой смысл тратить мощности ради увеличения энтропии Вселенной?

В принципе, нам на python не привыкать искать обходные пути: например, python непригоден для реализации числодробилок, поэтому вычисления принято выкидывать в сишные либы.

Для SQLAlchemy тоже есть обходные пути, и их сразу два, и оба сводятся к кэшированию по разным стратегиям. Первый -- применение bindparam и lru_cache. Второй предлагает документация -- future_select. Рассмотрим их преимущества и недостатки.

bindparam + lru_cache

Это самое простое и при этом самое производительное решение. Мы покупаем производительность по цене памяти -- просто кэшируем собранный объект запроса, который в себе кэширует отрендеренный запрос. Это выгодно до тех пор, пока нам не грозит комбинаторный взрыв, то есть пока число вариаций запроса находится в разумных пределах. В своём проекте в большинстве представлений я использую именно этот подход. Для удобства я применяю декоратор cached_classmethod, реализующий композицию декораторов classmethod и lru_cache:

from functools import lru_cachedef cached_classmethod(target):    cache = lru_cache(maxsize=None)    cached = cache(target)    cached = classmethod(cached)    return cached

Для статических представлений тут всё понятно -- функция, создающая ORM-запрос не должна принимать параметров. Для динамических представлений можно добавить аргументы функции. Так как lru_cache под капотом использует dict, аргументы должны быть хешируемыми. Я остановился на варианте, когда функция-обработчик запроса генерирует "сводку" запроса и параметры, передаваемые в сгенерированный запрос во время непосредственно исполнения. "Сводка" запроса реализует что-то типа плана ORM-запроса, на основании которой генерируется сам объект запроса -- это хешируемый инстанс frozenset, который в моём примере называется query_params:

class BaseViewMixin:    def build_query_plan(self):        self.query_kwargs = {}        self.query_params = frozenset()    async def main(self):        self.build_query_plan()        query = self.query(self.query_params)        async with BaseModel.session() as session:            respone = await session.execute(                query,                self.query_kwargs,            )            mappings = respone.mappings()        return self.serialize(mappings)
Некоторое пояснение по query_params и query_kwargs

В простейшем случае query_params можно получить, просто преобразовав ключи query_kwargs во frozenset. Обращаю ваше внимание, что это не всегда справедливо: флаги в query_params запросто могут поменять сам SQL-запрос при неизменных query_kwargs.

На всякий случай предупреждаю: не стоит слепо копировать код. Разберитесь с ним, адаптируйте под свой проект. Даже у меня данный код на самом деле выглядит немного иначе, он намеренно упрощён, из него выкинуты некоторые несущественные детали.

Сколько же памяти я заплатил за это? А немного. На все вариации запросов я расходую не более мегабайта.

future_select

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

stmt = lambdas.lambda_stmt(lambda: future_select(Customer))stmt += lambda s: s.where(Customer.id == id_)

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

Наброски фасада, решающего проблему дикого синтаксиса

По идее, future_select через FutureSelectWrapper можно пользоваться почти как старым select, что нивелирует дикий синтаксис:

class FutureSelectWrapper:    def __init__(self, clause):        self.stmt = lambdas.lambda_stmt(            lambda: future_select(clause)        )        def __getattribute__(self, name):        def outer(clause):            def inner(s):                callback = getattr(s, name)                return callback(clause)                        self.stmt += inner            return self        return outer

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

Промежуточный вывод: низкую производительность query builder в SQLAlchemy можно нивелировать кэшем запросов. Дикий синтаксис future_select можно спрятать за фасадом.

А ещё я не уделил должного внимания prepared statements. Эти исследования я проведу чуть позже.

Как я открывал для себя ORM заново

Мы добрались главного -- ради этого раздела я писал статью. В этом разделе я поделюсь своими откровениями, посетившими меня в процессе работы.

Модульность

Когда я реализовывал на SQL дикую аналитику, старой болью отозвалось отсутствие модульности и интроспекции. При последующем переносе на ORM у меня уже была возможность выкинуть весь подзапрос поля FROM в отдельную функцию (по факту метод класса), а в последующем эти функции было легко комбинировать и на основании флагов реализовывать паттерн Стратегия, а также исключать дублирование одинакового функционала через наследование.

Собственные типы

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

Создание собственных простых типов рассмотрено в документации:

class ColorType(TypeDecorator):    impl = Integer    cache_ok = True    def process_result_value(self, value, dialect):        if value is None:            return        return color(value)    def process_bind_param(self, value, dialect):        if value is None:            return        value = color(value)        return value.value

Сыр-бор тут только в том, что мне стрельнуло хранить цвета не строками, а интами. Это исключает некорректность данных, но усложняет их сериализацию и десериализацию.

Теперь про ENUM. Меня категорически не устроило, что документация предлагает хранить ENUM в базе в виде VARCHAR. Особенно уникальные целочисленные Enum хотелось хранить интами. Очевидно, объявлять этот тип мы должны, передавая аргументом целевой Enum. Ну раз String при объявлении требует указать длину -- задача, очевидно, уже решена. Штудирование исходников вывело меня на TypeEngine -- и тут вместо примеров использования вас встречает "our source code is open 24/7". Но тут всё просто:

class IntEnumField(TypeEngine):    def __init__(self, target_enum):        self.target_enum = target_enum        self.value2member_map = target_enum._value2member_map_        self.member_map = target_enum._member_map_    def get_dbapi_type(self, dbapi):        return dbapi.NUMBER    def result_processor(self, dialect, coltype):        def process(value):            if value is None:                return            member = self.value2member_map[value]            return member.name        return process    def bind_processor(self, dialect):        def process(value):            if value is None:                return            member = self.member_map[value]            return member.value        return process

Обратите внимание: обе функции -- result_processor и bind_processor -- должны вернуть функцию.

Собственные функции, тайп-хинты и вывод типов

Дальше больше. Я столкнулся со странностями реализации json_arrayagg в mariadb: в случае пустого множества вместо NULL возвращается строка "[NULL]" -- что ни под каким соусом не айс. Как временное решение я накостылил связку из group_concat, coalesce и concat. В принципе неплохо, но:

  1. При вычитывании результата хочется нативного преобразования строки в JSON.

  2. Если делать что-то универсальное, то оказывается, что строки надо экранировать. Благо, есть встроенная функция json_quote. Про которую SQLAlchemy не знает.

  3. А ещё хочется найти workaround-функции в объекте sqlalchemy.func

Оказывается, в SQLAlchemy эти проблемы решаются совсем влёгкую. И если тайп-хинты мне показались просто удобными, то вывод типов поверг меня в восторг: типозависимое поведение можно инкапсулировать в саму функцию, что сгенерирует правильный код на SQL.

Мне заказчик разрешил опубликовать код целого модуля!
from sqlalchemy.sql.functions import GenericFunction, register_functionfrom sqlalchemy.sql import sqltypesfrom sqlalchemy import func, literal_columndef register(target):    name = target.__name__    register_function(name, target)    return target# === Database functions ===class json_quote(GenericFunction):    type = sqltypes.String    inherit_cache = Trueclass json_object(GenericFunction):    type = sqltypes.JSON    inherit_cache = True# === Macro ===empty_string = literal_column("''", type_=sqltypes.String)json_array_open = literal_column("'['", type_=sqltypes.String)json_array_close = literal_column("']'", type_=sqltypes.String)@registerdef json_arrayagg_workaround(clause):    clause_type = clause.type    if isinstance(clause_type, sqltypes.String):        clause = func.json_quote(clause)    clause = func.group_concat(clause)    clause = func.coalesce(clause, empty_string)    return func.concat(        json_array_open,        clause,        json_array_close,        type_=sqltypes.JSON,    )def __json_pairs_iter(clauses):    for clause in clauses:        clause_name = clause.name        clause_name = "'%s'" % clause_name        yield literal_column(clause_name, type_=sqltypes.String)        yield clause@registerdef json_object_wrapper(*clauses):    json_pairs = __json_pairs_iter(clauses)    return func.json_object(*json_pairs)

В рамках эксперимента я также написал функцию json_object_wrapper, которая из переданных полей собирает json, где ключи -- это имена полей. Буду использовать или нет -- ХЗ. Причём тот факт, что эти макроподстановки не просто работают, а даже правильно, меня немного пугает.

Примеры того, что генерирует ORM
SELECT concat(  '[',  coalesce(group_concat(product.tag_id), ''),  ']') AS product_tags
SELECT json_object(  'name', product.name,  'price', product.price) AS product,

PS: Да, в случае json_object_wrapper я изначально допустил ошибку. Я человек простой: вижу константу -- вношу её в код. Что привело к ненужным bindparam на месте ключей этого json_object. Мораль -- держите ORM в ежовых рукавицах. Упустите что-то -- и она вам такого нагенерит! Только literal_column позволяет надёжно захардкодить константу в тело SQL-запроса.

Такие макроподстановки позволяют сгенерировать огромную кучу SQL кода, который будет выполнять логику формирования представлений. И что меня восхищает -- эта куча кода работает эффективно. Ещё интересный момент -- эти макроподстановки позволят прозрачно реализовать паттерн Стратегия -- я надеюсь, поведение json_arrayagg пофиксят в следующих релизах MariaDB, и тогда я смогу своё костылище заменить на связку json_arrayagg+coalesce незаметно для клиентского кода.

Выводы

SQLAlchemy позволяет использовать преимущества наследования и полиморфизма (и даже немного иннкапсуляции. Флеш-рояль, однако) в SQL. При этом она не загоняет вас в рамки задач уровня Hello, World! архитектурными ограничениями, а наоборот даёт вам максимум возможностей.

Субъективно это прорыв. Я обожаю реляционные базочки, и наконец-то я получаю удовольствие от реализации хитрозакрученной аналитики. У меня в руках все преимущества ООП и все возможности SQL.

Подробнее..

Перевод Компилятор всё оптимизирует? Ну уж нет

17.06.2021 12:20:28 | Автор: admin
Многие программисты считают, что компиляторы это волшебные чёрные ящики, на вход в которые можно подать хаотичный код, а на выходе получить красивый оптимизированный двоичный файл. Доморощенные философы часто начинают рассуждать о том, какие фишки языка или флаги компилятора следует использовать, чтобы раскрыть всю мощь магии компилятора. Если вы когда-нибудь видели кодовую базу GCC, то и в самом деле могли поверить, что он выполняет какие-то волшебные оптимизации, пришедшие к нам из иных миров.

Тем не менее, если вы проанализируете результаты работы компиляторов, то узнаете, что они не очень-то хорошо справляются с оптимизацией вашего кода. Не потому, что пишущие их люди не знают, как генерировать эффективные команды, а просто потому, что компиляторы способны принимать решения только в очень малой части пространства задач. [В своём докладе Data Oriented Design (2014 год) Майк Эктон сообщил, что в проанализированном фрагменте кода компилятор теоретически может оптимизировать лишь 10% задачи, а 90% он оптимизировать не имеет никакой возможности. Если бы вам интересно было узнать больше о памяти, то стоит прочитать статью What every programmer should know about memory. Если вам любопытно, какое количество тактов тратят конкретные команды процессора, то изучите таблицы команд процессоров]

Чтобы понять, почему волшебные оптимизации компилятора не ускорят ваше ПО, нужно вернуться назад во времени, к той эпохе, когда по Земле ещё бродили динозавры, а процессоры были чрезвычайно медленными. На графике ниже показаны относительные производительности процессоров и памяти в разные годы (1980-2010 гг.). [Информация взята из статьи Pitfalls of object oriented programming Тони Альбрехта (2009 год), слайд 17. Также можно посмотреть его видео
(2017 год) на ту же тему.]


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

  • В 1980 году задержка ОЗУ составляла примерно 1 такт процессора
  • В 2010 году задержка ОЗУ составляла примерно 400 тактов процессора

Ну и что же? Мы тратим чуть больше тактов на загрузку из памяти, но компьютеры всё равно намного быстрее, чем раньше. Какая разница, сколько тактов мы тратим?

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

В таблице ниже указаны параметры задержки самых распространённых операций. [Таблица взята из книги Systems Performance: Enterprise and the cloud (2nd Edition 2020).] В столбце Задержка в масштабе указана задержка в значениях, которые проще понимать людям.

Событие Задержка Задержка в масштабе
1 такт ЦП 0,3 нс 1 с
Доступ к кэшу L1 0,9 нс 3 с
Доступ к кэшу L2 3 нс 10 с
Доступ к кэшу L3 10 нс 33 с
Доступ к основной памяти 100 нс 6 мин
Ввод-вывод SSD 10-100 мкс 9-90 ч
Ввод-вывод жёсткого диска 1-10 мс 1-12 месяцев

Посмотрев на столбец задержек в масштабе, мы быстро поймём, что доступ к памяти затратен, и что в случае подавляющего большинства приложений процессор просто бездельничает, ожидая ответа от памяти. [Узким местом не всегда является память. Если вы записываете или считываете много данных, то узким местом, скорее всего, будет жёсткий диск. Если вы рендерите большой объём данных на экране, то узким местом может стать GPU.]

На то есть две причины:

  1. Языки программирования, которые мы используем и сегодня, создавались во времена, когда процессоры были медленными, а задержки памяти не были такими критичными.
  2. Best practices отрасли по-прежнему связаны с объектно-ориентированным программированием, которое показывает на современном оборудовании не очень высокую производительность.

Языки программирования


Язык Время создания
C 1975 год
C++ 1985 год
Python 1989 год
Java 1995 год
Javascript 1995 год
Ruby 1995 год
C# 2002 год

Перечисленные выше языки программирования придуманы более 20 лет назад, и принятые их разработчиками проектные решения, например, глобальная блокировка интерпретатора Python или философия Java всё это объекты, в современном мире неразумны. [Все мы знаем, какой бардак представляет собой C++. И да, успокойтесь, я знаю, что в списке нет вашего любимого нишевого языка, а C# всего 19 лет.] Оборудование подверглось огромным изменениям, у процессоров появились кэши и многоядерность, однако языки программирования по-прежнему основаны на идеях, которые уже не истинны.

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

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

Да, компьютеры чрезвычайно быстры, но только если вы пишете ПО таким образом, что оно хорошо взаимодействует с железом. На одном и том же оборудовании вы может работать и очень плавная 3D-игра и заметно лагающий MS Word. Очевидно, что проблема здесь не в оборудовании и что мы можем выжать из него гораздо больше, чем среднестатистическое приложение.

Совершенствовалось оборудование, но не языки


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

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

Объяснение будет долгим, но давайте начнём с примера:

Представьте, что мы симулируем колонию муравьёв. Административный отдел колонии был уничтожен атакой муравьеда, поэтому он не знает, сколько муравьёв-воинов осталось живо в колонии.

Поможем нашему муравью-администратору посчитать муравьёв-воинов!

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

class Ant {    public String name = "unknownAnt";    public String color = "red";    public boolean isWarrior = false;    public int age = 0;}// shh, it's a tiny ant colonyList<Ant> antColony = new ArrayList<>(100);// fill the colony with ants// count the warrior antslong numOfWarriors = 0;for (Ant ant : antColony) {    if (ant.isWarrior) {         numOfWarriors++;    }}

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

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

  1. Уменьшив объём данных, которые нужно получать для нашей задачи.
  2. Храня необходимые данные в соседних блоках, чтобы полностью использовать строки кэша.

В приведённом выше примере мы будем считать, что из памяти запрашиваются следующие данные (я предполагаю, что используются compressed oops; поправьте меня, если это не так):

+ 4 байта на ссылку имени
+ 4 байта на ссылку цвета
+ 1 байт на флаг воина
+ 3 байта заполнителя
+ 4 байта на integer возраста
+ 8 байт на заголовки класса
---------------------------------
24 байта на каждый экземпляр муравья


Сколько раз нам нужно обратиться к основной памяти, чтобы подсчитать всех муравьёв-воинов (предполагая, что данные колонии муравьёв ещё не загружены в кэш)?

Если учесть, что в современных процессорах строка кэша имеет размер 64 байта, то мы можем получать не больше 2,6 экземпляра муравьёв на строку кэша. Так как этот пример написан на языке Java, в котором всё это объекты, находящиеся где-то в куче, то мы знаем, что экземпляры муравьёв могут находиться в разных строках кэша. [Если распределить все экземпляры одновременно, один за другим, то есть вероятность, что они будут расположены один за другим и в куче, что ускорит итерации. В общем случае лучше всего заранее распределить все данные при запуске, чтобы экземпляры не разбросало по всей куче, однако если вы работаете с managed-языком, то сложно будет понять, что сделают сборщики мусора в фоновом режиме. Например, JVM-разработчики утверждают, что распределение мелких объектов и отмена распределения сразу после их использования обеспечивает бОльшую производительность, чем хранение пула заранее распределённых объектов. Причина этого в принципах работы сборщиков мусора, учитывающих поколения объектов.]

В наихудшем случае экземпляры муравьёв не распределяются один за другим и мы можем получать только по одному экземпляру на каждую строку кэша. Это значит, что для обработки всей колонии муравьёв нужно обратиться к основной памяти 100 раз, и что из каждой полученной строки кэша (64 байта) мы используем только 1 байт. Другими словами, мы отбрасываем 98% полученных данных. Это довольно неэффективный способ пересчёта муравьёв.

Ускоряем работу


Можем ли мы переписать нашу программу так, чтобы запросы к памяти были более рациональными, и таким образом повысить производительность программы? Да, можем.

Мы используем максимально наивный Data Oriented Design. Вместо моделирования муравьёв по отдельности мы смоделируем целую колонию за раз:

class AntColony {    public int size = 0;    public String[] names = new String[100];    public String[] colors = new String[100];    public int[] ages = new int[100];    public boolean[] warriors = new boolean[100];    // I am aware of the fact that this array could be removed    // by splitting the colony in two (warriors, non warriors),    // but that is not the point of this story.    //     // Yes, you can also sort it and enjoy in an additional     // speedup due to branch predictions.}AntColony antColony_do = new AntColony();// fill the colony with ants and update size counter// count the warrior antslong numOfWarriors = 0;for (int i = 0; i < antColony_do.size; i++) {    boolean isWarrior = antColony_do.warriors[i];    if (isWarrior) {        numOfWarriors++;    }}

Эти два примера алгоритмически эквивалентны (O(n)), но ориентированное на данные решение превосходит по производительности объектно-ориентированное. Почему?

Потому что ориентированный на данные пример получает меньше данных, и эти данные запрашиваются соседними блоками мы получаем 64 флагов воинов за раз и не тратим ресурсы впустую. Так как мы получаем только нужные нам данные, нам достаточно обратиться к памяти всего два раза, а не по разу для каждого муравья (100 раз). Это намного более эффективный способ подсчёта муравьёв-воинов.

Я выполнил бенчмарки производительности при помощи тулкита Java Microbenchmark Harness (JMH), их результаты показаны в таблице ниже (измерения выполнялись на Intel i7-7700HQ с частотой 3,80 ГГц). Чтобы не загромождать таблицу, я не указал доверительные интервалы, но вы можете выполнить собственные бенчмарки, скачав и запустив код бенчмарка.

Задача (размер колонии) ООП DOD Ускорение
countWarriors (100) 10 874 045 операций/с 19 314 177 операций/с 78%
countWarriors (1000) 1 147 493 операций/с 1 842 812 операций/с 61%
countWarriors (10000) 102 630 операций/с 185 486 операций/с 81%

Сменив точку зрения на решение задачи, нам удалось добиться огромного ускорения. Стоит также учесть, что в нашем примере класс муравьёв довольно мал, поэтому данные с большой вероятностью останутся в одном из кэшей процессора и разница между двумя вариантами не так заметна (согласно таблице задержек, доступ к памяти из кэша в 30-100 раз быстрее).

Даже несмотря на то, что представленные выше примеры написаны в свободном стиле всё является публичным, ориентированное на данные проектирование не запрещает использовать корпоративный подход со строгим контролем. Вы можете делать любые поля private и final, создавать геттеры и сеттеры, реализовывать интерфейсы, если вам так нравится.

Кто знает, может, вы даже поймёте, что все эти энтерпрайзные фишки необязательны? Но самое важное здесь в том, что надо перестать думать о муравьях по отдельности, а воспринимать их как группу. В конце концов, вы когда-нибудь видели муравья-одиночку?

Где есть один, будет несколько.

Майк Эктон

Но постойте! Почему ООП настолько популярно, если имеет такую низкую производительность?

  1. Нагрузка часто зависит от ввода-вывода (по крайней мере, в бэкенде серверов), который примерно в 1000 раз медленнее доступа к памяти. Если вы записываете много данных на жёсткий диск, то улучшения, внесённые в структуру памяти, могут и почти не повлиять на показатели.
  2. Требования к производительности большинства корпоративного ПО чудовищно низки, и с ними справится любой старый код. Это ещё называют синдромом клиент за это не заплатит.
  3. Идеи в нашей отрасли движутся медленно, и сектанты ПО отказываются меняться. Всего 20 лет назад задержки памяти не были особой проблемой, и best practices пока не догнали изменения в оборудовании.
  4. Большинство языков программирования поддерживает такой стиль программирования, а концепцию объектов легко понять.
  5. Ориентированный на данные способ программирования тоже обладает собственным множеством проблем.

Проблемы: допустим, нам нужно найти, сколько в колонии красных муравьёв возрастом старше 1 года. В объектно-ориентированном подходе эта задача решается довольно просто:

long numOfChosenAnts = 0;for (Ant ant : antColony) {    if (ant.age > 1 && "red".equals(ant.color)) {        numOfChosenAnts++;     }}

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

long numOfChosenAnts = 0;for (int i = 0; i < antColony.size; i++) {    int age = antColony.ages[i];    String color = antColony.colors[i];    if (age > 1 && "red".equals(color)) {        numOfChosenAnts++;    }}

А теперь представьте, что кому-то нужно отсортировать всех муравьёв в колонии на основании их имени, а затем что-то сделать с отсортированными данными (например, посчитать всех красных муравьёв из первых 10% отсортированных данных. У муравьёв могут быть странные правила, не судите их строго). При объектно-ориентированном решении мы можем просто использовать функцию сортировки из стандартной библиотеки. При ориентированном на данные способе придётся сортировать массив имён, но в то же самое время сортировать все остальные массивы на основании того, как перемещаются индексы массива имён (мы предполагаем, что нам важно, какие цвет, возраст и флаг воина связаны с именем муравья). [Также можно скопировать массив имён, отсортировать их и найти соответствующее имя в исходном неотсортированном массиве имён, чтобы получить индекс соответствующего элемента. Получив индекс элемента в массиве, можно делать с ним что угодно, но подобные операции поиска выполнять кропотливо. Кроме того, если массивы большие, то такое решение будет довольно медленным. Понимайте свои данные! Также выше не упомянута проблема вставки или удаления элементов в середине массива. При добавлении или удалении элемента из середины массива обычно требуется копировать весь изменённый массив в новое место в памяти. Копирование данных медленный процесс, и если не быть внимательным при копировании данных, может закончиться память. Если порядок элементов в массивах не важен, можно также заменить удалённый элемент последним элементом массива и уменьшить внутренний счётчик, учитывающий количество активных элементов в группе. При переборе таких элементов в этой ситуации мы, по сути, будем перебирать только активную часть группы. Связанный список не является разумным решением этой задачи, потому что данные не расположены в соседних фрагментах, из-за чего перебор оказывается очень медленным (плохое использование кэша).]

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

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

Best practices


Если вы когда-нибудь работали в энтерпрайзе и засовывали нос в его кодовую базу, то, вероятнее всего, видели огромную кучу классов с множественными полями и интерфейсами. Большинство ПО по-прежнему пишут подобным образом, потому что из-за влияния прошлого в таком стиле программирования достаточно легко разобраться. Кроме того, те, кто работает с большими кодовыми базами естественным образом тяготеют к знакомому стилю, который видят каждый день. [См. также On navigating a large codebase]

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

Компилятор это инструмент, а не волшебная палочка!

Майк Эктон

Я написал эту статью потому, что многие программисты считают, что нужно быть гением или знать тайные компиляторные трюки, чтобы писать высокопроизводительный код. Чаще всего достаточно простых рассуждений о данных, движущихся в вашей системе. [Это не значит, что код всегда будет прост и лёгок для понимания код эффективного умножения матриц довольно запутан.]. В сложной программе сделать это не всегда просто, но и этому тоже должен узнать каждый, если хочет потратить время на изучение подобных концепций.

Если вы хотите больше узнать об этой теме, то прочитайте книгу Data-Oriented Design и остальные ссылки, которые приведены в статье в квадратных скобках.

[БОНУС] Статья, описывающая проблемы объектно-ориентированного программирования:
Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP).
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru