Прикладное программирование
Этот курс посвящён некотороым вопросам, связанным с разработкой, развёрткой и обслуживанием долгоработающих приложений и сервисов.
Взаимодействие с ОС
В настоящее время можно встретить более-менее четыре «разновидности» операционных систем:
- самое частое — т.н. POSIX-совместимые системы (например, всевозможные дистрибутивы GNU/Linux) и им подобные (FreeBSD, Mac OS)
- примерно настолько же распространены мобильные ОС Android/Linux и iOS
- существенно менее распространена ОС Windows (впрочем, типичный «десктоп» — это именно компьютер под управлением ОС Windows, но эти самые «десктопы» составляют весьма малую часть ЭВМ)
- всё вышеописанное содержит в своём составе веб-браузер; как ни странно, но современные браузеры обладают всеми признаками ОС
Более того, все современные версии Windows содержат в своём составе POSIX-окружение (WSL1) и полноценное ядро Linux (WSL2). К тому же так сложилось, что разработка программ обычно либо ведётся из под линуксоподобных систем или же использует утилиты и методологии, пришедшие из них.
Поэтому первым делом мы займёмся знакомством с POSIX-совместимыми системами.
Общие сведения
Бесполезно знакомиться с ОС, не имея её под рукой и регулярно не практикуясь. Поэтому первым делом необходимо POSIX-совместимой ОС обзавестись.
Это можно сделать одним из следующих образов:
- если у Вас на компьютере уже установлен какой-нибудь дистрибутив GNU/Linux — ничего делать не нужно
- если у Вас Mac OS или же FreeBSD (они не относятся к POSIX-совместимым ОС), при некотором желании можно пытаться использовать их, но некоторые вещи в них работают по-другому
- если у Вас Windows 10 или 11, то в нём достаточно включить WSL и установить на него какой-нибудь дистрибутив (рекомендуется Ubuntu LTS наиболее актуальной версии)
- если у Вас что-то другое, то нужно дополнительно установить на компьютер какой-нибудь дистрибутив Linux (например, Ubuntu LTS наиболее актуальной версии или же Arch Linux, к которому имеется очень обширная документация)
- если у Вас вообще нет ПК, а есть Android-устройство, то на них есть всякие POSIX-окружения, которые, хотя и не являются полноценными GNU-системами, всё равно вполне могут быть использованы
- если у Вас только iPhone или iPad, то, к сожалению, единственное, что остаётся — использовать какой-нибудь веб-сервис; например, replit даёт доступ к почти полноценному Linux-окружению
Интерпретатор команд
Интерпретатор команд (или, как его ещё называют, командная оболочка) — программа, соединённая стандартными потоками ввода/вывода с драйвером терминала, на вход которой пользователь подаёт команды, реализующие ту или иную функциональность ОС. Интерпретатор распознаёт эти команды и вызывает соответствующую функциональность ядра ОС.
Далее будут описаны только основы работы с интерпретатором bash (бывают и другие, но bash — де-факто стандартный интерпретатор в большинстве ОС).
Простейшие команды и запуск приложений
Наиболее простая форма команды для bash — просто последовательность слов, разделённых пробелами (количество пробелов между словами несущественно). Словом может являться произвольный текст, хотя некоторые слова нельзя ввести напрямую (например, нельзя напрямую ввести слово, содержащее пробел). Для ввода нетривиальных слов есть три механизма:
- Одинарные кавычки: то, что между ними, воспринимается как одно слово
- Двойные кавычки: некоторые символы и последовательности символов
между ними преобразуются в другие (это преобразование называется интерполяцией);
например, последовательность
\"
преобразуется в двойную кавычку (без обратной косой черты) - Конкатенация: два слова, записанных без пробелов между ними, объединяются;
например
"a\$b"waa'qq'"x"
— это словоa$bwaaqqx
Также важно помнить, что в рамках незакавыченных слов некоторые символы и их последовательности (как и внутри двойных кавычек) имеют специальный смысл, причём зачастую отличный от такового для двойных кавычек.
Последовательность слов bash интерпретирует следующим образом:
- если первое слово является одним из определённого набора зарезервированных слов или же названием одной из встроенных в bash функций, он выполняет действия, определённые семантикой соответствующего слова/функции
- если первое слово не содержит косых черт
/
, он ищет файл с таким названием в рамках одной из определённого набора папок (чем этот набор определяется, будет сказано немного ниже) и (в случае положительного результата поиска) запускает его - если же первое слово содержит
/
, bash считает это слово относительным или абсолютным путём к файлу; в случае наличия такого файла bash запускает его
Под запуском понимается следующее:
- если содержимое файла представляет собой один из форматов, которые ОС считает исполняемыми, bash создаёт потомка, процедура работы которого определяется тем, как ОС интерпретирует этот формат; при этом все слова команды передаются этому потомку при помощи механизма, позволяющего потомку их в случае надобности получить (слова команды называются аргументами командной строки; они нумеруются, начиная с нуля)
- если файл является текстовым и начинается со строчки вида
#!путь_к_файлу
, bash запускает указанный этим путём файл, передавая ему слова команды как аргументы командной строки
Поясним обе эти ситуации на примере. Предположим, что в системе установлен
интерпретатор языка Python, причём его исполняемая часть находится в файле
/usr/bin/python3
. Предположим также, что в текущей директории
находится файл foobar.py
с программой вида
import sys
print(sum(map(int,sys.argv[1:])))
Тогда команда
python3 foobar.py 1 2 3
приведёт к следующему:
- bash найдёт файл
python3
и запустит его, передав ему в качестве аргументов словаpython3
,foobar.py
,1
,2
,3
- файл
python3
представляет собой правильным образом обёрнутый машинный код с программой, интерпретирующей язык программирования Python; эта программа первый аргумент командной строки интерпретирует как название файла с программой на Python - в переменной
sys.argv
(языка Python) хранятся аргументы командной строки, начиная с первого; соответственно, программа вычислит значение выражения1+2+3
и напечатает его
Теперь командой chmod +x foobar.py
добавим хозяину файла (а также заодно —
всем остальным пользователям ОС) foobar.py
право его запускать. Также
допишем в начало этого файла комментарий:
#!/usr/bin/python3
После этого команда
./foobar.py 1 2 3
приведёт к следующему:
- поскольку ОС (скорее всего) откажется воспринимать текстовый файл как
исполняемый, bash посмотрит на его первую строчку, где написано
#!/usr/bin/python3
- bash выполнит команду
/usr/bin/python3 ./foobar.py 1 2 3
- далее всё произойдёт ровно так же, как и в предыдущем случае
Несколько простейших команд
Здесь мы приведём наиболее часто используемые команды (некоторые из них являются встроенными в bash, некоторые — отдельными исполняемыми файлами).
echo
— печатает свои аргументы, разделяя их пробелами, и заканчивая печать переходом на следующую строчкуecho -e
— интерпретирует некоторые экранирующие последовательности в своих аргментах (например,\n
как символ перехода на следующую строчку)echo -n
— не переходит на следующую строчкуecho -ne
илиecho -n -e
— совмещает два вышеописанных пунктаcat
— печатает друг за другом содержимое файлов, названия которых указаны в аргументахpwd
— текущий рабочий каталог для bashls
— его содержимоеls -a
— его содержимое, включая файлы, названия которых начинаются с точкиls -l
— содержимое с кучей подробностей~
— вообще говоря, не команда, а слово, которое, будучи незакавыченным, интерполируется в абсолютный путь к домашней папке пользователя*
— как и тильда, не команда, а слово, которое, будучи незакавыченным, интерполируется в содержимое текущей папкиcd
— смена текущей папки (если запустить без аргументов, то переходит в домашнюю папку)bash
— обычно используется с названием файла в качестве аргумента командной строки; выполняет все команды, записанные в файле, друг за другом в неинтерактивном режимеwhich
— для команд, соответствующих исполняемому файлу, показывает, какому именно файлу команда, поданная аргументом, соответствуетman
— стандартное средство просмотра документации; в качестве аргумента принимает название команды, документация к которой требуетсяinfo
— ещё одно средство просмотра документации; часть документации уinfo
свояless
— интерактивное средство просмотра текстовых файлов; выйти из него можно клавишейq
(ни Ctrl-C, ни Ctrl-D на него не работают)nano
— очень простой (и неудобный) текстовый редакторvi
илиvim
— один из лучших текстовых редакторов, существующих в природе (внимание: он обладает очень крутой кривой обучения)
Переменные окружения
В bash есть механизм переменных. У каждой переменной есть название — последовательность латинских букв и цифр, начинающаяся с буквы (при этом нижнее подчёркивание также считается буквой). Чтобы определить переменную, нужно написать
название_переменной=слово
Ключевой момент: между названием переменной, знаком =
и словом не должно
быть пробелов!
Далее значение этой переменной можно получить интерполяцией выражения
$название_переменной
. Здесь есть ещё один очень важный момент:
если интерполяция была произведена вне двойных кавычек, значение переменной
разбивается на отдельные слова. То есть, например, последовательность команд
foo="abc def"
cat $foo
печатает содержимое файлов abc
и def
, а не одного файла с названием abc def
.
Для достижения же последнего эффекта нужно дать команду cat "$foo"
.
Важно знать про следующие стандартные переменные:
PATH
— тут хранятся разделённые двоеточиями пути, в которых bash ищет исполняемые файлы0
,1
,2
,3
, и т.д. — аргументы командной строки (существенно, если bash запущен в неинтерактивном режиме)?
— код ошибки последней выполненной команды (равен 0, если команда завершилась без ошибок)
Перенаправление потоков
Когда команда запускается, её стандартные потоки ввода/вывода те же, что и у запустившей её оболочки. Но их можно изменить. Наиболее часто встречаются следующие варианты:
команда < foobar # на вход подаётся содержимое файла foobar
команда > foobar # выход записывается в новый файл foobar
команда >> foobar # выход дописывается в конец файла foobar
Также есть возможность объединить несколько команд в цепочку:
команда_1 | команда_2 | команда_3 | ... | команда_n
В такой цепочке стандартный выход каждой команды (кроме последней) связывается со стандартным входом следующей.
Ещё одно полезное средство — интерполяция стандартного выхода: если
команду взять в обратые кавычки (те, что ставятся клавишей с тильдой),
это выражение преобразуется в тот текст, который взятая в обратные кавычки команда
напечатает. Опять же, как и с интерполяцией переменных, если не взять
такое выражение в двойные кавычки, его значение будет разбито на отдельные слова.
Если хочется интерполировать результат работы нескольких команд, их
можно разделить точкой-с-запятой ;
. Например, так:
echo "`echo a; echo b`"
Полезные возможности
Стрелками вверх/вниз можно перематывать историю команд.
По кнопке Tab происходит автодополнение текущей команды. В современных версиях bash механизм автодополнения довольно продвинутый: он учитывает особенности большинства распространённых приложений.
Распространённые действия
Жёсткие и мягкие ссылки
Иногда бывает полезно дать доступ к какому-то файлу не из той папки, в которой он расположен, а из другой (или же — по другому названию).
Для этого существует механизм ссылок, которые бывают двух типов:
- жёсткие — так называются имена одного и того же файла (файловые системы, использующиеся POSIX-совместимыми ОС, поддерживают наличие у одного и того же набора данных более одного имени, по которому этот набор доступен)
- мягкие — это специальные файлы, хранящие внутри себя некоторый путь; при этом почти все операции ввода/вывода, получающие на вход имя файла, применённые к мягкой ссылке, переадресуются на тот путь, который мягкая ссылка в себе хранит
Как нетрудно заметить, любой файл по определению является жёсткой ссылкой.
Дополнительную жёсткую ссылку можно создать командой ln старое_имя новое_имя
.
Жёсткие ссылки можно перемещать и удалять. При этом содержимое файла будет удалено лишь после удаления всех жёстких ссылок этого файла. Друг от друга жёсткие ссылки одного и того же файла неотличимы.
Мягкую ссылку можно создать командой ln -s путь имя_ссылки
.
Настоятельно рекомендуется указывать абсолютный путь. За исключением,
конечно же, тех случаев, в которых Вам зачем-то нужна ссылка именно
на относительный путь (поведение такой ссылки, очевидно,
зависит от текущей рабочей папки).
Переключение между пользователями
Осуществляется командой su
. Также в системе может быть установлена
программа sudo
, позволяющая выполнить указанную команду от имени
пользователя-администратора.
Управление правами
Здесь мы опишем основы системы прав пользователей в POSIX-совместимых ОС.
В основе системы прав пользователей лежат две сущности:
- пользователи
- группы
Каждый пользователь может «являться членом» произвольного подмножества множества всех групп.
У каждого файла или папки есть:
- пользователь-владелец (изменяется программой
chown
) - группа (изменяется программой
chgrp
) - права доступа для владельца
- права доступа для группы
- права доступа для всех остальных
Есть три основных права:
- право на чтение (r)
- право на изменение (w)
- право на выполнение (x)
Первое даёт возможность открыть файл в режиме чтения или же узнать содержимое папки.
Второе даёт возможность открыть файл в режиме записи или дозаписи или же создавать и удалять файлы в папке.
Третье позволяет «запустить» файл или же «открыть» папку (с папкой без права на выполнение почти ничего нельзя сделать — даже многие из тех действий, которые управляются правами на чтение/запись).
Более редкие права (setuid, setgid, restricted deletion) описаны в
документации к программе chmod
, которая позволяет изменять права доступа.
Права для владельца, группы и всех остальных являются взаимоисключающими:
- пользователь
root
(администратор) может делать всё, что угодно - к владельцу файла применяются права владельца
- к невладельцу, но члену группы файла применяются права группы
- ко всем остальным применяются права всех остальных
Впрочем, обычно права являются монотонно невозрастающмим: права группы не более широкие, чем у владельца, а права остальных не более широкие, чем у группы. Но при немонотонных правах следует помнить о вышеописанной взаимоисключительности.
Архивация и сжатие
Обычно архивация данных (объединение нескольких файлов в один) и сжатие
(среднестатистическое уменьшение размера файла) осуществляется при помощи
программы tar
(впрочем, есть ещё ar
и jar
с другими форматами
архивов, но ровно таким же текстовым интерфейсом).
Стандартный способ создать архив следующий:
- создать папку
foo
- переменстить в папку
foo
всё, что нужно - выполнить команду
tar cvzf foo.tar.gz foo
Для того, чтобы такой архив распаковать в текущую папку, достаточно
выполнить команду tar xvzf foo.tar.gz
.
Не рекомендуется делать имя архива не содержащим имя архивируемой папки в качестве префикса. Также не рекомендуется архивировать более одной папки и не рекомендуется архивировать файлы, не лежащие в этой самой одной папке.
Часто применяемые разновидности команды tar
:
tar tzf foo.tar.gz
— посмотреть содержимое архиваtar cvf foo.tar foo
— сделать несжатый архив (работает быстрее, чем архивирование со сжатием)tar xvf foo.tar
— распаковать несжатый архивtar tf foo.tar
— просмотреть содержимое несжатого архива- буква
v
во всех вышеописанных командах отвечает только за то, что печатается во время работы архиватора; можно её и не ставить — тогда архиватор будет работать молча - буква
z
отвечает за алгоритм сжатия gzip; из распространённых алгоритмов есть ещёj
(обычно названия таких архивов заканчиваются наbz
,bz2
илиbzip2
) иJ
(названия таких архивов обычно заканчиваются наxz
)
Сборка приложений
Обычно при сборке хоть сколько-то нетривиальных приложений требуется какое-то количество нетривиальных действий вида:
- сделать какие-то файлы таким-то образом
- на основе этих файлов сгенерировать ещё какие-то файлы
- на основе сгенерированных файлов сделать ещё что-то
- и так далее
Для обеспечения возможности не повторять всю процедуру каждый раз заново при незначительных изменениях исходного кода существуют системы сборки.
Многие языки программирования поставляются со своими собственными системами сборки (которые зачастую ещё и совмещены с системами управления пакетами и зависимостями). Но есть и несколько универсальных, наиболее распространённой из которых является GNU Make.
Принцип работы GNU Make максимально примитивен:
- в специальном файле (который обычно называется
Makefile
) описываются компоненты приложения (на Make-жаргоне они называются «целями») - для каждой цели указываются её зависимости — имена файлов
- также для каждой цели указывается процедура её сборки — набор команд для стандартного интерпретатора команд; считается, что итогом этого набора команд должен стать файл, имя которого совпадает с именем цели
Make (запускается командой make имя_цели
или просто make
, если нужно
собрать самую первую цель из Makefile
) работает по примерно
следующему алгоритму:
- для указанной цели, а также — каждой цели, связанной с указанной отношением зависимости, проверяет её актуальность
- а именно, каждая такая цель должна существовать и быть изменена не раньше, чем любая из её зависимостей
- все неактуальные цели пересобираются при помощи соответствующих наборов команд (в таком порядке, что любая зависимость пересобирается до той цели, которая от неё зависит)
Синтаксис Makefile
в первом приближении следующий:
- каждая цель оформляется так
имя_цели: зависимость1 зависимость2 зависимость3
(зависимости указываются через пробелы, этих самых зависимостей может быть любое число, хоть даже ноль) - после цели идут команды, каждая команда предваряется символом табуляции (именно табуляцией, а не несколькими пробелами!)
Также отметим, что часто в Makefile
встречаются псевдоцели
(all
, install
, clean
и прочее подобное), предназначенные для
запуска каких-то служебных сценариев. Для того, чтобы такие цели не приводили
к проверке наличия и актуальности соответствующих файлов, их нужно указать
как зависимости специальной цели .PHONY
).
О более сложных сценариях использования GNU Make рекомендуем прочитать в документации.
Сетевое взаимодействие
Наиболее универсальный способ организации взаимодействия между двумя различными приложениями — так называемое «сетевое взаимодействие». Отметим, что этот термин не совсем соответствует общепринятому смыслу слова «сетевое». Например, в рамках одного компьютера (безо всякой компьютерной сети) сетевое взаимодействие тоже возможно (и, более того, очень активно используется).
Сетевой порт
Для того, чтобы программа могла получать данные «по сети», она должна зарезервировать у ОС сетевой порт.
Сетевой порт — это число в пределах от 0 до 65535. Каждая единица данных, отправляемая по сети, должна сопровождаться как минимум портом назначения. И именно той программе, которая сейчас резервирует соответствующий порт назначения, придёт эта единица данных (если, конечно, эта единица данных направлена на тот компьютер, на котором запущена программа, резервирующая упомянутый порт).
Обычно (но не всегда!) для свободного резервирования доступны порты из диапазона 1024-32767. Более того, весьма редко системные администраторы ОС настраивают права доступа пользователей к портам. При этом порты резервируются по принципу «кто первым встал — того и тапки». Поэтому правилом хорошего тона является не жёстко задавать используемые порты в программном коде, а давать пользователю программы возможность выбора портов, используемых программой.
Способ передачи данных
Перед тем, как передавать или получать данные «по сети», программа должна выбрать способ передачи данных. Таковых по факту используется два:
- TCP — аналог телефонной связи из реального мира; с точки зрения отправляющего и принимающего приложений между ними открывается двусторонний симметричный канал общения, по которому они могут пересылать друг другу данные; весь необходимый для этого обмен служебными сообщениями берёт на себя ОС
- UDP — почти полный аналог писем из реального мира; размер отправляемых данных ограничен (чуть менее чем 64 КиБ), судьба сетевого пакета, вообще говоря, неизвестна
Для большинства целей TCP гораздо предпочтительнее. Исключения — ситуации, требующие быстрейшей реакции (компьютерные игры, в которых важно время отклика; аудиоконференции; телефония и прочее подобное). Также UDP обычно используется при реализации каких-то собственных нетривиальных протоколов взаимодействия, отличных от TCP. В таких случаях говорят о «протоколе поверх UDP».
Адресация
Также для организации сетевого общения необходимо указать:
- порт назначения (какой программе предназначены данные)
- адрес назначения (на какой компьютер отправлять данные)
- порт отправителя (какая программа отправила данные)
- адрес отправителя (с какого компьютера отправлены данные)
При TCP-общении порт и адрес отправителя автоматически указываются операционной системой (у программиста к ним нет доступа). При UDP-общении эти адрес и порт можно указать любые (хотя многие ОС и языки программирования, в частности — Python, несколько затрудняют это действие).
Теперь по поводу настройки всего этого. Если с портами ситуация понята (они полностью управляются программистом), то с адресами всё сложнее (они управляются системным администратором, к тому же их смысл нестандартизирован).
Здесь мы опишем некие общие принципы, которых придерживаются почти все ОС и почти всё сетевое оборудование. При этом нужно понимать, что детали могут кардинально различаться.
Принципы следующие:
- компьютерные сети — это графы, вершины которых делятся на два класса: «компьютеры» и «мосты»(или, как их ещё называют, «звёзды»)
- пары (компьютер, выходящее из него ребро) называются сетевыми интерфейсами
- сетевой интерфейс может соответствать некоторому физическому способу подключения (обычно это либо RJ45-разъём, либо Wifi-модуль, но бывают и другие)
- также сетевой интерфейс может быть виртуальным — ОС делает полную видимость того, что у компьютера есть какой-то дополнительный разъём, но в реальности пользуется имеющимися
- соседями называются сетевые интерфейсы, между которыми существует путь, начинающийся в одном интерфейсе и заканчивающийся в другом, не содержащий при этом никаких других вершин-компьютеров (другими словами, любая промежуточная вершина должна быть мостом)
- каждый сетевой интерфейс имеет адрес
- доставка сетевых пакетов гарантируется только на адреса соседей, а также — на специальный loopback-адрес, который отождествляется с самим компьютером, отправляющим пакет
Адреса бывают двух видов: IPv4 и IPv6.
- каждый IPv4 адрес состоит из 32 бит, при этом традиционно записывается как 4 десятичных записи составляющих его октетов (восьмёрок бит), разделённые точками
- IPv4-loopback обозначается
127.0.0.1
- каждый IPv6 адрес состоит из 128 бит, традиционно записывающихся как восемь шестнадцатиричных записей составляющих его групп по 16 бит, разделённых двоеточиями
- если две и более группы подряд равны 0, то они могут быть опущены и заменены на двойное двоеточие
- IPv6-loopback обозначается
::1
(без сокращений0:0:0:0:0:0:0:1
)
При настройке сетевого интерфейса зачастую есть возможность разбить адрес на две части: номер подсети и номер узла. Это разбиение:
- не является частью адреса, а является лишь функцией, предоставляемой ОС
- обычно задаётся либо длиной префикса (количеством бит в подсети), либо
маской — «адресом», биты-единицы которого показывают,
какие из бит адреса относятся к номеру подсети; например, разбиения
10.23.45.67/10
(10 — длина префикса) и10.23.45.67/255.192.0.0
(255.192.0.0
— маска) эквивалентны - по идее должно быть таким, что разные сетевые интерфейсы одного компьютера имеют разные номера подсети, а любой сосед интерфейса имеет тот же номер подсети, что и сам интерфейс
Также есть ещё понятие шлюза (default gateway), который можно назначить сетевому интерфейсу. Это — адрес того соседа, на который будут отправлены все пакеты, адреса назначения которых отличны от адреса любого из соседей (за исключением, возможно, тех пакетов, адреса которых отличны от адреса интерфейса в той его части, которая является номером подсети). Что происходит при наличии нескольких шлюзов, при отсутствии шлюза, при несовпадении адреса назначения в части номера подсети с номером подсети интерфейса, имеющего шлюз, и прочие подобные вопросы — те самые тонкости, которые на разных сетевых устройствах могут быть устроены совершенно по-разному.
Скажем лишь, что некоторые ОС предоставляют команду route
, дающую доступ
к таблице маршрутизации, по которой можно понять, куда именно будет
отправлен тот или иной пакет с адресом, отличным от адреса соседа.
В непонятных ситуациях можно пытаться консультироваться с этой таблицей: обычно в ней перечислены адреса соседей вместе с длиной префикса, совпадения которого достаточно для отправки пакета этому соседу (но вот сам алгоритм выбора соседа при нескольких совпадениях может варьироваться).
Немного внутренностей
Операционная система с данными делает две вещи перед отправкой:
- сперва добавляет перед ними TCP/UDP-заголовок, в который входит информация о том, TCP или UDP выбран, а также — порты цели и отправителя
- затем перед TCP/UDP-заголовком добавляет IP-заголовок, в который входит информация об используемой версии IP, а также — адреса цели и отправителя
Знать эту внутреннюю структуру сетевого пакета полезно по весьма простой причине: поскольку протоколы TCP/UDP и IP появились существенно после ОС Unix, сетевая модель Unix не совсем точно ложится на эти протоколы. И в сложных случаях приходится вручную вмешиваться в эту модель (например, для того, чтобы выбрать адрес и порт отправителя при UDP-общении).
Ситуация ещё и осложняется тем, что многие языки программирования предоставляют свой взгляд на сетевое взаимодействие, отличный как от Unix-взгляда, так и от TCP/UDP/IP-взгляда. Но всё равно все альтернативные взгляды тем или иным образом транслируются в TCP/UDP/IP-модель.
Сетевое общение в Python
Python предоставляет низкоуровневый интерфейс (более-менее аналогичный
стандартному Unix-интерфейсу) сетевого взаимодействия посредством модуля
socket
.
Мы приведём основные схемы его использования (везде будем предполагать, что используются IPv4-адреса).
TCP-клиент
Устанавливает TCP-соединение с приложением по адресу 12.34.56.78
,
которое зарезервировало порт 90
.
import socket
sock = socket.socket()
sock.connect( ('12.34.56.78', 90) )
sock.send(b'Hello') ## отправка данных
msg = sock.recv(4096) ## приём данных
sock.close() ## завершает соединение
Число-вход метода recv
— это максимальное количество байт, которое
будет получено за этот вызов. Сам вызов блокирует исполнение программы,
но исполнение разблокируется в тот момент, как только будет получен
хотя бы один байт. Поэтому в качестве аргумента recv
можно писать
почти всё, что угодно (4096 — просто рекомендуемое значение).
Если соединение разрывается, то recv
становится неблокирующим и всегда
возвращает пустое сообщение.
Также отметим, что send
тоже может заблокировать дальнейшее исполнение
программы: это происходит, если мы отправили слишком много данных, а наш
«собеседник» почему-то данные не пытается от нас получить при
помощи recv
.
TCP-сервер
Ожидает соединение по порту 1234
.
import socket
sock = socket.socket()
sock.bind( ('', 1234) )
sock.listen() ## показывает нашу готовность принимать соединения
conn, addr = sock.accept() ## блокирует выполнение до установления соединения
conn.send(b'Hello') ## отправка данных
msg = conn.recv(4096) ## приём данных
conn.close() ## завершает соединение
Сервер по одному порту может принять сразу несколько соединений. С каждым
из них он общается через сокет, возвращённый методом accept
.
Методу listen
можно указать число-вход — максимальное количество
соединений, которые могут находиться в режиме ожидания accept
. Если
указать 0, то любому, кто будет пытаться соединиться с нашим сервером до
того момента, как он вызовет accept
, в этом соединении будет автоматически
отказано.
UDP-клиент
Отправляет пакет по адресу 12.34.56.78
на порт 90
.
import socket
sock = socket.socket(type=socket.SOCK_DGRAM)
sock.sendto(b'Hello', ('12.34.56.78', 90) )
При желании можно попытаться указать обратные адрес и порт при помощи
метода bind
. Вполне вероятно, что далеко не все возможные варианты
понравятся ОС.
Чтобы указать произвольные адрес и порт, нужно создать сокет типа
socket.SOCK_RAW
, который вообще убирает из пакета TCP/UDP-заголовок,
после чего приписать к своим данным правильный UDP-заголовок вручную
(ниже будет приведён пример).
UDP-сервер
Ожидает пакет на порт 1234
.
import socket
sock = socket.socket(type=socket.SOCK_DGRAM)
sock.bind( ('', 1234) )
msg, addr = sock.recvfrom(65536)
Длины сообщения 65536 хватает на любой UDP-пакет.
Внимание! В отличие от TCP, len(msg)
может быть равно 0 даже
при успешном получении сообщения. Впрочем, той ситуации, о которой
сигнализировало len(msg) == 0
в случае TCP (разрыв соединения),
в случае UDP не бывает вообще, так как UDP не предполагает никакого
«соединения».
Хитрый UDP-клиент
Если уж очень хочется явно указать порт (или даже адрес) отправителя при отправке UDP-пакета, то это можно сделать при помощи следующего кода:
## Обработка ошибок намеренно опущена, дабы не загромождать код.
import socket
def word16be(number):
return bytes([number//256, number%256])
def udp_send(sock, src_addr, src_port, dst_addr, dst_port, msg):
src_addr_bytes = bytes([int(x) for x in src_addr.split('.')])
dst_addr_bytes = bytes([int(x) for x in dst_addr.split('.')])
ip_header = (
bytes([4*16 + 5, 0]) +
word16be(ip_length) +
word16be(0) +
word16be(0) +
bytes([255, 17, 0, 0]) +
src_addr_bytes +
dst_addr_bytes
)
if len(ip_header) != 20:
raise RuntimeError("Wrong address length")
udp_length = len(msg) + 8
if udp_length > 65515:
raise RuntimeError("Message too long")
udp_header = (
word16be(src_port) +
word16be(dst_port) +
word16be(udp_length) +
word16be(0)
)
sock.sendto(ip_header+udp_header+msg, (dst_addr, dst_port))
foo = socket.socket(type=socket.SOCK_RAW, proto=socket.IPPROTO_RAW)
udp_send(foo, '123.45.67.89', 4321, '127.0.0.1', 1234, b'Hello, world!')
Можно при создании сокета указать proto=socket.IPPROTO_UDP
. Тогда
IP-заголовок будет сформирован автоматически (соответственно, его
не нужно будет добавлять к сообщению), но не будет возможности
указать произвольный адрес отправителя.
Также следует учитывать, что успешно выполнить такую программу можно, лишь имея соответствующие права (например, права администратора).
Домашнее задание
В файле реализован простейший TCP-сервер, отправляющий все данные, ему пришедшие, на стандартный выход в человекочитаемом виде.
Требуется:
- добавить к нему возможность перенаправления всех полученных им данных на указанный адрес (либо предварительно установив с этим адресом соединение, либо устанавливая соединение с этим адресом при каждом присоединении нового клиента)
- как более сложное задание: добавить перенаправление ответа, полученного с того адреса, с которым мы соединились, присоединённому клиенту, тем самым реализуя полноценный Man-in-the-middle (делаем вид, что мы — тот сервис, с которым хочет соединиться клиент, но при этом читаем весь трафик и, если нужно, его подменяем)
GIT
Иногда бывает удобно, работая над каким-то проектом, иметь доступ ко всем предыдущим точкам сохранения, чтобы, если возникнет в том нужда, можно было откатиться к какой-то предыдущей версии или же окинуть взглядом какой-то участок истории проекта.
Автоматизированные сервисы, обеспечивающие такую функциональность, называются системами контроля версий. Одной из наиболее распространённых на настоящий момент (начало 2022 года) является система git. О ней и пойдёт речь в этом разделе.
Взаимодействие с git
Хотя для git есть дикое количество графических интерфейсов, в его основе лежит консольная утилита, тонкими обёртками к которой вышеупомянутые графические интерфейсы являются.
Поэтому здесь мы обсудим именно процесс работы с консольной утилитой (который весьма специфичен по сравнению, например, с таковым для системы контроля версий гуглодокументов или же некоторых сред разработки; причём вся эта специфичность сохраняется и при работе с графическими интерфейсами).
Самое главное
Если хочется узнать что-то поподробнее, git help
Вам в помощь. Также
им рекомендуется пользоваться во всех непонятных ситуациях.
Начало работы
Для того, чтобы объявить текущую папку «git-репозиторием»
(проще говоря — отдать под управление git), можно воспользоваться
командой git init
, находясь в этой папке.
Также можно создать новую папку под управлением git командой git init название_папки
или же создать т.н. «голый» репозиторий командой git init название_папки --bare
. Зачем нужен последний — см. соответствующий подраздел.
Два основных этапа работы
Содержимое git-репозитория называется Working Tree. Для того, чтобы сохранить состояние Working Tree в истории репозитория, нужно сделать две вещи:
- пометить командой
git add
те файлы, изменения в которых следует сохранить - подтвердить сохранение изменений командой
git commit
Для простоты первый шаг можно делать командой git add -A
, которая помечает
все изменения во всех файлах Working Tree (в том числе — новые файлы и
удалённые файлы).
На втором шаге команда git commit
вызывает некоторый текстовый редактор,
в котором предлагается кратко описать суть произведённых изменений.
Этот файл нужно сохранить и закрыть текстовый редактор. Если Вам вызванный
git-ом текстовый редактор не нравится, можно либо перенастроить git
(см. git help config
), либо же пользоваться
командой git commmit -m краткое_описание
.
В заключение скажем, что если хочется игнорировать некоторые файлы,
находящиеся в Working Tree, их можно прописать в файл
с названием .gitignore
, который следует поместить в ту папку, внутри которой
(в том числе — в её подпапках, подпапках подпапок и т.д.) лежат
эти самые игнорируемые файлы.
Ветки
Самая неочевидная часть git — т.н. «ветки».
А именно, история версий может быть нелинейной: вернувшись к одной из предыдущих версий, можно на неё накатить какие-то изменения, при этом получив новое ответвление истории и сохранив все версии оригинальной истории.
Также можно соединить воедино два разных ответвления, получив версию, в которой отражены как изменения одного ответвления, так и другого.
Вышеупомянутая же неочевидность связана с очень неудачной терминологией, которую git использует: термином Branch названо не ответвление истории, как можно было бы предположить, а просто имя для какой-нибудь версии.
Для работы с Branch используются в основном три комадны:
git branch
— для создания новых веток и печати имеющихсяgit checkout
— для перехода к какой-то ветке или какой-то версииgit merge
— для слияния текущей ветки с какой-нибудь другой
Для того, чтобы объяснить, как эти команды работают, введём ещё один термин: словом HEAD называется та версия, в которой «мы сейчас находимся».
При этом голова (т.е. HEAD) может быть в одном из двух состояний:
- привязана к какой-то ветке
- привязана к какой-то версии, но не к ветке (такое состояние называется detached)
Состояние, в котором находится HEAD, влияет на поведение команды
git commit
:
- если голова привязана к ветке, то эта ветка (напомним ещё раз, что ветка — это просто название для версии, а не путь в графе) переходит на свежесозданную версию
- если голова привязана к версии, то никакие ветки никуда не двигаются
Изначально в свежесозданном репозитории есть ровно одна ветка (обычно
с названием master
или main
), к которой привязана голова.
Для того, чтобы привязать голову к какой-то версии, нужно выполнить
команду git checkout
с идентификатором этой версии в качестве аргумента
(идентификаторы можно посмотреть командой git log
).
Для того, чтобы привязать голову к какой-то ветке, нужно либо выполнить
команду git checkout
с этой самой веткой (напомним в третий раз, что ветка
— это всего лишь название для версии) в качестве аргумента, либо
выполнить команду git branch новая_ветка
.
Слияние веток и работа с удалёнными репозиториями
Иногда ответвления истории образуются как некоторые независимые друг от друга нити работы над проектом (например, один человек изменяет одну часть, другой — другую).
В такой ситуации ответвления приходится в некоторый момент соединять. Причём зачастую эти ответвления находятся в разных репозиториях, склонированных из какого-то одного общего.
Один репозиторий
Если ответвления находятся в одном репозитории, то всё весьма просто:
- переходим в одну из веток
- делаем
git merge другая_ветка
- если всё ок, то на этом всё заканчивается
- если же возникли какие-то конфликты, которые не удаётся исправить
встроенными в git эвристиками, исправляем их вручную и делаем
git commit
Настоятельно рекомендуется хотя бы раз попробовать вызвать нетривиальный конфликт и посмотреть, как именно git обозначает этот конфликт внутри соответствующего файла, чтобы потом это не стало неприятным сюрпризом.
Несколько репозиториев
Любой репозиторий (хоть — находящийся на том же компьютере, но
в другой папке, хоть — находящийся на другом компьютере)
можно склонировать командой git clone
, аргументом которой должен
быть путь к репозиторию. Обычно этот путь — одно из трёх:
- относительный или абсолютный путь папки на том же компьютере
- если другой компьютер доступен по ssh — имя другого компьютера
(такое, как настроено в
.ssh/config
), за которым следует двоеточие, за которым следует имя папки на другом компьютере - для сервисов типа github — просто обычный интернет-локатор наподобие
https://github.com/foobar/baz
Склонированный репозиторий называется удалённым (remote, а не deleted, как можно было бы подумать) по отношению к его клону. Внутри репозитория-клона становятся доступными новые команды:
git push
— перенести текущую ветку (со всеми необходимыми версиями) на удалённый репозиторий и слить там с одноимённой веткойgit pull
— перенести одноимённую с текущей ветку с удалённого репозитория в локальный и слить там с текущей
Голые репозитории
Некоторую проблему представляет git push
в ту ветку, которая на удалённом
репозитории является текущей. С этим можно бороться двумя способами:
- не делать
git push
в текущую для удалённого репозитория ветку - сделать удалённый репозиторий вообще без Working Tree (т.н. bare-репозиторий)
Второй подход настоятельно рекомендуется применять для основного репозитория проекта, с которого все, кто над проектом работает, делают клоны.
Для реализации этого самого второго подхода достаточно создать репозиторий
с опцией --bare
. Собственно, всевозможные коллаборационные сервисы типа
github как раз создают на своих серверах репозиторий именно такого типа.
Коллаборационные git-сервисы
Уже весьма давно широкое распространение получили сервисы, упрощающие совместную работу над проектом и при этом имеющие в своей основе git.
К наиболее крупным (на момент начала 2022 года) можно отнести github и gitlab.
В целом процесс работы с ними достаточно очевиден (а детали доступны в документации), тем не менее, у этих сервисов есть одна особенность, которая напрямую не поддерживается git: т.н. Pull (или Merge) Request.
Осуществление Pull Request
Для того, чтобы предложить изменение в какой-нибудь репозиторий, нужно сделать следующее:
- склонировать этот репозиторий при помощи интерфейса соответствующего
сервиса (обычно, чтобы не путать с
git clone
, это действие называют словом fork) - склонировать теперь уже при помощи
git clone
свою копию репозитория на свой компьютер - внести изменения
- сделать
git commit
иgit push
- в интерфейсе коллаборационного сервиса сделать Pull (или Merge) Request
- ждать ответа владельца оригинального репозитория
- после того, как Pull Request успешно прошёл, свою копию репозитория можно удалить