วิธีสร้างเซิร์ฟเวอร์ Python WebSocket อย่างง่ายโดยใช้ Tornado

เผยแพร่แล้ว: 2022-03-11

ด้วยความนิยมที่เพิ่มขึ้นของเว็บแอปพลิเคชันแบบเรียลไทม์ WebSockets ได้กลายเป็นเทคโนโลยีหลักในการใช้งาน วันที่คุณต้องกดปุ่มโหลดซ้ำเพื่อรับการอัปเดตจากเซิร์ฟเวอร์นั้นหายไปนาน เว็บแอปพลิเคชันที่ต้องการให้การอัปเดตตามเวลาจริงไม่ต้องสำรวจการเปลี่ยนแปลงของเซิร์ฟเวอร์อีกต่อไป แต่เซิร์ฟเวอร์จะผลักดันการเปลี่ยนแปลงไปตามกระแสที่เกิดขึ้น เว็บเฟรมเวิร์กที่แข็งแกร่งได้เริ่มรองรับ WebSockets แล้ว ตัวอย่างเช่น Ruby on Rails 5 ก้าวไปอีกขั้นและเพิ่มการรองรับสำหรับสาย Action

ในโลกของ Python มีเว็บเฟรมเวิร์กยอดนิยมมากมาย กรอบงาน เช่น Django ให้เกือบทุกอย่างที่จำเป็นในการสร้างเว็บแอปพลิเคชัน และสิ่งใดก็ตามที่ขาดหายไปสามารถประกอบขึ้นด้วยปลั๊กอินที่มีอยู่นับพันตัวสำหรับ Django อย่างไรก็ตาม เนื่องจากวิธีการทำงานของ Python หรือเว็บเฟรมเวิร์กส่วนใหญ่ การจัดการการเชื่อมต่อที่มีอายุการใช้งานยาวนานอาจกลายเป็นฝันร้ายได้อย่างรวดเร็ว โมเดลเธรดและล็อกล่ามส่วนกลางมักถูกมองว่าเป็นจุดอ่อนของ Python

แต่ทั้งหมดนั้นเริ่มเปลี่ยนไปแล้ว ด้วยคุณสมบัติใหม่บางอย่างของ Python 3 และเฟรมเวิร์กที่มีอยู่แล้วสำหรับ Python เช่น Tornado การจัดการการเชื่อมต่อที่มีอายุการใช้งานยาวนานไม่ใช่เรื่องยากอีกต่อไป Tornado ให้ความสามารถของเว็บเซิร์ฟเวอร์ใน Python ที่มีประโยชน์เป็นพิเศษในการจัดการการเชื่อมต่อที่มีอายุการใช้งานยาวนาน

ในบทความนี้ เราจะมาดูวิธีการสร้างเซิร์ฟเวอร์ WebSocket แบบง่ายใน Python โดยใช้ Tornado แอปพลิเคชันสาธิตจะช่วยให้เราสามารถอัปโหลดไฟล์ค่าที่คั่นด้วยแท็บ (TSV) แยกวิเคราะห์และทำให้เนื้อหาพร้อมใช้งานที่ URL ที่ไม่ซ้ำกัน

พายุทอร์นาโดและ WebSockets

Tornado เป็นไลบรารีเครือข่ายแบบอะซิงโครนัสและเชี่ยวชาญในการจัดการกับเครือข่ายที่ขับเคลื่อนด้วยเหตุการณ์ เนื่องจากสามารถรองรับการเชื่อมต่อแบบเปิดได้หลายหมื่นรายการพร้อมกัน เซิร์ฟเวอร์จึงสามารถใช้ประโยชน์จากสิ่งนี้และจัดการการเชื่อมต่อ WebSocket จำนวนมากภายในโหนดเดียว WebSocket เป็นโปรโตคอลที่ให้ช่องทางการสื่อสารฟูลดูเพล็กซ์ผ่านการเชื่อมต่อ TCP เดียว เนื่องจากเป็นซ็อกเก็ตแบบเปิด เทคนิคนี้ทำให้การเชื่อมต่อเว็บเป็นแบบเก็บสถานะ และอำนวยความสะดวกในการถ่ายโอนข้อมูลแบบเรียลไทม์ไปยังและจากเซิร์ฟเวอร์ เซิร์ฟเวอร์ที่รักษาสถานะของไคลเอ็นต์ ทำให้ง่ายต่อการใช้งานแอปพลิเคชันการแชทแบบเรียลไทม์หรือเกมบนเว็บโดยใช้ WebSockets

