Jenis untuk HTTP API yang ditulis dalam Python: pengalaman Instagram

Hari ini kami menerbitkan materi kedua dari seri yang ditujukan untuk penggunaan Python di Instagram. Terakhir kali itu memeriksa jenis kode server Instagram. Server adalah monolith yang ditulis dengan Python. Ini terdiri dari beberapa juta baris kode dan memiliki beberapa ribu titik akhir Django.



Artikel ini adalah tentang bagaimana Instagram menggunakan tipe untuk mendokumentasikan API HTTP dan untuk menegakkan kontrak ketika bekerja dengannya.

Tinjauan situasi


Ketika Anda membuka klien seluler Instagram, itu, melalui HTTP, mengakses JSON-API dari server Python (Django) kami.

Berikut adalah beberapa informasi tentang sistem kami yang akan memungkinkan Anda untuk mendapatkan gambaran tentang kompleksitas API yang kami gunakan untuk mengatur pekerjaan klien seluler. Jadi inilah yang kita miliki:

  • Lebih dari 2000 titik akhir di server.
  • Lebih dari 200 bidang tingkat atas dalam objek data klien yang mewakili gambar, video, atau cerita dalam suatu aplikasi.
  • Ratusan programmer yang menulis kode server (dan bahkan lebih banyak yang berurusan dengan klien).
  • Ratusan komitmen terhadap kode server dibuat setiap hari dan memodifikasi API. Ini diperlukan untuk memberikan dukungan untuk fitur sistem baru.

Kami menggunakan jenis untuk mendokumentasikan API HTTP kami yang kompleks dan terus berkembang serta untuk menegakkan kontrak ketika bekerja dengannya.

Jenis


Mari kita mulai dari awal. Deskripsi sintaks untuk tipe anotasi dalam kode Python muncul di PEP 484 . Mengapa menambahkan tipe anotasi ke kode?

Pertimbangkan fungsi yang mengunduh informasi tentang pahlawan Star Wars:

def get_character(id, calendar):     if id == 1000:         return Character(             id=1000,             name="Luke Skywalker",             birth_year="19BBY" if calendar == Calendar.BBY else ...         )     ... 

Untuk memahami fungsi ini, Anda perlu membaca kodenya. Setelah melakukan ini, Anda dapat menemukan yang berikut:

  • Dibutuhkan integer identifier ( id ) karakter.
  • Dibutuhkan nilai dari enumerasi yang sesuai ( calendar ). Misalnya, Calendar.BBY berarti "Sebelum Pertempuran Yavin," yaitu, "Sebelum Pertempuran Yavin."
  • Ini mengembalikan informasi tentang karakter dalam bentuk entitas yang berisi bidang yang mewakili pengidentifikasi karakter ini, nama dan tahun kelahirannya.

Fungsi memiliki kontrak implisit, artinya programmer harus mengembalikan setiap kali dia membaca kode fungsi. Tetapi kode fungsi hanya ditulis sekali, dan Anda harus membacanya berkali-kali, jadi pendekatan untuk bekerja dengan kode ini tidak terlalu baik.

Selain itu, sulit untuk memverifikasi bahwa mekanisme yang memanggil fungsi mematuhi kontrak implisit yang dijelaskan di atas. Demikian pula, sulit untuk memverifikasi bahwa kontrak ini dihormati di tubuh fungsi. Dalam basis kode besar, situasi seperti itu dapat menyebabkan kesalahan.

Sekarang pertimbangkan fungsi yang sama yang menyatakan tipe anotasi:

 def get_character(id: int, calendar: Calendar) -> Character:    ... 

Ketik anotasi memungkinkan Anda untuk secara eksplisit mengekspresikan kontrak fungsi ini. Untuk memahami apa yang perlu diinput ke suatu fungsi, dan apa fungsi ini kembali, baca saja tanda tangannya. Sistem pengecekan tipe dapat secara statis menganalisis fungsi dan memverifikasi kepatuhan dengan kontrak dalam kode. Ini memungkinkan Anda untuk menyingkirkan seluruh kelas kesalahan!

Jenis untuk berbagai API HTTP


Kami akan mengembangkan HTTP-API yang memungkinkan Anda menerima informasi tentang para pahlawan Star Wars. Untuk menjelaskan kontrak eksplisit yang digunakan saat bekerja dengan API ini, kami akan menggunakan jenis anotasi.

API kami harus menerima pengenal karakter ( id ) sebagai parameter URL dan nilai enumerasi calendar sebagai parameter permintaan. API harus mengembalikan respons JSON dengan informasi karakter.

