Auslesen von Dependencies aus NPM-/POM-Files

Dieser Artikel befasst sich mit dem Auslesen von Dependencies aus NPM-/POM-Files und dem anschließenden Bereitstellen dieser innerhalb eines Dependency-Dashboards.

Wir bei avocado software engineering GmbH haben uns zum Ziel gesetzt, bei der regelmäßigen Überprüfung der Dependencies unserer Projekte Zeit zu sparen, ohne jedoch auf Leistung verzichten zu müssen.  

Hierfür entwickelten wir eigens ein Dependency-Dashboard, eine Übersicht aller relevanten Dependencies der Projekte innerhalb unserer Organisation. Mit dessen Hilfe wir jegliche NPM-(JSON) und POM-(XML) Files einlesen und anschließend mittels erstellter Filter und Metriken auswerten. 

Das Dependency-Dashboard-Frontend:

BI-Dashboard
Dependency-Dashboard

Das Dependency-Dashboard soll als Übersicht aller Dependencies der innerhalb eines Unternehmens laufenden Projekte dienen. Um jedoch eine gezieltere Übersicht zu erlangen, kann man sich der Filter-Funktionen bemächtigen. 

Im Fall des Projekte-Filters bietet sich die Möglichkeit gezielt Projekte zu selektieren, um so zwei oder mehr Projekte (bzw. deren Dependencies) zu vergleichen. Somit lassen sich Fehler ausgehend von Dependency-Versionen in ähnlichen Projekten schneller finden. 

add-Metrik

Eine Metrik setzt sich aus einem Metrik-Typ und dem Metrik-Namen bzw. der eigentlichen Metrik zusammen.


Der Typ einer Metrik gibt an in welcher Art File nach dieser Metrik gesucht werden soll POM(XML) / NPM(JSON). 


Die gesuchte Metrik setzt sich aus dem Object-Namen z.B. “dependencies”, gefolgt von einem Punkt “.” und der gesuchten Dependency z.B. “@angular/core“ zusammen.
So würde diese als Beispiel verwendete Metrik den Metrik-Namen: “dependencies.@angular/core” erhalten.  

NPM-Dependencies
Dependencies
add-Filter

Ein Filter setzt sich aus dem Filternamen (unique) und einer unbestimmten Anzahl von Metriken zusammen.

So lassen sich Filter für bestimmte Anforderungen erstellen. Ein Beispiel hierfür wäre Bootstrap mit seinen Dependencies (utils.js).

evaluate-Projects

Bei dem Anlegen einer neunen Metrik müsste prinzipiell jedes bisher hochgeladenen Projekt überprüft werden. Da dies jede Menge Ressourcen verbrauchen würde, entschieden wir uns dazu die Funktion “Projekte evaluieren” aufzunehmen.

Diese soll das Backend entlasten und somit einen flüssigeren Arbeitsablauf gewährleisten.

Dabei besteht die Möglichkeit Projekte (erneut) zu evaluieren.
Folglich kann man alle bis dato neu erstellten Metriken im Projekt auslesen. 

Das Dependency-Dashboard-Backend:

