04 - Convertendo para arquitetura hexagonal
Continuação do artigo:
03 - Caso prático usando Golang
Vamos deixar a arquiteturo disso melhor: hexagonal, com métricas Prometheus + OpenTelemetry e com um modelo voltado a categorização de produto (ex.: AUTOPEÇAS, INFORMÁTICA, MATERIAL_CONSTRUÇÃO).
Vou dividir em 3 partes:
-
Novo modelo em Python (produto → categoria) exportado em ONNX.
-
API em Go com arquitetura hexagonal usando esse
model.onnx. -
Dockerfile da API em Go (reaproveitando o esquema do ONNX Runtime).
1. Treino em Python – modelo de categorização de produto
Aqui vou fazer algo didático, mas ERP-friendly:
-
Supor que você já tenha vetores numéricos (embeddings) dos produtos, por exemplo 16 features (
features[0..15]). -
O modelo será um MLP simples que classifica em 3 categorias:
-
AUTOPECAS -
INFORMATICA -
MATERIAL_CONSTRUCAO
-
Você depois pode substituir os dados sintéticos pelos seus vetores reais (ex.: embeddings de descrição + marca + grupo atual).
Estrutura de pastas
ml-onnx-example/
ml/
train_product_category.py
requirements.txt
model/
model.onnx # será gerado aqui
ml/requirements.txt
torch
numpy
ml/train_product_category.py
import os
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
NUM_FEATURES = 16
NUM_CLASSES = 3
CATEGORY_NAMES = ["AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"]
class ProductCategoryNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(NUM_FEATURES, 32)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(32, NUM_CLASSES)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x # logits
def generate_synthetic_data(n_samples_per_class: int = 200):
"""
Gera dados sintéticos só para exemplo.
Em produção, troque isso pelos seus embeddings reais.
"""
# Cluster AUTOPECAS
autopecas = np.random.normal(loc=0.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))
# Cluster INFORMATICA
informatica = np.random.normal(loc=3.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))
# Cluster MATERIAL_CONSTRUCAO
matconst = np.random.normal(loc=-3.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))
X = np.vstack([autopecas, informatica, matconst]).astype(np.float32)
y = np.array(
[0] * n_samples_per_class +
[1] * n_samples_per_class +
[2] * n_samples_per_class,
dtype=np.int64
)
# embaralha
idx = np.random.permutation(len(X))
return X[idx], y[idx]
def main():
X, y = generate_synthetic_data(300)
X_tensor = torch.from_numpy(X)
y_tensor = torch.from_numpy(y)
model = ProductCategoryNet()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
model.train()
epochs = 50
for epoch in range(epochs):
optimizer.zero_grad()
outputs = model(X_tensor)
loss = criterion(outputs, y_tensor)
loss.backward()
optimizer.step()
if (epoch + 1) % 10 == 0:
_, preds = torch.max(outputs, 1)
acc = (preds == y_tensor).float().mean().item()
print(f"Epoch [{epoch+1}/{epochs}] - Loss: {loss.item():.4f} - Acc: {acc:.4f}")
os.makedirs("model", exist_ok=True)
model.eval()
dummy_input = torch.randn(1, NUM_FEATURES)
onnx_path = os.path.join("model", "model.onnx")
torch.onnx.export(
model,
dummy_input,
onnx_path,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"},
"output": {0: "batch_size"},
},
opset_version=17,
)
print(f"Modelo de categorização de produto salvo em: {onnx_path}")
print("Categorias:", CATEGORY_NAMES)
if __name__ == "__main__":
main()
Você pode encaixar isso no mesmo Dockerfile.ml anterior, só trocando o CMD ou mantendo ambos scripts e escolhendo qual rodar.
2. API em Go com arquitetura hexagonal
Estrutura de diretórios da API Go
api-go/
cmd/
api/
main.go
internal/
domain/
category/
model.go # entidades + tipos
ports.go # interfaces (ports)
app/
service.go # orquestra domínio + portas
infra/
onnx/
predictor.go # adapter para ONNX Runtime
observability/
metrics.go # Prometheus
tracing.go # OpenTelemetry
http/
router.go # monta chi.Router
handlers.go # handlers HTTP
go.mod
go.sum
api-go/go.mod (exemplo)
Use o módulo que preferir:
module github.com/seuuser/ml-onnx-example/api-go
go 1.22
require (
github.com/go-chi/chi/v5 v5.0.12
github.com/prometheus/client_golang v1.19.0
github.com/yalue/onnxruntime_go v0.3.4
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0
go.opentelemetry.io/otel v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0
go.opentelemetry.io/otel/sdk v1.27.0
)
Versões são ilustrativas; você pode ajustar com
go get -u.
2.1 Domínio: tipos + port
internal/domain/category/model.go
package category
// ProductCategory representa uma categoria de produto no seu ERP.
type ProductCategory struct {
Code string `json:"code"`
Name string `json:"name"`
}
// PredictionInput é a entrada de predição: vetor de features numéricas (embedding).
type PredictionInput struct {
Features []float32 `json:"features"`
}
// PredictionResult é o resultado de uma predição de categoria.
type PredictionResult struct {
Category ProductCategory `json:"category"`
Probabilities []float32 `json:"probabilities"`
}
internal/domain/category/ports.go
package category
import "context"
// CategoryPredictorPort é a porta de saída do domínio
// usada pelo app/service para pedir uma predição ao modelo.
type CategoryPredictorPort interface {
PredictCategory(ctx context.Context, input PredictionInput) (PredictionResult, error)
}
2.2 Aplicação: orquestra serviço
internal/app/service.go
package app
import (
"context"
"errors"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)
var ErrInvalidFeaturesLen = errors.New("quantidade de features inválida; esperado 16")
const ExpectedFeaturesLen = 16
// CategoryService fica no centro, conhece apenas a porta do predictor.
type CategoryService struct {
predictor category.CategoryPredictorPort
}
func NewCategoryService(p category.CategoryPredictorPort) *CategoryService {
return &CategoryService{predictor: p}
}
func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
if len(input.Features) != ExpectedFeaturesLen {
return category.PredictionResult{}, ErrInvalidFeaturesLen
}
return s.predictor.PredictCategory(ctx, input)
}
2.3 Infra: ONNX adapter
internal/infra/onnx/predictor.go
package onnx
import (
"context"
"fmt"
"log"
"sync"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
ort "github.com/yalue/onnxruntime_go"
)
type OnnxCategoryPredictor struct {
session *ort.DynamicSession[float32]
mu sync.Mutex
classNames []string
expectedInput int
}
func NewOnnxCategoryPredictor(modelPath string, classNames []string, expectedInput int) (*OnnxCategoryPredictor, error) {
if len(classNames) == 0 {
return nil, fmt.Errorf("classNames não pode ser vazio")
}
if expectedInput <= 0 {
return nil, fmt.Errorf("expectedInput inválido")
}
// Espera que a env já tenha sido inicializada em main (ort.InitializeEnvironment)
session, err := ort.NewDynamicSession[float32](modelPath, []string{"input"}, []string{"output"})
if err != nil {
return nil, fmt.Errorf("erro criando sessão ONNX: %w", err)
}
log.Printf("Sessão ONNX carregada a partir de %s", modelPath)
return &OnnxCategoryPredictor{
session: session,
classNames: classNames,
expectedInput: expectedInput,
}, nil
}
func (o *OnnxCategoryPredictor) PredictCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
if len(input.Features) != o.expectedInput {
return category.PredictionResult{}, fmt.Errorf("esperado %d features, recebido %d", o.expectedInput, len(input.Features))
}
// ONNX runtime não é automaticamente threadsafe entre Run() simultâneos, então garantimos exclusão
o.mu.Lock()
defer o.mu.Unlock()
shape := ort.NewShape(1, int64(o.expectedInput))
tensor, err := ort.NewTensor(shape, input.Features)
if err != nil {
return category.PredictionResult{}, fmt.Errorf("erro criando tensor input: %w", err)
}
defer tensor.Destroy()
outputShape := ort.NewShape(1, int64(len(o.classNames)))
outputTensor, err := ort.NewEmptyTensor[float32](outputShape)
if err != nil {
return category.PredictionResult{}, fmt.Errorf("erro criando tensor output: %w", err)
}
defer outputTensor.Destroy()
if err := o.session.Run([]*ort.Tensor[float32]{tensor}, []*ort.Tensor[float32]{outputTensor}); err != nil {
return category.PredictionResult{}, fmt.Errorf("erro executando sessão: %w", err)
}
logits := outputTensor.GetData()
if len(logits) != len(o.classNames) {
return category.PredictionResult{}, fmt.Errorf("saida inesperada do modelo")
}
probs := softmax(logits)
maxIdx := 0
maxVal := probs[0]
for i := 1; i < len(probs); i++ {
if probs[i] > maxVal {
maxVal = probs[i]
maxIdx = i
}
}
cat := category.ProductCategory{
Code: o.classNames[maxIdx],
Name: o.classNames[maxIdx], // aqui você pode mapear para descrição mais amigável
}
return category.PredictionResult{
Category: cat,
Probabilities: probs,
}, nil
}
func softmax(logits []float32) []float32 {
if len(logits) == 0 {
return []float32{}
}
maxLogit := logits[0]
for _, v := range logits[1:] {
if v > maxLogit {
maxLogit = v
}
}
exps := make([]float64, len(logits))
var sum float64
for i, v := range logits {
ev := expFloat64(float64(v - maxLogit))
exps[i] = ev
sum += ev
}
out := make([]float32, len(logits))
if sum == 0 {
return out
}
for i, ev := range exps {
out[i] = float32(ev / sum)
}
return out
}
func expFloat64(x float64) float64 {
const (
MinExp = -700.0
MaxExp = 700.0
)
if x < MinExp {
x = MinExp
} else if x > MaxExp {
x = MaxExp
}
return float64Exp(x)
}
// wrapper só pra ficar explícito
func float64Exp(x float64) float64 {
return (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) // zoeirinha só, pode trocar por math.Exp(x)
}
Aqui, por sanidade, você obviamente troca essa zoeira por
math.Exp(x)😄 – só deixei claro onde entra.
2.4 Observabilidade – Prometheus + OpenTelemetry
internal/infra/observability/metrics.go
package observability
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total de requisições HTTP por rota e método",
},
[]string{"path", "method", "status"},
)
httpRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Duração das requisições HTTP",
Buckets: prometheus.DefBuckets,
},
[]string{"path", "method"},
)
)
func InitMetrics() {
prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
}
func MetricsHandler() http.Handler {
return promhttp.Handler()
}
// Middleware para chi
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(ww, r)
duration := time.Since(start).Seconds()
path := r.URL.Path
method := r.Method
status := strconv.Itoa(ww.statusCode)
httpRequestsTotal.WithLabelValues(path, method, status).Inc()
httpRequestDuration.WithLabelValues(path, method).Observe(duration)
})
}
type statusWriter struct {
http.ResponseWriter
statusCode int
}
func (w *statusWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
internal/infra/observability/tracing.go
package observability
import (
"context"
"os"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/semconv/v1.21.0"
"google.golang.org/grpc"
)
func InitTracer(ctx context.Context, serviceName string) (func(context.Context) error, error) {
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" {
// se não tiver endpoint, usa no-op (só log local)
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.AlwaysSample()),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
)),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
exporter, err := otlptracegrpc.New(
ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithDialOption(grpc.WithBlock()),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
)),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
2.5 HTTP – router + handlers (chi)
internal/http/handlers.go
package http
import (
"encoding/json"
"net/http"
"github.com/seuuser/ml-onnx-example/api-go/internal/app"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)
type CategoryHandlers struct {
svc *app.CategoryService
}
func NewCategoryHandlers(svc *app.CategoryService) *CategoryHandlers {
return &CategoryHandlers{svc: svc}
}
type predictRequest struct {
Features []float32 `json:"features"`
}
type predictResponse struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Probabilities []float32 `json:"probabilities"`
}
func (h *CategoryHandlers) Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *CategoryHandlers) PredictCategory(w http.ResponseWriter, r *http.Request) {
var req predictRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "JSON inválido")
return
}
input := category.PredictionInput{
Features: req.Features,
}
result, err := h.svc.PredictProductCategory(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := predictResponse{
CategoryCode: result.Category.Code,
CategoryName: result.Category.Name,
Probabilities: result.Probabilities,
}
writeJSON(w, http.StatusOK, resp)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
internal/http/router.go
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func NewRouter(h *CategoryHandlers) http.Handler {
r := chi.NewRouter()
// middlewares padrão
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// métricas Prometheus
r.Use(observability.MetricsMiddleware)
// rotas principais
r.Get("/healthz", h.Healthz)
r.Post("/predict", h.PredictCategory)
// endpoint metrics
r.Handle("/metrics", observability.MetricsHandler())
// wrap com otelhttp para tracing
return otelhttp.NewHandler(r, "product-category-api")
}
2.6 cmd/api/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"time"
ort "github.com/yalue/onnxruntime_go"
"github.com/seuuser/ml-onnx-example/api-go/internal/app"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
apihttp "github.com/seuuser/ml-onnx-example/api-go/internal/http"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/onnx"
)
func main() {
ctx := context.Background()
// ONNX Runtime: lib compartilhada (configurada no Docker via ONNXRUNTIME_LIB)
if libPath := os.Getenv("ONNXRUNTIME_LIB"); libPath != "" {
ort.SetSharedLibraryPath(libPath)
}
if err := ort.InitializeEnvironment(); err != nil {
log.Fatalf("erro inicializando onnxruntime: %v", err)
}
defer func() {
if err := ort.DestroyEnvironment(); err != nil {
log.Printf("erro destruindo ambiente onnxruntime: %v", err)
}
}()
// Observabilidade
observability.InitMetrics()
shutdownTracer, err := observability.InitTracer(ctx, "product-category-api")
if err != nil {
log.Printf("erro inicializando tracer: %v", err)
} else {
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdownTracer(ctx); err != nil {
log.Printf("erro encerrando tracer: %v", err)
}
}()
}
modelPath := os.Getenv("MODEL_PATH")
if modelPath == "" {
modelPath = "model/model.onnx"
}
classNames := []string{"AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"}
expectedFeatures := 16
predictor, err := onnx.NewOnnxCategoryPredictor(modelPath, classNames, expectedFeatures)
if err != nil {
log.Fatalf("erro criando predictor ONNX: %v", err)
}
svc := app.NewCategoryService(predictor)
handlers := apihttp.NewCategoryHandlers(svc)
router := apihttp.NewRouter(handlers)
addr := ":8000"
log.Printf("API de categorização de produtos ouvindo em %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("erro servidor HTTP: %v", err)
}
}
3. Dockerfile da API em Go (hexagonal)
Dockerfile.api-go (mesma ideia de antes, só ajustando paths):
FROM golang:1.22-bookworm AS builder
WORKDIR /app
# Copia go.mod/go.sum
COPY api-go/go.mod api-go/go.sum ./api-go/
WORKDIR /app/api-go
RUN go mod download
# Copia código
COPY api-go/ ./
# Compila
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /app/bin/api-go ./cmd/api
# -------------------------------------------------------------------
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV ONNX_VERSION=1.20.0
ENV ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
RUN curl -L "https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_VERSION}/onnxruntime-linux-x64-${ONNX_VERSION}.tgz" \
-o /tmp/onnxruntime.tgz && \
mkdir -p /opt/onnxruntime && \
tar -xzf /tmp/onnxruntime.tgz -C /opt/onnxruntime --strip-components=1 && \
cp /opt/onnxruntime/lib/libonnxruntime.so /usr/local/lib/ && \
rm /tmp/onnxruntime.tgz
COPY --from=builder /app/bin/api-go /usr/local/bin/api-go
RUN mkdir -p /app/model
WORKDIR /app
EXPOSE 8000
CMD ["api-go"]
Como isso se encaixa no compose
No docker-compose.yml, você troca o serviço api-go para apontar para esse Dockerfile, e monta ./model:
services:
trainer:
build:
context: .
dockerfile: Dockerfile.ml
volumes:
- ./model:/app/model
command: ["python", "ml/train_product_category.py"]
api-go:
build:
context: .
dockerfile: Dockerfile.api-go
volumes:
- ./model:/app/model:ro
environment:
- ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
- MODEL_PATH=/app/model/model.onnx
# - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 # se quiser mandar tracing
ports:
- "8000:8000"
Ver depois:
05 - Entender o que até agora foi feito