Beginilah tampilan permintaan API dan responsnya:

 curl -X GET https://api.starwars.com/characters/1000?calendar=BBY {    "id": 1000,    "name": "Luke Skywalker",    "birth_year": "19BBY" } 

Untuk mengimplementasikan API ini di Django, pertama-tama Anda harus mendaftarkan jalur URL dan fungsi tampilan yang bertanggung jawab untuk menerima permintaan HTTP yang dibuat di sepanjang jalur ini dan untuk mengembalikan respons.

 urlpatterns = [    url("characters/<id>/", get_character) ] 

Fungsi, sebagai input, menerima parameter permintaan dan URL (dalam kasus kami, id ). Ini mem-parsing dan melemparkan parameter permintaan calendar , yang merupakan nilai dari enumerasi yang sesuai, ke tipe yang diperlukan. Itu memuat data karakter dari toko dan mengembalikan kamus berseri dalam JSON dan dibungkus dengan respons HTTP.

 def get_character(request: IGWSGIRequest, id: str) -> JsonResponse:    calendar = Calendar(request.GET.get("calendar", "BBY"))    character = Store.get_character(id, calendar)    return JsonResponse(asdict(character)) 

Meskipun fungsi disediakan dengan anotasi tipe, ia tidak secara eksplisit menggambarkan kontrak keras untuk HTTP API. Dari tanda tangan fungsi ini kami tidak dapat menemukan nama atau tipe parameter permintaan, atau bidang respons dan tipenya.

Apakah mungkin untuk membuat tanda tangan dari fungsi-representasi menjadi sama persis informatif dengan tanda tangan dari fungsi yang sebelumnya dipertimbangkan dengan anotasi jenis?

 def get_character(id: int, calendar: Calendar) -> Character:    ... 

Parameter fungsi dapat berupa parameter kueri (URL, kueri, atau parameter badan kueri). Jenis nilai yang dikembalikan oleh fungsi dapat mewakili isi dari respons. Dengan pendekatan ini, kami akan memiliki kontrak eksplisit dan dapat dimengerti untuk HTTP API, yang ketaatannya dapat dipastikan dengan sistem pengecekan tipe.

Implementasi


Bagaimana cara menerapkan ide ini?

Kami menggunakan dekorator untuk mengubah fungsi representasi yang sangat diketik menjadi fungsi representasi Django. Langkah ini tidak memerlukan perubahan dalam hal bekerja dengan kerangka kerja Django. Kita dapat menggunakan middleware yang sama, rute yang sama, dan komponen lain yang biasa kita gunakan.

 @api_view def get_character(id: int, calendar: Calendar) -> Character:    ... 

Pertimbangkan detail api_view dekorator api_view :

 def api_view(view):    @functools.wraps(view)    def django_view(request, *args, **kwargs):        params = {            param_name: param.annotation(extract(request, param))            for param_name, param in inspect.signature(view).parameters.items()        }        data = view(**params)        return JsonResponse(asdict(data))       return django_view 

Ini adalah kode yang sulit untuk dipahami. Mari kita menganalisis fitur-fiturnya.
Kami, sebagai nilai input, mengambil fungsi representasi yang sangat diketik dan membungkusnya dalam fungsi representasi Django biasa, yang kami kembalikan:

 def api_view(view):    @functools.wraps(view)    def django_view(request, *args, **kwargs):        ...    return django_view 

Sekarang lihat implementasi fungsi tampilan Django. Pertama, kita perlu membuat argumen untuk fungsi presentasi yang sangat diketik. Kami menggunakan introspeksi dan modul inspeksi untuk mendapatkan tanda tangan dari fungsi ini dan beralih pada parameternya:

 for param_name, param in inspect.signature(view).parameters.items() 

Untuk setiap parameter, kami memanggil fungsi extract , yang mengekstrak nilai parameter dari permintaan.

Kemudian kami melemparkan parameter ke tipe yang diharapkan yang ditentukan dalam tanda tangan (misalnya, melemparkan calendar string ke nilai yang merupakan elemen dari enumerasi Calendar ).

 param.annotation(extract(request, param)) 

Kami memanggil fungsi tampilan yang sangat diketik dengan argumen yang kami buat:

 data = view(**params) 

Fungsi mengembalikan nilai yang sangat diketik dari kelas Character . Kami mengambil nilai ini, mengubahnya menjadi kamus dan membungkusnya dalam respons HTTP format JSON:

 return JsonResponse(asdict(data)) 