@PostMapping(value = "/dependencies")
public ResponseEntity postDependencies(@RequestBody List<MultipartFile> multipartFiles) {
    multipartFiles.forEach(multiPartFile -> {
        try {
            File file = new File(String.valueOf(Files.createTempFile(multiPartFile.getOriginalFilename(),
                   multiPartFile.getOriginalFilename()
                           .substring(multiPartFile.getOriginalFilename().lastIndexOf(".")))));
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(multiPartFile.getBytes());
            fos.close();
            String fileExtension = dependencyDashboardHandler.getFileExtension(file);
            if (fileExtension.equals("json")) {
                dependencyDashboardHandler.processNpmFile(file);
            } else if (fileExtension.equals("xml")) {
                dependencyDashboardHandler.processPomFile(file);
            }
            file.delete();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    });
    return new ResponseEntity(HttpStatus.OK);
}

Das Backend des Dependency-Dashboard übernimmt die Hauptaufgabe, das Auslesen der POM-(XML) und NPM-(JSON) Files.

Hierbei wird direkt bei Erhalt der Files unterschieden um anschließend gezielt das entsprechende File zu durchsuchen.

NPM (JSON) Dependencies:

public void processNpmFile(File file) {
    JSONParser jsonParser = new JSONParser();
    try (FileReader reader = new FileReader(file)) {
        JSONObject npmFileAsJSONObject = (JSONObject) jsonParser.parse(reader);
        int projectId = dependencyDashboardMapper.createOrGetProjectId(npmFileAsJSONObject.get("name").toString());
        DependencyDashboardUploadRecord upload = dependencyDashboardMapper.saveUploadedFile(FileType.NPM_FILE,npmFileAsJSONObject.toString(),projectId);
        dependencyDashboardMapper.checkDependenciesByMetrik(npmFileAsJSONObject, upload);
    } catch (IOException | ParseException e) {
        throw new RuntimeException(e);
    }
}

Jedes NPM-File wird zunächst mittels eines jsonParser analysiert und in ein JSONObject überführt, dies ermöglicht ein einfaches Auslesen des Files.

Des Weiteren wird zu jedem File (falls noch nicht vorhanden) ein Projekt mit dessen Namen, welcher unter dem Object “name” zu finden ist, in die Datenbank eingetragen.

Um einen in Zukunft realisierbaren Versionsverlauf erzeugen zu können, wird zu jedem File das erzeugte JSONObject als String in der Datenbank abgelegt.

Anschließend folgt der Check nach den Metriken innerhalb des Files (checkDependenciesByMetrik).

public void checkDependenciesByMetrik(JSONObject npmFileAsJSONObject, DependencyDashboardUploadRecord upload) {
    Timestamp now = new Timestamp(DateTime.now().getMillis());

    List<DependencyDashboardMetrikenRecord> dependencyDashboardMetrikenRecords = dslContext.select()
            .from(Tables.DEPENDENCY_DASHBOARD_METRIKEN)
            .where(Tables.DEPENDENCY_DASHBOARD_METRIKEN.TYPE.eq(upload.getType()))
            .fetchInto(DependencyDashboardMetrikenRecord.class);

    List<Integer> alreadyFoundMetrikIds = dslContext.select(Tables.DEPENDENCY_DASHBOARD.METRIKEN_ID)
            .from(Tables.DEPENDENCY_DASHBOARD)
            .where(Tables.DEPENDENCY_DASHBOARD.UPLOAD_ID.eq(upload.getId()))
            .fetchInto(Integer.class);

    List<DependencyDashboardMetrikenRecord> metrikList = dependencyDashboardMetrikenRecords.stream()
            .filter(metrik -> metrik.getType().equals(upload.getType())).collect(Collectors.toList());
    List<DependencyDashboardRecord> dependencyDashboardRecords = new ArrayList<>();
    metrikList.forEach(metrik -> {
        if (metrik.getName().contains(".")) {
            JSONObject filePathToCheck = npmFileAsJSONObject;
            String[] metrikArray = metrik.getName().split("\\.");
            for (int i = 0; i < metrikArray.length; i++) {
                if (i + 1 != metrikArray.length) {
                    filePathToCheck = (JSONObject) filePathToCheck.get(metrikArray[i]);
                } else {
                    JSONObject finalFilePathToCheck = filePathToCheck;
                    filePathToCheck.keySet().forEach(keyStr -> {
                        if (metrikArray[metrikArray.length - 1].equals(String.valueOf(keyStr))) {
                            if (!alreadyFoundMetrikIds.contains(metrik.getId())) {
                                DependencyDashboardRecord dependencyDashboardRecord = new DependencyDashboardRecord();
                                dependencyDashboardRecord.setGeneratedAt(now);
                                dependencyDashboardRecord.setUploadId(upload.getId());
                                dependencyDashboardRecord.setMetrikenId(metrik.getId());
                                dependencyDashboardRecord.setMetrikVersion(finalFilePathToCheck.get(keyStr).toString());
                                dependencyDashboardRecords.add(dependencyDashboardRecord);
                            }
                        }
                    });
                }
            }
        } else {
            npmFileAsJSONObject.keySet().forEach(keyStr -> {
                if (metrik.getName().equals(String.valueOf(keyStr))) {
                    if (!alreadyFoundMetrikIds.contains(metrik.getId())) {
                        DependencyDashboardRecord dependencyDashboardRecord = new DependencyDashboardRecord();
                        dependencyDashboardRecord.setGeneratedAt(now);
                        dependencyDashboardRecord.setUploadId(upload.getId());
                        dependencyDashboardRecord.setMetrikenId(metrik.getId());
                        dependencyDashboardRecord.setMetrikVersion(npmFileAsJSONObject.get(keyStr).toString());
                        dependencyDashboardRecords.add(dependencyDashboardRecord);
                    }
                }
            });
        }
    });
    this.dslContext.batchInsert(dependencyDashboardRecords).execute();
}

Hierbei wird das File nach allen bereits angelegten Metriken durchsucht, wobei jede Metrik mit “.”-Zeichen, an diesen geteilt wird. Dies ermöglicht eine Suche nach Dependencies, welche innerhalb des JSON-Files unterhalb eines Objects (z.B. dependencies) aufgeführt sind.

Um die Version der einzelnen Dependencies auszulesen, bedient man sich zweier Schleifen. Innerhalb dieser wird mittels der .keySet() Methode der Key ausgelesen und als String mit der gesuchten Metrik verglichen.

Die erlangten Versionen, sowie die zugehörigen Metadaten der vorhandenen Dependencies werden nun gesammelt in der Datenbank per batchInsert abgelegt. Diese Metadaten beinhalten die Informationen um welches Projekt es sich handelt, wann dieses erzeugt wurde und um welche Metrik es sich handelt.

POM (XML) Dependencies:

private String transformXMLToString(Document xmlFile) throws TransformerException        
    TransformerFactory tf = TransformerFactory.newInstance();
    Transformer transformer = tf.newTransformer();
    transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
    transformer.setOutputProperty(OutputKeys.INDENT, "no");
    StringWriter writer = new StringWriter();
    transformer.transform(new DOMSource(xmlFile), new StreamResult(writer));
    return writer.getBuffer().toString().replaceAll("\n|\r", "");
}

