Como projetar uma API REST complexa considerando o desempenho do banco de dados?

8

Eu tenho seguido alguns tutoriais sobre como criar APIs REST, mas ainda tenho alguns grandes pontos de interrogação. Todos esses tutoriais mostram recursos com hierarquias relativamente simples, e eu gostaria de saber como os princípios usados ​​naqueles se aplicam a um mais complexo. Além disso, eles permanecem em um nível arquitetural muito alto. Eles quase não mostram nenhum código relevante, muito menos a camada de persistência. Estou especialmente preocupado com a carga / desempenho do banco de dados, como Gavin King disse :

você economizará esforço se prestar atenção ao banco de dados em todas as etapas do desenvolvimento

Digamos que meu aplicativo forneça treinamento para Companies. Companiestem Departmentse Offices. Departmentstem Employees. Employeestem Skillse Courses, e certas Levelhabilidades são necessárias para poder assinar alguns cursos. A hierarquia é a seguinte, mas com:

-Companies
  -Departments
    -Employees
      -PersonalInformation
        -Address
      -Skills (quasi-static data)
        -Levels (quasi-static data)
      -Courses
        -Address
  -Offices
    -Address

Caminhos seria algo como:

companies/1/departments/1/employees/1/courses/1
companies/1/offices/1/employees/1/courses/1

Buscando um Recurso

Então, ok, ao retornar de uma empresa, eu, obviamente, não retornam toda a hierarquia companies/1/departments/1/employees/1/courses/1+ companies/1/offices/../. Posso devolver uma lista de links para os departamentos ou departamentos expandidos e ter que tomar a mesma decisão neste nível: devolvo uma lista de links para os funcionários do departamento ou para os funcionários expandidos? Isso dependerá do número de departamentos, funcionários etc.

Pergunta 1 : Meu pensamento está correto: "onde cortar a hierarquia" é uma decisão típica de engenharia que preciso tomar?

Agora, digamos que, quando solicitado GET companies/id, decido retornar uma lista de links para a coleção de departamentos e as informações expandidas do escritório. Minhas empresas não têm muitos escritórios, portanto, juntar-me às mesas Officese Addressesnão deve ser um grande problema. Exemplo de resposta:

GET /companies/1

200 OK
{
  "_links":{
    "self" : {
      "href":"http://trainingprovider.com:8080/companies/1"
      },
      "offices": [
            { "href": "http://trainingprovider.com:8080/companies/1/offices/1"},
            { "href": "http://trainingprovider.com:8080/companies/1/offices/2"},
            { "href": "http://trainingprovider.com:8080/companies/1/offices/3"}
      ],
      "departments": [
            { "href": "http://trainingprovider.com:8080/companies/1/departments/1"},
            { "href": "http://trainingprovider.com:8080/companies/1/departments/2"},
            { "href": "http://trainingprovider.com:8080/companies/1/departments/3"}
      ]
  }
  "name":"Acme",
  "industry":"Manufacturing",
  "description":"Some text here",
  "offices": {
    "_meta":{
      "href":"http://trainingprovider.com:8080/companies/1/offices"
      // expanded offices information here
    }
  }
}

No nível do código, isso implica que (usando o Hibernate, não tenho certeza de como é com outros provedores, mas acho que é praticamente o mesmo) não colocarei uma coleção Departmentcomo campo na minha Companyclasse, porque:

  • Como já disse, não estou carregando Company, então não quero carregá-lo avidamente
  • E se eu não carregá-lo com entusiasmo, é melhor removê-lo, porque o contexto de persistência será fechado depois que eu carrego uma empresa e não faz sentido tentar carregá-lo depois ( LazyInitializationException).

Depois, colocarei um Integer companyIdna Departmentclasse, para poder adicionar um departamento a uma empresa.

Além disso, preciso obter os IDs de todos os departamentos. Outro sucesso no DB, mas não pesado, então tudo bem. O código pode se parecer com:

