Основы сетевого программирования
Основные понятия сетевого программирования
Чем сетевые приложения отличаются от обычных?
Сетевыми приложениями мы будем называть любые приложения, которые обмениваются данными, используя компьютерную сеть. Это довольно широкое определение, и, конечно, мы не сможем рассмотреть все многообразие обширного мира сетевых технологий, который, вдобавок развивается очень быстро и новые технологии, приемы и методики появляются чуть ли не каждый день. Поэтому в данном пособии мы сконцентрируемся на освоении базовых схем обмена информацией по сети, которые лежат в основе всех более продвинутых вещей. Используя полученные знания вы сами сможете строить все более и более сложные схемы взаимодействия разных приложений и разных компонентов одного приложения по сети.
Сетевые приложения могут обмениваться информацией с другими, сторонними приложениями либо строить взаимодействие по сети между компонентами одного и того же приложения, написанного одним автором или одной командой.
Возможность обмениваться данными по сети открывает перед разработчиком широкий круг возможностей.
Вы можете обращаться к сторонним сервисам, имеющим открытое API. Сегодня существует множество сервисов, постоянно действующих в сети и предоставляющих способ обмена данными в автоматизированном формате через специальную схему взаимодействия, то есть публичный интерфейс, или API. Например, ваша программа может обратиться к погодному сервису и получить данные о погоде в определенном месте или городе.
Также вы можете сами разработать такой публичный сервис. Если ваше приложение может выдавать информацию по запросу неограниченному кругу лиц, вы можете опубликовать в Интернете приложение, которое будет обрабатывать входящие соединения и отвечать на запросы. Нужно только спроектировать API вашего сервиса, реализовать и задокументировать его.
Можно строить распределенные приложения. Сейчас довольно распространены приложения, основой функционирования которых является взаимодействие множества компонентов, запущенных на совершенно независимых компьютерах сети. Так работают, например, пиринговые сети, системы распределенных вычислений или ботнеты.
Добавьте к любой вашей программе возможность самостоятельно обновляться по сети. Почти все программы имеют подсистему автоматической и регулярной проверки сервера разработчика программы на предмет выхода новой версии. При наличии такой можно предложить пользователю скачать ее, либо обновиться самостоятельно.
Можно использовать централизованную схему клиент-сервер. В таком случае ваша программа может быть разделена на две логические части - клиентскую, которая запускается на компьютере пользователя и предоставляет ему интерфейс, и серверную, которая работает на сервере, принадлежащем разработчику, и может заниматься, например, доступом к базе данных. Логика работы программы тоже может быть разделена на две части.
Можно организовывать централизованное хранилище данных. Это удобно, если, например, вам нужно собирать данные от пользователей вашей программы в одном месте, либо предоставить пользователям возможность обмениваться сообщениями.
Если вы организуете взаимодействие с клиентами посредством нескольких каналов, можно позаботиться об омниканальности - возможности сохранять информацию о всех взаимодействиях с пользователем по любым каналам, создавая ощущение бесшовности. Такую схему активно используют, например, банки. Вы можете зайти в мобильное приложение, совершить там какую-то операцию, а затем зайти в личный кабинет на веб-сайте банка и продолжить работу с того же места.
Выводы:
- Сетевые приложения - это программы, которые обмениваются данными через сеть.
- В подавляющем большинстве случаев обмен идет на основе протоколов TCP/IP.
- В настоящее время все более или менее развитые приложения являются сетевыми в той или иной мере.
- Приложения обмениваются данными с другими либо между своими компонентами.
- Можно обращаться к сторонним сервисам
- Создание публичного сервиса - это тоже задача сетевого программирования.
- Многопользовательские приложения очень распространены в определенных предметных областях.
- Автоматические обновления - это возможность, которая есть почти во всех программных продуктах.
- Одна из частных задач сетевого программирования - удаленное хранение данных
- Для экономики и бизнеса важна омниканальность взаимодействия с клиентами и пользователями.
В чем сложности создания сетевых приложений?
Разработка сетевых приложений - отдельная дисциплина информатики, требующая от программиста некоторых дополнительных знаний и умений. Естественно, для создания приложений, использующих возможности компьютерной сети необходимо знать основы функционирования таких сетей, понимать главные сетевые протоколы, понятие маршрутизации и адресации. В подавляющем большинстве случаев на практике используются протоколы семейства TCP/IP. Это сейчас универсальный механизм передачи данных. Но сами сетевые приложения могут интенсивно использовать разные конкретные протоколы, находящиеся на разных уровнях модели OSI.
Например, в этой теме мы в основном будем говорить об использовании TCP и UDP сокетов, то есть будем работать на транспортном уровне модели OSI. Но можно работать и на других уровнях с использованием других протоколов. Так веб приложения наиболее активно полагаются на протокол HTTP и его защищенную модификацию - HTTPS. Поэтому так важно знать все многообразие существующих схем и протоколов обмена данными. Ведь при проектировании приложения нужно выбрать тот протокол, уровень, который наилучшим образом подходит для решений стоящей перед разработчиками прикладной задачи.
Кроме того, при проектировании и реализации сетевых приложений на вас, как на разработчике лежит задача продумывания конкретного обмена данными. Используемый протокол регламентирует порядок передачи данных, но конкретную схему, последовательность обмена создает разработчик конкретного приложения. Вам надо определиться, какие данные будут передаваться по сети, в каком формате, в каком порядке. Будете ли вы использовать, например, JSON или XML, может, стоит придумать свой формат данных? После соединения, какой модуль начинает передачу, сколько итераций передачи будет проходить за одно соединение, как обозначить конец передачи, нужно ли регламентировать объем передаваемой информации, нужно ли использовать шифрование данных - это все примеры вопросов, которые необходимо будет решить в ходе разработки. Фактически, каждое сетевое приложение - это по сути еще один, очень конкретный протокол передачи, которые для себя придумывают разработчики специально для этого приложения.
Еще одна область, непосредственно связанная с сетевым обменом - это параллельное программирование. Сетевые приложения обычно очень активно используют многопоточности или другие средства обеспечения многозадачности. Какие-то операции должны выполняться в фоне, пока выполняются другие. Такое приложение становится гораздо более сложным по своей структуре. И необходимо отдельно заботиться о том, чтобы оно работало корректно при всех возможных условиях. Это происходит за счет обеспечения потокобезопасности, использования правильных архитектурных и дизайнерских шаблонов, специальных алгоритмов и структур данных.
Отдельный вопрос, непосредственно связанный с сетевым программированием - обеспечение безопасности и конфиденциальности обмена данными. Любое приложение, принимающее данные по сети должно быть рассчитано на то, что его может вызвать и передать данные любое приложение или пользователь. В том числе, с неизвестными или деструктивными целями. Поэтому в сетевых приложениях необходимо применять проверки входных данных, валидацию всей принимаемой информации, возможно - механизмы аутентификации пользователей. Если же ваше приложение отправляет данные - здесь тоже возможны риски того, что они попадут не по назначению. Так можно применять те же механизмы аутентификации, шифрования трафика. Особенно аккуратным надо быть, если ваше приложение собирает, хранит или передает персональные данные пользователей. В этом случае могут применяться определенные юридические нормы и обязательства, которые разработчики обязаны соблюдать.
Надо помнить, что сетевые приложения используют не только протоколы и соглашения передачи данных, но и конкретную физическую сетевую инфраструктуру. И у нее есть определенные параметры, которые необходимо учитывать - полоса пропускания, надежность, доступность. Как будет работать приложение, если в какой-то момент перестанет быть доступна сеть? Какая скорость передачи данных нужна для бесперебойной работы серверной части приложения? На сколько одновременных подключений рассчитан каждый программный модуль? За этим всем нужно следить не только на этапе проектирования, принятия решений, но и в процессе работы приложения - организовывать постоянный мониторинг, продумывать вопросы управления нагрузкой, дублирования, репликации данных и так далее.
Выводы:
- Нужно знать основы организации компьютерной сети.
- Для некоторых приложений необходимо знать особенности конкретных сетевых протоколов, например, HTTP.
- Необходимо также отдельно заботиться о согласованности обмена информацией между компонентами приложения.
- Написание многопоточных приложений требует специальных усилий по обеспечению потокобезопасности.
- Также не нужно забывать о вопросах безопасности, конфиденциальности, валидации получаемых данных.
- Также существуют вопросы управления нагрузкой ваших сервисов и сетевой инфраструктуры.
- Необходимо также помнить о доступности сети и ограниченности полосы пропускания.
Какие основные подходы к их построению?
При создании сетевых приложений первый вопрос, который должен решить для себя разработчик - создание высокоуровневой архитектуры приложений. Из каких частей (модулей) оно будет состоять, как именно они будут обмениваться данными и с кем. Будет ли обмен происходить только между модулями самого приложения или будут предусмотрены обращения к внешним сервисам?
Не затрагивая пока вопросов использования сторонних сервисов и программ, рассмотрим два самых популярных архитектурных паттерна для сетевых приложений. В принципе, любое сетевое приложение может быть либо клиент-серверным, либо распределенным.
Самая популярная архитектура сетевых приложений - клиент серверная. Она подразумевает, что приложение состоит из серверной части и клиентской. Сервером мы будем называть именно часть программной системы, модуль, который постоянно (резидентно) выполняется и ждет запросов от клиентов. Когда запрос поступает, сервер его обрабатывает, понимает, что клиент хочет получить и выдает ему ответ.
При этом может быть одновременно запущенно несколько экземпляров клиентского модуля программы. Но как правило, они все идентичны (во всяком случае, они должны использовать идентичный протокол обмена данными), поэтому нет смысла их рассматривать отдельно, мы будем говорить об одном клиентском модуле.
Клиент в такой схеме - это модуль программы, который непосредственно взаимодействует с пользователем программы и в зависимости от его действий может инициировать соединение с сервером, чтобы произвести определенные операции.
Сервер непосредственно с клиентом не взаимодействует. Его задача - выполнять запросы клиентов. Он в такой схеме является центральным элементом. Распределение функционала между клиентом и сервером - другими словами, какие операции вашей программы должны относиться к клиентской части, а какие к серверной - тоже предмет проектирования. Определенно одно - все, что касается пользовательского интерфеса - это прерогатива клиентской части. В зависимости от задачи вы можете делать клиент более “тонким”, то есть оставить только интерфейс и больше ничего (тогда при любых действиях пользователя клиент будет запрашивать сервер и просить его выполнять операции), либо более “толстым” - то есть выносить на клиент часть непосредственного фукнционала приложения.
Достоинством клиент-серверной архитектуры является ее простота и понятность. Приложение явно делиться на четко обозначенные модули, между ними налаживается довольно типичная схема обмена данными. Но у нее есть и недостатки. Если по каким-то причинам сервер становится недоступен, то полноценно пользоваться приложением нельзя. То есть сервер - это потенциальная точка отказа.
Тем не менее, на основе клиент-серверного принципа работают большинство сетевых приложений, почи все сетевые службы, на работу с ним ориентированы большинство библиотек и фреймворков. Так что инструменты разработки обычно помогают реализовывать именно такую архитектуру приложений.
Альтернативой клиент-серверным приложениям выступают распределенные. В них программа не делится на клиент и сервер, а состоит из множества однотипных модулей, которые совмещают в себе функции и клиента и сервера. Такие модули, будучи запущенными одновременно, могут подсоединяться друг к другу и выполнять обмен данными в произвольном порядке.
Типичным примером распределенных приложений являются пиринговые сети. В них каждый экземпляр приложения может подключаться к любому другому и налаживать многосторонний обмен информацией. Такие приложения образуют сеть подключений, подобно тому, как организован сам Интернет. Такое приложение не зависит от работы центрального сервера и может продолжать функционировать даже если большая часть этой сети будет отсутствовать или будет недоступной.
Несмотря на все достоинства, проектировать, реализовывать и поддерживать распределенные приложения может быть значительно сложнее, чем клиент-серверные. Это происходит потому, что клиент-сервер, более четко определенный протокол, ему нужно лишь следовать и все будет работать так, как предполагается. А в распределенных приложениях все приходится проектировать и продумывать с нуля.
Выводы:
- Есть две главные архитектуры построения сетевых приложений - клиент-серверная и распределенная.
- Сервер - это компьютер, программа или процесс, обеспечивающий доступ к информационным ресурсам.
- Клиент обычно инициирует соединение и делает запрос к ресурсу.
- Клиент-серверная архитектура является централизованной со всеми присущими недостатками и преимуществами.
- Распределенная архитектура может обойти некоторые ограничения централизованной.
- Распределенные приложения сложнее проектировать и управлять ими.
Основы взаимодействия через сокеты
Что такое TCP-сокеты?
Со́кет (англ. socket — разъём) — название программного интерфейса для обеспечения обмена данными между процессами. Процессы при таком обмене могут исполняться как на одной ЭВМ, так и на различных ЭВМ, связанных между собой сетью.
Сокеты - это самый базовый механизм сетевого взаимодействия программ, абстрагированный от конкретной реализации сети. Сокеты работают на транспортном уровне модели OSI - там же, где и протокол TCP и UDP.
Каждый сетевой интерфейс IP-сети имеет уникальный в этой сети адрес (IP-адрес). Упрощенно можно считать, что каждый компьютер в сети Интернет имеет собственный IP-адрес. При этом в рамках одного сетевого интерфейса может быть несколько (до 65536) сетевых портов. Для установления сетевого соединения приложение клиента должно выбрать свободный порт и установить соединение с серверным приложением, которое слушает (listen) порт с определенным номером на удаленном сетевом интерфейсе. Пара IP-адрес и порт характеризуют сокет (гнездо) - начальную (конечную) точку сетевой коммуникации.
Сокеты могут применяться для связи процессов как на удаленной машине, так и на локальной. В первом случае, естественно, необходимо, чтобы удаленная машина была доступна по сети. Это можно проверить при помощи команды пинг. Во втором случае сокеты могут выступать как механизм межпроцессного взаимодействия. Или вы можете использовать одну машину для всех процессов-компонентов вашей программной системы, например, для отладки в процессе разработки.
Для создания соединения TCP/IP необходимо два сокета: один на локальной машине, а другой - на удаленной. Таким образом, каждое сетевое соединение имеет IP-адрес и порт на локальной машине, а также IP-адрес и порт на удаленной машине. Как правило, порт локальной машины (исходящий порт) не так важен и его номер не особенно используется в практике. Но порт серверного сокета - это важная информация
Сокеты могут быть клиентские и серверные. Серверный сокет - это функция в программе, которая сидит на определенном порту и “слушает” входящие соединения. Процедура создания серверного сокета аналогична вводу текста из консоли: программа блокируется до тех пор, пока пользователь не ввел что-то. Когда это случилось, программа разблокируется и может продолжать выполнение и обработку полученных данных. Также и серверный сокет: ждет, когда к нему подключится клиент и тогда продолжает выполнение программы и может считывать данные из сокета (которые послал клиент) и отправлять данные в сокет. Клиентский же сокет, наоборот, сразу пытается подключиться к определенном узлу сети (это может быть локальная машина, или, чаще, удаленный компьютер) и на определенный сетевой порт. Если на этой машине на этом порту “сидит” серверный сокет, то подключение происходит успешно. Если же данный сокет никем не прослушивается, то процедура подключения возвращает ошибку.
В языке программирования Python существует стандартный модуль socket, который реализует все необходимые функции для организации обмена сообщениями через сокеты. Для его использования его достаточно импортировать (так как это модуль стандартной библиотеки, устанавливать его не нужно, он идет в поставке с дистрибутивом Python):
1
import socket
Для начала построения сетевого взаимодействия необходимо создать сокет:
1
sock = socket.socket()
Здесь ничего особенного нет и данная часть является общей и для клиентских и для серверных сокетов. Дальше мы будем писать код отдельно для сервера и для клиента.
Существует несколько видов сокетов, которые немного различаются по сфере применения и деталях реализации. Самыми распространенными являются Интернет-сокеты. Они используются для пересылки информации между процессами. Есть еще сокеты UNIX, они не используют Интернет-протоколы для обмена сообщениями, и используются для организации межпроцессного взаимодействия.
Также среди Интернер сокетов существуют потоковые и датаграммные сокеты. Датаграммные сокеты называют “сокеты без соединения”, они используют протокол UDP вместо TCP. Потоковые сокеты обеспечивают гарантированную доставку, очередность сообщений, они более надежны. Протокол HTTP использует именно потоковые сокеты для соединения клиента с сервером. UDP обычно используется для передачи потокового медиа, когда скорость критичнее риска потери единичных пакетов.
Выводы:
- Сокеты - это базовый механизм сетевого взаимодействия программ.
- Для работы сокетов не нужно специальное программное обеспечение, они работают на уровне операционных систем.
- Сокет состоит из адреса хоста и номера сетевого порта.
- Для соединения необходимо создать два сокета - в двух модуля программы, которые нужно соединить.
- Стандартный модуль Python socket позволяет создавать сокеты и работать с ними.
- Еще отдельный вид сокетов применяется для организации межпроцессного взаимодействия в *nix системах.
- Сокеты могут использовать протокол TCP либо UDP.
Каковы правила использования номеров портов?
Для эффективного использования сетевых сокетов необходимо вспомнить концепцию сетевых портов, так как сокеты их активно используют.
IP-адрес или любой другой способ адресации хоста позволяет нам идентифицировать узел сети. Номер порта позволяет указать конкретное приложение на этом хосте, которому предназначен пакет. Номер порта нужен, так как на любом компьютере может быть одновременно запущено множество приложений, осуществляющих обмен данными по сети. Если использовать аналогию с почтовыми адресами, то IP-адрес - это номер дома, а порт - это номер квартиры в этом доме.
Номер порта - это всего лишь 16-битное число, которое указывается в пакете, передающемся по сети. Не нужно путать сетевой порт с физическими разъемами, это чисто программная концепция.
Так как на номер порта отведено 16 бит, существует всего 65536 возможных портов. Причем, номера портов отдельно считаются по протоколам TCP и UDP. Таким образом, на компьютере одновременно может существовать более 130 тысяч процессов, обменивающихся данными. На практике, свободных портов всегда в избытке и хватает даже для работы множества высоконагруженных серверов.
Но не все номера портов созданы равными. Первые 1024 являются “системными” и используются в основном стандартными приложениями. Существует общепринятое соглашение, какие сетевые службы используют системные порты. Например, служба веб-сервера по умолчанию использует 80 порт для соединений по протоколу HTTP и 443 - для протокола HTTPS. Служба SSH использует порт номер 22. И так далее. Любая стандартная сетевая служба имеет некоторый порт по умолчанию. Кстати, хорошим показателем практикующего администратора является запоминание часто используемых номеров стандартных портов. Специально это учить не нужно, только если вы не хотите блеснуть знаниями на собеседовании.
Для использования системных портов обычно требуются повышенные привилегии. Это сделано для того, чтобы обычные пользователи не “забивали” стандартные порты и не мешали работе системных сетевых служб. Мы вообще не рекомендуем использовать системные порты. Остальные могут использоваться совершенно произвольно и их более чем достаточно для повседневных нужд.
Кстати, хоть сетевые службы используют определенные стандартные порты, они вполне могут быть переназначены на свободные. Служба не “привязана” к номеру порта, это легко регулируется настройками. Например, строго рекомендуется при настройке службы SSH менять стандартный 22 порт на случайный для повышения безопасности.
Порты назначаются процессу при попытке открыть серверный сокет. В этот момент происходит связывание сокета с номером порта, который выбрал программист. Помните, что если вы пытаетесь использовать занятый порт, то это спровоцирует программную ошибку. Поэтому в реальных приложениях стоит сначала проверять, свободен ли порт или нет, либо (что гораздо проще) обрабатывать исключение при попытке связаться с занятым портом.
Сетевые администраторы могут в целях безопасности блокировать соединения на некоторые порты. Так же поступают и программы-файерволлы. Это требуется для повышения безопасности сервера. Вообще, по умолчанию, все порты “закрыты”, то есть подключения к ним блокируется файерволлом. При необходимости системный администратор может “открыть” обмен данными по определенному номеру порта в настройках файерволла. Это следует учитывать при попытках подключения к удаленным машинам.
Выводы:
- Порт - это всего лишь число в IP-пакете.
- Номер порта нужен, чтобы обратиться к определенному процессу на конкретном хосте.
- Всего существует 65536 TCP-портов и 65536 UDP-портов.
- Первые 1024 порта являются системными - их можно использовать только администраторам.
- Распространенные сетевые службы имеют стандартные номера портов, их лучше не занимать.
- Порт назначается при открытии серверного сокета. Можно занять только свободный порт.
- Системные администраторы, программы-файерволлы могут заблокировать, “закрыть” обмен данными по номерам портов.
Почему стоит начать именно с изучения сокетов?
Сокеты, которые мы рассматриваем как базовый инструмент сетевого взаимодействия, относятся к транспортному уровню модели OSI. Мы можем рассматривать механизмы передачи данных на любом уровне. Но начинаем изучение сетевых приложений именно с транспортного уровня и на это есть свои причины.
Более высокоуровневые механизмы требуют установленного и настроенного специального программного обеспечения. Чтобы написать веб-приложение, нам нужен веб-клиент и веб-сервер, настроенные и готовые к работе. Такая же ситуация с любой другой службой Интернета. Конечно, на практике большинство популярных сетевых приложений используют более высокоуровневые протоколы, например, тот же HTTP.
Использование сокетов позволяет строить приложения, обменивающиеся данными по сети, но при этом не требующие специального программного обеспечения. Даже если вы планируете использовать в профессиональной деятельности прикладные протоколы, все они в основе своей работы используют механизм сокетов, так что его понимание будет полезно всем.
С другой стороны, более низкоуровневые протоколы не абстрагируются от конкретной реализации сети. Например, на физическом уровне мы должны будем иметь дело с протоколами передачи данных по витой паре, беспроводному каналу, решать проблемы помех, интерференции сигналов и прочие технические вопросы. Это не позволит нам сконцентрироваться на сутевых вопросах создания сетевых алгоритмов.
Поэтому транспортные сокеты - это компромиссный вариант между прикладными и физическими протоколами.
Выводы:
- Сокеты не требуют специального программного обеспечения.
- Сокеты не зависят от конкретной физической реализации сети.
- Сокеты хороши для понимания основ сетевого взаимодействия.
Как организуется обмен данными через TCP-сокеты?
Для соединения двух программ необходимо создать два сокета - один серверный и один клиентский. Они обычно создаются в разных процессах. Причем эти процессы могут быть запущены как на одной машине, так и на разных, на механизм взаимодействия это не влияет.
Работа с сокетами происходит через механизм системных вызовов. Все эти вызовы аналогичны вызовам, осуществляющим операции с файлами. Список этих вызовов определен в стандарте POSIX и не зависит от используемой операционной системы или языка программирования. В большинстве языков программирования присутствуют стандартные библиотеки, которые реализуют интерфейс к этим системным вызовам. Поэтому работа с сокетами достаточно просто и бесшовно организуется на любом языке программирования и на любой операционной системе.
Давайте рассмотрим общую схему взаимодействия через сокеты и последовательность действий, которые нужно совершить, чтобы организовать соединение. Первым всегда создается серверный сокет. Он назначается на определенный порт и начинает его ожидать (прослушивать) входящие соединения. Создание сокета, связывание сокета с портом и начало прослушивания - это и есть системные вызовы.
Причем при выполнении системных вызовов могут случиться нестандартные ситуации, то есть исключения. Обычно это происходит при связывании с портом. Если вы попытаетесь занять системный порт, не обладая парами администратора, или попытаетесь занять порт, уже занятый другим процессом, возникнет программная ошибка времени выполнения. Такие ситуации надо обрабатывать в программе.
Прослушивание порта - это блокирующая операция. Это значит, что программа при начале прослушивания останавливается и ждет входящих подключений. Как только поступит подключение, программа возобновится и продолжит выполнение со следующей инструкции.
В другом процессе создается клиентский сокет. Клиент может подключиться к серверному сокету по адресу и номеру порта. Если этот порт является открытым (то есть его прослушивает какой-то процесс), то произойдет соединение и на сервере выполнится метод accept. Важно следить за тем, чтобы клиент подключился именно к нужному порту. На практике из-за этого происходит много ошибок.
Соединение с серверным сокетом - это тоже системный вызов. При этом вызове тоже часто случаются ошибки. Естественно, если не удастся установить сетевое соединение с удаленным хостом по адресу, это будет ошибка. Такое случается, если вы неправильно указали IP-адрес, либо удаленная машина недоступна по сети. Еще ошибка может возникнуть, если порт, к которому мы хотим подключиться свободен, то есть не прослушивается никаким процессом. Также не стоит забывать, что порт на удаленной машине может быть закрыт настройками файерволла.
После этого устанавливается двунаправленное соединение, которое можно использовать для чтения и записи данных. Интерфейс взаимодействия с сокетом очень похож на файловые операции, только при записи информации, она не сохраняется на диск, а посылается в сокет, откуда может быть прочитана другой стороной. Чтение информации из сокета - это также блокирующая операция.
Следует помнить, что сокеты предоставляют потоковый интерфес передачи данных. Это значит, что через сокет не получится посылать сообщения “пакетами”. По сути, сокет предоставляет два непрерывных потока - один от сервера к клиенту, другой - от клиента к серверу. Мы не можем прочитать отдельное сообщение из этого потока. При чтении мы указываем количество бит, которые хотим прочитать. И процесс будет ждать, пока другой не отправит в сокет необходимое количество (ну либо не закроет сокет). Более понятны особенности работы в потоковом режиме станут на практике.
После обмена данными, сокеты рекомендуется закрывать. Закрытие сокета - это тоже специальный системный вызов. Закрывать нужно как клиентский, так и серверный сокет. Если вы забудете закрыть сокет, то операционная система все еще будет считать соответствующий порт занятым. После завершения процесса, который открыл сокет, все открытые дескрипторы (файлы, устройства, сокеты) автоматически закрываются операционной системой. Но при этом может пройти определенной время, в течении которого сокет будет считаться занятым. Это доставляет довольно много проблем, например, при отладке программ, когда приходится часто подключаться и переподключаться к одному и тому же порту. Так что серверные сокеты надо закрывать обязательно.
Еще одно замечание. Так как сокет - это пара адрес-порт, и сокета для соединения нужно два, получается что и порта тоже нужно два? Формально, да, у нас есть клиент - его адрес и номер порта, и сервер - его адрес и номер порта. Первые называются исходящими (так как запрос на соединение происходит от них), а вторые - входящие (запрос приходит на них, на сервер). Для того, чтобы соединение прошло успешно, клиент должен знать сокет сервера. А вот сервер не должен знать сокет клиента. При установке соединения, адрес и порт клиента отправляются серверу для информации. И если адрес клиента иногда используется для его идентификации (я тебя по IP вычислю), то исходящий порт обычно не нужен. Но откуда вообще берется этот номер? Он генерируется операционной системой случайно из незанятых несистемных портов. При желании его можно посмотреть, но пользы от этого номера немного.
Выводы:
- Серверный сокет назначается на определенный свободный порт и ждет входящих соединений.
- Клиентский сокет сразу соединяется с северным. Тот должен уже существовать.
- Сокет - это двунаправленное соединение. В него можно читать и писать, как в файл.
- Сокет - это битовый потоковый протокол, строки нужно определенным образом кодировать в битовый массив пред отправкой.
- После использования сокет нужно закрыть, иначе порт будет считаться занятым.
- Существует входящий и исходящий номер порта. Но исходящий номер назначается случайно и редко используется.
Простейшие клиент и сервер
Что мы хотим сделать?
Давайте приступим к практическому знакомству с обменом информацией между процессами через сокеты. Для этого мы создадим простейшую пару клиент-сервер. Это будут две программы на Python, которые связываются друг с другом, передают друг другу какую-то информацию и закрываются.
Мы начнем с самой простой схемы передачи данных, а затем будем ее усложнять и на этих примерах будет очень легко понять, как работают сокеты, как они устроены и для чего могут быть применены.
Мы будем организовывать один набор сокетов. Так что один из них должен быть клиентским, а другой - серверным. Поэтому один процесс мы будем называть сервером, а другой - клиентом. Однако, такое разделение условно и одна и та же программа может выступать и клиентом для одного взаимодействия и сервером - для другого. Можно сказать, что клиент и сервер - это просто роли в сетевом взаимодействии. Инициирует соединение всегда клиент, это и определяет его роль в сетевом взаимодействии. То есть, то процесс, который первый начинает “стучаться” - тот и клиент.
При разработке сетевых приложений вам придется много раз запускать оба этих процесса. Вполне естественно, что часто они будут завершаться ошибками. Помните, что для соединения всегда первым должен быть запущен сервер, а затем клиент.
Как создать простой эхо-сервер?
Для начала нам нужно определиться, какой порт будет использовать наш сервер. Мы можем использовать любой несистемный номер порта. Лучше заранее убедиться, что он в данный момент не используется в вашей системе. Например, если вы используете сервер баз данных MS SQL он обычно занимает порт 5432. Мы в учебных примерах будет использовать номер 9090 - это запоминающееся, но ничем не особенное число. Вы можете использовать другой.
Для начала импортируем модуль socket:
1
import socket
Теперь можно создать сокет одноименным конструктором из этого модуля:
1
sock = socket.socket()
Теперь переменная sock хранит ссылку на объект сокета. И через эту переменную с сокетом можно производить все необходимые операции.
После этого свяжем сокет с номером порта при помощи метода bind:
1
sock.bind(('', 9090))
Этот метод принимает один аргумент - кортеж из двух элементов. Поэтому обратите внимание на двойные круглые скобки. Первый элемент кортежа - это сетевой интерфейс. Он задает имя сетевого адаптера, соединения к которому нас интересуют. Вы же знаете, что на компьютере может быть несколько адаптеров, и он может быть подключен одновременно к нескольким сетям. Вот с помощью этого параметра можно выбрать соединения из какой конкретно сети мы будем принимать. Если оставить эту строку пустой, то сервер будет доступен для всех сетевых интерфейсов. Так как мы не будем заниматься программированием межсетевых экранов, мы будем использовать именно это значение.
Второй элемент кортежа, который передается в метод bind - это, собственно, номер порта. Для примера выберем порт 9090.
Теперь у нас все готово, чтобы принимать соединения. С помощью метода listen мы запустим для данного сокета режим прослушивания. Метод принимает один аргумент — максимальное количество подключений в очереди. Установим его в единицу. Чуть позже мы узнаем, на что влияем длина очереди подключений сервера.
1
sock.listen(1)
Теперь, мы можем принять входящее подключение с помощью метода accept, который возвращает кортеж с двумя элементами: новый сокет (объект подключения) и адрес клиента. Именно этот новый сокет и будет использоваться для приема и посылке клиенту данных.
1
conn, addr = sock.accept()
Обратите внимание, что это блокирующая операция. То есть в этот момент выполнение программы приостановится и она будет ждать входящие соединения.
Объект conn мы будем использовать для общения с этим подключившимся клиентом. Переменная addr - это всего лишь кортеж их двух элементов - адреса хоста клиента и его исходящего порта. При желании можно эту информацию вывести на экран.
Так мы установили с клиентом связь и можем с ним общаться. Чтобы получить данные нужно воспользоваться методом recv, который в качестве аргумента принимает количество байт для чтения. Мы будем читать из сокета 1024 байт (или 1 кб):
1
2
data = conn.recv(1024)
conn.send(data.upper())
Обратите внимание, что прием и отправка сообщений происходит через объект conn, а не sock. Объект conn - это подключение к конкретному клиенту. Это особенность работы именно TCP-сокетов, при использовании протокола UDP все немного по-другому.
Тут есть один неудобный момент. При чтении нам нужно указать объем данных, которые мы хотим прочитать. Это обязательный параметр. Помните мы говорили, что сокеты - это потоковый механизм? Именно поэтому нельзя прочитать сообщение от клиента “целиком”. Ведь клиент может присылать в сокет информацию порциями, в произвольный момент времени, произвольной длины. В сокете, а точнее во входящем потоке, все эти сообщения “склеются” и будут представлены единым байтовым массивом.
Поэтому сервер обычно не знает, какое именно количество информации ему нужно прочитать. Но это решается довольно просто. Можно организовать бесконечный цикл, в котором читать данные какой-то определенной длины и каждый раз проверять, получили мы что-то или нет:
1
2
3
4
5
while True:
data = conn.recv(1024)
if not data:
break
conn.send(data)
Это работает потому, что после того, как клиент отослал всю информацию, он закрывает соединение. В таком случае на сервере метод recv возвращает пустое значение. Это сигнал завершения передачи.
Метод recv(), как мы говорили, тоже является блокирующей операцией. Он разблокируется (то есть продолжает выполняться) в двух случаях: когда клиент прислал необходимый объем данных, либо когда клиент закрыл соединение.
Еще, если вы обратили внимание, после получения данных мы их отправляем обратно клиенту методом send(). Это метод посылает данные через сокет. Это уже не блокирующая операция, ведь данные посылаются тут же и не нужно ничего ждать.
Конечно, на практике такой сервер был бы бесполезным. Реальные сервера посылают клиенту какие-то другие данные, ответы на его запросы, например. Но наш простой сервер служит только для отладки и обучения. Такой сервер по понятным причинам называется “эхо-сервер”.
После получения порции данных и отсылки их обратно клиенту можно и закрыть соединение:
1
conn.close()
На этом написание сервера закончено. Он принимает соединение, принимает от клиента данные, возвращает их клиенту и закрывает соединение. Вот что у нас получилось в итоге
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python
import socket
sock = socket.socket()
sock.bind(('', 9090))
sock.listen(1)
conn, addr = sock.accept()
print 'connected:', addr
while True:
data = conn.recv(1024)
if not data:
break
conn.send(data)
conn.close()
Как создать простейший клиент?
Клиентское приложение еще короче и проще. Клиент использует точно такой же объект socket.
1
2
import socket
sock = socket.socket()
Вместо привязывания и прослушивания порта мы сразу осуществляем соединение методом connect. Для этого указывается IP-адрес хоста, на котором запущен сервер и номер порта, который он прослушивает.
1
sock.connect(('localhost', 9090))
В качестве адреса можно использовать IP-адрес, доменное имя, либо специальное имя localhost. В нашем случае, так как мы пока подключаемся к другому процессу на той же машине, будем использовать его (либо адрес 127.0.0.1, что абсолютно эквивалентно).
При неуспешном соединении метод listen выбросит исключение. Существует несколько причин - хост может быть недоступен по сети, либо порт может не прослушиваться никаким процессом.
Послание данных в сокет осуществляется методом send. Но тут есть один подводный камень. Дело в том, что сокеты - это еще и байтовый протокол. Поэтому в него не получится просто так отправить, например, строку. Ее придется преобразовать в массив байт. Для этого в Python существует специальный строковый метод - encode(). Его параметром является кодировка, которую нужно использовать для кодирования текста. Если кодировку не указывать, то будет использоваться Unicode. Рекомендуем это так и оставить. Вот что получится в итоге:
1
2
msg = 'hello, world!'
sock.send(msg.encode())
Дальше мы читаем 1024 байт данных и закрываем сокет. Для большей надежности чтение данных можно организовать так же как на сервере - в цикле.
1
2
data = sock.recv(1024)
sock.close()
Если мы обмениваемся текстовой информацией, то надо помнить, что данные полученные из сокета - это тоже байтовый массив. Чтобы преобразовать его в строку (например, для вывода на экран), нам нужно воспользоваться методом decode(), который выполняет операцию, обратную encode(). Понятно, что кодировки при этом должны совпадать. Вообще, нет ни одной причины использовать не Unicode.
1
print(data.decode())
Здесь важно помнить вот что. Этот клиент хорошо подходит к тому серверу, который мы создавали в предыдущем пункте. Ведь после установки соединения сервер ожидает приема информации (методом recv). А клиент после соединения сразу начинает отдавать информацию (методом send). Если бы же обе стороны начали ждать приема, они бы намертво заблокировали друг друга. О порядке передачи информации нужно определиться заранее. Но в общем случае, обычно именно клиент первым передает запрос, сервер его читает, передает ответ, который читает клиент.
Вот что у нас получилось в итоге:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python
import socket
sock = socket.socket()
sock.connect(('localhost', 9090))
msg = 'hello, world!'
sock.send(msg.encode())
data = sock.recv(1024)
sock.close()
print(data.decode())
Какие ограничения данного подхода?
Созданные нами клиент и сервер вполне функциональны, но у них есть ряд серьезных ограничений, которые могут затруднить более сложную работу. Например, можно заметить, что сервер выполняет только одну операцию, то есть принимает запрос, выдает ответ и на этом соединение заканчивается. Это, в принципе, не так страшно, ведь всегда можно открыть повторное соединение.
Но наш сервер может обслужить только одного клиента. Ведь после закрытия сокета программа заканчивается и сервер завершается. На практике сервера обычно работают постоянно, в бесконечном цикле, и ждут запросов от клиента. После завершения работы с одним клиентом они продолжают ожидать входящих соединений. Но это исправить можно очень просто (в следующем пункте).
Еще можно заметить, что в работе и клиента и сервера часто встречаются блокирующие операции. Само по себе это неплохо, и даже необходимо. Но представим себе, что клиент по каким-то причинам аварийно завершил свою работу. Или упала сеть. Если в это время сервер ждал сообщения от клиента (то есть заблокировался методом recv), то он останется ждать неопределенно долго. То есть упавший клиент может нарушить работу сервера. Это не очень надежно. Но это тоже легко исправить с помощью установки таймаутов.
Более сложная проблема состоит в том, что сервер способен одновременно работать только с одним клиентом. Вот это исправить уже сложнее, так как потребует многопоточного программирования. Но есть еще один вариант - использовать UDP-сокеты.
Так что давайте познакомимся с некоторыми приемами, которые позволяют обойти или исправить эти недостатки.
Как сделать сервер многоразовым?
Одно из самых заметных отличий нашего простейшего сервера от “настоящего” - его одноразовость. То есть он рассчитан только на одно подключение, после чего просто завершается. исправить эту ситуацию можно очень просто, обернув все действия после связывания сокета с портом и до закрытия сокета в вечный цикл:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python
import socket
sock = socket.socket()
sock.bind(('', 9090))
sock.listen(1)
while True:
conn, addr = sock.accept()
print 'connected:', addr
while True:
data = conn.recv(1024)
if not data:
break
conn.send(data)
conn.close()
Теперь наш сервер после завершения соединения с одним клиентом переходит на следующую итерацию цикла и начинает снова слушать входящие соединение. И к нему может подключиться как тот же самый клиент, так и новый.
Заодно можно в коде сервера сделать небольшой рефакторинг, чтобы он был более универсальным и удобным для внесения изменений. Рефакторинг - это изменение кода без изменение функциональности. Чем понятнее, проще и системнее организация кода, тем проще его развивать, поддерживать и вносить изменения. Инструкции, отвечающие за разные вещи в хорошем коде должны быть отделены от других. Это помогает в нем ориентироваться.
Например, само собой напрашивается создание констант для параментов сокета - интерфейса и номера порта:
1
2
3
4
5
HOST = ""
PORT = 33333
TYPE = socket.AF_INET
PROTOCOL = socket.SOCK_STREAM
Кроме адреса мы еще выделили тип и протокол сокета. Это параметры конструктора socket(), которые мы раньше не использовали. Первый параметр задает тип создаваемого сокета - Интернет-сокет или UNIX-сокет. Мы будем использовать только первый тип, но сейчас мы зададим его явно. Второй параметр - это тип используемого протокола. Потоковые сокеты используют протокол TCP, а датаграммные - UDP. Скоро мы будем создавать и UDP-соединение, так что не лишним будет прописать это тоже в явном виде.
После этих изменений код создания и связывания сокета будет выглядеть следующим образом:
1
2
srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))
Еще одним этапом рефакторинга можно выделить функцию, обрабатывающую запрос от клиента. Сейчас у нас на сервере обработки запроса не происходит, но в будущем любой сервер каким-то образом что-то делает с запросом клиента и выдает получившийся результат. Логично сделать специальную функцию, чтобы работа с запросом была отделена в коде от механики организации соединения:
1
2
3
4
5
6
7
8
9
10
def do_something(x):
# ...
return x
# ...
srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))
# ...
Еще хорошей идеей, хотя бы на этапе разработки будет добавить отладочные сообщения в тем моменты, когда на сервере выполняются определенные действия. Это поможет нам визуально понимать, как выполняется код сервера, следить за ним по сообщениям в консоли и очень полезно для обучения.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
srv.listen(1)
print("Слушаю порт 33333")
sock, addr = srv.accept()
print("Подключен клиент", addr)
while 1:
pal = sock.recv(1024)
if not pal:
break
print("Получено от %s:%s:" % addr, pal)
lap = do_something(pal)
sock.send(lap)
print("Отправлено %s:%s:" % addr, lap)
sock.close()
print("Соединение закрыто")
Кроме того, такие отладочные сообщения служат заменой комментариям в коде. Сейчас комментарии особенно и не нужны, потому что их названия переменных, отладочных сообщений, имен функций понятно, что происходит в программе. Такой код называется самодокументируемым.
Конечно, код можно улучшать до бесконечности, но мы на этом остановимся. Вот что у нас получилось в итоге - многоразовый эхо-сервер:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import socket, string
HOST = ""
PORT = 33333
TYPE = socket.AF_INET
PROTOCOL = socket.SOCK_STREAM
def do_something(x):
return x
srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))
while 1:
srv.listen(1)
print("Слушаю порт 33333")
sock, addr = srv.accept()
print("Подключен клиент", addr)
while 1:
pal = sock.recv(1024)
if not pal:
break
print("Получено от %s:%s:" % addr, pal)
lap = do_something(pal)
sock.send(lap)
print("Отправлено %s:%s:" % addr, lap)
sock.close()
print("Соединение закрыто")
Как задать таймаут прослушивания?
Как мы говорили раньше, блокирующие операции в коде программы должны быть объектом пристального внимания программиста. Блокировки программы могут существенно замедлять ее выполнение (об этом мы поговорим потом, когда будем изучать многозадачное программирование), могут быть источником ошибок.
Модуль socket позволяет выполнять блокирующие операции в нескольких режимах. Режим по умолчанию - блокирующий. В нем при выполнении операции программа будет ждать неопределенно долго наступления внешнего события - установки соединения или отправки другой стороной данных.
Можно использовать другой режим - режим таймаута - путем вызова метода settimeout(t) перед выполнением потенциально блокирующей операции. Тогда программа тоже заблокируется, но будет ждать только определенное время - t секунд. Если по прошествии этого времени нужное событие не произойдет, то метод выбросит исключение.
Таймаут можно “вешать” как на подключение, как и на чтение данных их сокета:
1
2
3
4
5
6
7
8
9
10
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("",0))
sock.listen(1)
# accept can throw socket.timeout
sock.settimeout(5.0)
conn, addr = sock.accept()
# recv can throw socket.timeout
conn.settimeout(5.0)
conn.recv(1024)
В таком случае, лучше обрабатывать возможное исключение, чтобы оно не привело к аварийному завершения программы:
1
2
3
4
5
6
# recv can throw socket.timeout
conn.settimeout(5.0)
try:
conn.recv(1024)
except socket.timeout:
print("Клиент не отправил данные")
Как обмениваться объектами по сокетам?
В работе сетевых приложений часто возникает задача передать через сокет не просто байтовый массив или строку, а объект или файл произвольного содержания. Во многих языках программирования существует специальный механизм, который позволяет преобразовать произвольный объект в строку таким образом, чтобы потом весь объект можно было бы восстановить их этой строки.
Такой процесс называется сериализация объектов. Она применяется, например, при сохранении данных программы в файл. Но с тем же успехом ее можно использовать и для передачи объектов через сокеты.
В Python существует стандартный модуль pickle, который нужен для сериализации объектов. Из этого модуля нам понадобится две операции. Метод dumps(obj) преобразует объект в строковое представление, сериализует его. Метод loads(str) восстанавливает объект из строки - десериализует его.
Давайте рассмотрим пример, когда в сокет отправляется сериализованный словарь:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import socket
import pickle
s = socket.socket()
s.bind(("", 9090))
s.listen(1)
while True:
clientsocket, address = s.accept()
d = {1:"hi", 2: "there"}
msg = pickle.dumps(d).encode()
clientsocket.send(msg)
Имейте в виду, что на месте словаря может быть любой сложный объект. А это значит, что мы можем передавать через сокеты в сериализованном виде практически любою информацию.
На другой стороне прочитать и восстановить объект из сокета можно, например, так:
1
2
3
msg = conn.recv(1024)
d = pickle.loads(msg.decode())
В чем особенности UDP-сокетов?
До сих пор мы рассматривали только сокеты, использующие в своей работе протокол TCP. Этот протокол еще называют протоколом с установкой соединения. Но в сокетах можно использовать и протокол UDP. И такие сокеты (их еще называют датаграммными) имеют с воей реализации несколько отличий. Давайте их рассмотрим.
Создание UDP сокета очень похоже, только надо явно указывать агрументы конструктора socket() ведь по умолчанию создаются TCP сокеты:
1
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
Обратите внимание на второй параметр - именно он задает протокол передачи данных. Раньше мы использовали значение socket.SOCK_STREAM - это TCP-сокет. Для UDP-сокетов нужно значение socket.SOCK_DGRAM.
Связывание серверного сокета с портом происходит точно так же:
1
s.bind(("", 9090))
Дальше пойдут различия. UDP-сокеты не требуют установки соединения. Они способны принимать данные сразу. Но для этого используется другой системный вызов (и соответствующий ему метод Python) - recvfrom. Этот метод блокирует программу и когда присоединится любой клиент, возвращает сразу и данные и сокет клиента:
1
data, addr = s.recvfrom(1024)
Этот метод также работает в потоковом режиме. То есть нам нужно указывать количество байт, которые мы хотим считать. Но при следующем вызове метода recvfrom() могут придти данные уже от другого клиента. Надо понимать, что UDP-сокеты являются “неразборчивыми”, то есть в него может писать несколько клиентов одновременно, “вперемешку”. Для этого каждый раз и возвращается исходящий сокет, чтобы мы могли понять, от какого клиента пришли эти данные.
Что касается клиентского сокета, то здесь все еще проще:
1
2
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(msg, (host, port))
То есть сразу после создания сокет готов к передаче данных. Но каждый раз нам нужно указывать, куда мы эти данные посылаем.
UDP-сокеты даже проще по своей организации, чем TCP. Меньше шагов нужно сделать, чтобы обмениваться информацией. Но UDP является протоколом без гарантированной доставки. Они ориентирован на скорость и простоту передачи данных, в то время как TCP - на надежность связи.
В конце приведем пример кода сервера и клиента, обменивающихся данными через UDP-сокеты. Код сервера совсем короткий. Но это такой же эхо-сервер, только работающий на другом протоколе:
1
2
3
4
5
6
7
8
import socket
port = 5000
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("", port))
while 1:
data, addr = s.recvfrom(1024)
print(data)
s.sendto(data, addr)
Код клиента приведем в более подробном виде. Механику работы сокетов мы уже рассмотрели, попробуйте проанализировать в этом коде обработку исключений и вывод отладочных сообщений:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import socket
import sys
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except socket.error:
print('Failed to create socket')
sys.exit()
host = 'localhost';
port = 8888;
while(1) :
msg = raw_input('Enter message to send: ')
try :
s.sendto(msg, (host, port))
reply, addr = s.recvfrom(1024)
print('Server reply : ' + reply)
except socket.error, msg:
print('Error Code : ' + str(msg[0]) + ' Message ' + msg[1])
sys.exit()