WebSockets ได้รับการออกแบบมาเพื่อใช้ในเว็บเบราว์เซอร์และเซิร์ฟเวอร์ และขณะนี้ได้รับการสนับสนุนในเว็บเบราว์เซอร์หลักทั้งหมด การเชื่อมต่อจะเปิดขึ้นเพียงครั้งเดียวและข้อความสามารถเดินทางไปมาได้หลายครั้งก่อนที่จะปิดการเชื่อมต่อ

การติดตั้ง Tornado นั้นค่อนข้างง่าย มีการระบุไว้ใน PyPI และสามารถติดตั้งได้โดยใช้ pip หรือ easy_install:

 pip install tornado

Tornado มาพร้อมกับการใช้งาน WebSockets ของตัวเอง สำหรับวัตถุประสงค์ของบทความนี้ นี่คือสิ่งที่เราต้องการ

WebSockets ในการดำเนินการ

ข้อดีอย่างหนึ่งของการใช้ WebSocket คือคุณสมบัติ stateful สิ่งนี้เปลี่ยนวิธีที่เราคิดโดยทั่วไปเกี่ยวกับการสื่อสารระหว่างไคลเอนต์กับเซิร์ฟเวอร์ กรณีการใช้งานเฉพาะกรณีนี้คือกรณีที่เซิร์ฟเวอร์จำเป็นต้องดำเนินการกระบวนการที่ช้านานและค่อย ๆ สตรีมผลลัพธ์กลับไปยังไคลเอนต์

ในแอปพลิเคชันตัวอย่างของเรา ผู้ใช้จะสามารถอัปโหลดไฟล์ผ่าน WebSocket ตลอดอายุของการเชื่อมต่อ เซิร์ฟเวอร์จะเก็บไฟล์ที่แยกวิเคราะห์ไว้ในหน่วยความจำ เมื่อมีการร้องขอ เซิร์ฟเวอร์จะส่งส่วนหลังของไฟล์ไปยังส่วนหน้า นอกจากนี้ ไฟล์จะพร้อมใช้งานที่ URL ซึ่งผู้ใช้หลายคนสามารถดูได้ หากมีการอัปโหลดไฟล์อื่นใน URL เดียวกัน ทุกคนที่ดูจะสามารถเห็นไฟล์ใหม่ได้ทันที

สำหรับ front-end เราจะใช้ AngularJS กรอบงานและไลบรารีนี้จะช่วยให้เราจัดการการอัปโหลดไฟล์และการแบ่งหน้าได้อย่างง่ายดาย อย่างไรก็ตาม สำหรับทุกสิ่งที่เกี่ยวข้องกับ WebSockets เราจะใช้ฟังก์ชัน JavaScript มาตรฐาน

แอปพลิเคชั่นที่เรียบง่ายนี้จะถูกแบ่งออกเป็นสามไฟล์แยกกัน:

  • parser.py: ที่เซิร์ฟเวอร์ Tornado ของเราพร้อมตัวจัดการคำขอถูกใช้งาน
  • templates/index.html: เทมเพลต HTML ส่วนหน้า
  • static/parser.js: สำหรับ JavaScript . ส่วนหน้าของเรา

การเปิด WebSocket

จาก front-end สามารถสร้างการเชื่อมต่อ WebSocket ได้โดยการสร้างอินสแตนซ์วัตถุ WebSocket:

 new WebSocket(WEBSOCKET_URL);

