Home Building a Web-Based gRPC Inspector with Go, React and Kubernetes Integration
Post
Cancel

Building a Web-Based gRPC Inspector with Go, React and Kubernetes Integration

Browser’dan gRPC Servislerini Test Etmek: Sıfırdan Inspector Aracı Geliştirme

Postman’ın gRPC için yetersiz kaldığı bir anda başladı bu proje. K8s’te 8–10 tane gRPC servisimiz vardı, her biri farklı namespace’de, ve her test için terminal açıp grpcurl komutu yazmak can sıkıcı hale gelmişti. Bu yazıda, sıfırdan bir web tabanlı gRPC inspector geliştirme sürecini — mimari kararlardan K8s entegrasyonuna kadar — adım adım anlatıyorum.


İçindekiler

  1. Problem ve Mimari Karar
  2. gRPC 101: Browser Neden Doğrudan Konuşamaz?
  3. Backend: Go ile gRPC Client
  4. Server Reflection: Proto Dosyası Olmadan Servis Keşfi
  5. Dynamic Invocation: Runtime’da Proto Mesajı Oluşturma
  6. Streaming: WebSocket Köprüsü
  7. Test Sunucusu: protoc Olmadan gRPC Server
  8. Frontend: React + Monaco Editor
  9. K8s Entegrasyonu: Port-Forward Otomasyonu
  10. Çoklu Kubeconfig: Dev/Prod Ortam Yönetimi
  11. Öğrendiklerim

1. Problem ve Mimari Karar

Neden Mevcut Araçlar Yetmez?

  • grpcurl: Güçlü ama terminal bazlı. Her seferinde uzun komutlar yazmak ve response’ları manuel parse etmek yorucu.
  • Postman (gRPC): Temel unary çağrılar için iyi ama reflection desteği kısıtlı, K8s entegrasyonu yok.
  • BloomRPC / Evans: Masaüstü uygulaması, takıma dağıtımı zor.

Mimari Seçim: Neden “Thin Browser + Fat Backend”?

gRPC, HTTP/2 üzerinde çalışır ve browser bu bağlantıyı doğrudan kuramaz. Bu yüzden iki seçenek var:

1
2
3
4
5
6
7
Seçenek A: gRPC-Web
Browser → gRPC-Web Proxy → gRPC Server
(Proxy her sunucuya eklenmeli, kontrol yok)

Seçenek B: Go Backend
Browser (HTTP/WS) → Go Backend → gRPC Server
(Backend tam kontrol, K8s entegrasyonu, reflection, history)

Seçenek B‘yi seçtik. Go backend hem gerçek gRPC client olarak davranıyor hem de REST/WebSocket API sunuyor.

1
2
3
4
┌─────────────┐     HTTP/REST      ┌─────────────┐     gRPC/HTTP2     ┌─────────────┐
│   Browser   │ ◄────────────────► │  Go Backend │ ◄────────────────► │ gRPC Server │
│  React/Vite │    WebSocket (ws)  │   :8080     │    Server Reflect  │   :50051    │
└─────────────┘                    └─────────────┘                     └─────────────┘

2. gRPC 101: Browser Neden Doğrudan Konuşamaz?

gRPC’yi anlamak için önce Protocol Buffers ve HTTP/2’yi kavramak gerekir.

Protocol Buffers (Protobuf)

gRPC, JSON yerine binary format kullanır. Bir servis tanımı şöyle görünür:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// hello.proto
syntax = "proto3";
package helloworld;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
  rpc SayHelloMultiple (HelloRequest) returns (stream HelloReply); // server streaming
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

protoc derleyicisi bu dosyayı Go, Python, Java vb. dillere çevirir. Ama biz çalışma zamanında proto dosyası olmadan çalışmak istiyoruz — bunu Server Reflection ile çözüyoruz.

HTTP/2 ve Multiplexing

gRPC, HTTP/2’nin şu özelliklerini kullanır:

  • Binary framing: Her mesaj binary header + data frame olarak gönderilir
  • Multiplexing: Tek TCP bağlantısı üzerinde paralel stream’ler
  • Server push: Server istemci beklemeden veri gönderebilir

Browser, HTTP/2 üzerinden fetch() veya XMLHttpRequest yapabilir — ama gRPC’nin beklediği özel framing formatını üretemez. İşte bu yüzden bir proxy (bizim durumda Go backend) gerekiyor.


3. Backend: Go ile gRPC Client

Bağlantı Havuzu