Hebat! Kami sekarang memiliki fungsi tampilan Django yang membungkus fungsi tampilan sangat diketik. Akhirnya, lihat fungsi extract :

 def extract(request: HttpRequest, param: Parameter) -> Any:    if request.resolver_match.route.contains(f"<{param}>"):        return request.resolver_match.kwargs.get(param.name)    else:        return request.GET.get(param.name) 

Setiap parameter dapat berupa parameter URL atau parameter permintaan. Jalur URL permintaan (jalur yang kami daftarkan di awal) tersedia di objek rute sistem pencari lokasi Django. Kami memeriksa nama parameter di jalur. Jika ada nama, maka kita memiliki parameter URL. Ini berarti bahwa kami dapat mengekstraknya dari permintaan. Kalau tidak, ini adalah parameter kueri dan kita juga bisa mengekstraknya, tetapi dengan cara lain.

Itu saja. Ini adalah implementasi yang disederhanakan, tetapi menggambarkan ide dasar mengetik API.

Tipe data


Jenis yang digunakan untuk mewakili konten respons HTTP (mis., Character ) dapat direpresentasikan baik dengan dataclass atau kamus yang diketik.

Kelas data adalah format deskripsi kelas kompak yang mewakili data.

 from dataclasses import dataclass @dataclass(frozen=True) class Character:    id: int    name: str    birth_year: str luke = Character(    id=1000,    name="Luke Skywalker",    birth_year="19BBY" ) 

Instagram biasanya menggunakan kelas data untuk memodelkan objek respons HTTP. Berikut adalah fitur utama mereka:

  • Mereka secara otomatis menghasilkan konstruksi templat dan berbagai metode pembantu.
  • Dapat dimengerti untuk mengetikkan sistem pemeriksaan, yang berarti bahwa nilai-nilai dapat dikenakan pemeriksaan jenis.
  • Mereka mempertahankan kekebalan berkat konstruk frozen=True .
  • Mereka tersedia di pustaka standar Python 3.7, atau sebagai backport dalam Indeks Paket Python.

Sayangnya, Instagram memiliki basis kode yang ketinggalan zaman yang menggunakan kamus besar yang tidak diketik, yang dialihkan antara fungsi dan modul. Tidak mudah menerjemahkan semua kode ini dari kamus ke kelas data. Akibatnya, kami, menggunakan kelas data untuk kode baru, dan dalam kode lama kami menggunakan kamus yang diketik .

Menggunakan kamus yang diketik memungkinkan kami untuk menambahkan anotasi jenis ke objek kamus klien dan, tanpa mengubah perilaku sistem kerja, menggunakan kemampuan memeriksa jenis.

 from mypy_extensions import TypedDict class Character(TypedDict):    id: int    name: str    birth_year: str luke: Character = {"id": 1000} luke["name"] = "Luke Skywalker" luke["birth_year"] = 19 # type error, birth_year expects a str luke["invalid_key"] # type error, invalid_key does not exist 

Menangani kesalahan


Fungsi tampilan diharapkan untuk mengembalikan informasi karakter dalam bentuk entitas Character . Apa yang harus kita lakukan jika kita perlu mengembalikan kesalahan ke klien?

Anda bisa melempar pengecualian yang akan ditangkap oleh kerangka kerja dan dikonversi menjadi respons HTTP dengan informasi kesalahan.

 @api_view("GET") def get_character(id: str, calendar: Calendar) -> Character:    try:        return Store.get_character(id)    except CharacterNotFound:        raise Http404Exception() 

Contoh ini juga menunjukkan metode HTTP di dekorator, yang menetapkan metode HTTP yang diizinkan untuk API ini.

Alat-alatnya


HTTP API sangat diketik menggunakan metode HTTP, tipe permintaan, dan tipe respons. Kami dapat mengintrospeksi API ini dan menentukan bahwa ia harus menerima permintaan GET dengan string id di jalur URL dan dengan nilai calendar terkait dengan enumerasi terkait dalam string kueri. Kita juga dapat belajar bahwa dalam menanggapi permintaan semacam itu, respons JSON harus diberikan dengan informasi tentang sifat Character .

Apa yang dapat dilakukan dengan semua informasi ini?

