Seberapa malas seorang resolver GraphQL?

Oct 03 2020

Seberapa malas seorang resolver GraphQL?

Untuk beberapa konteks, berikut adalah gambaran dari arsitektur saya: GraphQL -> Resolvers -> | Domain Boundary | -> Layanan -> Loader -> Sumber Data (Postgres / Redis / Elasticsearch)

Melewati batas domain, tidak ada konstruksi khusus GraphQL. Layanan mewakili berbagai dimensi domain, dan resolver hanya memproses SomeQueryInput, mendelegasikan ke layanan yang tepat, lalu membuat SomeQueryResult yang sesuai dengan hasil operasi. Semua aturan bisnis, termasuk otorisasi, berlaku di domain. Loader menyediakan akses ke objek domain dengan abstraksi melalui sumber data, terkadang menggunakan pola DataLoader dan terkadang tidak.

Izinkan saya mengilustrasikan pertanyaan saya dengan skenario: Katakanlah ada Pengguna yang memiliki-Proyek, dan Proyek memiliki-banyak Dokumen. Sebuah proyek juga memiliki banyak Pengguna, dan beberapa pengguna mungkin tidak diizinkan untuk melihat semua Dokumen.

Mari buat skema, dan kueri untuk mengambil semua dokumen yang dapat dilihat pengguna saat ini.

type Query {
  project(id:ID!): Project
}

type Project {
  id: ID!
  documents: [Document!]! 
}

type Document {
  id: ID!
  content: String!
}
{
  project(id: "cool-beans") {
    documents {
      id
      content
    }   
  }
}
Assume the user state is processed outside of the GraphQL context and injected into the context.

Dan beberapa kode infrastruktur yang sesuai:

const QueryResolver = {
  project: (parent, args, ctx) => {
    return projectService.findById({ id: args.id, viewer: ctx.user });
  },
}

const ProjectResolver = {
  documents: (project, args, ctx) => {
    return documentService.findDocumentsByProjectId({ projectId: project.id, viewer: ctx.user })
  }
}

const DocumentResolver = {
  content: (parent, args, ctx) => {
    let document = await documentLoader.load(parent.id);
    return document.content;
  }
}


const documentService => {
  findDocumentsByProjectId: async ({ projectId, viewer }) {
    /* return a list of document ids that the viewer is eligible to view */
    return getThatData(`SELECT id FROM Documents where projectId = $1 AND userCanViewEtc()`)
  }
}

Jadi, eksekusi kueri akan berjalan: Selesaikan proyek, dapatkan daftar dokumen yang dapat dilihat pemirsa, selesaikan dokumen, dan selesaikan isinya. Anda dapat membayangkan DocumentLoader menjadi sangat umum dan tidak peduli dengan aturan bisnis: Satu-satunya tugas adalah mendapatkan objek ID secepat mungkin.

select * from Documents where id in $1

Pertanyaan saya berkisar pada documentService.findDocumentsByProjectId. Tampaknya ada beberapa pendekatan di sini: Layanan, seperti sekarang, memiliki beberapa pengetahuan GraphQL yang dimasukkan ke dalamnya: Ia mengembalikan "rintisan" dari objek yang diperlukan, mengetahui bahwa mereka akan diselesaikan menjadi objek yang tepat. Ini memperkuat domain GraphQL, tetapi melemahkan domain layanan. Jika layanan lain memanggil layanan ini, mereka akan mendapatkan rintisan yang tidak berguna.

Mengapa tidak hanya findDocumentsByProjectId melakukan hal berikut:

SELECT id, name, content FROM "Documents" JOIN permisssions, etc etc

Sekarang Layanan lebih kuat dan mengembalikan seluruh objek bisnis, tetapi domain GraphQL menjadi lebih rapuh: Anda dapat membayangkan skenario yang lebih kompleks di mana skema GraphQL dikueri dengan cara yang tidak diharapkan layanan, Anda berakhir dengan kueri yang rusak dan data yang hilang. Sekarang Anda juga dapat ... menghapus resolver yang Anda tulis, karena sebagian besar server akan dengan mudah menyelesaikan objek yang sudah terhidrasi ini. Anda telah mengambil langkah mundur menuju pendekatan titik akhir REST.