Her gRPC sunucusuna karşı bir grpc.ClientConn açıyoruz ve bunları thread-safe bir havuzda saklıyoruz:

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
29
30
31
32
33
34
35
// grpcclient/pool.go
type Pool struct {
    mu    sync.RWMutex
    conns map[string]*ManagedConn
}

type ConnectOptions struct {
    Address     string
    TLS         bool
    Insecure    bool // self-signed cert için
    Metadata    map[string]string
    DialTimeout time.Duration
}

func (p *Pool) Connect(id string, opts ConnectOptions) (*ManagedConn, error) {
    var dialOpts []grpc.DialOption

    if opts.TLS {
        if opts.Insecure {
            // Self-signed cert — doğrulama atla
            dialOpts = append(dialOpts, grpc.WithTransportCredentials(
                credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}),
            ))
        } else {
            dialOpts = append(dialOpts, grpc.WithTransportCredentials(
                credentials.NewClientTLSFromCert(nil, ""),
            ))
        }
    } else {
        dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
    }

    conn, err := grpc.NewClient(opts.Address, dialOpts...)
    // ...
}

Neden grpc.NewClient ve grpc.Dial değil? grpc.Dial deprecated oldu. grpc.NewClient lazy bağlantı kurar — gerçek bağlantı ilk RPC’de açılır.

REST API Tasarımı

Backend’in endpoint’leri şu şemayı takip ediyor:

1
2
3
4
5
6
7
8
9
10
11
12
POST   /api/connections                          → Yeni bağlantı
GET    /api/connections                          → Bağlantı listesi
DELETE /api/connections/{id}                     → Bağlantıyı kapat
POST   /api/connections/{id}/test               → Ping

GET    /api/connections/{id}/reflect/services   → Servis listesi
GET    /api/connections/{id}/reflect/service/{svc} → Metod detayları

POST   /api/connections/{id}/invoke              → Unary çağrı
WS     /api/connections/{id}/stream              → Streaming

GET    /api/history                              → Çağrı geçmişi

4. Server Reflection: Proto Dosyası Olmadan Servis Keşfi

gRPC Server Reflection protokolü, sunucunun kendi şemasını istemcilere expose etmesini sağlar. grpcurl‘ün list komutu da bunu kullanır.

Reflection’ı Etkinleştirmek

Sunucu tarafında tek satır yeterli:

1
2
3
4
import "google.golang.org/grpc/reflection"

s := grpc.NewServer()
reflection.Register(s) // bu kadar!

Reflection Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// grpcclient/reflection.go
func (r *ReflectionClient) ListServices(ctx context.Context) ([]string, error) {
    // Reflection stub oluştur
    stub := grpc_reflection_v1alpha.NewServerReflectionClient(r.conn)
    stream, err := stub.ServerReflectionInfo(ctx)
    if err != nil {
        return nil, err
    }

    // Servis listesi iste
    err = stream.Send(&grpc_reflection_v1alpha.ServerReflectionRequest{
        MessageRequest: &grpc_reflection_v1alpha.ServerReflectionRequest_ListServices{
            ListServices: "",
        },
    })

    resp, err := stream.Recv()
    // resp.ListServicesResponse.Service'den isimleri çek
    var services []string
    for _, svc := range resp.GetListServicesResponse().Service {
        services = append(services, svc.Name)
    }
    return services, nil
}

FileDescriptorProto ile Şema Keşfi

Reflection sadece servis isimlerini değil, tam proto şemasını da döner. FileDescriptorProto binary formatında gelir ve biz bunu parse ederek field tiplerini, nested message’ları çıkarırız:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (r *ReflectionClient) FileDescriptorForSymbol(ctx context.Context, symbol string) (*descriptorpb.FileDescriptorProto, error) {
    // Symbol için FileDescriptorProto iste
    err := stream.Send(&grpc_reflection_v1alpha.ServerReflectionRequest{
        MessageRequest: &grpc_reflection_v1alpha.ServerReflectionRequest_FileContainingSymbol{
            FileContainingSymbol: symbol,
        },
    })
    
    resp, _ := stream.Recv()
    fdBytes := resp.GetFileDescriptorResponse().FileDescriptorProto[0]
    
    var fd descriptorpb.FileDescriptorProto
    proto.Unmarshal(fdBytes, &fd)
    return &fd, nil
}

Bu sayede kullanıcı hiçbir .proto dosyası yüklemeden servisleri, metodları ve mesaj şemalarını görebiliyor.