นี่คือสิ่งที่เราจะต้องทำในการโหลดหน้า เมื่อสร้างอินสแตนซ์วัตถุ WebSocket แล้ว จะต้องแนบตัวจัดการเพื่อจัดการกับเหตุการณ์สำคัญสามเหตุการณ์:

  • เปิด: ถูกไล่ออกเมื่อมีการสร้างการเชื่อมต่อ
  • ข้อความ: เริ่มทำงานเมื่อได้รับข้อความจากเซิร์ฟเวอร์
  • ปิด: เริ่มทำงานเมื่อปิดการเชื่อมต่อ
 $scope.init = function() { $scope.ws = new WebSocket('ws://' + location.host + '/parser/ws'); $scope.ws.binaryType = 'arraybuffer'; $scope.ws.onopen = function() { console.log('Connected.') }; $scope.ws.onmessage = function(evt) { $scope.$apply(function () { message = JSON.parse(evt.data); $scope.currentPage = parseInt(message['page_no']); $scope.totalRows = parseInt(message['total_number']); $scope.rows = message['data']; }); }; $scope.ws.onclose = function() { console.log('Connection is closed...'); }; } $scope.init();

เนื่องจากตัวจัดการเหตุการณ์เหล่านี้จะไม่ทริกเกอร์ $scope lifecycle ของ AngularJS โดยอัตโนมัติ เนื้อหาของฟังก์ชันตัวจัดการจึงต้องรวมอยู่ใน $apply ในกรณีที่คุณสนใจ มีแพ็คเกจเฉพาะของ AngularJS ที่ช่วยให้รวม WebSocket ในแอปพลิเคชัน AngularJS ได้ง่ายขึ้น

เป็นเรื่องที่ควรค่าแก่การกล่าวไว้ว่าการเชื่อมต่อ WebSocket ที่หลุดจะไม่ถูกสร้างใหม่โดยอัตโนมัติ และจะต้องให้แอปพลิเคชันพยายามเชื่อมต่อใหม่เมื่อมีการทริกเกอร์ตัวจัดการเหตุการณ์ที่ปิด นี่เป็นบิตนอกเหนือขอบเขตของบทความนี้

การเลือกไฟล์ที่จะอัพโหลด

เนื่องจากเรากำลังสร้างแอปพลิเคชันหน้าเดียวโดยใช้ AngularJS การพยายามส่งแบบฟอร์มพร้อมไฟล์แบบเก่าจะไม่ได้ผล เพื่อให้ง่ายขึ้น เราจะใช้ไลบรารี ng-file-upload ของ Danial Farid สิ่งที่เราต้องทำเพื่ออนุญาตให้ผู้ใช้อัปโหลดไฟล์คือเพิ่มปุ่มในเทมเพลตส่วนหน้าของเราด้วยคำสั่ง AngularJS ที่เฉพาะเจาะจง:

 <button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>

ไลบรารี่ช่วยให้เราตั้งค่านามสกุลและขนาดไฟล์ที่ยอมรับได้ การคลิกที่ปุ่มนี้ เช่นเดียวกับองค์ประกอบ <input type=”file”> จะเป็นการเปิดตัวเลือกไฟล์มาตรฐาน

กำลังอัพโหลดไฟล์

เมื่อคุณต้องการโอนข้อมูลไบนารี คุณสามารถเลือกระหว่างบัฟเฟอร์อาร์เรย์และหยดได้ หากเป็นเพียงข้อมูลดิบเช่นไฟล์รูปภาพ ให้เลือก blob และจัดการอย่างถูกต้องในเซิร์ฟเวอร์ บัฟเฟอร์อาร์เรย์ใช้สำหรับบัฟเฟอร์ไบนารีที่มีความยาวคงที่ และไฟล์ข้อความ เช่น TSV สามารถถ่ายโอนได้ในรูปแบบสตริงไบต์ ข้อมูลโค้ดนี้แสดงวิธีการอัปโหลดไฟล์ในรูปแบบบัฟเฟอร์อาร์เรย์

 $scope.uploadFile = function(file, errFiles) { ws = $scope.ws; $scope.f = file; $scope.errFile = errFiles && errFiles[0]; if (file) { reader = new FileReader(); rawData = new ArrayBuffer(); reader.onload = function(evt) { rawData = evt.target.result; ws.send(rawData); } reader.readAsArrayBuffer(file); } }