@Service
@Path("/companies")
public class CompanyResource {

    @Autowired
    private CompanyService companyService;

    @Autowired
    private CompanyParser companyParser;

    @Path("/{id}")
    @GET
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response findById(@PathParam("id") Integer id) {
        Optional<Company> company = companyService.findById(id);
        if (!company.isPresent()) {
            throw new CompanyNotFoundException();
        }
        CompanyResponse companyResponse = companyParser.parse(company.get());
        // Creates a DTO with a similar structure to Company, and recursivelly builds
        // sub-resource DTOs such as OfficeDTO
        Set<Integer> departmentIds = companyService.getDepartmentIds(id);
        // "SELECT id FROM departments WHERE companyId = id"
        // add list of links to the response
        return Response.ok(companyResponse).build();
    }
}
@Entity
@Table(name = "companies")
public class Company {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    private String industry;

    @OneToMany(fetch = EAGER, cascade = {ALL}, orphanRemoval = true)
    @JoinColumn(name = "companyId_fk", referencedColumnName = "id", nullable = false)
    private Set<Office> offices = new HashSet<>();

    // getters and setters
}
@Entity
@Table(name = "departments")
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    private Integer companyId;

    @OneToMany(fetch = EAGER, cascade = {ALL}, orphanRemoval = true)
    @JoinColumn(name = "departmentId", referencedColumnName = "id", nullable = false)
    private Set<Employee> employees = new HashSet<>();

    // getters and setters
}

Atualizando um Recurso

Para a operação de atualização, posso expor um terminal com PUTou POST. Como quero que eu PUTseja idempotente, não posso permitir atualizações parciais . Mas, se quiser modificar o campo de descrição da empresa, preciso enviar toda a representação do recurso. Isso parece muito inchado. O mesmo ao atualizar um funcionário PersonalInformation. Eu não acho que faz sentido ter que enviar todo o Skills+ Coursesjunto com isso.

Pergunta 2 : O PUT é usado apenas para recursos refinados?

Eu vi nos logs que, ao mesclar uma entidade, o Hibernate executa várias SELECTconsultas. Eu acho que é apenas para verificar se alguma coisa mudou e atualizar as informações necessárias. Quanto mais alta a entidade na hierarquia, mais pesadas e complexas as consultas. Mas algumas fontes aconselham o uso de recursos granulares grosseiros . Então, novamente, precisarei verificar quantas tabelas são demais e encontrar um compromisso entre a granularidade de recursos e a complexidade da consulta ao banco de dados.

Pergunta 3 : Esse é apenas mais um "saber onde cortar" a decisão de engenharia ou estou perdendo alguma coisa?

Pergunta 4 : Esse é, ou não, qual é o "processo de reflexão" correto ao projetar um serviço REST e procurar um compromisso entre granularidade de recurso, complexidade de consulta e propriedade da rede?

user3748908
fonte
1
1. sim; como as chamadas REST são caras, é importante tentar obter a granularidade correta.
Robert Harvey
1
2. Não. O verbo PUT não tem nada a ver com granularidade, por si só.
Robert Harvey
1
3. sim Não, você não está perdendo nada.
Robert Harvey
1
4. O pensamento correto é "faça o que melhor atende aos seus requisitos de escalabilidade, desempenho, manutenção e outros problemas". Isso pode exigir alguma experimentação para encontrar o ponto ideal.
Robert Harvey
4
Demasiado longo. Não leu. Isso pode ser cuspido em 4 perguntas reais?
MetaFight

Respostas:

7

Eu acho que você tem complexidade, porque você está começando com excesso de complicações:

Caminhos seria algo como:

companies/1/departments/1/employees/1/courses/1
companies/1/offices/1/employees/1/courses/1

Em vez disso, introduziria um esquema de URL mais simples como este:

GET companies/
    Returns a list of companies, for each company 
    return short essential info (ID, name, maybe industry)