OpenAPI adalah format deskripsi API yang menjadi dasar dibuatnya seperangkat alat bantu yang kaya. Ini adalah keseluruhan ekosistem. Jika kita menulis beberapa kode untuk melakukan introspeksi titik akhir dan menghasilkan spesifikasi OpenAPI berdasarkan data yang diterima, ini berarti bahwa kita akan memiliki kemampuan alat-alat ini.

 paths:  /characters/{id}:    get:      parameters:        - in: path          name: id          schema:            type: integer          required: true        - in: query          name: calendar          schema:            type: string            enum: ["BBY"]      responses:        '200':          content:            application/json:              schema:                type: object                ... 

Kami dapat membuat dokumentasi HTTP API untuk get_character API, yang mencakup nama, tipe, permintaan, dan informasi respons. Ini adalah tingkat abstraksi yang sesuai untuk pengembang klien yang perlu memenuhi permintaan ke titik akhir yang sesuai. Mereka tidak perlu membaca kode implementasi Python untuk titik akhir ini.


Dokumentasi API

Atas dasar ini, Anda dapat membuat alat tambahan. Misalnya, sarana untuk mengeksekusi permintaan dari browser. Ini memungkinkan pengembang untuk mengakses API HTTP yang menarik bagi mereka tanpa harus menulis kode. Kami bahkan dapat membuat kode klien tipe-aman untuk memastikan bahwa tipe berfungsi dengan benar pada klien dan server. Karena hal ini, kami dapat menggunakan API yang diketik dengan ketat di server, panggilan yang dilakukan menggunakan kode klien yang diketik dengan ketat.

Selain itu, kami dapat membuat sistem pemeriksaan kompatibilitas mundur. Apa yang terjadi jika kita merilis versi baru dari kode server untuk mengakses API yang dimaksud, kita perlu menggunakan id , name , dan birth_year , dan kemudian kita mengerti bahwa kita tidak tahu tanggal lahir semua karakter? Dalam hal ini, parameter birth_year perlu dibuat opsional, tetapi versi lama klien yang mengharapkan parameter yang sama mungkin berhenti bekerja. Meskipun API kami berbeda dalam pengetikan eksplisit, tipe yang sesuai dapat berubah (katakanlah, API akan berubah jika menggunakan tahun kelahiran karakter adalah yang wajib pertama dan kemudian menjadi opsional). Kami dapat melacak perubahan API dan memperingatkan pengembang API dengan memberi mereka konfirmasi pada saat yang tepat bahwa, dengan membuat beberapa perubahan, mereka dapat mengganggu kinerja klien.

Ringkasan


Ada berbagai macam protokol aplikasi yang dapat digunakan komputer untuk berkomunikasi satu sama lain.

Satu sisi spektrum ini diwakili oleh kerangka kerja RPC seperti penghematan dan gRPC. Mereka berbeda dalam hal mereka biasanya menetapkan tipe ketat untuk permintaan dan tanggapan dan menghasilkan kode klien dan server untuk mengatur operasi permintaan. Mereka dapat melakukannya tanpa HTTP dan bahkan tanpa JSON.

Di sisi lain, ada kerangka kerja web tidak terstruktur yang ditulis dengan Python yang tidak memiliki kontrak eksplisit untuk permintaan dan tanggapan. Pendekatan kami memberikan peluang yang khas untuk kerangka kerja terstruktur yang lebih jelas, tetapi pada saat yang sama memungkinkan Anda untuk terus menggunakan bundel HTTP + JSON dan berkontribusi pada fakta bahwa Anda harus melakukan sedikit perubahan pada kode aplikasi.

Penting untuk dicatat bahwa ide ini bukan hal baru. Ada banyak kerangka kerja yang ditulis dalam bahasa yang diketik sangat yang menyediakan pengembang dengan fitur yang kami jelaskan. Jika kita berbicara tentang Python, maka ini adalah, misalnya, kerangka kerja APIStar .

Kami telah berhasil menugaskan penggunaan tipe untuk API HTTP. Kami dapat menerapkan pendekatan yang dijelaskan untuk mengetik API di seluruh basis kode kami karena fakta bahwa itu juga berlaku untuk fungsi presentasi yang ada. Nilai dari apa yang kami lakukan jelas bagi semua programmer kami. Yaitu, kita berbicara tentang fakta bahwa dokumentasi yang dihasilkan secara otomatis telah menjadi sarana komunikasi yang efektif antara mereka yang mengembangkan server dan mereka yang menulis klien Instagram.

Pembaca yang budiman! Bagaimana Anda mendekati desain API HTTP di proyek Python Anda?


Source: https://habr.com/ru/post/id474308/


All Articles