ng-file-upload directive มีฟังก์ชัน uploadFile ที่นี่ คุณสามารถแปลงไฟล์เป็นบัฟเฟอร์อาร์เรย์โดยใช้ FileReader และส่งผ่าน WebSocket

โปรดทราบว่าการส่งไฟล์ขนาดใหญ่ผ่าน WebSocket โดยการอ่านลงในบัฟเฟอร์อาร์เรย์อาจไม่ใช่วิธีที่เหมาะสมที่สุดในการอัปโหลด เนื่องจากอาจใช้หน่วยความจำจำนวนมากได้อย่างรวดเร็วส่งผลให้ประสบการณ์ใช้งานไม่ดี

รับไฟล์บนเซิร์ฟเวอร์

โต๊ะ

Tornado กำหนดประเภทข้อความโดยใช้ 4 บิต opcode และส่งกลับ str สำหรับข้อมูลไบนารีและ unicode สำหรับข้อความ

 if opcode == 0x1: # UTF-8 data self._message_bytes_in += len(data) try: decoded = data.decode("utf-8") except UnicodeDecodeError: self._abort() return self._run_callback(self.handler.on_message, decoded) elif opcode == 0x2: # Binary data self._message_bytes_in += len(data) self._run_callback(self.handler.on_message, data)

ในเว็บเซิร์ฟเวอร์ Tornado จะได้รับบัฟเฟอร์อาร์เรย์ในรูปแบบ str

ในตัวอย่างนี้ ประเภทของเนื้อหาที่เราคาดหวังคือ TSV ดังนั้นไฟล์จะถูกแยกวิเคราะห์และแปลงเป็นพจนานุกรม แน่นอน ในการใช้งานจริง มีวิธีที่เหมาะสมกว่าในการจัดการกับการอัปโหลดโดยพลการ

 def make_message(self, page_no=1): page_size = 100 return { "page_no": page_no, "total_number": len(self.rows), "data": self.rows[page_size * (page_no - 1):page_size * page_no] } def on_message(self, message): if isinstance(message, str): self.rows = [csv.reader([line], delimiter="\t").next() for line in (x.strip() for x in message.splitlines()) if line] self.write_message(self.make_message())

ขอเพจ

เนื่องจากเป้าหมายของเราคือการแสดงข้อมูล TSV ที่อัปโหลดเป็นส่วนๆ ของหน้าเล็กๆ เราจึงต้องการวิธีการขอหน้าเฉพาะ เพื่อให้ง่ายขึ้น เราจะใช้การเชื่อมต่อ WebSocket เดียวกันเพื่อส่งหมายเลขหน้าไปยังเซิร์ฟเวอร์ของเรา

 $scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }

เซิร์ฟเวอร์จะได้รับข้อความนี้เป็นยูนิโค้ด:

 def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))

ความพยายามที่จะตอบสนองด้วย dict จากเซิร์ฟเวอร์ Tornado WebSocket จะเข้ารหัสโดยอัตโนมัติในรูปแบบ JSON ดังนั้นจึงเป็นเรื่องปกติที่จะส่ง dict ที่มีเนื้อหา 100 แถว

แบ่งปันการเข้าถึงกับผู้อื่น

เพื่อให้สามารถแชร์การเข้าถึงการอัปโหลดเดียวกันกับผู้ใช้หลายราย เราต้องสามารถระบุการอัปโหลดได้โดยไม่ซ้ำกัน เมื่อใดก็ตามที่ผู้ใช้เชื่อมต่อกับเซิร์ฟเวอร์ผ่าน WebSocket UUID แบบสุ่มจะถูกสร้างขึ้นและกำหนดให้กับการเชื่อมต่อของพวกเขา

 def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())