GET companies/1
    Returns single company info like this:

    {
        "name":"Acme",
        "description":"Some text here"
        "industry":"Manufacturing"
        departments: {
            "href":"/companies/1/departments"
            "count": 5
        }
        offices: {
            "href":"/companies/1/offices"
            "count": 3
        }
    }

    We don't expand the data for internal sub-resources, 
    just return the count, so client knows that some data is present.
    In some cases count may be not needed too.
GET companies/1/departments
    Returns company departments, again short info for each department
GET departments/
    Here you need to decide if it makes sense to expose 
    a list of departments or not. 
    If not - leave only companies/X/departments method.

    Note, that you can also use query string to make this 
    method "searchable", like:
        /departments?company=1 - list of all departments for company 1
        /departments?type=support - all 'support' departments for all companies
GET departments/1
    Returns department 1 data

Dessa forma, ele responde à maioria das suas perguntas - você "corta" a hierarquia imediatamente e não vincula seu esquema de URL à estrutura de dados interna. Por exemplo, se soubermos o ID do funcionário, você esperaria consultá-lo, gostando employees/:IDou não companies/:X/departments/:Y/employees/:ID?

Em relação às solicitações PUTvs POST, na sua pergunta, é claro que você acha que as atualizações parciais serão mais eficientes para seus dados. Então, eu apenas usaria POSTs.

Na prática, você realmente deseja armazenar em cache leituras de dados ( GETsolicitações) e é menos importante para atualizações de dados. E a atualização geralmente não pode ser armazenada em cache, independentemente do tipo de solicitação que você faz (como se o servidor definir automaticamente o tempo de atualização - será diferente para cada solicitação).

Atualização: em relação ao "processo de raciocínio" correto - uma vez que é baseado em HTTP, podemos aplicar o modo de pensar regular ao projetar a estrutura do site. Nesse caso, na parte superior, podemos ter uma lista de empresas e mostrar uma breve descrição de cada uma com um link para a página "visualizar empresa", onde mostramos detalhes da empresa e links para escritórios / departamentos e assim por diante.

Boris Serebrov
fonte
5

IMHO, acho que você está perdendo o ponto.

Primeiro, o desempenho da API REST e do banco de dados não está relacionado .

A API REST é apenas uma interface , ela não define de maneira alguma como você faz coisas ocultas. Você pode mapeá-lo para qualquer estrutura de banco de dados que desejar por trás dele. Portanto:

  1. projetar sua API para que seja fácil para o usuário
  2. projete seu banco de dados para que ele possa ser dimensionado razoavelmente:
    • verifique se você tem os índices certos
    • se você armazenar objetos, verifique se eles não são muito grandes.

É isso aí.

... e, por último, isso cheira a otimização prematura. Mantenha-o simples, experimente e adapte-o, se necessário.

dagnelies
fonte
2

Pergunta 1: Meu pensamento está correto: "onde cortar a hierarquia" é uma decisão típica de engenharia que preciso tomar?

Talvez - eu estaria preocupado que você estivesse fazendo isso ao contrário, no entanto.

Então, ok, ao retornar uma empresa, eu obviamente não retorno toda a hierarquia

Eu não acho isso óbvio. Você deve devolver representações da empresa apropriadas para os casos de uso que você está apoiando. Por que você não? Realmente faz sentido que a API dependa do componente de persistência? Não faz parte do ponto que os clientes não precisam ser expostos a essa escolha na implementação? Você preservará uma API comprometida quando trocar um componente de persistência por outro?

Dito isto, se seus casos de uso não precisarem de toda a hierarquia, não será necessário retorná-la. Em um mundo ideal, a API produziria representações da empresa perfeitamente adequadas às necessidades imediatas do cliente.

Pergunta 2: O PUT é usado apenas para recursos refinados?

Praticamente - é bom comunicar a natureza idempotente de uma mudança implementando como put, mas a especificação HTTP permite que os agentes façam suposições sobre o que realmente está acontecendo.