Selain itu, metode kedua dapat memanfaatkan indeks sumber data yang dimaksudkan untuk tujuan tertentu, sedangkan DataLoader menggunakan pendekatan yang lebih brutal.

Bagaimana Anda menyeimbangkan kekhawatiran ini? Saya mengerti ini mungkin pertanyaan besar, tetapi ini adalah sesuatu yang sering saya pikirkan. Apakah Model Domain kehilangan konsep yang bisa berguna di sini? Haruskah kueri DataLoader lebih spesifik daripada sekadar menggunakan ID universal? Saya berjuang untuk menemukan keseimbangan yang elegan.

Saat ini, layanan saya memiliki keduanya: findDocumentStubs, dan findDocuments. Yang pertama digunakan oleh resolver, yang kedua digunakan oleh layanan internal lain karena mereka tidak dapat mengandalkan resolusi GraphQL, tetapi ini juga terasa kurang tepat. Bahkan dengan batching dan caching DataLoader, masih terasa seperti seseorang melakukan pekerjaan yang tidak perlu.

Jawaban

DanielRearden Oct 03 2020 at 14:46

Jika Anda menulis resolver seperti ini

function resolveFullName ({ first_name, last_name }) => {
  return `${first_name} ${last_name}`;
}

maka Anda bisa dibilang melakukan sesuatu yang salah.

Apa yang Anda lakukan secara efektif dalam kasus tersebut adalah menarik logika domain keluar dari lapisan domain Anda dan memasukkannya ke dalam lapisan API Anda. Jika Anda mengikuti praktik yang baik untuk mendesain database Anda, maka lapisan data Anda akan menjadi kekacauan yang dinormalisasi yang tidak dapat dikonsumsi secara langsung. Ini adalah tugas lapisan domain Anda untuk menerapkan aturan bisnis dan mengubah data tersebut menjadi bentuk yang kemudian dapat digunakan oleh bagian lain dari aplikasi Anda.

Kau menulis:

Sekarang Anda juga dapat ... menghapus resolver yang Anda tulis, karena sebagian besar server akan dengan mudah menyelesaikan objek yang sudah terhidrasi ini. Anda telah mengambil langkah mundur menuju pendekatan titik akhir REST.

Saya tidak berpikir itu penilaian yang adil. Anda masih memanfaatkan GraphQL untuk menggabungkan berbagai objek domain yang dikembalikan oleh layanan Anda ke dalam satu grafik. Aplikasi klien masih dapat membuat satu permintaan ke API Anda dan mendapatkan semua data yang dibutuhkannya - tidak ada yang seperti REST tentang apa yang Anda lakukan.

Jika kekhawatiran Anda adalah mengoptimalkan kueri database Anda, maka Anda pasti dapat memanfaatkan pola DataLoader yang lebih kompleks untuk mencapai tujuan itu. Metode yang diekspos oleh layanan Anda juga dapat menerima larik kolom sebagai argumen, yang memungkinkan Anda lebih selektif tentang kolom mana yang akan dipilih dan kolom mana yang akan digabungkan saat "menghidrasi" objek domain Anda. Penyelesai GraphQL dapat dengan mudah memperoleh array bidang ini dari objek GraphQLResolveInfo yang diteruskan sebagai parameter keempatnya.

AdventureBeard Oct 06 2020 at 02:42

(Menjawab pertanyaan saya sendiri setelah melakukan penelitian dan mensintesis beberapa rekomendasi @ Daniel)

Izinkan saya mencoba membahas perhatian utama Anda, yang berpusat pada pengambilan koleksi yang sesuai dengan beberapa kriteria. Friksi yang Anda rasakan berasal dari pengambilan kumpulan id dokumen, lalu berbalik dan membuat kueri serupa untuk menyelesaikan sisa bidang pada dokumen tersebut. Saya pikir masuk akal untuk merasa bahwa ini adalah upaya duplikat pada awalnya, terutama karena baru mengenal GraphQL: Mengapa Anda tidak dengan bersemangat mengambil semua bidang yang dibutuhkan dari database pada kueri pertama itu? Ada alasan bagus:

Katakanlah kita dengan bersemangat mengambil data dokumen yang kita "tahu" yang kita perlukan: Daripada mengambil daftar id di ProjectResolver, dan mengambil lagi di DocumentResolver untuk menyelesaikan Dokumen, kita dengan bersemangat mengambil semuanya di ProjectResolver, lalu biarkan server GraphQL kami menyelesaikan bidang Dokumen dengan mudah. Ini tampaknya berfungsi dengan baik, tetapi kami telah memindahkan beban resolusi Dokumen ke penyelesai Proyek. Mari tambahkan pengguna tipe dengan bidang createDocuments: [Document!] !.

type User {
  id: ID!
  name: String!
  createdDocuments: [Document!]!
}

Apa yang terjadi ketika Anda menanyakan dokumen yang dibuat di User? Tidak ada yang membantu, kecuali kita memiliki UserResolver mengambil data Dokumen juga ... Dengan mengizinkan orang tua menjadi satu-satunya sumber data untuk anak-anak mereka, kami memaksa semua orang tua di masa depan untuk melakukan hal yang sama. Hal ini membuat API GraphQL kami rapuh dan sulit untuk dirawat dan diperluas. Jika kita baru saja membuat ProjectResolver malas dan hanya mengembalikan nilai minimum, lalu memaksa DocumentResolver melakukan semua pekerjaan yang terkait dengan Dokumen, kita tidak mengalami masalah ini.

Masih ada rasa gatal dari dua perjalanan pulang pergi tersebut ke DB. Anda dapat mengambil jalan tengah dengan lebih condong ke DataLoader Anda dan menggunakan cache priming. The Facebook JS DataLoader implementasi memiliki metode yang disebut prime (), yang memungkinkan Anda untuk Data benih ke dalam cache loader Anda. Jika Anda menggunakan sekumpulan DataLoader, Anda mungkin akan memiliki beberapa pemuat yang merujuk ke objek yang sama dalam konteks yang berbeda. (Ini akan terasa familiar jika Anda menggunakan Apollo Client untuk pekerjaan front-end). Saat Anda mengambil beberapa objek dalam satu konteks, cukup utamakan untuk konteks lain sebagai langkah pasca-pemrosesan.

Saat Anda mengambil daftar dokumen untuk sebuah proyek, lanjutkan dan ambil konten dengan bersemangat juga, tetapi gunakan hasil dari itu untuk menjalankan DocumentLoader. Sekarang ketika DocumentResolver Anda dimulai, semua data ini akan siap, tetapi masih akan mencukupi sendiri jika tidak ada hasil yang diambil sebelumnya. Anda harus menggunakan penilaian terbaik Anda saat melakukan ini berdasarkan kebutuhan aplikasi Anda. Anda juga dapat menggunakan saran Daniel Rearden dan menggunakan GraphQLResolveInfo untuk memutuskan secara bersyarat untuk mengambil sebelumnya seperti ini, tetapi pastikan untuk tidak terjebak di dalam gulma saat melakukan pengoptimalan mikro.

Bayangkan skenario di mana Anda memiliki dua DataLoaders: ProjectDocumentsLoader dan DocumentLoader. ProjectDocumentsLoader dapat menggunakan DocumentLoader dengan hasil sebagai langkah pasca-pemrosesan. Saya suka membungkus DataLoaders saya dalam abstraksi ringan untuk menangani pra-dan pasca-pemrosesan.


class Loader {
  load(id) {
    let results = await this.loader.load(id)
    return this.postProcess(results);
  }
  
  postProcess(data) {
    return data;
  }

  prime(key, value) {
    this.dataLoader.prime(key, value);
  }
}

class ProjectDocumentsLoader extends Loader {
  constructor(context) {
    this.context = context;
    this.loader = new DataLoader(/* function to get collection of documents by project */);
  }
  
  postProcess(documents) {
    documents.forEach(doc => this.context.documentLoader.prime(doc.id, doc));
    return documents;
  }
}

class DocumentLoader extends Loader {
  constructor(context) {
    this.context = context;
    this.loader = new DataLoader(/* function to get documents by id */);
  }
}

Jadi jawaban terakhir: Penyelesai GraphQL Anda harus sangat malas, dengan opsi pengambilan sebelumnya selama itu adalah pengoptimalan dan bukan sumber kebenaran.