private Document transformStringToXML(String xmlStr) throws ParserConfigurationException, IOException, SAXException {
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    DocumentBuilder builder;
    builder = factory.newDocumentBuilder();
    Document xmlDocument = builder.parse(new InputSource(new StringReader(xmlStr)));
    return xmlDocument;
}

Ähnlich der Bearbeitung eines JSON-Files, wird das POM-File mittels der Hilfsfunktion “transformXMLToString” in einen String konvertiert und anschließend dessen Inhalt in die Datenbank geschrieben.

Bei einem erneuten Auslesen der Files findet die Funktion “transformStringToXML” Verwendung, diese wandelt den zuvor generierten String zurück in ein XML-Object.

public Boolean processPomFile(File xmlFile) {
    try {
        FileInputStream fileIS = new FileInputStream(xmlFile);
        DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = builderFactory.newDocumentBuilder();
        Document xmlDocument = builder.parse(fileIS);
        String xPathName = "/project/name/text()";
        Node name = (Node) xPath.compile(xPathName).evaluate(xmlDocument, XPathConstants.NODE);
        int projectId = dependencyDashboardMapper.createOrGetProjectId(name.getNodeValue());
        String xmlString = transformXMLToString(xmlDocument);
        DependencyDashboardUploadRecord upload = dependencyDashboardMapper.saveUploadedFile(FileType.POM_FILE, xmlString, projectId);

        Map<Integer, String> metriken = getMapFromXPath(xmlDocument);

        if (metrikMapper.saveMetrikenToUpload(metriken, upload.getId())) {
            return true;
        }

        return false;
    } catch (XPathExpressionException | SAXException | IOException | ParserConfigurationException | TransformerException e) {
        throw new RuntimeException(e);
    }
}

Die Verarbeitung eines XML-Files gestaltet sich aufwendiger, als die eines JSON-Files. Dies liegt dem Aufbau der Files zugrunde, so wird JSON von vielen Programmiersprachen wie C#, Java und Python als Standard “data-interchange language” (Schnittstelle für verschiedene Technologien zum Daten Transfer) verwendet.  Wohingegen XML den alten Standard widerspiegelt, welcher teilweise von JSON abgelöst wurde.

Um das File auszuwerten wird eine xPath Bibliothek verwendet. Eine Möglichkeit mit einer Path Language durch die Knoten, aus welchen eine XML-File hauptsächlich besteht, zu navigieren.

Die “Sprache“ hat Ähnlichkeiten zu der Navigation eines File Systems, so wird durch Knoten mit “/” gesprungen. Als Beispiel: Der Knoten “Project” hat die Childnodes “name” und “version”, somit navigiert man durch diese Knoten mittels: project/name bzw. project/version.

Der Knotennavigation werden noch Funktionen hinzugefügt, so liest die Funktion “text()” den Inhalt aus. Dies erinnert bei komplexen Anfragen an REGEX.

Um das File auf xPath vorzubereiten wird das File mit Hilfe eines Document-builders in ein Document umgewandelt um dann mit der xPath Library und den Expressions (z.B “project/version”) kompiliert.

Als Ergebnis des Kompilier-Vorgangs erhält man ein Object des Datentyps “node”, aus diesem man ganz einfach die Werte abfragen kann.