Observe este comentário do RFC 7231

Uma solicitação PUT aplicada ao recurso de destino pode ter efeitos colaterais em outros recursos.

Em outras palavras, você pode COLOCAR uma mensagem (um "recurso refinado") que descreve um efeito colateral a ser executado no seu recurso primário (entidade). Você precisa tomar alguns cuidados para garantir que sua implementação seja idempotente.

Pergunta 3: Esse é apenas mais um "saber onde cortar" a decisão de engenharia ou estou perdendo alguma coisa?

Talvez. Pode estar tentando dizer que suas entidades não estão no escopo correto.

Pergunta 4: Esse é, ou não, qual é o "processo de reflexão" correto ao projetar um serviço REST e procurar um compromisso entre granularidade de recurso, complexidade de consulta e propriedade da rede?

Isso não parece certo para mim, na medida em que parece que você está tentando acoplar seu esquema de recursos a suas entidades e está deixando sua escolha de persistência direcionar seu design, e não o contrário.

O HTTP é fundamentalmente um aplicativo de documento; se as entidades em seu domínio são documentos, então é ótimo - mas as entidades não são documentos, você precisa pensar. Veja a palestra de Jim Webber : REST in Practice, particularmente a partir dos 36m40s.

Essa é a sua abordagem de recursos "refinada".

VoiceOfUnreason
fonte
Na sua resposta à pergunta 1, por que você diz que eu posso voltar atrás?
precisa saber é o seguinte
Porque parecia que você estava tentando ajustar os requisitos à restrição da camada de persistência, e não o contrário.
VoiceOfUnreason
2

Em geral, você não deseja nenhum detalhe de implementação exposto na API. As respostas msw e VoiceofUnreason estão comunicando isso, por isso é importante entender.

Lembre-se do princípio de menos espanto , principalmente porque você está preocupado com a idempotência. Dê uma olhada em alguns dos comentários no artigo que você postou ( https://stormpath.com/blog/put-or-post/ ); existe muita discordância sobre como o artigo apresenta a idempotência. A grande idéia que eu retiraria do artigo é que "solicitações de venda idênticas devem causar resultados idênticos". Ou seja, se você colocar uma atualização no nome de uma empresa, o nome da empresa muda e nada mais muda para essa empresa como resultado dessa PUT. A mesma solicitação 5 minutos depois deve ter o mesmo efeito.

Uma pergunta interessante para se pensar (confira o comentário do gtrevg no artigo): qualquer solicitação de PUT, incluindo uma atualização completa, modificará a dateUpdated mesmo que um cliente não a especifique. Isso não faria nenhuma solicitação de PUT violar a idempotência?

Então, de volta à API. Aspectos gerais a serem considerados:

  • Os detalhes de implementação expostos na API devem ser evitados
  • Se a implementação mudar, sua API ainda deve ser intuitiva e fácil de usar
  • A documentação é importante
  • Tente não distorcer a API para obter melhorias de desempenho
Gelby
fonte
1
menor aparte : a idempotência é contextualmente vinculada. Como exemplo, os processos de Log e Auditoria podem ser acionados dentro de uma PUT e essas ações não são idempotentes. Mas esses são detalhes internos da implementação e não afetam as representações expostas pela abstração do serviço; portanto, no que diz respeito à API , o PUT é idempotente.
K. Alan Bates
0

Para o seu Q1 sobre onde cortar as decisões de engenharia, que tal escolher o ID exclusivo de uma entidade que, de outra forma, forneceria os detalhes necessários no back-end? Por exemplo, "empresas / 1 / departamento / 1" terá um identificador exclusivo por si só (ou podemos ter um para representar o mesmo) para fornecer a hierarquia, você pode usá-lo.

Para o seu Q3 em PUT com informações completas, você pode sinalizar os campos que foram atualizados e enviar essas informações adicionais de metadados ao servidor para que você possa examinar e atualizar esses campos sozinho.

itsraghz
fonte