5. Dynamic Invocation: Runtime’da Proto Mesajı Oluşturma

En ilginç kısım bu: .proto dosyası compile etmeden, sadece reflection’dan gelen descriptor’larla proto mesajı oluşturmak.

dynamicpb Paketi

Go’nun google.golang.org/protobuf/types/dynamicpb paketi tam da bunu yapıyor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (h *InvokeHandler) Unary(w http.ResponseWriter, r *http.Request) {
    // 1. Reflection'dan MessageDescriptor al
    inputTypeName := method.GetInputType() // ".helloworld.HelloRequest"
    
    mt, err := protoregistry.GlobalTypes.FindMessageByName(
        protoreflect.FullName(strings.TrimPrefix(inputTypeName, ".")),
    )
    
    // 2. Dynamic message oluştur
    msg := dynamicpb.NewMessage(mt.Descriptor())
    
    // 3. JSON body'yi bu mesaja unmarshal et
    jsonBytes, _ := json.Marshal(req.Payload)
    protojson.Unmarshal(jsonBytes, msg)
    
    // 4. gRPC invoke
    var response dynamicpb.Message
    err = conn.Invoke(ctx, fullMethod, msg, &response)
    
    // 5. Response'u JSON'a çevir
    responseJSON, _ := protojson.Marshal(response)
}

Proto Registry

Reflection’dan gelen FileDescriptorProto‘ları global registry’e kaydetmek gerekiyor ki FindMessageByName çalışsın:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func registerFile(fdp *descriptorpb.FileDescriptorProto) {
    fd, err := protodesc.NewFile(fdp, protoregistry.GlobalFiles)
    if err != nil {
        return // zaten kayıtlı
    }
    protoregistry.GlobalFiles.RegisterFile(fd)
    
    // Mesaj tiplerini de kaydet
    msgs := fd.Messages()
    for i := 0; i < msgs.Len(); i++ {
        mt := dynamicpb.NewMessageType(msgs.Get(i))
        protoregistry.GlobalTypes.RegisterMessage(mt)
    }
}

Not: fd.Messages().Range() çalışmaz — MessageDescriptors interface’inde .Range() metodu yok. Index-based loop kullanmak gerekiyor.


6. Streaming: WebSocket Köprüsü

gRPC 4 tip RPC destekler:

Tip İstemci Sunucu Kullanım
Unary Tek mesaj Tek mesaj Klasik REST benzeri
Server streaming Tek mesaj Çok mesaj Live feed, log stream
Client streaming Çok mesaj Tek mesaj Upload, batch
Bidi streaming Çok mesaj Çok mesaj Chat, real-time

Browser’dan streaming yapabilmek için WebSocket kullandık. Protokol şöyle:

1
2
3
4
5
6
7
8
9
10
11
Browser → WS → Backend → gRPC Stream

1. Browser bağlanır (WS handshake)
2. İlk mesaj: { service, method, metadata }
3. Sonraki mesajlar: { type: "message", payload: {...} }
4. Kapatmak için: { type: "end" }

Backend → Browser:
- { type: "message", payload: {...} }   her gRPC mesajı için
- { type: "trailer", meta: {...} }      stream bitişinde
- { type: "error", error: "..." }       hata durumunda
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
29
30
31
32
33
34
35
36
// handler/invoke.go (sadeleştirilmiş)
func (h *InvokeHandler) Stream(w http.ResponseWriter, r *http.Request) {
    ws, _ := upgrader.Upgrade(w, r)
    
    // İlk frame: init
    var init struct { Service, Method string; Metadata map[string]string }
    ws.ReadJSON(&init)
    
    // gRPC stream aç
    stream, _ := conn.NewStream(ctx, &grpc.StreamDesc{
        ServerStreams: method.GetServerStreaming(),
        ClientStreams: method.GetClientStreaming(),
    }, fullMethod)
    
    // WS → gRPC (client mesajları)
    go func() {
        for {
            var frame struct { Type string; Payload json.RawMessage }
            ws.ReadJSON(&frame)
            if frame.Type == "end" { stream.CloseSend(); return }
            
            msg := buildDynamicMessage(frame.Payload, inputDescriptor)
            stream.SendMsg(msg)
        }
    }()
    
    // gRPC → WS (server mesajları)
    for {
        resp := dynamicpb.NewMessage(outputDescriptor)
        err := stream.RecvMsg(resp)
        if err == io.EOF { ws.WriteJSON(map[string]string{"type": "end"}); break }
        if err != nil { ws.WriteJSON(map[string]string{"type": "error", "error": err.Error()}); break }
        
        ws.WriteJSON(map[string]interface{}{"type": "message", "payload": resp})
    }
}