Achtung: Es wird in dieser Bibliothek ausschließlich xPath Version 1 verwendet, somit fallen die komplexen Funktionen und Verweise leider weg.

Die OutputProperties geben an, wie das Dokument umgewandelt werden sollte. INDENT gibt an, ob der Transformer zusätzliche Whitespaces hinzufügen soll. OMIt_XML_DECLARATION gibt an, ob die XML-Deklaration auch ausgegeben werden soll.

private Map<Integer, String> getMapFromXPath(Document xmlDocument) throws XPathExpressionException {
    Map<Integer, String> metriken = new HashMap<>();
    List<Node> dependencyNames = new ArrayList<>();

    List<Metrik> metrikenList = metrikMapper.getAllMetriken();
    String xPathName = "//dependency[version]/artifactId/text()";
    NodeList dependencyNamenWithVersion = (NodeList) xPath.compile(xPathName).evaluate(xmlDocument, XPathConstants.NODESET);
    xPathName = "//dependency/version/text()";
    NodeList dependencyVersion = (NodeList) xPath.compile(xPathName).evaluate(xmlDocument, XPathConstants.NODESET);
    xPathName = "//dependency[not(version)]/artifactId/text()";
    NodeList dependencyNamenWithoutVersion = (NodeList) xPath.compile(xPathName).evaluate(xmlDocument, XPathConstants.NODESET);
    xPathName = "//parent/version/text()";
    Node parentVersion = (Node) xPath.compile(xPathName).evaluate(xmlDocument, XPathConstants.NODE);

    for (int i = 0; i < dependencyNamenWithVersion.getLength(); i++) {
        dependencyNames.add(dependencyNamenWithVersion.item(i));
    }
    for (int i = 0; i < dependencyNamenWithoutVersion.getLength(); i++) {
        dependencyNames.add(dependencyNamenWithoutVersion.item(i));
    }

    for (int i = 0; i < dependencyNames.size(); i++) {
        Node dependency = dependencyNames.get(i);
        if (dependency != null) {
            List<Integer> existingMetrik = metrikenList.stream().filter(m -> m.getName().equals(dependency.getTextContent())).map(Metrik::getId).collect(Collectors.toList());

            if (dependencyVersion.item(i) != null && !existingMetrik.isEmpty()) {
                metriken.put(existingMetrik.get(0), dependencyVersion.item(i).getNodeValue());
            } else if (!parentVersion.getNodeValue().isEmpty() && !existingMetrik.isEmpty()) {
                metriken.put(existingMetrik.get(0), parentVersion.getNodeValue());
            }
        }
    }
    return metriken;
}

Durch das Problem, dass verschiedene Dependencies ohne Versionen in dem XML-File stehen und somit auf die Spring-Framework-Version verweisen, kann nicht einfach jede Version mit der dazugehörigen Dependency verbunden werden. Der „normale“ Aufbau einer Dependency innerhalb der POM (XML) gliedert sich in 3 Attribute: Dem Namen (ArtifactId), der Gruppe (zu welcher Dependency Gruppe diese gehört) und der Version.

Falls die Version fehlt bedeutet dies, dass eine Dependency eine identische Version wie das Spring-Framework besitzt.

Um dies herauszufinden wird eine komplexe xPath Abfrage benötigt (//dependency/(version/string(), /project/parent/version/string()) [1]), die aber so nicht in Java umsetzbar ist. Deswegen wurde die Abfrage in Teilabfragen aufgebrochen und abgearbeitet.

Im Code wird jede Dependency mit der eigenen Version verbunden. Im Falle, dass eine Dependency keine Version besitzt, wird die Parent-Version benutzt (Parent-Version = Version des Spring-Frameworks).

Mit dieser erstellten Map aus Namen und Verison, werden die gefundenen Dependencies des Projektes in die Datenbank geschrieben.

Ein spannendes Projekt mit einer für uns neuen Technologie zu verbinden ist sehr aufregend und stark motivierend. Die Gestaltung im Team war überaus lehrreich, und im Zuge dessen konnte eine Menge Erfahrung in der simultanen Arbeit an Projekten und dem allgemeinen Aufbau dieser erlangt werden. Insbesondere die Teamarbeit bringt neue Einsichten und Perspektiven in die Entwicklung und wirkt sich positiv auf die Arbeitsatmosphäre aus. Die mit der Implementierung einhergehenden Änderungen und Optimierungen konnten somit auch schnell und motiviert vollbracht werden.

Marvin Käfer (Student Informatik, 5. Semester), Louis Kuhnt (Student Informatik, 5. Semester)

Veröffentlicht am 16. August 2022