uuid.uuid4() สร้าง UUID แบบสุ่ม และ str() แปลง UUID เป็นสตริงของเลขฐานสิบหกในรูปแบบมาตรฐาน

หากผู้ใช้รายอื่นที่มี UUID เชื่อมต่อกับเซิร์ฟเวอร์ อินสแตนซ์ที่เกี่ยวข้องของ FileHandler จะถูกเพิ่มลงในพจนานุกรมที่มี UUID เป็นคีย์ และจะถูกลบออกเมื่อการเชื่อมต่อถูกปิด

 @classmethod @tornado.gen.coroutine def add_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.append(client) else: FileHandler.clients[doc_uuid] = [client] @classmethod @tornado.gen.coroutine def remove_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.remove(client) if len(clients_with_uuid) == 0: del cls.clients[doc_uuid]

พจนานุกรมไคลเอนต์อาจส่ง KeyError เมื่อเพิ่มหรือลบไคลเอนต์พร้อมกัน เนื่องจาก Tornado เป็นไลบรารีเครือข่ายแบบอะซิงโครนัสจึงมีกลไกการล็อกสำหรับการซิงโครไนซ์ การล็อคแบบธรรมดาที่มี coroutine เหมาะกับกรณีนี้ของการจัดการพจนานุกรมไคลเอ็นต์

หากผู้ใช้อัปโหลดไฟล์หรือย้ายไปมาระหว่างหน้า ผู้ใช้ทั้งหมดที่มี UUID เดียวกันจะดูหน้าเดียวกัน

 @classmethod def send_messages(cls, doc_uuid): clients_with_uuid = cls.clients[doc_uuid] message = cls.make_message(doc_uuid) for client in clients_with_uuid: try: client.write_message(message) except: logging.error("Error sending message", exc_info=True)

วิ่งตาม Nginx

การนำ WebSockets ไปใช้นั้นง่ายมาก แต่มีบางสิ่งที่ยากที่ควรพิจารณาเมื่อใช้งานในสภาพแวดล้อมที่ใช้งานจริง Tornado เป็นเว็บเซิร์ฟเวอร์ ดังนั้นจึงสามารถรับคำขอของผู้ใช้ได้โดยตรง แต่การใช้งาน Nginx อาจเป็นทางเลือกที่ดีกว่าด้วยเหตุผลหลายประการ อย่างไรก็ตาม การใช้ WebSockets ผ่าน Nginx ต้องใช้ความพยายามเพิ่มขึ้นเล็กน้อย:

 http { upstream parser { server 127.0.0.1:8080; } server { location ^~ /parser/ws { proxy_pass http://parser; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } }

สองคำสั่ง proxy_set_header ทำให้ Nginx ส่งส่วนหัวที่จำเป็นไปยังเซิร์ฟเวอร์ส่วนหลังซึ่งจำเป็นสำหรับการอัพเกรดการเชื่อมต่อกับ WebSocket

อะไรต่อไป?

ในบทความนี้ เราได้ติดตั้งเว็บแอปพลิเคชัน Python อย่างง่ายที่ใช้ WebSockets เพื่อรักษาการเชื่อมต่อแบบต่อเนื่องระหว่างเซิร์ฟเวอร์และไคลเอนต์แต่ละเครื่อง ด้วยเฟรมเวิร์กเครือข่ายแบบอะซิงโครนัสที่ทันสมัยเช่น Tornado การเชื่อมต่อแบบเปิดหลายหมื่นรายการพร้อมกันใน Python นั้นเป็นไปได้ทั้งหมด

แม้ว่าลักษณะการใช้งานบางอย่างของแอปพลิเคชันสาธิตนี้อาจทำได้แตกต่างออกไป ฉันหวังว่ามันยังคงช่วยสาธิตการใช้งาน WebSockets ใน https://www.toptal.com/tornado framework ซอร์สโค้ดของแอปพลิเคชันสาธิตมีอยู่ใน GitHub

ที่เกี่ยวข้อง: Python Multithreading Tutorial: Concurrency และ Parallelism