7. Test Sunucusu: protoc Olmadan gRPC Server

protoc yüklemek zorunda kalmadan test için bir gRPC sunucusu yazmak istedik. Go’nun descriptorpb paketi ile proto şemasını kod içinde tanımlayabiliriz:

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
29
30
31
32
33
34
35
36
37
38
39
40
// main.go - helloProto() fonksiyonu
func helloProto() *descriptorpb.FileDescriptorProto {
    return &descriptorpb.FileDescriptorProto{
        Name:    str("hello/hello.proto"),
        Package: str("helloworld"),
        Syntax:  str("proto3"),
        MessageType: []*descriptorpb.DescriptorProto{
            {
                Name: str("HelloRequest"),
                Field: []*descriptorpb.FieldDescriptorProto{
                    {
                        Name:   str("name"),
                        Number: int32p(1),
                        Label:  labelOptional(),
                        Type:   typeString(),
                    },
                },
            },
            // HelloReply...
        },
        Service: []*descriptorpb.ServiceDescriptorProto{
            {
                Name: str("Greeter"),
                Method: []*descriptorpb.MethodDescriptorProto{
                    {
                        Name:       str("SayHello"),
                        InputType:  str(".helloworld.HelloRequest"),
                        OutputType: str(".helloworld.HelloReply"),
                    },
                    {
                        Name:            str("SayHelloMultiple"),
                        InputType:       str(".helloworld.HelloRequest"),
                        OutputType:      str(".helloworld.HelloReply"),
                        ServerStreaming: boolp(true),
                    },
                },
            },
        },
    }
}

Bu descriptor’ı global registry’e kaydedince reflection otomatik çalışıyor.

grpc.MethodDesc Handler İmzası

grpc.MethodDesc.Handler alanı için unexported bir tip var: grpc.methodHandler. Buna type alias atayamazsınız, tam imzayı yazmanız gerekiyor:

1
2
3
4
5
6
7
8
9
10
// ❌ Çalışmaz
type myHandler func(...) (interface{}, error)
func makeHandler() myHandler { ... }

// ✅ Çalışır — tam imza
func makeHandler() func(interface{}, context.Context, func(interface{}) error, grpc.UnaryServerInterceptor) (interface{}, error) {
    return func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
        // ...
    }
}

8. Frontend: React + Monaco Editor

Proje Yapısı

1
2
3
4
5
6
7
8
9
10
11
src/
├── api/client.ts          # Fetch + WebSocket wrapper
├── stores/index.ts        # Zustand state management
├── types/index.ts         # TypeScript tipleri
└── components/
    ├── ConnectionBar/      # Adres girişi, TLS toggle, bağlantı listesi
    ├── ServiceExplorer/    # Sol panel, servis/metod ağacı
    ├── RequestBuilder/     # Monaco editor, metadata, send butonu
    ├── ResponseViewer/     # Monaco (unary) veya mesaj listesi (stream)
    ├── HistoryPanel/       # Geçmiş çağrılar, reuse
    └── K8sDiscovery/       # K8s servis keşfi

Monaco Editor Entegrasyonu

Request body için Monaco kullandık — JSON syntax highlighting ve otomatik formatlama için:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Editor from '@monaco-editor/react'

<Editor
    height="100%"
    language="json"
    value={requestBody}
    onChange={(v) => setRequestBody(v || '{}')}
    theme={theme === 'dark' ? 'vs-dark' : 'vs'}
    options={{
        minimap: { enabled: false },
        fontSize: 13,
        fontFamily: 'JetBrains Mono',
        wordWrap: 'on',
        formatOnPaste: true,
    }}
/>

Zustand ile State Yönetimi

Context API yerine Zustand tercih ettik — daha az boilerplate, selector desteği:

1
2
3
4
5
6
7
8
// stores/index.ts
export const useConnectionStore = create<ConnectionStore>((set) => ({
    connections: [],
    activeConnectionId: null,
    setConnections: (connections) => set({ connections }),
    addConnection: (c) => set((s) => ({ connections: [...s.connections, c] })),
    setActiveConnection: (id) => set({ activeConnectionId: id }),
}))

CSS Custom Properties ile Dark/Light Theme

1
2
3
4
5
6
7
8
9
10
11
12
:root {
    --bg-base:      #0a0b0e;
    --accent:       #4ade80;
    --text-primary: #e8eaf0;
    /* ... */
}

