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
grpcurlkomutu 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
- Problem ve Mimari Karar
- gRPC 101: Browser Neden Doğrudan Konuşamaz?
- Backend: Go ile gRPC Client
- Server Reflection: Proto Dosyası Olmadan Servis Keşfi
- Dynamic Invocation: Runtime’da Proto Mesajı Oluşturma
- Streaming: WebSocket Köprüsü
- Test Sunucusu: protoc Olmadan gRPC Server
- Frontend: React + Monaco Editor
- K8s Entegrasyonu: Port-Forward Otomasyonu
- Çoklu Kubeconfig: Dev/Prod Ortam Yönetimi
- Öğ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 —MessageDescriptorsinterface’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 deprecated — grpc.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 unexported — MethodDesc.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ışmaz — ExternalName 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-WebRequest — curl.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.