[data-theme="light"] {
    --bg-base:      #f0f2f5;
    --accent:       #16a34a;
    --text-primary: #111318;
}

Toggle:

1
document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'light' : 'dark')

Vite Proxy

Dev ortamında CORS sorununu proxy ile aşıyoruz:

1
2
3
4
5
6
7
8
9
// vite.config.ts
server: {
    proxy: {
        '/api': {
            target: 'http://localhost:8080',
            changeOrigin: true,
        },
    },
},

9. K8s Entegrasyonu: Port-Forward Otomasyonu

Sorun

K8s’teki gRPC servisleri cluster içinde — dışarıdan erişmek için her servis için ayrı kubectl port-forward açmak gerekiyor. 8–10 servis için bu pratik değil.

Çözüm: SPDY Port-Forward API

client-go kütüphanesi kubectl port-forward‘un aynısını programatik yapmanı sağlar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// k8s/manager.go
func (m *Manager) runPortForward(
    namespace, podName string,
    localPort, remotePort int,
    stopCh, readyCh chan struct{},
) error {
    // K8s API Server URL'i oluştur
    u, _ := url.Parse(m.restConfig.Host)
    u.Path = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward",
        namespace, podName)
    
    // SPDY transport
    transport, upgrader, _ := spdy.RoundTripperFor(m.restConfig)
    dialer := spdy.NewDialer(
        upgrader,
        &http.Client{Transport: transport},
        http.MethodPost,
        u,
    )
    
    ports := []string{fmt.Sprintf("%d:%d", localPort, remotePort)}
    fw, _ := portforward.New(dialer, ports, stopCh, readyCh, nil, nil)
    return fw.ForwardPorts() // blocking
}

Servis → Pod Çözümlemesi

kubectl port-forward svc/... aslında arka planda pod bulur. Biz de aynısını yapıyoruz:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (m *Manager) findPodForService(ctx context.Context, ns, svcName string) (string, error) {
    // Servisin selector'ını al
    svc, _ := m.client.CoreV1().Services(ns).Get(ctx, svcName, metav1.GetOptions{})
    selector := metav1.FormatLabelSelector(&metav1.LabelSelector{
        MatchLabels: svc.Spec.Selector,
    })
    
    // Bu selector'a uyan pod bul
    pods, _ := m.client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
        LabelSelector: selector,
    })
    
    for _, pod := range pods.Items {
        if pod.Status.Phase == corev1.PodRunning {
            for _, c := range pod.Status.ContainerStatuses {
                if c.Ready {
                    return pod.Name, nil
                }
            }
        }
    }
    return "", fmt.Errorf("no ready pod found")
}

Auto-detect: In-cluster vs Local

Backend hem cluster içinde hem dışında çalışabilsin diye:

1
2
3
4
5
6
7
8
9
10
11
func buildConfig() (*rest.Config, error) {
    // Önce in-cluster dene (K8s'te çalışıyorsak ServiceAccount token vardır)
    cfg, err := rest.InClusterConfig()
    if err == nil {
        return cfg, nil
    }
    
    // Yoksa ~/.kube/config
    kubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config")
    return clientcmd.BuildConfigFromFlags("", kubeconfig)
}

10. Çoklu Kubeconfig: Dev/Prod Ortam Yönetimi

Tasarım

Her kubeconfig dosyası ayrı bir KubeconfigEntry olarak saklanır. Her entry’nin birden fazla context’i olabilir (ör. dev-east, dev-west, staging).

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
// k8s/config_store.go
type KubeconfigEntry struct {
    ID            string        `json:"id"`
    Name          string        `json:"name"`
    Contexts      []ContextInfo `json:"contexts"`
    ActiveContext string        `json:"activeContext"`
    UploadedAt    time.Time     `json:"uploadedAt"`
}

func (s *ConfigStore) Add(name string, data []byte) (*KubeconfigEntry, error) {
    cfg, err := clientcmd.Load(data) // kubeconfig parse
    
    // Context listesi çıkar
    for ctxName, ctx := range cfg.Contexts {
        contexts = append(contexts, ContextInfo{
            Name:    ctxName,
            Cluster: ctx.Cluster,
            User:    ctx.AuthInfo,
        })
    }
    
    // Aktif context için k8s client oluştur ve bağlantıyı test et
    entry.buildClient(cfg.CurrentContext)
    
    s.entries[id] = entry
    return &entry.KubeconfigEntry, nil
}

Context Switch

Context değişince yeni rest.Config ve kubernetes.Clientset oluşturulur:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (e *configEntry) buildClient(contextName string) error {
    clientConfig := clientcmd.NewDefaultClientConfig(
        *e.rawConfig,
        &clientcmd.ConfigOverrides{CurrentContext: contextName},
    )
    restCfg, _ := clientConfig.ClientConfig()
    client, _ := kubernetes.NewForConfig(restCfg)
    
    // Bağlantı testi
    ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
    defer cancel()
    _, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
    if err != nil {
        return fmt.Errorf("cluster unreachable: %w", err)
    }
    
    e.activeClient = client
    e.activeRestCfg = restCfg
    return nil
}

Frontend Upload

Kubeconfig dosyasını binary olarak upload ediyoruz — YAML parse’ı backend’e bırakıyoruz:

1
2
3
4
5
6
7
8
9
10
11
12
async function uploadConfig(file: File) {
    const form = new FormData()
    form.append('file', file)
    form.append('name', labelInput || file.name)
    
    const res = await fetch('/api/k8s/kubeconfigs', {
        method: 'POST',
        body: form, // Content-Type: multipart/form-data otomatik set edilir
    })
    const entry = await res.json()
    setConfigs(prev => [...prev, entry])
}

11. Öğrendiklerim

Proje boyunca karşılaştığım ve gelecekte aynı sorunları yaşamamanızı istediğim birkaç nokta:

Go gRPC

grpc.Dial deprecatedgrpc.NewClient kullan. Fark: NewClient bağlantıyı lazy açar, ilk RPC’de dial eder.

protoreflect.MessageDescriptors.Range() yok — Index-based loop kullanmak gerekiyor:

1
2
3
4
msgs := fd.Messages()
for i := 0; i < msgs.Len(); i++ {
    // msgs.Get(i)
}

grpc.methodHandler unexportedMethodDesc.Handler alanına type alias atayamazsın. Fonksiyonun tam imzasını literal olarak yaz.

Kubernetes

port-forward SPDY üzerine çalışır, WebSocket üzerine değil. Bazı cluster’larda SPDY devre dışı bırakılmış olabilir.

Service selector boşsa port-forward çalışmazExternalName tipi servislerde ve bazı headless servislerde selector olmaz. Bunu handle et.

kubectl port-forward svc/... vs pod/... — Servis üzerinden forward açınca K8s rastgele pod seçer. Biz de aynısını yapıyoruz: önce pod bul, sonra pod üzerinden forward.

Frontend

PowerShell’de curl = Invoke-WebRequestcurl.exe yazarsan gerçek curl, Invoke-RestMethod daha temiz seçenek.

Monaco Editor + Vite@monaco-editor/react paketi büyük (2MB+). Lazy load öneririm:

1
const Editor = lazy(() => import('@monaco-editor/react'))

WebSocket ile streaming — Backend kapanınca WS bağlantısı CLOSE_GOING_AWAY ile kapanır. Frontend’de reconnect logic eklemek iyi pratik.


Sonuç

Bu projenin en değerli kısmı, gRPC’nin nasıl çalıştığını — binary encoding, HTTP/2 multiplexing, server reflection protokolü — derinlemesine anlama fırsatı vermesi oldu. Bir araç geliştirirken o araç için çözdüğünüz sorunlar, kütüphane kullanırken hiç görmediğiniz katmanları görünür kılar.

Repo yapısı:

1
2
3
4
5
6
7
8
9
10
11
12
13
grpc-inspector/
├── backend/
│   ├── main.go
│   ├── grpcclient/      # Connection pool + reflection
│   ├── handler/         # HTTP handlers
│   ├── k8s/             # K8s discovery + port-forward
│   └── history/         # In-memory call history
└── frontend/
    ├── src/
    │   ├── api/          # Backend client
    │   ├── stores/       # Zustand state
    │   └── components/   # UI bileşenleri
    └── vite.config.ts

Çalıştırmak için:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Backend
cd backend
go mod tidy
go run ./...

# Test sunucusu (ayrı terminal)
cd testserver
go mod tidy
go run .

# Frontend
cd frontend
npm install
npm run dev
# → http://localhost:5173

Bu projeyi genişletmek için fikirler: gRPC-Web desteği, proto dosyasından otomatik test case üretimi, response diff görünümü, team sharing için persistent storage.

This post is licensed under CC BY 4.0 by the author.